diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18ca400..8a6c911 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,18 @@ jobs: # integrity — a branch pointer can be force-pushed or rebased, # silently changing the code CI builds against. # - # This SHA is the tip of sphere-sdk `main` (PR #402 / #401 — invoice - # OUTBOX verifier wiring on top of PR #400 / #397's route-invoice- - # delivery-through-the-token-pipeline architectural fix). + # This SHA is the tip of sphere-sdk `main` (PR #405 — + # `AccountingModule.returnAllInvoicePayments`, the bulk-refund + # primitive this CLI exposes via `sphere invoice return ` / + # `--recipient @nametag`). Without this pin the CLI's bulk-return + # branch (forms B/C in src/legacy/legacy-cli.ts:invoice-return) + # fails typecheck — `returnAllInvoicePayments` doesn't exist on + # AccountingModule before #405. + # + # Carries forward everything #402 (OUTBOX verifier wiring), #401 + # (publishUxfBundle into OUTBOX/SENT), #400/#397 (route invoice + # delivery through TOKEN_TRANSFER pipeline), and #404's surrounding + # state-transition-sdk migration commits already required. # # Why this matters for sphere-cli: #397 replaces the bespoke # `invoice_delivery:` NIP-17 DM with the standard TOKEN_TRANSFER @@ -51,9 +60,7 @@ jobs: # roundtrip.sh` soak (and any cross-device invoice consumer) only # works against this new pipeline; the prior pin (3f3dadf, the # CID-delivery merge BEFORE #397) leaves the receiver decoder - # unable to ingest invoice DMs. Local repro: alice's `invoice - # list` returns "No invoices found" forever even though bob's - # `invoice deliver` reports `sent: 1, failed: 0`. + # unable to ingest invoice DMs. # # This SHA also keeps the exports `src/shared/sphere-providers.ts` # consumes (`createUxfCarPublisher`, `DEFAULT_IPFS_GATEWAYS`, @@ -64,7 +71,7 @@ jobs: # this whole workaround once sphere-sdk publishes v0.7.1+ to npm # with the post-extraction exports. env: - SPHERE_SDK_SHA: d9498445e96be63f6a474f2607372eb4c16247d2 + SPHERE_SDK_SHA: 0e3290a1e3f41e3d30ca9f6a02b304da507106c7 run: | git clone https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk # The pinned SHA may not be on a branch tip after future merges; diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index 47e05f3..3ff763e 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -293,8 +293,29 @@ function renderInvoiceCreateResult(p: Record): void { } function renderIdStatusResult(p: Record): void { + // Bulk-refund shape: { refunds: [ { id, status, recipient, amount, coin }, ... ] } + // emitted by `invoice return ` (no args) and `invoice return --recipient `. + if (Array.isArray(p.refunds)) { + const rows = p.refunds as Array>; + if (rows.length === 0) { + console.log(' (no refunds executed — nothing was attributed to this invoice)'); + return; + } + console.log(` ${rows.length} refund(s) submitted:`); + rows.forEach((r, i) => { + const recipient = (r.recipient as string) ?? '(unknown)'; + const recipientShort = recipient.length > 32 ? `${recipient.slice(0, 28)}…` : recipient; + console.log(` [${i}] ${r.amount ?? '?'} ${r.coin ?? '?'} → ${recipientShort}`); + console.log(` id : ${r.id ?? '(unknown)'}`); + console.log(` status : ${r.status ?? '(unknown)'}`); + if (r.error) console.log(` error : ${r.error}`); + }); + return; + } console.log(` id : ${p.id ?? '(unknown)'}`); console.log(` status : ${p.status ?? '(unknown)'}`); + if (p.recipient) console.log(` recipient : ${p.recipient}`); + if (p.amount && p.coin) console.log(` amount : ${p.amount} ${p.coin}`); if (p.error) console.log(` error : ${p.error}`); if (p.txHash) console.log(` txHash : ${p.txHash}`); } @@ -755,6 +776,23 @@ async function resolveInvoiceId(sphere: Sphere, idOrPrefix: string, cmdName: str return matched[0].invoiceId; } +/** + * Resolve a user-facing recipient (@nametag, chain pubkey, alpha1, or + * DIRECT://) to a DIRECT:// address. Same pattern as `invoice-create`'s + * target resolution. Returns DIRECT:// addresses unchanged. + */ +async function resolveRecipientToDirect(sphere: Sphere, recipient: string, cmdName: string): Promise { + if (recipient.startsWith('DIRECT://')) return recipient; + const resolved = await sphere.resolve(recipient); + if (!resolved || !resolved.directAddress) { + failWithHelp( + cmdName, + `could not resolve recipient "${recipient}" to a DIRECT:// address — provide an @nametag, chain pubkey, alpha1 address, or DIRECT:// address`, + ); + } + return resolved.directAddress; +} + /** * Canonical asset-pair parser (issue #32). * @@ -1527,14 +1565,20 @@ const COMMAND_HELP: Record = { ], }, 'invoice-return': { - usage: 'invoice-return --recipient
--asset ', - description: 'Manually return a payment to a sender for a specific invoice.', + usage: 'invoice-return [--recipient
] [--asset ]', + description: 'Refund attributed payments on an invoice. With no flags, refunds every sender. With --recipient, refunds all of that sender\'s balances. With --recipient + --asset, refunds exactly that amount.', flags: [ - { flag: '--recipient
', description: 'Recipient address or @nametag (required)' }, - { flag: '--asset ', description: 'Asset to return — two positional tokens (e.g., --asset 100000 UCT). Amount is in smallest units.' }, + { flag: '--recipient
', description: '@nametag, chain pubkey, alpha1, or DIRECT://. Limit refunds to this sender. Resolved to DIRECT:// before SDK handoff.' }, + { flag: '--asset ', description: 'Refund exactly this amount of this coin to --recipient. Two positional tokens (e.g., --asset 100 UCT). Amount is in HUMAN units. Requires --recipient.' }, ], examples: [ - 'sphere invoice return a1b2c3d4 --recipient @bob --asset 100000 UCT', + 'sphere invoice return a1b2c3d4 # refund every sender\'s full balance', + 'sphere invoice return a1b2c3d4 --recipient @alice # refund all balances owed to @alice', + 'sphere invoice return a1b2c3d4 --recipient @alice --asset 3 UCT # refund exactly 3 UCT to @alice', + ], + notes: [ + 'Forms without --asset delegate to sphere-sdk AccountingModule.returnAllInvoicePayments — sender addresses come from invoice status\'s senderBalances. This handles masked-predicate sends where the on-chain sender is a per-send one-time DIRECT://.', + 'Refunds use the :B (back) direction in the memo. The invoice state is NOT terminated — netCovered drops and the invoice can be paid again.', ], }, 'invoice-receipts': { @@ -4853,44 +4897,109 @@ async function main(): Promise { const invoiceId = await resolveInvoiceId(sphere, idOrPrefix, 'invoice-return'); const recipientIdx = args.indexOf('--recipient'); const assetIdx3 = args.indexOf('--asset'); - - if (recipientIdx === -1 || !args[recipientIdx + 1]) { - failWithHelp('invoice-return', '--recipient
is required'); - } - - // Canonical asset input (issue #32): --asset - if (assetIdx3 === -1) { - failWithHelp('invoice-return', '--asset is required'); - } - const pair = consumeAssetPair(args, assetIdx3 + 1); - if (!pair) { - failWithHelp('invoice-return', '--asset expects two positional tokens: --asset '); - } - // Canonical UX (#32): amount is in HUMAN units (e.g. "100 UCT" = 100 - // whole UCT). Convert to smallest-unit integer before handing off to - // the SDK. Same SDK convention as invoice-create — the - // AccountingModule's ReturnPaymentParams.coinId is the symbol - // (UCT, USDU, ...), not the 64-char hex. - const { symbol: returnSymbol, decimals: returnDecimals } = resolveCoin(pair.coin); - let returnSmallest: bigint; - try { - returnSmallest = toSmallestUnit(pair.amount, returnDecimals); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - failWithHelp('invoice-return', `invalid amount "${pair.amount}" — ${msg}`); - } - if (returnSmallest <= 0n) { - failWithHelp('invoice-return', `invalid amount "${pair.amount}" — must be positive`); + const explicitRecipient = recipientIdx !== -1 ? args[recipientIdx + 1] : undefined; + const hasAsset = assetIdx3 !== -1; + + // Three forms, in order of specificity: + // (A) --recipient --asset → refund EXACTLY this. + // (B) --recipient → refund ALL of that + // sender's attributed balances on this invoice (across coins). + // (C) (no flags) → refund EVERY sender's + // attributed balances on this invoice. + // + // Forms B and C delegate to sphere-sdk's `returnAllInvoicePayments` + // (added in sphere-sdk feat/accounting-return-all-invoice-payments), + // which iterates `getInvoiceStatus().senderBalances` internally — so + // the user doesn't have to fish per-send DIRECT://… addresses out of + // invoice status JSON. This particularly matters for masked-predicate + // sends, where the on-chain sender address is a one-time DIRECT://… + // that the user CANNOT guess from their own wallet identity. + // + // Recipient addresses (@nametag / chain pubkey / alpha1 / DIRECT://) + // are resolved to DIRECT:// here before the SDK call, matching the + // pattern `invoice deliver --to` and `invoice create --target` + // already use. + if (hasAsset && !explicitRecipient) { + failWithHelp( + 'invoice-return', + '--asset requires --recipient — refunding a specific amount needs a specific sender. Drop --asset to refund all balances, or add --recipient.', + ); } - const returnParams: ReturnPaymentParams = { - recipient: args[recipientIdx + 1], - amount: returnSmallest.toString(), - coinId: returnSymbol, - }; + if (hasAsset) { + // ----- Form A: explicit recipient + asset ----- + const pair = consumeAssetPair(args, assetIdx3 + 1); + if (!pair) { + failWithHelp('invoice-return', '--asset expects two positional tokens: --asset '); + } + const { symbol: returnSymbol, decimals: returnDecimals } = resolveCoin(pair.coin); + let returnSmallest: bigint; + try { + returnSmallest = toSmallestUnit(pair.amount, returnDecimals); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + failWithHelp('invoice-return', `invalid amount "${pair.amount}" — ${msg}`); + } + if (returnSmallest <= 0n) { + failWithHelp('invoice-return', `invalid amount "${pair.amount}" — must be positive`); + } - const result = await sphere.accounting!.returnInvoicePayment(invoiceId, returnParams); - formatOutput({ id: result.id, status: result.status }, 'invoice-return-result', 'Return payment result:'); + const recipientDirect = await resolveRecipientToDirect(sphere, explicitRecipient!, 'invoice-return'); + const result = await sphere.accounting!.returnInvoicePayment(invoiceId, { + recipient: recipientDirect, + amount: returnSmallest.toString(), + coinId: returnSymbol, + } as ReturnPaymentParams); + formatOutput( + { id: result.id, status: result.status, recipient: recipientDirect, amount: pair.amount, coin: returnSymbol }, + 'invoice-return-result', + 'Return payment result:', + ); + } else { + // ----- Form B or C: bulk-refund via SDK ----- + const sdkOptions: { recipient?: string } = {}; + if (explicitRecipient) { + sdkOptions.recipient = await resolveRecipientToDirect(sphere, explicitRecipient, 'invoice-return'); + } + const results = await sphere.accounting!.returnAllInvoicePayments(invoiceId, sdkOptions); + if (results.length === 0) { + const reason = explicitRecipient + ? `no refundable balance for recipient "${explicitRecipient}" on invoice ${invoiceId.slice(0, 16)}…` + : `no refundable balance found on invoice ${invoiceId.slice(0, 16)}… — nothing to return`; + failWithHelp('invoice-return', reason); + } + // The SDK returns TransferResult per row but doesn't echo back the + // (recipient, amount, coin) tuple — re-attach those from status so + // the human renderer can show what got refunded where. + const status = await sphere.accounting!.getInvoiceStatus(invoiceId); + const refundRows: Array> = []; + let cursor = 0; + for (const target of status.targets) { + for (const ca of target.coinAssets) { + const [coinId] = ca.coin; + const { decimals } = resolveCoin(coinId); + for (const sb of ca.senderBalances) { + // Skip rows that returnAllInvoicePayments wouldn't have + // refunded (zero balance, or recipient filter). + if (sdkOptions.recipient !== undefined && sb.senderAddress !== sdkOptions.recipient) continue; + if (cursor >= results.length) break; + const result = results[cursor++]; + refundRows.push({ + id: result.id, + status: result.status, + recipient: sb.senderAddress, + amount: toHumanReadable(sb.netBalance, decimals), + coin: coinId, + }); + } + } + } + formatOutput( + { refunds: refundRows } as Record, + 'invoice-return-result', + 'Return payment results:', + ); + } await syncAfterWrite(sphere); await closeSphere();