-
Notifications
You must be signed in to change notification settings - Fork 1
upload prog payouts spec #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
martinsaposnic
wants to merge
1
commit into
main
Choose a base branch
from
programmatic-payouts-doc
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,378 @@ | ||
| # Plan: Programmatic Payouts for MDK | ||
|
|
||
| ## Problem Statement | ||
|
|
||
| Users of the MDK nextjs/replit packages need to programmatically pay out from their Lightning wallet. This must be **secure by default** to protect against: | ||
|
|
||
| 1. **Attackers** - Someone exploiting insecure vibecoded implementations to drain wallets | ||
| 2. **Accidental self-harm** - Bugs in user code that trigger infinite/unintended payments | ||
| 3. **Input injection** - User-controlled data passed directly to payment destinations | ||
|
|
||
| ## Target Use Cases | ||
|
|
||
| 1. **Gambling/gaming with instant payouts** - Roulette where visitors bet and winners get paid on the spot | ||
| 2. **Multiplayer betting games** - Sudoku where players bet, winner takes the pool | ||
| 3. **Vibecoded websites** - Any site that needs automatic payouts based on user actions | ||
| 4. **Agent-to-agent payments** - AI agents paying for tool/API usage on Replit | ||
|
|
||
| ### Critical Security Insight | ||
|
|
||
| In these use cases: | ||
| - **Destination comes from the user** (winner provides their Lightning address) | ||
| - **Payouts are triggered by game outcomes**, not admin actions | ||
| - **Real-time, low-friction** - Can't require manual approval for each win | ||
| - **Dynamic amounts** - Payout depends on pool size, game rules | ||
|
|
||
| This means **destination allowlisting doesn't work** - we don't know who will win ahead of time. | ||
|
|
||
| ## Recommendation: Separate `@moneydevkit/payouts` Package | ||
|
|
||
| Create a new package rather than adding to `core/nextjs/replit` because: | ||
|
|
||
| - **Explicit opt-in** - Users must consciously install and configure payouts | ||
| - **Separate credentials** - Different secret from checkout, limiting blast radius | ||
| - **No accidental exposure** - Checkout routes can't trigger payouts | ||
| - **Clear security boundary** - Easier to audit, document, and reason about | ||
| - **Different threat model** - Outbound payments need stricter controls than inbound | ||
|
|
||
| ## Security Architecture | ||
|
|
||
| ### Core Principle: Capped Payouts | ||
|
|
||
| Strict spending limits enforced at infrastructure level: | ||
|
|
||
| ``` | ||
| Per-payment limit: 1k sats (default, max 100k hardcoded ceiling) | ||
| Daily limit: 10k sats (default, max 1M hardcoded ceiling) | ||
| Rate limit: 10/minute | ||
| ``` | ||
|
|
||
| **Critical implementation requirements:** | ||
|
|
||
| 1. **Atomic limit checks** - All limit checks (rate, per-payment, daily) must be atomic. Use compare-and-swap or transactions. Never check-then-decrement separately, or concurrent requests bypass limits. | ||
|
|
||
| 2. **Rolling 24h window** - Daily limit uses rolling window, not midnight reset. Prevents "wait for reset" attacks. | ||
|
|
||
| 3. **Invoice amount validation** - Before paying, validate resolved invoice amount matches requested amount. Reject if invoice asks for more. | ||
|
|
||
| 4. **Hardcoded ceilings** - Even if env vars are manipulated, absolute maximums can't be exceeded: | ||
| - Per-payment: 100k sats max (can't be raised via env) | ||
| - Daily: 1M sats max (can't be raised via env) | ||
|
|
||
| 5. **No HTTP handler export** - Only export `payout()` function for direct server-side use. Don't provide `createPayoutHandler()` - this prevents SSRF via user-created proxy endpoints. | ||
|
|
||
| ### Layer 0: Server-Only Enforcement (Prevents Browser Console Attacks) | ||
|
|
||
| The package must be **impossible to use from the browser**: | ||
|
|
||
| 1. **No client exports** - Package only exports server-side code, no React components | ||
| 2. **Runtime check** - Throws immediately if `typeof window !== 'undefined'` | ||
| 3. **No NEXT_PUBLIC_ prefix** - `MDK_PAYOUT_SECRET` is never exposed to client bundle | ||
| 4. **Request validation** - Reject requests with browser indicators: | ||
| - Has `Origin` header from different host (browser CORS) | ||
| - Has `Sec-Fetch-Mode: cors` or `navigate` (browser fetch) | ||
| - Missing server-side auth header | ||
|
|
||
| ```typescript | ||
| // This MUST fail if called from browser | ||
| import { payout } from '@moneydevkit/payouts' // throws: "Cannot use payouts in browser" | ||
| ``` | ||
|
|
||
| **Why this matters:** A vibecoder might accidentally expose payout logic in a way that's callable from browser. By making the package server-only at runtime, even bad code can't be exploited from console. | ||
|
|
||
| ### Layer 1: Separate Authentication | ||
|
|
||
| ``` | ||
| MDK_ACCESS_TOKEN → Checkout operations (receiving payments) | ||
| MDK_PAYOUT_SECRET → Payout operations (sending payments) - NEW | ||
| MDK_MNEMONIC → Wallet access (shared, server-side only) | ||
| ``` | ||
|
|
||
| - `MDK_PAYOUT_SECRET` must be different from `MDK_ACCESS_TOKEN` | ||
| - Validation at startup: refuse to start if secrets are identical | ||
| - Longer, higher-entropy secret requirement (min 32 chars) | ||
| - If secret starts with `NEXT_PUBLIC_`, refuse to start (common mistake) | ||
|
|
||
| ### Layer 2: Spending Limits (Enforced Server-Side) | ||
|
|
||
| ```typescript | ||
| // Environment variables with sensible defaults | ||
| MDK_PAYOUT_MAX_SINGLE=10000 // Max sats per payment (default: 10k sats) | ||
| MDK_PAYOUT_MAX_DAILY=100000 // Max sats per 24h rolling window (default: 100k sats) | ||
| MDK_PAYOUT_MAX_HOURLY=50000 // Max sats per hour (default: 50k sats) | ||
| ``` | ||
|
|
||
| - Limits tracked on moneydevkit.com - checked server-side before each payout | ||
| - Limits enforced **before** payment attempt, not just documented | ||
| - Same limits on signet and mainnet - override via env vars if needed | ||
| - Requires explicit override to increase (environment variable) | ||
|
|
||
| ### Layer 3: Destination Allowlist (Optional) | ||
|
|
||
| ```typescript | ||
| // Environment variable: comma-separated BOLT12 offers, node pubkeys, or domains | ||
| MDK_PAYOUT_ALLOWED_DESTINATIONS=offer1...,03abcd...,*.wallet.com | ||
| ``` | ||
|
|
||
| - If set, payments to unlisted destinations are rejected | ||
| - LNURL domain validation prevents open redirect attacks | ||
| - BOLT11 validated against node pubkey if nodeId allowlist is set | ||
|
|
||
| ### Layer 4: Rate Limiting (Built-In) | ||
|
|
||
| ```typescript | ||
| // Defaults, configurable via env | ||
| MDK_PAYOUT_RATE_LIMIT=10 // Max payments per minute (default: 10) | ||
| MDK_PAYOUT_RATE_LIMIT_WINDOW=60000 // Window in ms (default: 60s) | ||
| ``` | ||
|
|
||
| - Sliding window rate limiter | ||
| - Throws `RateLimitError` with retry-after when exceeded | ||
| - Prevents runaway loops from draining wallet | ||
|
|
||
| ### Layer 5: Idempotency Keys (Required) | ||
|
|
||
| ```typescript | ||
| // User MUST provide an idempotency key | ||
| await payout.send({ | ||
| destination: 'lno1...', | ||
| amountSats: 1000, | ||
| idempotencyKey: 'order-123-payout', // Required, user-provided | ||
| }) | ||
| ``` | ||
|
|
||
| - Same key within 24h returns previous result (success or failure) | ||
| - Prevents duplicate payments from retries or bugs | ||
| - Keys stored in VSS, expire after 24h | ||
|
|
||
| ### Layer 6: Confirmation Callback (Optional) | ||
|
|
||
| ```typescript | ||
| const result = await payout({ | ||
| destination: '[email protected]', | ||
| amountSats: 1000, | ||
| idempotencyKey: 'order-123', | ||
|
|
||
| // Called BEFORE payment is sent - return false to abort | ||
| beforePayout: async (payment) => { | ||
| return await checkApprovalSystem(payment) | ||
| }, | ||
| // Called AFTER payment completes | ||
| afterPayout: async (result) => { | ||
| await logToDatabase(result) | ||
| } | ||
| }) | ||
| ``` | ||
|
|
||
| - Allows users to add approval workflows inline | ||
| - Async, so can call external systems | ||
| - Timeout on beforePayout prevents hanging | ||
|
|
||
| ## API Design | ||
|
|
||
| ### Package: `@moneydevkit/payouts` | ||
|
|
||
| ```typescript | ||
| // packages/payouts/src/index.ts - Server-only exports | ||
| export { payout } from './payout' | ||
| export { getBalance } from './balance' | ||
| export { paidFetch } from './paid-fetch' // For agent mode | ||
| export { createPaidEndpoint } from './paid-endpoint' // For tool providers | ||
| export type { PayoutOptions, PayoutResult, Balance } from './types' | ||
|
|
||
| // NO createPayoutHandler - prevents SSRF via proxy endpoints | ||
| ``` | ||
|
|
||
| ### Basic Usage | ||
|
|
||
| ```typescript | ||
| import { payout, getBalance } from '@moneydevkit/payouts' | ||
|
|
||
| // Check balance | ||
| const balance = await getBalance() | ||
| // { sats: 50000, btc: 0.0005, usd: 25.00, eur: 23.00 } | ||
|
|
||
| // Payout in sats | ||
| const result = await payout({ | ||
| destination: '[email protected]', | ||
| amount: 1000, | ||
| currency: 'sats', // 'sats' | 'btc' | 'usd' | 'eur' | etc. | ||
| idempotencyKey: 'game-123-win', | ||
| }) | ||
|
|
||
| // Payout in USD - moneydevkit.com returns sats equivalent | ||
| const result = await payout({ | ||
| destination: '[email protected]', | ||
| amount: 5.00, | ||
| currency: 'usd', // Converted to sats by moneydevkit.com | ||
| idempotencyKey: 'game-456-win', | ||
| }) | ||
| // result.amountSats = 10000 (actual sats paid based on current rate) | ||
|
|
||
| if (result.success) { | ||
| console.log('Paid:', result.paymentId, result.amountSats) | ||
| } else { | ||
| console.error('Failed:', result.error) // Structured error with code, no sensitive info | ||
| } | ||
| ``` | ||
|
|
||
| **Currency conversion:** moneydevkit.com provides BTC price. Amount converted to sats before payment. | ||
|
|
||
| **Balance check:** Before each payout, balance is checked. If insufficient, returns `InsufficientBalanceError` with current balance and required amount. | ||
|
|
||
| **Security:** Per-payment limit (1k sats), daily limit (10k sats), rate limited, idempotency required, server-only, browser-rejected. Errors include codes but no sensitive info. | ||
|
|
||
| ### L402 / Agent Payments | ||
|
|
||
| **Tool provider (accepting payments - this is RECEIVING, not sending):** | ||
| ```typescript | ||
| import { createPaidEndpoint } from '@moneydevkit/payouts' | ||
|
|
||
| // This is an HTTP handler, but it RECEIVES payments (like checkout) | ||
| // It does NOT call payout() - it generates invoices and waits for payment | ||
| export const POST = createPaidEndpoint({ | ||
| priceSats: 10, | ||
| handler: async (req, { payment }) => { | ||
| return Response.json(await doWork(req.body)) | ||
| }, | ||
| }) | ||
| ``` | ||
|
|
||
| **Agent (paying for APIs):** | ||
| ```typescript | ||
| import { paidFetch } from '@moneydevkit/payouts' | ||
|
|
||
| // Handles 402 flow: get invoice -> pay -> retry with preimage | ||
| const response = await paidFetch('https://tool.replit.app/api/work', { | ||
| method: 'POST', | ||
| body: data, | ||
| payment: { maxSats: 100 }, // Refuse to pay more | ||
| }) | ||
| ``` | ||
|
|
||
| **Security:** `maxSats` caps per-request. Per-domain hourly limits. | ||
|
|
||
| **Protocol:** Simple invoice-in-header (not full L402 with macaroons). Add macaroons later if needed. | ||
|
|
||
| ### Supported Destinations | ||
|
|
||
| ```typescript | ||
| type PayoutDestination = | ||
| | string // Auto-detect: BOLT11, BOLT12, LNURL, Lightning Address | ||
| | { type: 'bolt11', invoice: string } | ||
| | { type: 'bolt12', offer: string } | ||
| | { type: 'lnurl', url: string } | ||
| | { type: 'lightningAddress', address: string } | ||
| ``` | ||
|
|
||
| ## Attack Scenarios & Mitigations | ||
|
|
||
| ### Scenario 0: Browser console attack | ||
| ```javascript | ||
| fetch('/api/payout', { method: 'POST', body: JSON.stringify({ to: 'attacker@wallet' }) }) | ||
| ``` | ||
| **Mitigation:** Handler rejects requests with browser headers (`Sec-Fetch-Mode`, cross-origin `Origin`). Package throws if imported in browser. | ||
|
|
||
| ### Scenario 1: Exposed endpoint without auth | ||
| ```typescript | ||
| app.post('/payout', (req, res) => { | ||
| await payout({ destination: req.body.address, amountSats: 1000 }) | ||
| }) | ||
| ``` | ||
| **Mitigation:** 1k/payment, 10k/day limits. Rate limiting. Worst case: 10k sats/day. | ||
|
|
||
| ### Scenario 2: Infinite loop | ||
| ```typescript | ||
| while (true) { | ||
| await payout({ destination: winner, amountSats: 100, idempotencyKey: 'same-key' }) | ||
| } | ||
| ``` | ||
| **Mitigation:** Same idempotency key = same result (no duplicate payment). Rate limiter kicks in. Daily limit caps total. | ||
|
|
||
| ### Scenario 3: Logic error pays 10x | ||
| ```typescript | ||
| await payout({ amountSats: winnings * 10 }) // Typo: 10000 instead of 1000 | ||
| ``` | ||
| **Mitigation:** Per-payment limit (1k default) rejects. Payment fails, no money sent. | ||
|
|
||
| ### Scenario 4: Malicious LNURL | ||
| ```typescript | ||
| await payout({ destination: 'lnurl1...evil', amountSats: 1000 }) | ||
| ``` | ||
| **Mitigation:** Amount specified by caller. If resolved invoice requests more, payment fails. | ||
|
|
||
| ### Scenario 5: Agent overcharged | ||
| ```typescript | ||
| await paidFetch('https://evil.com/api', { payment: { maxSats: 100 } }) | ||
| // Tool returns invoice for 10000 sats | ||
| ``` | ||
| **Mitigation:** `maxSats` enforced. Invoice for 10000 sats rejected. | ||
|
|
||
| ### Scenario 6: Agent infinite loop | ||
| ```typescript | ||
| while (true) { | ||
| await paidFetch('https://tool.com/api', { payment: { maxSats: 10 } }) | ||
| } | ||
| ``` | ||
| **Mitigation:** Per-domain + global hourly limits. Requests fail after limit hit. | ||
|
|
||
| ### Scenario 7: Concurrent request flood | ||
| ```typescript | ||
| // 100 simultaneous requests | ||
| Promise.all(Array(100).fill().map((_, i) => | ||
| payout({ destination: addr, amountSats: 1000, idempotencyKey: `key-${i}` }) | ||
| )) | ||
| ``` | ||
| **Mitigation:** Atomic limit checks. All 100 requests race for the same daily limit counter. First 10 succeed (10k daily), rest rejected. | ||
|
|
||
| ### Scenario 8: Invoice amount mismatch | ||
| ```typescript | ||
| // User's Lightning address returns invoice for 100k sats | ||
| await payout({ destination: '[email protected]', amountSats: 1000 }) | ||
| ``` | ||
| **Mitigation:** Validate resolved invoice amount matches requested amount. Invoice for 100k when we asked for 1k = rejected before payment. | ||
|
|
||
| ### Scenario 9: SSRF via proxy (payout) | ||
| ```typescript | ||
| // Vibecoder wrote a proxy endpoint | ||
| app.post('/proxy', async (req, res) => { | ||
| const response = await fetch(req.body.url, req.body.options) | ||
| res.json(await response.json()) | ||
| }) | ||
| // Attacker uses it to call payout internally, bypassing browser detection | ||
| ``` | ||
| **Mitigation:** No HTTP handler exported. `payout()` must be called directly in code, can't be triggered via HTTP. Limits still cap damage if user writes unsafe endpoint. | ||
|
|
||
| ### Scenario 11: SSRF via paidFetch | ||
| ```typescript | ||
| // Vibecoder wrote a tool-calling proxy | ||
| app.post('/call-tool', async (req, res) => { | ||
| const result = await paidFetch(req.body.url, { payment: { maxSats: 100 } }) | ||
| res.json(await result.json()) | ||
| }) | ||
| // Attacker makes server pay arbitrary URLs | ||
| ``` | ||
| **Mitigation:** Per-domain hourly limits. `maxSats` caps each payment. Daily limit caps total. Attacker can't drain wallet, just make server pay for random tools within limits. | ||
|
|
||
| ### Scenario 10: Env var manipulation | ||
| ```typescript | ||
| // Attacker gains env write access, sets: | ||
| // MDK_PAYOUT_MAX_SINGLE=1000000 | ||
| ``` | ||
| **Mitigation:** Hardcoded ceilings (100k/payment, 1M/day) can't be exceeded regardless of env vars. Attacker can raise to ceiling but not beyond. | ||
|
|
||
| ## Configuration Summary | ||
|
|
||
| ```bash | ||
| # Required | ||
| MDK_PAYOUT_SECRET=your-secure-payout-secret-min-32-chars | ||
|
|
||
| # Recommended | ||
| MDK_PAYOUT_MAX_SINGLE=10000 # 10k sats max per payment | ||
| MDK_PAYOUT_MAX_DAILY=100000 # 100k sats max per day | ||
| MDK_PAYOUT_ALLOWED_DESTINATIONS=lno1...,03abc... | ||
|
|
||
| # Optional | ||
| MDK_PAYOUT_MAX_HOURLY=50000 # 50k sats max per hour | ||
| MDK_PAYOUT_RATE_LIMIT=10 # 10 payments per minute max | ||
| ``` | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This section states the defaults are 1k per-payment and 10k per-day, but later in the same document the environment defaults and configuration summary describe 10k/100k. That inconsistency can cause implementers to pick the higher limits unintentionally, undermining the “secure by default” goal for payouts. Please reconcile the default values so a single, unambiguous set of limits is specified.
Useful? React with 👍 / 👎.