From cac042ad1dccc7ecbaab15965afab7792775ce8b Mon Sep 17 00:00:00 2001 From: linzheming Date: Tue, 25 Aug 2026 15:19:14 +0800 Subject: [PATCH] feat(bsv): add deferred upto payments --- specs/schemes/upto/scheme_upto_bsv.md | 506 ++++++++++ typescript/.changeset/add-bsv-upto-scheme.md | 5 + typescript/packages/mechanisms/bsv/README.md | 84 +- .../packages/mechanisms/bsv/package.json | 30 + .../packages/mechanisms/bsv/src/index.ts | 7 +- .../packages/mechanisms/bsv/src/types.ts | 116 ++- .../mechanisms/bsv/src/upto/client/index.ts | 2 + .../mechanisms/bsv/src/upto/client/scheme.ts | 312 ++++++ .../bsv/src/upto/facilitator/index.ts | 6 + .../bsv/src/upto/facilitator/scheme.ts | 898 ++++++++++++++++++ .../mechanisms/bsv/src/upto/server/index.ts | 5 + .../mechanisms/bsv/src/upto/server/scheme.ts | 148 +++ .../mechanisms/bsv/src/upto/shared.ts | 92 ++ .../mechanisms/bsv/src/upto/transaction.ts | 727 ++++++++++++++ .../bsv/test/unit/constants.test.ts | 7 +- .../bsv/test/unit/upto-client.test.ts | 323 +++++++ .../bsv/test/unit/upto-facilitator.test.ts | 839 ++++++++++++++++ .../bsv/test/unit/upto-server.test.ts | 165 ++++ .../bsv/test/unit/upto-transaction.test.ts | 599 ++++++++++++ .../packages/mechanisms/bsv/tsup.config.ts | 3 + 20 files changed, 4863 insertions(+), 11 deletions(-) create mode 100644 specs/schemes/upto/scheme_upto_bsv.md create mode 100644 typescript/.changeset/add-bsv-upto-scheme.md create mode 100644 typescript/packages/mechanisms/bsv/src/upto/client/index.ts create mode 100644 typescript/packages/mechanisms/bsv/src/upto/client/scheme.ts create mode 100644 typescript/packages/mechanisms/bsv/src/upto/facilitator/index.ts create mode 100644 typescript/packages/mechanisms/bsv/src/upto/facilitator/scheme.ts create mode 100644 typescript/packages/mechanisms/bsv/src/upto/server/index.ts create mode 100644 typescript/packages/mechanisms/bsv/src/upto/server/scheme.ts create mode 100644 typescript/packages/mechanisms/bsv/src/upto/shared.ts create mode 100644 typescript/packages/mechanisms/bsv/src/upto/transaction.ts create mode 100644 typescript/packages/mechanisms/bsv/test/unit/upto-client.test.ts create mode 100644 typescript/packages/mechanisms/bsv/test/unit/upto-facilitator.test.ts create mode 100644 typescript/packages/mechanisms/bsv/test/unit/upto-server.test.ts create mode 100644 typescript/packages/mechanisms/bsv/test/unit/upto-transaction.test.ts diff --git a/specs/schemes/upto/scheme_upto_bsv.md b/specs/schemes/upto/scheme_upto_bsv.md new file mode 100644 index 0000000000..b614036cce --- /dev/null +++ b/specs/schemes/upto/scheme_upto_bsv.md @@ -0,0 +1,506 @@ +# BSV Deferred-Amount Extension (`upto`) + +> **Status: experimental.** This is a thin extension of the +> [BSV `exact` profile](../exact/scheme_exact_bsv.md), not a second BSV payment +> protocol: +> +> ```text +> upto = exact + reusable cap signature + deferred actual amount +> ``` + +## 1. Scope + +With `exact`, the client knows the amount before it creates the payment +transaction. With `upto`, the client first authorizes a maximum and the actual +amount is fixed later by one ordinary, fully signed BSV transaction. + +One authorization is still single-use and has at most one x402 settlement. +Applications can reach that transaction in either of two ways: + +- construct one agreed amount after usage is known; or +- retain successively signed, cumulative transactions while usage is + delivered, then settle one of them. + +Stream transactions share inputs and therefore conflict with one another. They +are alternatives for one settlement, not multiple pay-per-chunk settlements. +Application chunks are application data; this profile defines no stream object, +stream hash, commitment output, or additional settlement type. + +Unless changed below, implementations MUST reuse BSV `exact` behavior: + +- BRC-42/BRC-29 identities and derived recipient outputs; +- BSV CAIP-2 network, native asset, freshness, and wallet-network checks; +- BEEF or Atomic BEEF transaction transport and subject selection; +- source transaction, script, signature, and SPV validation; and +- recipient-wallet `internalizeAction` with BRC-29 remittance metadata. + +The public implementation remains a thin extension of the existing +`@x402/bsv` client, server, and facilitator. SDK transaction objects, source +hydration, sighash construction, and owner accounting remain internal. + +## 2. Wire model + +The authorization payload retains the exact-compatible fields +`derivationPrefix`, `derivationSuffix`, `senderIdentityKey`, and primary +`outputIndex`. It adds one authorization: + +```text +UptoBsvAuthorization = { + authorizationId, // lowercase hex canonical digest + terms, // UptoBsvAuthorizationTerms from section 4 + capSignatures: [ + { + inputIndex, + transactionSignature, // base64 DER, signed for 0x43 + authorizationSignature // base64 DER over authorizationDigest + }, ... + ] +} +``` + +There MUST be exactly one signature pair for every cap input and none for +control inputs. `transactionSignature` contains DER only; the verifier appends +the `0x43` sighash byte when constructing the input unlocking script. +`authorizationSignature` directly authenticates the canonical terms that the +transaction sighash does not completely cover, including owner accounting, +recipient indices, and timing. + +At settlement, the application adds one `UptoBsvTransactionVersion`: + +```text +UptoBsvTransactionVersion = { + authorizationId, + transaction // base64 BEEF or Atomic BEEF +} +``` + +This wrapper intentionally contains no declared amount, txid, revision, chunk +index, sequence, or Final flag. The verifier derives the txid, owner net deltas, +common control-input `nSequence`, and cooperative-close condition from the +signed transaction. This avoids duplicate fields that could disagree with the +evidence. + +## 3. Amount is an owner net delta + +Every value-bearing input and output has one opaque `owner` identifier: + +```text +netDelta[owner] = sum(owner input values) - sum(owner output values) +``` + +- positive delta means the owner pays net satoshis; +- negative delta means the owner receives net satoshis; and +- all owner deltas sum to the transaction fee. + +An owner may have multiple inputs and outputs. The verifier calculates amounts +only from sums; no individual output is the authoritative payment amount. + +`input.kind` is only a signing-mechanism classification: + +- `cap`: reusable payer authorization signature; +- `control`: fresh signature over each complete transaction. + +Neither `owner` nor `kind` creates a payer/payee business-role hierarchy. +`chargedOwners` identifies which positive owner deltas form the x402 charge: + +```text +maxAmount = sum( + value(cap input) - value(its fixed same-index floor output) +) for charged owners + +actualAmount = sum(netDelta[owner]) for charged owners + +0 <= actualAmount <= maxAmount +``` + +At verify time, `PaymentRequirements.amount` is `maxAmount`. At settle time it +is `actualAmount`, recomputed from the signed transaction. The facilitator MUST +re-verify the authorization against the original maximum before comparing the +recomputed actual amount with settlement requirements. + +The wire authorization and verifier permit multiple inputs and outputs. The +default client/facilitator adapter constructs the common two-party layout: one +cap input, one or more small recipient control inputs, payer floor and refund +outputs, and recipient payment output. Advanced allocations supply all output +amounts explicitly. + +`owner` is signed accounting metadata, not proof of script ownership. The +recipient MUST independently derive every `paymentOutputIndexes` script from +the exact-compatible BRC-29 context and, before signing and again before +settlement, enforce: + +```text +recipientReceipt = + sum(recipient payment outputs) + - sum(recipient control inputs) + + fee + +recipientReceipt >= actualAmount +``` + +This aggregate check preserves multiple inputs and outputs without trusting a +refund output's owner label. One x402 requirement still names one `payTo`; this +profile does not claim independently priced multiple payees in one settlement. + +## 4. Authorization + +`UptoBsvAuthorizationTerms` fixes: + +- authorization profile version, network, and native BSV asset; +- `payTo`, payer identity, and exact-compatible BRC-29 derivation fields; +- every ordered input's opaque owner, signing kind, source transaction and + output index, and source P2PKH public key; +- every ordered output's opaque owner, locking script, and optional fixed value; +- charged owners and recipient payment-output indices; +- transaction fee, first control sequence, `validAfter`, protocol `deadline`, + and the transaction's absolute `nLockTime`. + +Cap inputs MUST precede control inputs. Each cap input at index `i` has a fixed, +same-owner floor output at index `i`, locked to that cap key. Every other output +attributed to a charged owner MUST also use one of that owner's cap-input keys; +an owner label alone is not proof that value was returned to that owner. Adding, +removing, replacing, or reordering an input creates a different authorization. + +The authorization identifier is: + +```text +authorizationDigest = SHA256( + UTF8("x402-bsv-upto-authorization-v1") || 0x00 || + UTF8(JSON.stringify(orderedCanonicalTuple)) +) + +authorizationId = lowercase_hex(authorizationDigest) +``` + +The tuple is exactly: + +```text +[ + version, + network, + "BSV", + lowercase(payTo), + lowercase(senderIdentityKey), + derivationPrefix, + derivationSuffix, + inputs.map(input => [ + input.owner, + input.kind, + lowercase(resolvedSubjectTxid), + input.sourceOutputIndex, + canonicalDecimal(resolvedSourceSatoshis), + lowercase(resolvedSourceLockingScriptHex), + lowercase(input.publicKey) + ]), + outputs.map(output => [ + output.owner, + lowercase(output.lockingScript), + output.fixedAmount === absent ? null : canonicalDecimal(output.fixedAmount) + ]), + chargedOwners, + paymentOutputIndexes, + canonicalDecimal(fee), + sequenceStart, + validAfter, + deadline, + nLockTime +] +``` + +`JSON.stringify` means UTF-8 JSON with no insignificant whitespace. Array order +is preserved; JSON integer members use ordinary base-10 notation; strings use +standard JSON escaping. `canonicalDecimal` is unsigned base 10 with no leading +zeroes except `"0"`. Opaque owners, network, and derivation strings are not +case-folded or Unicode-normalized. Source BEEF is represented by its resolved +subject txid, selected output index, value, and locking script, so two valid +BEEF encodings of the same source do not change the authorization. Each cap key +directly signs `authorizationDigest` as authorization evidence. + +The three signed Unix-second fields have distinct meanings: + +```text +validAfter < nLockTime < deadline +validAfter <= now < deadline // verify, create, and settle +now >= nLockTime // additional non-final settlement condition +``` + +`nLockTime` MUST be a time-based uint32 lock time (at least 500,000,000) and is +written directly to every transaction. A cooperative close makes every input +final, so it bypasses only `nLockTime`; it does not bypass the protocol window. +`deadline` is a participant acceptance cutoff, not a script expiry. Previously +signed P2PKH transaction bytes do not become cryptographically invalid at the +deadline. Local wall-clock checks also do not predict node or miner admission; +wallet or node acceptance remains authoritative. + +### 4.1 Authorization digest vector + +The following pretty-printed tuple is illustrative. The digest uses the compact +UTF-8 bytes produced by `JSON.stringify` as specified above, not the whitespace +shown here: + +```json +[ + 1, + "bsv:testnet", + "BSV", + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "AQIDBAUGBwg=", + "MTcwMDAwMDAwMDAwMA==", + [ + [ + "payer", + "cap", + "ddd60c9ce45e2f236aa3d0dca3805e04d662632f212da07b66e9c2d6938b504f", + 0, + "1001", + "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac", + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ], + [ + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "control", + "4db9cd33e35292077b0498e4d9517e255d57cd61687f2393afae96f5edbea18d", + 0, + "2", + "76a91406afd46bcdfd22ef94ac122aa11f241244a37ecc88ac", + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + ] + ], + [ + ["payer", "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac", "1"], + ["payer", "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac", null], + [ + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "76a91406afd46bcdfd22ef94ac122aa11f241244a37ecc88ac", + null + ] + ], + ["payer"], + [2], + "1", + 1, + 1700000000, + 1700000300, + 1700000120 +] +``` + +the `authorizationId` is: + +```text +7a9096b069b220dfc3158c41b816f453e98d53fbe7fdaa61c6137f23e5371a1a +``` + +## 5. Transaction signatures + +### 5.1 Reusable cap signature + +Every cap input at index `i` uses: + +```text +input[i].nSequence = 0xffffffff +output[i] = fixed same-owner floor +sighash = SINGLE | FORKID (0x43) +``` + +`ANYONECANPAY` MUST NOT be set. Fork-id `hashPrevouts` therefore binds every +ordered cap and control prevout. `SIGHASH_SINGLE` binds the cap input's own final +sequence and same-index floor output while leaving other input sequences and +other output values available for later transactions. + +The cap signature is reused unchanged in every signed transaction, including a +cooperative close. + +### 5.2 Control signatures and `nSequence` + +Every control input signs each complete transaction with +`ALL | FORKID` (`0x41`) without `ANYONECANPAY`. All control inputs use one common +`nSequence` and sign again whenever that sequence or any output changes. + +The first non-final transaction uses signed `sequenceStart`. Each locally +accepted next stream transaction uses the preceding retained transaction's +control sequence plus one. Amount MUST NOT decrease. A participant verifies +this progression against the previous fully signed transaction it retained; +nodes do not perform this protocol comparison. + +For cooperative close, only control inputs change to `0xffffffff` and re-sign. +All cap inputs already use `0xffffffff`, so all inputs are then final and +`nLockTime` is inoperative. + +## 6. Default two-party allocation + +Let the payer cap input contain `floor + maxAmount`. Let recipient control +inputs contain total value `R`, and let the fixed fee be `fee`, with `R > fee`. +For actual amount `A`: + +```text +payer floor output = floor +payer refund output = maxAmount - A +recipient output = R - fee + A +``` + +This needs no matching recipient liquidity. The recipient supplies only a small +input used to control the complete transaction and to prevent its output value +from being redirected. `R > fee` also leaves a positive recipient output when +`A = 0`. + +Example: + +```text +payer cap input = 1100, floor = 100, maxAmount = 1000 +control input R = 51, fee = 50, actual A = 700 + +outputs: payer floor 100, payer refund 300, recipient 701 +payer delta = 1100 - (100 + 300) = 700 +recipient delta = 51 - 701 = -650 +sum deltas = 50 = fee +``` + +## 7. Verify and settle + +1. The client obtains the recipient's small control input and immutable timing + terms through an application-supplied transport. +2. The client creates the cap input and returns an authorization payload without + a transaction version. +3. The facilitator verifies the authorization against the maximum before the + resource handler runs. Every source MUST carry complete BEEF ancestry, and a + required verifier MUST validate the full graph and its PoW/SPV anchors. +4. For negotiated-once or each cumulative stream update, the recipient signs one + complete transaction. Each party may retain and validate the exact BEEF. +5. At settlement, the resource server's existing `enrichSettlementPayload` hook + attaches the locally selected signed transaction, and settlement requirements + carry the actual amount. +6. The facilitator re-verifies the authorization and every transaction input, + independently checks the recipient receipt, recomputes the amount, and + validates the selected transaction's complete BEEF. +7. Before wallet settlement, the facilitator atomically claims + `(authorizationId, txid)` in shared durable storage, then internalizes all + declared recipient payment outputs. + +A non-final transaction is eligible for settlement only at or after its future +`nLockTime`. A cooperative-close transaction bypasses that delay but remains +subject to `validAfter <= now < deadline`. The stock facilitator chooses: + +```text +validAfter = now +nLockTime = validAfter + nonFinalDelaySeconds +deadline = validAfter + maxTimeoutSeconds +``` + +where `0 < nonFinalDelaySeconds < maxTimeoutSeconds`. One authorization is +intended for at most one successful settlement, including a zero-amount +settlement. + +Because BEEF validation and the settlement-store call are asynchronous, the +facilitator rechecks the signed window after each and immediately before wallet +settlement. A claim that completes outside the window remains guarded but MUST +NOT be sent to the wallet. + +Complete BEEF proves that referenced transaction dependencies are present; the +required verifier additionally validates scripts and PoW/SPV anchors. Neither +check proves that an outpoint is currently unspent, and neither implies reliable +data retention. Ordinary wallet and node submission still determine acceptance +or double-spend observation. + +The transaction-selection callback and control-input exchange are deliberately +transport-neutral. HTTP streaming alone is one-way after response headers; a +full stream implementation therefore uses an application endpoint, callback, +or duplex transport to exchange newly signed transactions. + +Shared outpoints prevent multiple conflicting transactions from all becoming the +chain result, but do not prevent multiple x402 callers from observing successful +wallet settlement. The facilitator MUST therefore use an atomic settlement +store keyed by `authorizationId`, shared by replicas and durable across restarts. +The claim records the selected `txid`, an opaque per-acquisition token unique +across replicas and restarts, and a Unix-millisecond deletion bound covering the +signed deadline. Only that token can release the claim, and only when the wallet +definitively returns `accepted: false` without evidence of newly internalized +satoshis. Success, wallet replay, malformed wallet results, and indeterminate +outcomes retain it. Failure to claim or release fails settlement closed. + +This settlement claim does not guarantee that an application handler runs +exactly once before settlement. Applications with non-idempotent delivery MUST +separately reserve `authorizationId` in shared durable storage after verify and +before the business side effect. Both records are application/facilitator state, +not node state or new BSV protocol objects. + +## 8. Node observation and conflicts + +Each signed version is an ordinary BSV transaction. Because versions share +inputs, any different transaction spending one of those outpoints is a double +spend from the observing party's transaction perspective. + +Node submission uses the same observation as `exact`: + +```text +accepted | double-spend | invalid | unknown +``` + +A concrete endpoint may additionally return competing txids, node evidence, or +an observation time. `nSequence` can influence that endpoint's local admission +or conflict policy when a party submits a later transaction. The observation is +always attached to that submitted transaction; this profile adds no node state +or node query. + +`double-spend` proves a transaction conflict. It does not by itself prove which +party acted dishonestly: an older signed transaction may already have +propagated, nodes may have different caches, or the parties may independently +have selected different signed results. + +## 9. Final and evidence + +For each party, Final means only that the party has retained and accepted one +fully signed transaction as its result. Final is not a wire object or shared +state, and one party does not need a centralized acknowledgement to recognize +its own result. If both parties independently select the same transaction they +naturally agree; otherwise both signed transactions remain evidence of +disagreement. Later PoW/SPV inclusion evidence may support a chain-consensus +result, but it does not create an evidence-retention service. + +Signatures authenticate the retained bytes and PoW/SPV can prove inclusion +relative to a header-chain view. Neither signatures, blockchain inclusion, nor +SPV imply permanent or reliable data retention. No wallet, facilitator, node, +relay, database, or blockchain is appointed as an evidence archive. + +## 10. Required checks + +Implementations MUST reject: + +- an authorization ID or authorization signature that does not match its terms; +- a changed/reordered prevout, changed cap floor, non-final cap sequence, or + `ANYONECANPAY` cap signature; +- a transaction whose control inputs do not share one valid sequence or whose + signatures do not validate with `0x41`; +- a source or terminal BEEF with missing ancestry, or one rejected by the + configured complete-graph and PoW/SPV verifier; +- inputs minus outputs differing from the signed fee; +- a charged-owner output not locked to one of that owner's cap keys; +- a charged-owner delta below zero or above its cap; +- a recipient receipt below the charged-owner amount, irrespective of owner + labels; +- timing that does not satisfy `validAfter < nLockTime < deadline`, creation or + settlement outside the protocol window, or a non-final settlement before + `nLockTime`; +- a settlement amount differing from the recomputed transaction amount; +- a decreasing or non-consecutive locally compared stream transaction; and +- a duplicate transaction, or a second different transaction, after settlement + of the same authorization; and +- settlement when the facilitator cannot atomically claim the authorization. + +Required positive cases include zero actual amount, cooperative close, cap +signature reuse across increasing control sequences, multiple inputs/outputs in +the authorization verifier, complete BEEF and Atomic BEEF sources, non-final +settlement between `nLockTime` and `deadline`, historical signature verification +after `deadline`, cross-replica authorization consumption, and exact-path +regression tests. + +## 11. References + +- [BSV `exact` scheme](../exact/scheme_exact_bsv.md) +- [Network-agnostic `upto` scheme](./scheme_upto.md) +- [BRC-29](https://bsv.brc.dev/payments/0029) +- [BRC-42](https://bsv.brc.dev/key-derivation/0042) +- [BRC-62 BEEF](https://bsv.brc.dev/transactions/0062) +- [BRC-95 Atomic BEEF](https://bsv.brc.dev/transactions/0095) +- [BRC-100](https://bsv.brc.dev/wallet/0100) diff --git a/typescript/.changeset/add-bsv-upto-scheme.md b/typescript/.changeset/add-bsv-upto-scheme.md new file mode 100644 index 0000000000..351b1b0360 --- /dev/null +++ b/typescript/.changeset/add-bsv-upto-scheme.md @@ -0,0 +1,5 @@ +--- +'@x402/bsv': minor +--- + +Extend the BSV mechanism with the `upto` scheme. Clients create a reusable maximum-payment authorization, recipients determine the actual satoshi amount in a fully signed transaction, and one-shot or cumulative streaming workflows share the existing BRC-29, BEEF, and recipient-wallet settlement path. Add role-specific `@x402/bsv/upto/client`, `@x402/bsv/upto/server`, and `@x402/bsv/upto/facilitator` entrypoints. diff --git a/typescript/packages/mechanisms/bsv/README.md b/typescript/packages/mechanisms/bsv/README.md index c6f045533a..1e105bd753 100644 --- a/typescript/packages/mechanisms/bsv/README.md +++ b/typescript/packages/mechanisms/bsv/README.md @@ -1,6 +1,6 @@ # @x402/bsv -BSV (Bitcoin SV) blockchain implementation of the x402 payment protocol using the **Exact** payment scheme with **BRC-29 / BRC-121 native satoshi payments**. +BSV (Bitcoin SV) blockchain implementation of the x402 payment protocol using the **Exact** and **Upto** payment schemes with **BRC-29 / BRC-121 native satoshi payments**. ## Installation @@ -10,7 +10,14 @@ npm install @x402/bsv ## Overview -This package provides three components for handling x402 payments on BSV: +This package provides client, server, and facilitator components for handling x402 payments on BSV. `exact` fixes the payment amount when the client creates the transaction. `upto` extends that same path with a reusable maximum-payment signature and defers the actual amount until a fully signed transaction is selected. + +| Scheme | Amount semantics | Client entrypoint | Server entrypoint | Facilitator entrypoint | +| --- | --- | --- | --- | --- | +| `exact` | One transaction for the required amount | `@x402/bsv/exact/client` | `@x402/bsv/exact/server` | `@x402/bsv/exact/facilitator` | +| `upto` | Maximum first, actual amount determined later | `@x402/bsv/upto/client` | `@x402/bsv/upto/server` | `@x402/bsv/upto/facilitator` | + +For `exact`, the three roles behave as follows: - **Client** — Derives a per-payment key from the recipient's identity key (BRC-42/BRC-29) and asks the client's BRC-100 wallet to create a fully-signed, fully-funded transaction paying it (BEEF format, SPV ancestry included) - **Facilitator** — Wraps the _recipient's_ BRC-100 wallet: verifies payload structure, freshness, and exact amount, then settles by internalizing the payment output into the wallet (`internalizeAction`), which SPV-validates, takes custody, and rejects replays @@ -27,6 +34,73 @@ Unlike account-based chains, a BSV payment output is locked to a key that by def 5. Settlement internalizes the output into the recipient wallet; replays are rejected via a facilitator txid dedup cache plus the wallet's merge signal (`isMerge` without newly internalized satoshis) 6. The wallet handles network propagation (e.g. via ARC) +## Upto Authorization and Settlement + +`upto` preserves the existing BRC-29 identities, derivation metadata, BEEF transport, BSV asset rules, and recipient-wallet settlement path. It adds only the authorization needed to choose the actual amount later: + +1. The server's `PaymentRequirements.amount` is the maximum authorized amount. +2. The recipient facilitator supplies a small control input. The payer signs its cap input with `SIGHASH_SINGLE | SIGHASH_FORKID`, binding its maximum debit and same-index floor output without selecting the actual amount. +3. The recipient signs a complete transaction version after the actual amount is known. The payer's charged amount is recomputed from its net input/output difference. The wire authorization and verifier permit multiple inputs and outputs; the default adapters construct the ordinary one-payer/one-recipient layout. +4. An application may stop after the first agreed transaction or retain successively higher cumulative versions while streaming. This is application behavior, not a signed `mode` or a separate stream settlement object. + +The application supplies the transport for `UptoBsvControlProvider`, retains the signed authorization and transactions it needs, and configures the server's `getTransactionVersion` callback to select one at settlement. A transaction version carries only its `authorizationId` and exact BEEF bytes; amount, txid, `nSequence`, and cooperative close are derived during verification. Nodes only receive ordinary BSV transactions; conflicting versions are ordinary double spends, not an x402-specific node state machine. + +The signed `validAfter`, `nLockTime`, and `deadline` fields are deliberately separate. Non-final versions settle only after `nLockTime`; every protocol operation remains inside `[validAfter, deadline)`. A cooperative close finalizes the control inputs and bypasses only `nLockTime`, never the deadline. The deadline is participant policy, not a script expiry. + +```typescript +import { UptoBsvScheme as UptoBsvClientScheme } from "@x402/bsv/upto/client"; +import { UptoBsvScheme as UptoBsvServerScheme } from "@x402/bsv/upto/server"; +import { + UptoBsvScheme as UptoBsvFacilitatorScheme, + type UptoBsvSettlementStore, +} from "@x402/bsv/upto/facilitator"; +import type { UptoBsvPayload, UptoBsvTransactionVersion } from "@x402/bsv"; +``` + +The role-level flow is deliberately small: + +```typescript +const facilitatorScheme = await UptoBsvFacilitatorScheme.create({ + wallet: recipientWallet, + feeSatoshis: 1, + nonFinalDelaySeconds: 120, + // Required: validate the complete BEEF graph, scripts, and PoW/SPV anchors. + verifyBeef: verifyCompleteBeefAgainstHeaderChain, + // Required: an atomic shared store keyed by authorizationId. Its claims + // must survive facilitator restarts until their Unix-ms deleteAfterMs time. + settlementStore: sharedSettlementStore satisfies UptoBsvSettlementStore, +}); + +// In production this method is normally exposed through an authenticated, +// rate-limited application endpoint. +const clientScheme = new UptoBsvClientScheme(payerWallet, { + controlProvider: facilitatorScheme, +}); +const created = await clientScheme.createPaymentPayload(2, maximumRequirements); +const payload = created.payload as UptoBsvPayload; +const actualAmount = "250"; +let previousVersion: UptoBsvTransactionVersion | undefined; + +await facilitatorScheme.verify( + { x402Version: 2, accepted: maximumRequirements, payload }, + maximumRequirements, +); +const selected = await facilitatorScheme.createTransactionVersion(payload, maximumRequirements, { + amount: actualAmount, + previous: previousVersion, +}); + +// The payer independently verifies the signed bytes before retaining them. +clientScheme.verifyTransactionVersion(payload, selected, previousVersion); +previousVersion = selected; + +const serverScheme = new UptoBsvServerScheme({ + getTransactionVersion: () => selected, +}); +// Register serverScheme normally. At settlement, x402 Core attaches `selected` +// and the settlement override carries `actualAmount` in requirements.amount. +``` + ## Supported Assets | Type | Symbol | Description | Decimals | @@ -127,6 +201,11 @@ facilitator.register("bsv:mainnet", scheme); - Freshness window: the timestamp encoded in `derivationSuffix` must be within ±30 s at verify time (configurable via `paymentWindowMs`); at settle time the window extends by `maxTimeoutSeconds`, the advertised settlement budget - Replay protection: a facilitator-side txid dedup cache plus the wallet's merge signal (`isMerge` without newly internalized satoshis — both wallet-toolbox extensions to BRC-100); verification re-runs at settlement - The subject transaction is resolved via the Atomic BEEF `atomicTxid`, matching what the wallet internalizes; SPV validity is enforced by the wallet during `internalizeAction` — settlement is the authoritative acceptance step +- An `upto` cap signature is a maximum-payment authorization, not a cryptographic expiry. `nLockTime` only constrains when a transaction becomes eligible for mining. Each party independently recognizes one fully signed version as its terminal transaction; there is no central `Final` state. +- `upto` fails closed without an application-supplied complete-BEEF and PoW/SPV verifier. This validates ancestry and anchors before resource work and again for the selected transaction; it does not prove an outpoint is unspent and does not imply data retention. +- Output `owner` values are signed accounting labels, not ownership proofs. The recipient independently derives its BRC-29 payment scripts and requires its real payment-output increase, adjusted for its control inputs and the fee, to cover the charged amount. +- `upto` requires an atomic `UptoBsvSettlementStore` shared by facilitator replicas and durable through each claim's Unix-ms `deleteAfterMs`; this consumes an `authorizationId` before wallet settlement. The store returns a per-acquisition token that must remain unique across replicas and restarts. A definitively rejected wallet attempt releases only that matching token, while success, replay, and indeterminate outcomes retain it. This settlement guard is separate from application delivery: non-idempotent handlers must also reserve `authorizationId` in shared durable storage before executing the business side effect. +- Cap and control sources are wallet `noSend` actions. Applications must release abandoned actions through their wallet's normal lifecycle. A remotely exposed control-proposal endpoint must authenticate, rate-limit, and deduplicate requests so callers cannot reserve recipient wallet funds without bound. ## References @@ -137,3 +216,4 @@ facilitator.register("bsv:mainnet", scheme); - [BRC-69: Revealing Key Linkages](https://bsv.brc.dev/key-derivation/0069) / [BRC-94: Verifiable Shared-Secret Revelation (Schnorr)](https://bsv.brc.dev/key-derivation/0094) - [BRC-100: Wallet-to-Application Interface](https://bsv.brc.dev/wallet/0100) - Spec: `specs/schemes/exact/scheme_exact_bsv.md` +- Upto spec: `specs/schemes/upto/scheme_upto_bsv.md` diff --git a/typescript/packages/mechanisms/bsv/package.json b/typescript/packages/mechanisms/bsv/package.json index 5ef23921bb..6286433c2e 100644 --- a/typescript/packages/mechanisms/bsv/package.json +++ b/typescript/packages/mechanisms/bsv/package.json @@ -89,6 +89,36 @@ "types": "./dist/cjs/exact/facilitator/index.d.ts", "default": "./dist/cjs/exact/facilitator/index.js" } + }, + "./upto/client": { + "import": { + "types": "./dist/esm/upto/client/index.d.mts", + "default": "./dist/esm/upto/client/index.mjs" + }, + "require": { + "types": "./dist/cjs/upto/client/index.d.ts", + "default": "./dist/cjs/upto/client/index.js" + } + }, + "./upto/server": { + "import": { + "types": "./dist/esm/upto/server/index.d.mts", + "default": "./dist/esm/upto/server/index.mjs" + }, + "require": { + "types": "./dist/cjs/upto/server/index.d.ts", + "default": "./dist/cjs/upto/server/index.js" + } + }, + "./upto/facilitator": { + "import": { + "types": "./dist/esm/upto/facilitator/index.d.mts", + "default": "./dist/esm/upto/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/upto/facilitator/index.d.ts", + "default": "./dist/cjs/upto/facilitator/index.js" + } } }, "files": [ diff --git a/typescript/packages/mechanisms/bsv/src/index.ts b/typescript/packages/mechanisms/bsv/src/index.ts index c5c52b4c69..b6b14566a5 100644 --- a/typescript/packages/mechanisms/bsv/src/index.ts +++ b/typescript/packages/mechanisms/bsv/src/index.ts @@ -2,13 +2,16 @@ * x402/bsv * * BSV (Bitcoin SV) blockchain implementation of the x402 payment protocol - * using the `exact` payment scheme with BRC-29 / BRC-121 native satoshi - * payments internalized by the recipient's BRC-100 wallet. + * using the `exact` and `upto` payment schemes with BRC-29 / BRC-121 native + * satoshi payments internalized by the recipient's BRC-100 wallet. */ // Exact scheme exports export { ExactBsvScheme } from "./exact"; +// Upto client scheme export; server and facilitator use role-specific entrypoints +export { UptoBsvScheme } from "./upto/client/scheme"; + // Types export * from "./types"; diff --git a/typescript/packages/mechanisms/bsv/src/types.ts b/typescript/packages/mechanisms/bsv/src/types.ts index 7d11c84f66..67a18e5e6c 100644 --- a/typescript/packages/mechanisms/bsv/src/types.ts +++ b/typescript/packages/mechanisms/bsv/src/types.ts @@ -1,5 +1,5 @@ /** - * BSV x402 V2 types. + * BSV x402 payload types. * * The payload mirrors the BRC-29 payment message (as used by BRC-121 * "Simple 402 Payments"): a fully-signed, fully-funded BSV transaction in @@ -47,3 +47,117 @@ export interface ExactBsvPayloadV2 { /** Zero-based index of the payment output in the transaction */ outputIndex: number; } + +/** An immutable input included in a BSV `upto` authorization. */ +export interface UptoBsvInput { + /** Identity that owns the input for net-delta accounting. */ + owner: string; + /** Cap signatures are reusable; control inputs sign every transaction version. */ + kind: "cap" | "control"; + /** Base64 BEEF or Atomic BEEF containing the source transaction. */ + sourceTransaction: string; + sourceOutputIndex: number; + /** Compressed public key matching the source P2PKH output. */ + publicKey: string; +} + +/** An immutable output slot included in a BSV `upto` authorization. */ +export interface UptoBsvOutput { + /** Identity that owns the output for net-delta accounting. */ + owner: string; + /** Hex-encoded locking script; its value is supplied by each signed version. */ + lockingScript: string; + /** Fixed value for a cap input's same-index floor output. */ + fixedAmount?: string; +} + +/** Canonical fields approved before the actual amount is known. */ +export interface UptoBsvAuthorizationTerms { + version: 1; + network: string; + asset: "BSV"; + payTo: string; + senderIdentityKey: string; + derivationPrefix: string; + derivationSuffix: string; + inputs: UptoBsvInput[]; + outputs: UptoBsvOutput[]; + /** Owners whose positive net deltas form the x402 amount. */ + chargedOwners: string[]; + /** Outputs internalized by the recipient wallet under the BRC-29 remittance. */ + paymentOutputIndexes: number[]; + fee: string; + sequenceStart: number; + /** Unix seconds before which the authorization is not accepted. */ + validAfter: number; + /** Unix seconds at or after which the authorization is no longer accepted. */ + deadline: number; + /** Absolute transaction lock time used by non-final control inputs. */ + nLockTime: number; +} + +/** Reusable signatures made by one cap input owner. */ +export interface UptoBsvCapSignature { + inputIndex: number; + /** Base64 DER signature for `SIGHASH_SINGLE | SIGHASH_FORKID`. */ + transactionSignature: string; + /** Base64 DER signature over the canonical authorization digest. */ + authorizationSignature: string; +} + +/** A single-use maximum-payment authorization. */ +export interface UptoBsvAuthorization { + /** Lowercase SHA-256 digest of the canonical terms. */ + authorizationId: string; + terms: UptoBsvAuthorizationTerms; + capSignatures: UptoBsvCapSignature[]; +} + +/** One ordinary, fully signed transaction produced under an authorization. */ +export interface UptoBsvTransactionVersion { + authorizationId: string; + /** Base64 BEEF or Atomic BEEF for the fully signed transaction. */ + transaction: string; +} + +/** Pure-data result of verifying one signed transaction version locally. */ +export interface UptoBsvTransactionVerification { + txid: string; + amount: string; + nSequence: number; + cooperativeClose: boolean; + ownerDeltas: Readonly>; +} + +/** x402 payload for the BSV `upto` scheme. */ +export interface UptoBsvPayload { + /** Inherited BRC-29 fields, kept top-level for exact-path compatibility. */ + derivationPrefix: string; + derivationSuffix: string; + senderIdentityKey: string; + outputIndex: number; + authorization: UptoBsvAuthorization; + /** Added for settlement when one fully signed transaction is selected. */ + transactionVersion?: UptoBsvTransactionVersion; +} + +/** Request sent to the recipient when obtaining its small control input. */ +export interface UptoBsvControlRequest { + network: string; + payTo: string; + senderIdentityKey: string; + derivationPrefix: string; + derivationSuffix: string; + maxAmount: string; + maxTimeoutSeconds: number; +} + +/** Recipient-provided control input and transaction timing terms. */ +export interface UptoBsvControlProposal { + inputs: UptoBsvInput[]; + fee: string; + sequenceStart: number; + validAfter: number; + deadline: number; + nLockTime: number; +} diff --git a/typescript/packages/mechanisms/bsv/src/upto/client/index.ts b/typescript/packages/mechanisms/bsv/src/upto/client/index.ts new file mode 100644 index 0000000000..c1ad85367e --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/client/index.ts @@ -0,0 +1,2 @@ +export { UptoBsvScheme, type UptoBsvClientConfig, type UptoBsvControlProvider } from "./scheme"; +export type { UptoBsvTransactionVerification } from "../../types"; diff --git a/typescript/packages/mechanisms/bsv/src/upto/client/scheme.ts b/typescript/packages/mechanisms/bsv/src/upto/client/scheme.ts new file mode 100644 index 0000000000..0e1498a13c --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/client/scheme.ts @@ -0,0 +1,312 @@ +import { Utils } from "@bsv/sdk"; +import type { WalletInterface } from "@bsv/sdk"; +import type { PaymentPayload, PaymentRequirements, SchemeNetworkClient } from "@x402/core/types"; +import type { + UptoBsvControlProposal, + UptoBsvControlRequest, + UptoBsvPayload, + UptoBsvTransactionVerification, + UptoBsvTransactionVersion, +} from "../../types"; +import { BRC29_PROTOCOL_ID, DEFAULT_PAYMENT_WINDOW_MS, MAX_SATOSHIS } from "../../constants"; +import { + BSV_UPTO_PROTOCOL_ID, + createBrc29PaymentContext, + p2pkhLockingScript, + uptoControlKeyId, + validateUptoBsvPaymentRequirements, +} from "../shared"; +import { + assertUptoVersionProgression, + findBeefOutput, + inspectUptoInput, + signUptoAuthorization, + uptoMaximumAmount, + verifyUptoTransactionVersion, +} from "../transaction"; + +export interface UptoBsvControlProvider { + /** + * Obtains the recipient's small control input after both BRC-42 identities + * and the per-payment derivation are known. + * + * @param request - Exact-compatible payment and timing context + * @returns Recipient-owned control inputs and immutable timing terms + */ + createControlProposal(request: UptoBsvControlRequest): Promise; +} + +export interface UptoBsvClientConfig { + controlProvider: UptoBsvControlProvider; + /** Same-index amount the reusable cap signature returns to the payer. */ + floorSatoshis?: number; + /** Optional BRC-100 originator passed to wallet calls. */ + originator?: string; +} + +/** + * BSV client implementation for `upto`. + * + * It reuses exact's BRC-29 identity/payment derivation, then signs one maximum + * debit input with `SINGLE|FORKID`. The actual amount is absent from the client + * payload and is supplied later by a fully signed transaction version. + */ +export class UptoBsvScheme implements SchemeNetworkClient { + readonly scheme = "upto"; + + /** + * Creates a BSV upto client. + * + * @param wallet - Payer BRC-100 wallet + * @param config - Control-input transport and local authorization options + */ + constructor( + private readonly wallet: WalletInterface, + private readonly config: UptoBsvClientConfig, + ) {} + + /** + * Creates a maximum-payment authorization without choosing the actual amount. + * + * @param x402Version - x402 protocol version + * @param requirements - Requirements whose amount is the maximum satoshi debit + * @returns Exact-compatible BRC-29 context plus the upto authorization + */ + async createPaymentPayload( + x402Version: number, + requirements: PaymentRequirements, + ): Promise> { + validateUptoBsvPaymentRequirements(requirements); + const originator = this.config.originator; + const floorSatoshis = this.config.floorSatoshis ?? 1; + if (!Number.isSafeInteger(floorSatoshis) || floorSatoshis < 0) { + throw new Error("floorSatoshis must be a non-negative safe integer"); + } + const maximum = BigInt(requirements.amount); + if (maximum + BigInt(floorSatoshis) > BigInt(MAX_SATOSHIS)) { + throw new Error("amount plus floorSatoshis exceeds the BSV satoshi range"); + } + + const payment = await createBrc29PaymentContext(this.wallet, requirements.payTo, originator); + const controlRequest: UptoBsvControlRequest = { + network: requirements.network, + payTo: requirements.payTo, + senderIdentityKey: payment.senderIdentityKey, + derivationPrefix: payment.derivationPrefix, + derivationSuffix: payment.derivationSuffix, + maxAmount: requirements.amount, + maxTimeoutSeconds: requirements.maxTimeoutSeconds, + }; + const proposal = await this.config.controlProvider.createControlProposal(controlRequest); + await this.validateControlProposal(proposal, controlRequest, originator); + + const capKeyId = `${payment.derivationPrefix} ${payment.derivationSuffix} cap-0`; + const { publicKey: capPublicKey } = await this.wallet.getPublicKey( + { + protocolID: BSV_UPTO_PROTOCOL_ID, + keyID: capKeyId, + counterparty: "self", + }, + originator, + ); + const capScript = p2pkhLockingScript(capPublicKey); + const capSatoshis = Number(maximum + BigInt(floorSatoshis)); + const action = await this.wallet.createAction( + { + description: "x402 upto authorization", + outputs: [ + { + satoshis: capSatoshis, + lockingScript: capScript, + outputDescription: "x402 upto cap", + customInstructions: JSON.stringify({ + derivationPrefix: payment.derivationPrefix, + derivationSuffix: payment.derivationSuffix, + payee: requirements.payTo, + keyId: capKeyId, + }), + tags: ["x402", "upto"], + }, + ], + labels: ["x402", "upto"], + options: { noSend: true, randomizeOutputs: false }, + }, + originator, + ); + if (!action.tx) throw new Error("Wallet createAction did not return a cap transaction"); + const capSource = Utils.toBase64(Array.from(action.tx)); + const { outputIndex: capOutputIndex } = findBeefOutput(capSource, capScript, capSatoshis); + + const terms = { + version: 1 as const, + network: requirements.network, + asset: "BSV" as const, + payTo: requirements.payTo, + senderIdentityKey: payment.senderIdentityKey, + derivationPrefix: payment.derivationPrefix, + derivationSuffix: payment.derivationSuffix, + inputs: [ + { + owner: payment.senderIdentityKey, + kind: "cap" as const, + sourceTransaction: capSource, + sourceOutputIndex: capOutputIndex, + publicKey: capPublicKey, + }, + ...proposal.inputs, + ], + outputs: [ + { + owner: payment.senderIdentityKey, + lockingScript: capScript, + fixedAmount: String(floorSatoshis), + }, + { owner: payment.senderIdentityKey, lockingScript: capScript }, + { owner: requirements.payTo, lockingScript: payment.lockingScript }, + ], + chargedOwners: [payment.senderIdentityKey], + paymentOutputIndexes: [2], + fee: proposal.fee, + sequenceStart: proposal.sequenceStart, + validAfter: proposal.validAfter, + deadline: proposal.deadline, + nLockTime: proposal.nLockTime, + }; + if (uptoMaximumAmount(terms) !== requirements.amount) { + throw new Error("constructed authorization does not equal requirements.amount"); + } + const authorization = await signUptoAuthorization(terms, { + 0: async digest => { + const { signature } = await this.wallet.createSignature( + { + protocolID: BSV_UPTO_PROTOCOL_ID, + keyID: capKeyId, + counterparty: "self", + hashToDirectlySign: digest, + }, + originator, + ); + return Array.from(signature); + }, + }); + const payload: UptoBsvPayload = { + derivationPrefix: payment.derivationPrefix, + derivationSuffix: payment.derivationSuffix, + senderIdentityKey: payment.senderIdentityKey, + outputIndex: 2, + authorization, + }; + return { x402Version, payload: payload as unknown as PaymentPayload["payload"] }; + } + + /** + * Verifies a recipient-signed transaction before the payer accepts or retains it. + * + * When `previous` is supplied, the method also requires the next control + * sequence and a nondecreasing cumulative amount. The return value contains + * only facts derived from the signed transaction. + * + * @param payload - Payer's maximum authorization payload + * @param version - Fully signed transaction to verify + * @param previous - Optional previously accepted signed transaction + * @returns Derived transaction facts with no SDK transaction object + */ + verifyTransactionVersion( + payload: UptoBsvPayload, + version: UptoBsvTransactionVersion, + previous?: UptoBsvTransactionVersion, + ): UptoBsvTransactionVerification { + const verified = verifyUptoTransactionVersion(payload.authorization, version); + if (previous) { + assertUptoVersionProgression(payload.authorization, previous, version); + } else if ( + !verified.cooperativeClose && + verified.nSequence !== payload.authorization.terms.sequenceStart + ) { + throw new Error("first non-final transaction must use sequenceStart"); + } + return { + txid: verified.txid, + amount: verified.amount, + nSequence: verified.nSequence, + cooperativeClose: verified.cooperativeClose, + ownerDeltas: verified.ownerDeltas, + }; + } + + /** + * Checks that the recipient proposal is small, identity-bound, and time-bounded. + * + * @param proposal - Recipient-provided control inputs + * @param request - Context sent to the recipient + * @param originator - Optional BRC-100 originator + */ + private async validateControlProposal( + proposal: UptoBsvControlProposal, + request: UptoBsvControlRequest, + originator?: string, + ): Promise { + if (!proposal || !Array.isArray(proposal.inputs) || proposal.inputs.length < 1) { + throw new Error("control proposal must contain at least one input"); + } + if (!/^\d+$/.test(proposal.fee)) { + throw new Error("control proposal fee must be non-negative decimal satoshis"); + } + const seenOutpoints = new Set(); + let total = 0n; + const sources = proposal.inputs.map(input => { + const source = inspectUptoInput(input); + const outpoint = `${source.txid}:${input.sourceOutputIndex}`; + if (seenOutpoints.has(outpoint)) throw new Error(`duplicate control input ${outpoint}`); + seenOutpoints.add(outpoint); + total += BigInt(source.satoshis); + return source; + }); + if (total <= BigInt(proposal.fee)) { + throw new Error("control input value must exceed the terminal transaction fee"); + } + for (const [index, input] of proposal.inputs.entries()) { + const expectedKeyId = uptoControlKeyId( + request.derivationPrefix, + request.derivationSuffix, + index, + ); + if (input.kind !== "control" || input.owner.toLowerCase() !== request.payTo.toLowerCase()) { + throw new Error(`control input ${index} is not bound to payTo`); + } + const { publicKey } = await this.wallet.getPublicKey( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: expectedKeyId, + counterparty: request.payTo, + }, + originator, + ); + if (publicKey.toLowerCase() !== input.publicKey.toLowerCase()) { + throw new Error(`control input ${index} BRC-42 key mismatch`); + } + if (sources[index].lockingScript !== p2pkhLockingScript(input.publicKey)) { + throw new Error(`control input ${index} source script does not match its public key`); + } + } + const now = Math.floor(Date.now() / 1000); + const skew = Math.ceil(DEFAULT_PAYMENT_WINDOW_MS / 1000); + if ( + proposal.sequenceStart !== 1 || + !Number.isSafeInteger(proposal.validAfter) || + !Number.isSafeInteger(proposal.deadline) || + !Number.isSafeInteger(proposal.nLockTime) || + proposal.validAfter < 0 || + proposal.nLockTime <= proposal.validAfter || + proposal.deadline <= proposal.nLockTime || + proposal.nLockTime < 500_000_000 || + proposal.deadline > 0xffffffff || + proposal.validAfter > now + skew || + proposal.deadline <= now - skew || + proposal.deadline > now + request.maxTimeoutSeconds + skew || + proposal.deadline - proposal.validAfter > request.maxTimeoutSeconds + ) { + throw new Error("control proposal has invalid authorization timing"); + } + } +} diff --git a/typescript/packages/mechanisms/bsv/src/upto/facilitator/index.ts b/typescript/packages/mechanisms/bsv/src/upto/facilitator/index.ts new file mode 100644 index 0000000000..d2389b7dc5 --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/facilitator/index.ts @@ -0,0 +1,6 @@ +export { + UptoBsvScheme, + type CreateUptoBsvVersionArgs, + type UptoBsvSchemeConfig, + type UptoBsvSettlementStore, +} from "./scheme"; diff --git a/typescript/packages/mechanisms/bsv/src/upto/facilitator/scheme.ts b/typescript/packages/mechanisms/bsv/src/upto/facilitator/scheme.ts new file mode 100644 index 0000000000..4c6d8a8695 --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/facilitator/scheme.ts @@ -0,0 +1,898 @@ +import { Utils } from "@bsv/sdk"; +import type { WalletInterface } from "@bsv/sdk"; +import type { + Network, + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import type { + UptoBsvControlProposal, + UptoBsvControlRequest, + UptoBsvPayload, + UptoBsvTransactionVersion, +} from "../../types"; +import { + BRC29_PROTOCOL_ID, + BSV_ASSET_IDENTIFIER, + BSV_WILDCARD_CAIP2, + COMPRESSED_PUBKEY_REGEX, + DEFAULT_PAYMENT_WINDOW_MS, + MAX_SATOSHIS, + MIN_DERIVATION_PREFIX_BYTES, + isBsvNetwork, + toBsvWalletNetwork, +} from "../../constants"; +import { brc29PaymentKeyId, p2pkhLockingScript, uptoControlKeyId } from "../shared"; +import { + assertUptoVersionProgression, + buildUptoTransactionVersion, + findBeefOutput, + uptoInputSatoshis, + verifyUptoAuthorization, + verifyUptoTransactionVersion, + type BuildUptoBsvTransactionVersionArgs, + type UptoBsvDigestSigner, + type VerifiedUptoBsvAuthorization, + type VerifiedUptoBsvTransactionVersion, +} from "../transaction"; + +const FINAL_SEQUENCE = 0xffffffff; +const SETTLEMENT_CACHE_TTL_FLOOR_MS = 600_000; +const SETTLEMENT_CACHE_TTL_MARGIN_MS = 60_000; + +export interface UptoBsvSchemeConfig { + /** Recipient BRC-100 wallet used for control signatures and settlement. */ + wallet: WalletInterface; + /** Recipient identity key; must equal PaymentRequirements.payTo. */ + identityKey: string; + /** Fixed miner fee reserved by every transaction version. */ + feeSatoshis: number; + /** Value of the recipient control input; defaults to fee + 1 satoshi. */ + controlSatoshis?: number; + /** Seconds before a non-final transaction becomes eligible; must be below maxTimeoutSeconds. */ + nonFinalDelaySeconds: number; + paymentWindowMs?: number; + originator?: string; + /** Required full BEEF graph and PoW/SPV-anchor verifier. */ + verifyBeef: (beefBytes: number[], subjectTxid: string) => Promise; + /** Shared durable guard that atomically consumes one authorization at settlement. */ + settlementStore: UptoBsvSettlementStore; +} + +/** + * Atomic storage boundary for single-use BSV `upto` authorizations. + * + * Implementations must share claims between facilitator replicas and retain + * them across restarts until at least `deleteAfterMs`. `tryClaim` atomically + * inserts `(authorizationId, txid)` only when no unexpired claim exists. + * `release` atomically removes only the acquisition identified by its opaque + * token, which must be unique across replicas and restarts so a delayed release + * cannot remove a later claim. + */ +export interface UptoBsvSettlementStore { + tryClaim( + authorizationId: string, + txid: string, + /** Earliest deletion time as Unix milliseconds. */ + deleteAfterMs: number, + ): Promise<{ claimed: true; token: string } | { claimed: false; txid: string }>; + release(authorizationId: string, token: string): Promise; +} + +export interface CreateUptoBsvVersionArgs { + /** Expected charged-owner net delta. */ + amount: string; + /** Last fully signed version accepted locally; required to advance a stream. */ + previous?: UptoBsvTransactionVersion; + /** Finalize every control input so nLockTime no longer delays settlement. */ + cooperativeClose?: boolean; + /** Advanced multi-input/output allocation; default two-party allocation when omitted. */ + outputAmounts?: readonly string[]; +} + +interface AuthorizationCheck { + parsed: UptoBsvPayload; + verified: VerifiedUptoBsvAuthorization; + payer: string; +} + +/** + * BSV facilitator for `upto` authorizations and their terminal transaction. + * + * The recipient wallet supplies only a small control input. It signs each + * ordinary transaction version with `ALL|FORKID`; the payer's reusable cap + * signature fixes the maximum debit. Settlement uses the same BRC-29 remittance, + * BEEF transport, wallet network, and `internalizeAction` path as BSV exact. + * The settlement store consumes an authorization across replicas and restarts. + * It does not make a pre-settlement application handler exactly-once; + * non-idempotent handlers need a separate application-level shared reservation. + */ +export class UptoBsvScheme implements SchemeNetworkFacilitator { + readonly scheme = "upto"; + readonly caipFamily = BSV_WILDCARD_CAIP2; + + private readonly wallet: WalletInterface; + private readonly identityKey: string; + private readonly feeSatoshis: number; + private readonly controlSatoshis: number; + private readonly nonFinalDelaySeconds: number; + private readonly paymentWindowMs: number; + private readonly originator?: string; + private readonly verifyBeef: (beefBytes: number[], subjectTxid: string) => Promise; + private readonly settlementStore: UptoBsvSettlementStore; + private walletNetworkPromise: Promise | undefined; + + /** + * Creates a recipient-wallet facilitator. + * + * @param config - Recipient wallet, fixed fee, and validation options + */ + constructor(config: UptoBsvSchemeConfig) { + if (!config.identityKey || !COMPRESSED_PUBKEY_REGEX.test(config.identityKey)) { + throw new Error("identityKey must be a compressed secp256k1 public key"); + } + if (!Number.isSafeInteger(config.feeSatoshis) || config.feeSatoshis < 0) { + throw new Error("feeSatoshis must be a non-negative safe integer"); + } + if (!Number.isSafeInteger(config.nonFinalDelaySeconds) || config.nonFinalDelaySeconds <= 0) { + throw new Error("nonFinalDelaySeconds must be a positive safe integer"); + } + if (typeof config.verifyBeef !== "function") { + throw new Error("verifyBeef is required for BSV upto authorization"); + } + if ( + !config.settlementStore || + typeof config.settlementStore.tryClaim !== "function" || + typeof config.settlementStore.release !== "function" + ) { + throw new Error("settlementStore is required for BSV upto settlement"); + } + const controlSatoshis = config.controlSatoshis ?? config.feeSatoshis + 1; + if ( + !Number.isSafeInteger(controlSatoshis) || + controlSatoshis <= config.feeSatoshis || + controlSatoshis > MAX_SATOSHIS + ) { + throw new Error("controlSatoshis must be a safe integer greater than feeSatoshis"); + } + this.wallet = config.wallet; + this.identityKey = config.identityKey.toLowerCase(); + this.feeSatoshis = config.feeSatoshis; + this.controlSatoshis = controlSatoshis; + this.nonFinalDelaySeconds = config.nonFinalDelaySeconds; + this.paymentWindowMs = config.paymentWindowMs ?? DEFAULT_PAYMENT_WINDOW_MS; + this.originator = config.originator; + this.verifyBeef = config.verifyBeef; + this.settlementStore = config.settlementStore; + } + + /** + * Fetches the recipient identity key from its BRC-100 wallet. + * + * @param config - Configuration without identityKey + * @returns Ready-to-register upto facilitator + */ + static async create(config: Omit): Promise { + const { publicKey } = await config.wallet.getPublicKey( + { identityKey: true }, + config.originator, + ); + return new UptoBsvScheme({ ...config, identityKey: publicKey }); + } + + /** + * Returns no static fee-payer metadata; the control proposal is per payment. + * + * @param _ - Network identifier + * @returns Undefined + */ + getExtra(_: Network): Record | undefined { + return undefined; + } + + /** + * Returns the recipient wallet identity key. + * + * @param _ - Network identifier + * @returns One recipient identity key + */ + getSigners(_: string): string[] { + return [this.identityKey]; + } + + /** + * Creates the recipient's small, no-send control input. + * + * This transport-neutral method is intended to be exposed by the recipient's + * own service and supplied to the client as its `controlProvider`. + * + * @param request - Payer identity, BRC-29 derivation, maximum, and timeout + * @returns Recipient-owned control input and immutable timing terms + */ + async createControlProposal(request: UptoBsvControlRequest): Promise { + await this.validateControlRequest(request); + const keyId = uptoControlKeyId(request.derivationPrefix, request.derivationSuffix, 0); + const { publicKey } = await this.wallet.getPublicKey( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: keyId, + counterparty: request.senderIdentityKey, + forSelf: true, + }, + this.originator, + ); + const lockingScript = p2pkhLockingScript(publicKey); + const action = await this.wallet.createAction( + { + description: "x402 upto control input", + outputs: [ + { + satoshis: this.controlSatoshis, + lockingScript, + outputDescription: "x402 upto control", + customInstructions: JSON.stringify({ + senderIdentityKey: request.senderIdentityKey, + derivationPrefix: request.derivationPrefix, + derivationSuffix: request.derivationSuffix, + keyId, + }), + tags: ["x402", "upto"], + }, + ], + labels: ["x402", "upto"], + options: { noSend: true, randomizeOutputs: false }, + }, + this.originator, + ); + if (!action.tx) throw new Error("Wallet createAction did not return a control transaction"); + const sourceTransaction = Utils.toBase64(Array.from(action.tx)); + const { outputIndex } = findBeefOutput(sourceTransaction, lockingScript, this.controlSatoshis); + const validAfter = Math.floor(Date.now() / 1000); + const nLockTime = validAfter + this.nonFinalDelaySeconds; + const deadline = validAfter + request.maxTimeoutSeconds; + if (deadline > 0xffffffff) throw new Error("authorization timeout exceeds uint32 time"); + return { + inputs: [ + { + owner: this.identityKey, + kind: "control", + sourceTransaction, + sourceOutputIndex: outputIndex, + publicKey, + }, + ], + fee: String(this.feeSatoshis), + sequenceStart: 1, + validAfter, + deadline, + nLockTime, + }; + } + + /** + * Creates one fully signed transaction version for a negotiated amount or chunk. + * + * @param payload - Client authorization payload + * @param requirements - Original requirements whose amount is the maximum + * @param args - Actual amount, prior version, close flag, and optional output allocation + * @returns Fully signed BEEF transaction version retained by both sides + */ + async createTransactionVersion( + payload: UptoBsvPayload, + requirements: PaymentRequirements, + args: CreateUptoBsvVersionArgs, + ): Promise { + const check = await this.checkAuthorizationPayload(payload, requirements, "create"); + if (!/^\d+$/.test(args.amount) || BigInt(args.amount) > BigInt(check.verified.maximumAmount)) { + throw new Error("transaction amount exceeds the authorized maximum"); + } + const outputAmounts = + args.outputAmounts ?? this.defaultOutputAmounts(check.verified, args.amount); + this.assertRecipientReceipt(check.verified, outputAmounts, args.amount); + const prior = args.previous + ? verifyUptoTransactionVersion(check.verified.authorization, args.previous) + : undefined; + if (prior?.cooperativeClose) { + throw new Error("a cooperatively closed transaction cannot advance"); + } + if (!args.cooperativeClose && prior?.nSequence === FINAL_SEQUENCE - 1) { + throw new Error("control nSequence is exhausted; cooperatively close instead"); + } + const nSequence = args.cooperativeClose + ? FINAL_SEQUENCE + : prior + ? prior.nSequence + 1 + : check.verified.terms.sequenceStart; + const controlSigners: Record = {}; + let controlOrdinal = 0; + check.verified.terms.inputs.forEach((input, index) => { + if (input.kind !== "control") return; + const expectedKeyId = uptoControlKeyId( + payload.derivationPrefix, + payload.derivationSuffix, + controlOrdinal, + ); + controlOrdinal += 1; + controlSigners[index] = async digest => { + const { signature } = await this.wallet.createSignature( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: expectedKeyId, + counterparty: payload.senderIdentityKey, + hashToDirectlySign: digest, + }, + this.originator, + ); + return Array.from(signature); + }; + }); + const buildArgs: BuildUptoBsvTransactionVersionArgs = { + nSequence, + outputAmounts, + }; + const version = await buildUptoTransactionVersion( + check.verified.authorization, + buildArgs, + controlSigners, + ); + const verifiedVersion = verifyUptoTransactionVersion(check.verified.authorization, version); + if (verifiedVersion.amount !== args.amount) { + throw new Error("output allocation does not equal the requested net amount"); + } + if (args.previous) { + assertUptoVersionProgression(check.verified.authorization, args.previous, version); + } + return version; + } + + /** + * Verifies a cap authorization before the resource handler runs. + * + * @param payload - x402 payload containing the signed authorization + * @param requirements - Requirements whose amount is the maximum + * @returns Verification result and payer identity + */ + async verify( + payload: PaymentPayload, + requirements: PaymentRequirements, + ): Promise { + try { + const parsed = this.parsePayload(payload.payload); + const checked = await this.checkAuthorizationPayload(parsed, requirements, "verify", payload); + return { isValid: true, payer: checked.payer }; + } catch (error) { + return { + isValid: false, + invalidReason: this.invalidReason(error), + payer: this.payerFrom(payload.payload), + }; + } + } + + /** + * Settles one locally unconsumed, fully signed transaction. + * + * @param payload - x402 payload enriched with transactionVersion + * @param requirements - Settlement requirements whose amount is the actual charge + * @returns Settlement response including the recomputed amount + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + ): Promise { + const network = requirements.network; + let parsed: UptoBsvPayload; + let checked: AuthorizationCheck; + try { + parsed = this.parsePayload(payload.payload); + checked = await this.checkAuthorizationPayload(parsed, requirements, "settle", payload); + } catch (error) { + return this.failure(network, this.payerFrom(payload.payload), this.invalidReason(error)); + } + const version = parsed.transactionVersion; + if (!version) return this.failure(network, checked.payer, "missing_transaction_version"); + + let verifiedVersion: VerifiedUptoBsvTransactionVersion; + try { + verifiedVersion = verifyUptoTransactionVersion(checked.verified.authorization, version); + if (!/^\d+$/.test(requirements.amount) || verifiedVersion.amount !== requirements.amount) { + return this.failure(network, checked.payer, "invalid_upto_bsv_amount_mismatch"); + } + this.assertRecipientReceipt( + checked.verified, + verifiedVersion.outputAmounts, + verifiedVersion.amount, + ); + if ( + !verifiedVersion.cooperativeClose && + Math.floor(Date.now() / 1000) < checked.verified.terms.nLockTime + ) { + return this.failure(network, checked.payer, "transaction_non_final"); + } + const beefBytes = Utils.toArray(version.transaction, "base64"); + if (!(await this.verifyBeef(beefBytes, verifiedVersion.txid))) { + return this.failure(network, checked.payer, "invalid_upto_bsv_spv"); + } + const claimTime = Math.floor(Date.now() / 1000); + if ( + claimTime < checked.verified.terms.validAfter || + claimTime >= checked.verified.terms.deadline + ) { + return this.failure(network, checked.payer, "upto_authorization_out_of_window"); + } + if (!verifiedVersion.cooperativeClose && claimTime < checked.verified.terms.nLockTime) { + return this.failure(network, checked.payer, "transaction_non_final"); + } + } catch (error) { + return this.failure(network, checked.payer, this.invalidReason(error)); + } + + const authorizationId = checked.verified.authorization.authorizationId; + let claim: { claimed: true; token: string } | { claimed: false; txid: string }; + try { + claim = await this.settlementStore.tryClaim( + authorizationId, + verifiedVersion.txid, + this.settlementDeleteAfter(checked.verified.terms.deadline), + ); + } catch { + return this.failure(network, checked.payer, "settlement_store_unavailable"); + } + if (!claim.claimed) { + const reason = + claim.txid === verifiedVersion.txid + ? "duplicate_settlement" + : "authorization_already_settled"; + return this.failure(network, checked.payer, reason); + } + const walletTime = Math.floor(Date.now() / 1000); + if ( + walletTime < checked.verified.terms.validAfter || + walletTime >= checked.verified.terms.deadline + ) { + return this.failure(network, checked.payer, "upto_authorization_out_of_window"); + } + if (!verifiedVersion.cooperativeClose && walletTime < checked.verified.terms.nLockTime) { + return this.failure(network, checked.payer, "transaction_non_final"); + } + const result = await this.internalize( + parsed, + checked, + verifiedVersion, + requirements, + network, + checked.payer, + ); + if (!result.success && result.errorReason === "settlement_rejected_by_wallet") { + try { + await this.settlementStore.release(authorizationId, claim.token); + } catch { + return this.failure(network, checked.payer, "settlement_store_unavailable"); + } + } + return result; + } + + /** + * Builds the default two-party output allocation. + * + * @param authorization - Verified maximum-payment authorization + * @param amount - Actual charged-owner net delta + * @returns One satoshi value for every authorized output slot + */ + private defaultOutputAmounts( + authorization: VerifiedUptoBsvAuthorization, + amount: string, + ): string[] { + const { terms, maximumAmount } = authorization; + if ( + terms.inputs.filter(input => input.kind === "cap").length !== 1 || + terms.outputs.length !== 3 || + terms.paymentOutputIndexes.length !== 1 || + terms.paymentOutputIndexes[0] !== 2 + ) { + throw new Error("multi-input/output authorization requires explicit outputAmounts"); + } + const controlTotal = terms.inputs + .filter(input => input.kind === "control") + .reduce((sum, input) => sum + BigInt(uptoInputSatoshis(input)), 0n); + const recipientAmount = controlTotal - BigInt(terms.fee) + BigInt(amount); + if (recipientAmount < 0n) throw new Error("control inputs do not cover the fee"); + return [ + terms.outputs[0].fixedAmount ?? "0", + (BigInt(maximumAmount) - BigInt(amount)).toString(), + recipientAmount.toString(), + ]; + } + + /** + * Ensures the recipient's proven outputs cover the amount inferred from owner labels. + * + * `owner` is signed accounting metadata, not independent proof of script control. The + * recipient therefore checks its BRC-29 payment outputs against its real control-input + * contribution before signing or accepting a transaction. + * + * @param authorization - Verified authorization with resolved control input values + * @param outputAmounts - Proposed or verified transaction output values + * @param amount - Charged-owner amount inferred from the transaction + */ + private assertRecipientReceipt( + authorization: VerifiedUptoBsvAuthorization, + outputAmounts: readonly string[], + amount: string, + ): void { + const paymentTotal = authorization.terms.paymentOutputIndexes.reduce((sum, index) => { + const value = outputAmounts[index]; + if (value === undefined || !/^\d+$/.test(value)) { + throw new Error("invalid_upto_bsv_recipient_amount_shortfall"); + } + return sum + BigInt(value); + }, 0n); + const controlTotal = authorization.terms.inputs + .filter(input => input.kind === "control") + .reduce((sum, input) => sum + BigInt(input.sourceSatoshis), 0n); + const available = paymentTotal - controlTotal + BigInt(authorization.terms.fee); + if (available < BigInt(amount)) { + throw new Error("invalid_upto_bsv_recipient_amount_shortfall"); + } + } + + /** + * Parses the stable JSON payload shape. + * + * @param payload - Scheme-specific x402 payload object + * @returns Parsed BSV upto payload + */ + private parsePayload(payload: Record): UptoBsvPayload { + if (!payload || typeof payload !== "object") throw new Error("invalid_upto_bsv_payload_format"); + const parsed = payload as unknown as UptoBsvPayload; + if ( + typeof parsed.derivationPrefix !== "string" || + typeof parsed.derivationSuffix !== "string" || + typeof parsed.senderIdentityKey !== "string" || + !Number.isInteger(parsed.outputIndex) || + !parsed.authorization || + typeof parsed.authorization !== "object" + ) { + throw new Error("invalid_upto_bsv_payload_format"); + } + return parsed; + } + + /** + * Runs inherited exact checks plus maximum-authorization checks. + * + * @param parsed - Parsed BSV upto payload + * @param requirements - Verification or settlement requirements + * @param phase - Current facilitator phase + * @param envelope - Optional full x402 payload for accepted-requirement checks + * @returns Verified authorization context + */ + private async checkAuthorizationPayload( + parsed: UptoBsvPayload, + requirements: PaymentRequirements, + phase: "verify" | "create" | "settle", + envelope?: PaymentPayload, + ): Promise { + if (phase === "verify" && Object.prototype.hasOwnProperty.call(parsed, "transactionVersion")) { + throw new Error("unexpected_transaction_version"); + } + if (envelope && envelope.accepted.scheme !== this.scheme) throw new Error("unsupported_scheme"); + if (requirements.scheme !== this.scheme) throw new Error("unsupported_scheme"); + if (envelope && envelope.accepted.network !== requirements.network) { + throw new Error("invalid_network"); + } + if (!isBsvNetwork(requirements.network)) throw new Error("invalid_network"); + const asset = requirements.asset ?? BSV_ASSET_IDENTIFIER; + if (asset !== "" && asset.toUpperCase() !== BSV_ASSET_IDENTIFIER) { + throw new Error("invalid_upto_bsv_payload_asset"); + } + if (!COMPRESSED_PUBKEY_REGEX.test(parsed.senderIdentityKey)) { + throw new Error("invalid_upto_bsv_payload_sender_key"); + } + if ((requirements.payTo ?? "").toLowerCase() !== this.identityKey) { + throw new Error("invalid_upto_bsv_payload_payee_mismatch"); + } + await this.assertWalletNetwork(requirements.network); + this.assertTimestamp(parsed.derivationSuffix, requirements, phase); + const prefix = Utils.toArray(parsed.derivationPrefix, "base64"); + if (prefix.length < MIN_DERIVATION_PREFIX_BYTES) { + throw new Error("invalid_upto_bsv_payload_derivation_prefix"); + } + const verified = verifyUptoAuthorization(parsed.authorization); + const terms = verified.terms; + if ( + terms.network !== requirements.network || + terms.asset !== "BSV" || + terms.payTo.toLowerCase() !== this.identityKey || + terms.senderIdentityKey.toLowerCase() !== parsed.senderIdentityKey.toLowerCase() || + terms.derivationPrefix !== parsed.derivationPrefix || + terms.derivationSuffix !== parsed.derivationSuffix || + terms.paymentOutputIndexes[0] !== parsed.outputIndex + ) { + throw new Error("invalid_upto_bsv_authorization_context"); + } + const controlTotal = terms.inputs + .filter(input => input.kind === "control") + .reduce((sum, input) => sum + BigInt(input.sourceSatoshis), 0n); + const lifetime = terms.deadline - terms.validAfter; + if ( + terms.fee !== String(this.feeSatoshis) || + controlTotal !== BigInt(this.controlSatoshis) || + terms.sequenceStart !== 1 || + terms.nLockTime - terms.validAfter !== this.nonFinalDelaySeconds || + !Number.isSafeInteger(requirements.maxTimeoutSeconds) || + requirements.maxTimeoutSeconds <= 0 || + lifetime <= 0 || + lifetime > requirements.maxTimeoutSeconds + ) { + throw new Error("invalid_upto_bsv_facilitator_policy"); + } + const maximum = envelope?.accepted.amount ?? requirements.amount; + if (!/^\d+$/.test(maximum) || verified.maximumAmount !== maximum) { + throw new Error("invalid_upto_bsv_maximum_mismatch"); + } + const now = Math.floor(Date.now() / 1000); + if (now < terms.validAfter || now >= terms.deadline) { + throw new Error("upto_authorization_out_of_window"); + } + if (phase !== "settle") { + const checkedSources = new Set(); + for (const input of terms.inputs) { + const txid = input.source.id("hex"); + if (checkedSources.has(txid)) continue; + checkedSources.add(txid); + const valid = await this.verifyBeef(Utils.toArray(input.sourceTransaction, "base64"), txid); + if (!valid) throw new Error("invalid_upto_bsv_source_spv"); + } + } + await this.assertRecipientKeys(parsed, verified); + return { parsed, verified, payer: parsed.senderIdentityKey }; + } + + /** + * Verifies that BRC-42 payment and control keys belong to payTo. + * + * @param parsed - Parsed payload carrying exact-compatible derivation data + * @param authorization - Verified authorization whose scripts are checked + */ + private async assertRecipientKeys( + parsed: UptoBsvPayload, + authorization: VerifiedUptoBsvAuthorization, + ): Promise { + const paymentKey = await this.wallet.getPublicKey( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: brc29PaymentKeyId(parsed.derivationPrefix, parsed.derivationSuffix), + counterparty: parsed.senderIdentityKey, + forSelf: true, + }, + this.originator, + ); + const paymentScript = p2pkhLockingScript(paymentKey.publicKey); + for (const index of authorization.terms.paymentOutputIndexes) { + if ( + authorization.terms.outputs[index].lockingScript.toLowerCase() !== + paymentScript.toLowerCase() + ) { + throw new Error("invalid_upto_bsv_payload_destination_mismatch"); + } + } + let ordinal = 0; + for (const input of authorization.terms.inputs) { + if (input.kind !== "control") continue; + const keyId = uptoControlKeyId(parsed.derivationPrefix, parsed.derivationSuffix, ordinal); + ordinal += 1; + if (input.owner.toLowerCase() !== this.identityKey) { + throw new Error("invalid_upto_bsv_control_identity"); + } + const { publicKey } = await this.wallet.getPublicKey( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: keyId, + counterparty: parsed.senderIdentityKey, + forSelf: true, + }, + this.originator, + ); + if (publicKey.toLowerCase() !== input.publicKey.toLowerCase()) { + throw new Error("invalid_upto_bsv_control_identity"); + } + } + } + + /** + * Checks a control request before allocating a wallet output. + * + * @param request - Client request for a recipient-owned control input + */ + private async validateControlRequest(request: UptoBsvControlRequest): Promise { + if ( + request.payTo.toLowerCase() !== this.identityKey || + !COMPRESSED_PUBKEY_REGEX.test(request.senderIdentityKey) || + !isBsvNetwork(request.network as Network) || + !/^\d+$/.test(request.maxAmount) || + BigInt(request.maxAmount) <= 0n || + BigInt(request.maxAmount) > BigInt(MAX_SATOSHIS) || + !Number.isSafeInteger(request.maxTimeoutSeconds) || + request.maxTimeoutSeconds <= this.nonFinalDelaySeconds + ) { + throw new Error("invalid BSV upto control request"); + } + await this.assertWalletNetwork(request.network as Network); + this.assertTimestamp( + request.derivationSuffix, + { + scheme: this.scheme, + network: request.network as Network, + amount: request.maxAmount, + asset: "BSV", + payTo: request.payTo, + maxTimeoutSeconds: request.maxTimeoutSeconds, + extra: {}, + }, + "verify", + ); + } + + /** + * Ensures that wallet and requested BSV network agree. + * + * @param network - Requested BSV CAIP-2 network + */ + private async assertWalletNetwork(network: Network): Promise { + const expected = toBsvWalletNetwork(network); + if (!expected) throw new Error("invalid_network"); + try { + this.walletNetworkPromise ??= this.wallet + .getNetwork({}, this.originator) + .then(result => result.network); + if ((await this.walletNetworkPromise) !== expected) throw new Error("invalid_network"); + } catch (error) { + if (error instanceof Error && error.message === "invalid_network") throw error; + this.walletNetworkPromise = undefined; + throw new Error("unexpected_verify_error"); + } + } + + /** + * Enforces the inherited BRC-121 freshness window. + * + * @param suffix - Base64 timestamp derivation suffix + * @param requirements - Requirements supplying the settlement time budget + * @param phase - Verification or settlement phase + */ + private assertTimestamp( + suffix: string, + requirements: PaymentRequirements, + phase: "verify" | "create" | "settle", + ): void { + let decoded: string; + try { + decoded = Utils.toUTF8(Utils.toArray(suffix, "base64")); + } catch { + throw new Error("invalid_upto_bsv_payload_timestamp"); + } + if (!/^\d+$/.test(decoded)) throw new Error("invalid_upto_bsv_payload_timestamp"); + const timestamp = Number(decoded); + const settleBudget = phase === "verify" ? 0 : requirements.maxTimeoutSeconds * 1000; + const age = Date.now() - timestamp; + if ( + !Number.isFinite(timestamp) || + age < -this.paymentWindowMs || + age > this.paymentWindowMs + settleBudget + ) { + throw new Error("invalid_upto_bsv_payload_timestamp_out_of_window"); + } + } + + /** + * Internalizes every recipient-owned BRC-29 output. + * + * @param parsed - Parsed BRC-29 remittance fields + * @param checked - Verified authorization context + * @param version - Verified fully signed transaction + * @param requirements - Settlement requirements carrying the actual amount + * @param network - BSV settlement network + * @param payer - Verified payer identity key + * @returns x402 settlement response + */ + private async internalize( + parsed: UptoBsvPayload, + checked: AuthorizationCheck, + version: VerifiedUptoBsvTransactionVersion, + requirements: PaymentRequirements, + network: Network, + payer: string, + ): Promise { + try { + const result = (await this.wallet.internalizeAction( + { + tx: Utils.toArray(version.version.transaction, "base64"), + outputs: checked.verified.terms.paymentOutputIndexes.map(outputIndex => ({ + outputIndex, + protocol: "wallet payment" as const, + paymentRemittance: { + derivationPrefix: parsed.derivationPrefix, + derivationSuffix: parsed.derivationSuffix, + senderIdentityKey: parsed.senderIdentityKey, + }, + })), + description: "x402 upto payment", + }, + this.originator, + )) as { accepted: boolean; isMerge?: boolean; satoshis?: number }; + const newlyInternalized = typeof result.satoshis === "number" && result.satoshis > 0; + if (result.isMerge && !newlyInternalized) { + return this.failure(network, payer, "duplicate_settlement"); + } + if (result.accepted === false && !newlyInternalized) { + return this.failure(network, payer, "settlement_rejected_by_wallet"); + } + if (result.accepted !== true) { + return this.failure(network, payer, "settlement_indeterminate: invalid wallet result"); + } + return { + success: true, + network, + transaction: version.txid, + payer, + amount: requirements.amount, + }; + } catch (error) { + return this.failure( + network, + payer, + `settlement_indeterminate: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Retains a settlement claim past the signed authorization deadline. + * + * @param deadline - Signed authorization deadline in Unix seconds + * @returns Earliest safe deletion time in Unix milliseconds + */ + private settlementDeleteAfter(deadline: number): number { + return Math.max( + deadline * 1000 + SETTLEMENT_CACHE_TTL_MARGIN_MS, + Date.now() + SETTLEMENT_CACHE_TTL_FLOOR_MS, + ); + } + + /** + * Converts one internal error into a stable invalidReason. + * + * @param error - Caught verification error + * @returns Stable invalid reason string + */ + private invalidReason(error: unknown): string { + return error instanceof Error ? error.message : "unexpected_verify_error"; + } + + /** + * Reads a claimed payer for failure responses only. + * + * @param payload - Untrusted scheme payload + * @returns Claimed payer key or an empty string + */ + private payerFrom(payload: Record): string { + return typeof payload?.senderIdentityKey === "string" ? payload.senderIdentityKey : ""; + } + + /** + * Builds a failed settlement response. + * + * @param network - Requested settlement network + * @param payer - Verified or claimed payer key + * @param errorReason - Stable failure reason + * @returns Failed x402 settlement response + */ + private failure(network: Network, payer: string, errorReason: string): SettleResponse { + return { success: false, network, transaction: "", payer, errorReason }; + } +} diff --git a/typescript/packages/mechanisms/bsv/src/upto/server/index.ts b/typescript/packages/mechanisms/bsv/src/upto/server/index.ts new file mode 100644 index 0000000000..dbb5dea17b --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/server/index.ts @@ -0,0 +1,5 @@ +export { + UptoBsvScheme, + type UptoBsvServerConfig, + type UptoBsvTransactionVersionProvider, +} from "./scheme"; diff --git a/typescript/packages/mechanisms/bsv/src/upto/server/scheme.ts b/typescript/packages/mechanisms/bsv/src/upto/server/scheme.ts new file mode 100644 index 0000000000..4e6916948b --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/server/scheme.ts @@ -0,0 +1,148 @@ +import type { + AssetAmount, + MoneyParser, + Network, + PaymentRequirements, + Price, + SchemeNetworkServer, +} from "@x402/core/types"; +import type { SettleContext } from "@x402/core/server"; +import { ExactBsvScheme } from "../../exact/server/scheme"; +import type { UptoBsvTransactionVersion } from "../../types"; + +/** Resolves the fully signed transaction selected for one settle call. */ +export type UptoBsvTransactionVersionProvider = ( + context: SettleContext, +) => UptoBsvTransactionVersion | Promise; + +/** Application integration used to select the transaction to settle. */ +export interface UptoBsvServerConfig { + getTransactionVersion: UptoBsvTransactionVersionProvider; +} + +/** + * BSV server scheme for upto payments. + * + * Upto changes when the final amount is selected, not how BSV prices or + * assets are represented. This adapter therefore delegates those rules to + * the exact scheme and only preserves the `upto` scheme identity. + */ +export class UptoBsvScheme implements SchemeNetworkServer { + readonly scheme = "upto"; + private readonly exact = new ExactBsvScheme(); + private readonly getTransactionVersion: UptoBsvTransactionVersionProvider; + + /** + * Creates a BSV upto server adapter. + * + * @param config - Required signed-transaction selector + */ + constructor(config: UptoBsvServerConfig) { + if (!config || typeof config.getTransactionVersion !== "function") { + throw new Error("getTransactionVersion is required for BSV upto settlement"); + } + this.getTransactionVersion = config.getTransactionVersion; + } + + /** + * The exact-compatible BSV asset transfer method. + * + * @returns The default transfer method + */ + get defaultAssetTransferMethod(): string { + return this.exact.defaultAssetTransferMethod; + } + + /** + * The exact-compatible authorization payment flow. + * + * @returns The supported payment flow map + */ + get paymentFlows(): ExactBsvScheme["paymentFlows"] { + return this.exact.paymentFlows; + } + + /** + * Registers an exact-compatible money parser. + * + * @param parser - Custom function returning an asset amount or null + * @returns This instance for chaining + */ + registerMoneyParser(parser: MoneyParser): this { + this.exact.registerMoneyParser(parser); + return this; + } + + /** + * Parses a BSV price using the exact scheme's native-satoshi rules. + * + * @param price - Price to parse + * @param network - BSV network identifier + * @returns Parsed asset amount + */ + parsePrice(price: Price, network: Network): Promise { + return this.exact.parsePrice(price, network); + } + + /** + * Returns the exact scheme's precision for a BSV asset. + * + * @param asset - Asset identifier + * @param network - BSV network identifier + * @returns The asset precision + */ + getAssetDecimals(asset: string, network: Network): number { + return this.exact.getAssetDecimals(asset, network); + } + + /** + * Applies the exact scheme's BSV asset and facilitator-extra rules while + * retaining `upto` as the payment scheme. + * + * @param requirements - Payment requirements to enhance + * @param supportedKind - Facilitator-supported scheme and network metadata + * @param supportedKind.x402Version - X402 protocol version + * @param supportedKind.scheme - Payment scheme identifier + * @param supportedKind.network - BSV network identifier + * @param supportedKind.extra - Optional facilitator metadata + * @param extensionKeys - Extension keys to apply + * @returns Enhanced upto requirements + */ + async enhancePaymentRequirements( + requirements: PaymentRequirements, + supportedKind: { + x402Version: number; + scheme: string; + network: Network; + extra?: Record; + }, + extensionKeys: string[], + ): Promise { + const enhanced = await this.exact.enhancePaymentRequirements( + { ...requirements, scheme: this.scheme }, + supportedKind, + extensionKeys, + ); + + return { ...enhanced, scheme: this.scheme }; + } + + /** + * Adds the signed transaction selected by the application to a settle-local payload. + * + * The core settlement phase is passed through unchanged; the application selects + * one ordinary, fully signed transaction for that settle call. + * + * @param context - The settlement invocation being enriched + * @returns The selected transaction version + */ + enrichSettlementPayload = async (context: SettleContext): Promise> => { + const transactionVersion = await this.getTransactionVersion(context); + if (!transactionVersion) { + throw new Error( + `No signed BSV upto transaction version is available for ${context.phase} settlement`, + ); + } + return { transactionVersion }; + }; +} diff --git a/typescript/packages/mechanisms/bsv/src/upto/shared.ts b/typescript/packages/mechanisms/bsv/src/upto/shared.ts new file mode 100644 index 0000000000..a7ad207241 --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/shared.ts @@ -0,0 +1,92 @@ +import { PublicKey, Random, Utils } from "@bsv/sdk"; +import type { WalletInterface, WalletProtocol } from "@bsv/sdk"; +import type { PaymentRequirements } from "@x402/core/types"; +import { + BRC29_PROTOCOL_ID, + BSV_ASSET_IDENTIFIER, + COMPRESSED_PUBKEY_REGEX, + MAX_SATOSHIS, + isBsvNetwork, +} from "../constants"; + +/** Internal wallet protocol used for payer-owned `upto` cap keys. */ +export const BSV_UPTO_PROTOCOL_ID: WalletProtocol = [2, "x402 BSV upto"]; + +export interface Brc29PaymentContext { + derivationPrefix: string; + derivationSuffix: string; + senderIdentityKey: string; + recipientPublicKey: string; + lockingScript: string; +} + +export const brc29PaymentKeyId = (prefix: string, suffix: string): string => `${prefix} ${suffix}`; + +export const uptoControlKeyId = (prefix: string, suffix: string, index: number): string => + `${prefix} ${suffix} upto-control-${index}`; + +export const p2pkhLockingScript = (publicKey: string): string => { + const pkh = PublicKey.fromString(publicKey).toHash("hex") as string; + return `76a914${pkh}88ac`; +}; + +export const createBrc29PaymentContext = async ( + wallet: WalletInterface, + payTo: string, + originator?: string, +): Promise => { + const derivationPrefix = Utils.toBase64(Random(8)); + const derivationSuffix = Utils.toBase64(Utils.toArray(String(Date.now()), "utf8")); + const { publicKey: recipientPublicKey } = await wallet.getPublicKey( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: brc29PaymentKeyId(derivationPrefix, derivationSuffix), + counterparty: payTo, + }, + originator, + ); + const { publicKey: senderIdentityKey } = await wallet.getPublicKey( + { identityKey: true }, + originator, + ); + return { + derivationPrefix, + derivationSuffix, + senderIdentityKey, + recipientPublicKey, + lockingScript: p2pkhLockingScript(recipientPublicKey), + }; +}; + +export const validateUptoBsvPaymentRequirements = (requirements: PaymentRequirements): void => { + if (requirements.scheme !== "upto") { + throw new Error(`Unsupported scheme: ${requirements.scheme}`); + } + if (!isBsvNetwork(requirements.network)) { + throw new Error(`Unsupported BSV network: ${requirements.network}`); + } + const asset = requirements.asset ?? BSV_ASSET_IDENTIFIER; + if (asset !== "" && asset.toUpperCase() !== BSV_ASSET_IDENTIFIER) { + throw new Error( + `Unsupported asset "${requirements.asset}": only native ${BSV_ASSET_IDENTIFIER} (satoshis) is supported`, + ); + } + if (!requirements.amount || !/^\d+$/.test(requirements.amount)) { + throw new Error("amount must be a non-empty decimal string of satoshis"); + } + const satoshis = BigInt(requirements.amount); + if (satoshis <= 0n || satoshis > BigInt(MAX_SATOSHIS)) { + throw new Error(`amount must be between 1 and ${MAX_SATOSHIS} satoshis`); + } + if ( + !Number.isSafeInteger(requirements.maxTimeoutSeconds) || + requirements.maxTimeoutSeconds <= 0 + ) { + throw new Error("maxTimeoutSeconds must be a positive safe integer"); + } + if (!requirements.payTo || !COMPRESSED_PUBKEY_REGEX.test(requirements.payTo)) { + throw new Error( + "payTo must be the recipient's identity public key (33-byte compressed secp256k1 hex)", + ); + } +}; diff --git a/typescript/packages/mechanisms/bsv/src/upto/transaction.ts b/typescript/packages/mechanisms/bsv/src/upto/transaction.ts new file mode 100644 index 0000000000..b3c2b0ca7c --- /dev/null +++ b/typescript/packages/mechanisms/bsv/src/upto/transaction.ts @@ -0,0 +1,727 @@ +import { + Beef, + BigNumber, + ECDSA, + Hash, + LockingScript, + PublicKey, + Signature, + Spend, + Transaction, + TransactionSignature, + UnlockingScript, + Utils, +} from "@bsv/sdk"; +import type { + UptoBsvAuthorizationTerms, + UptoBsvAuthorization, + UptoBsvCapSignature, + UptoBsvInput, + UptoBsvTransactionVersion, +} from "../types"; +import { BSV_ASSET_IDENTIFIER, COMPRESSED_PUBKEY_REGEX, MAX_SATOSHIS } from "../constants"; + +const AUTHORIZATION_DOMAIN = "x402-bsv-upto-authorization-v1"; +const FINAL_SEQUENCE = 0xffffffff; +const LOCKTIME_TIMESTAMP_THRESHOLD = 500_000_000; +const CAP_SIGHASH = TransactionSignature.SIGHASH_SINGLE | TransactionSignature.SIGHASH_FORKID; +const CONTROL_SIGHASH = TransactionSignature.SIGHASH_ALL | TransactionSignature.SIGHASH_FORKID; + +export type UptoBsvDigestSigner = (digest: number[]) => Promise; +export type UptoBsvInputSigners = Readonly>; + +interface DecodedInput extends UptoBsvInput { + source: Transaction; + sourceSatoshis: number; + sourceLockingScript: string; +} + +interface DecodedTerms extends Omit { + inputs: DecodedInput[]; +} + +export interface VerifiedUptoBsvAuthorization { + authorization: UptoBsvAuthorization; + terms: DecodedTerms; + maximumAmount: string; +} + +export interface VerifiedUptoBsvTransactionVersion { + version: UptoBsvTransactionVersion; + transaction: Transaction; + txid: string; + /** Common nSequence used by every control input. */ + nSequence: number; + /** True when final control sequences make nLockTime inoperative. */ + cooperativeClose: boolean; + outputAmounts: string[]; + ownerDeltas: Readonly>; + amount: string; +} + +export interface BuildUptoBsvTransactionVersionArgs { + /** Common nSequence for every control input. */ + nSequence: number; + outputAmounts: readonly string[]; +} + +const decodeBeefSubject = (encoded: string): Transaction => { + const bytes = Utils.toArray(encoded, "base64"); + if (!Beef.fromBinary(bytes).isValid()) { + throw new Error("BEEF must contain complete source ancestry"); + } + return Transaction.fromBEEF(bytes); +}; + +const sourceOutpoint = (input: DecodedInput): string => + `${input.source.id("hex")}:${input.sourceOutputIndex}`; + +const parseSatoshis = (value: string, name: string): bigint => { + if (!/^\d+$/.test(value)) throw new Error(`${name} must be decimal satoshis`); + const parsed = BigInt(value); + if (parsed < 0n || parsed > BigInt(MAX_SATOSHIS)) { + throw new Error(`${name} is outside the BSV satoshi range`); + } + return parsed; +}; + +const toSafeSatoshis = (value: bigint, name: string): number => { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${name} cannot be represented safely`); + } + return Number(value); +}; + +const assertIdentifier = (value: string, name: string): void => { + if (typeof value !== "string" || value.length < 1 || value.length > 512) { + throw new Error(`${name} must contain 1..512 characters`); + } +}; + +const assertPublicKey = (value: string, name: string): void => { + if (!COMPRESSED_PUBKEY_REGEX.test(value)) throw new Error(`${name} is not a compressed key`); + PublicKey.fromString(value); +}; + +const p2pkhHash = (lockingScript: string): string | undefined => + /^76a914([0-9a-f]{40})88ac$/i.exec(lockingScript)?.[1]?.toLowerCase(); + +const assertInputKey = (input: DecodedInput, index: number): void => { + const expected = PublicKey.fromString(input.publicKey).toHash("hex") as string; + if (p2pkhHash(input.sourceLockingScript) !== expected.toLowerCase()) { + throw new Error(`input ${index} source is not P2PKH for its publicKey`); + } +}; + +const decodeTerms = (terms: UptoBsvAuthorizationTerms): DecodedTerms => { + if (terms.version !== 1) throw new Error("unsupported BSV upto authorization version"); + assertIdentifier(terms.network, "network"); + if (terms.asset.toUpperCase() !== BSV_ASSET_IDENTIFIER) { + throw new Error("BSV upto supports native BSV only"); + } + assertPublicKey(terms.payTo, "payTo"); + assertPublicKey(terms.senderIdentityKey, "senderIdentityKey"); + assertIdentifier(terms.derivationPrefix, "derivationPrefix"); + assertIdentifier(terms.derivationSuffix, "derivationSuffix"); + if (!Array.isArray(terms.inputs) || terms.inputs.length < 2) { + throw new Error("authorization requires cap and control inputs"); + } + if (!Array.isArray(terms.outputs) || terms.outputs.length < 2) { + throw new Error("authorization requires outputs"); + } + if (!Number.isInteger(terms.sequenceStart) || terms.sequenceStart < 1) { + throw new Error("sequenceStart must be a positive uint32"); + } + if (terms.sequenceStart >= FINAL_SEQUENCE) { + throw new Error("sequenceStart must leave room for non-final sequences"); + } + if ( + !Number.isSafeInteger(terms.validAfter) || + !Number.isSafeInteger(terms.deadline) || + !Number.isSafeInteger(terms.nLockTime) || + terms.validAfter < 0 || + terms.nLockTime <= terms.validAfter || + terms.deadline <= terms.nLockTime || + terms.nLockTime < LOCKTIME_TIMESTAMP_THRESHOLD || + terms.deadline > FINAL_SEQUENCE + ) { + throw new Error("validAfter, nLockTime, and deadline must define a time-based uint32 window"); + } + parseSatoshis(terms.fee, "fee"); + + const inputs: DecodedInput[] = terms.inputs.map((input, index) => { + assertIdentifier(input.owner, `input ${index} owner`); + if (input.kind !== "cap" && input.kind !== "control") { + throw new Error(`input ${index} has an invalid kind`); + } + assertPublicKey(input.publicKey, `input ${index} publicKey`); + if (!Number.isInteger(input.sourceOutputIndex) || input.sourceOutputIndex < 0) { + throw new Error(`input ${index} has an invalid sourceOutputIndex`); + } + const source = decodeBeefSubject(input.sourceTransaction); + const output = source.outputs[input.sourceOutputIndex]; + if (!output || output.satoshis === undefined) { + throw new Error(`input ${index} source output is missing`); + } + if ( + !Number.isSafeInteger(output.satoshis) || + output.satoshis < 0 || + output.satoshis > MAX_SATOSHIS + ) { + throw new Error(`input ${index} source satoshis are outside the BSV satoshi range`); + } + const decoded: DecodedInput = { + ...input, + source, + sourceSatoshis: output.satoshis, + sourceLockingScript: output.lockingScript.toHex(), + }; + assertInputKey(decoded, index); + return decoded; + }); + const inputTotal = inputs.reduce((sum, input) => sum + BigInt(input.sourceSatoshis), 0n); + if (inputTotal > BigInt(MAX_SATOSHIS)) { + throw new Error("authorization input total is outside the BSV satoshi range"); + } + + const firstControl = inputs.findIndex(input => input.kind === "control"); + if (firstControl < 1 || inputs.slice(firstControl).some(input => input.kind !== "control")) { + throw new Error("cap inputs must precede control inputs"); + } + if (terms.outputs.length < firstControl) { + throw new Error("every cap input requires a same-index floor output"); + } + const seenOutpoints = new Set(); + inputs.forEach(input => { + const outpoint = sourceOutpoint(input); + if (seenOutpoints.has(outpoint)) throw new Error(`duplicate input ${outpoint}`); + seenOutpoints.add(outpoint); + }); + + const owners = new Set(inputs.map(input => input.owner)); + terms.outputs.forEach((output, index) => { + assertIdentifier(output.owner, `output ${index} owner`); + owners.add(output.owner); + if (!/^(?:[0-9a-f]{2})+$/i.test(output.lockingScript)) { + throw new Error(`output ${index} lockingScript must be hex`); + } + if (index < firstControl) { + if ( + output.owner !== inputs[index].owner || + output.fixedAmount === undefined || + output.lockingScript.toLowerCase() !== inputs[index].sourceLockingScript.toLowerCase() + ) { + throw new Error(`output ${index} must be its cap input's same-owner floor`); + } + const floor = parseSatoshis(output.fixedAmount, `output ${index} fixedAmount`); + if (floor > BigInt(inputs[index].sourceSatoshis)) { + throw new Error(`output ${index} floor exceeds its cap input`); + } + } else if (output.fixedAmount !== undefined) { + parseSatoshis(output.fixedAmount, `output ${index} fixedAmount`); + } + }); + + if (!Array.isArray(terms.chargedOwners) || terms.chargedOwners.length < 1) { + throw new Error("chargedOwners must not be empty"); + } + const uniqueCharged = new Set(terms.chargedOwners); + if ( + uniqueCharged.size !== terms.chargedOwners.length || + terms.chargedOwners.some(owner => !owners.has(owner)) + ) { + throw new Error("chargedOwners must be unique known owners"); + } + for (const owner of terms.chargedOwners) { + if (!inputs.some(input => input.kind === "cap" && input.owner === owner)) { + throw new Error(`charged owner ${owner} has no cap input`); + } + const ownerScripts = new Set( + inputs + .filter(input => input.kind === "cap" && input.owner === owner) + .map(input => input.sourceLockingScript.toLowerCase()), + ); + if ( + terms.outputs.some( + output => output.owner === owner && !ownerScripts.has(output.lockingScript.toLowerCase()), + ) + ) { + throw new Error(`charged owner ${owner} has an output not controlled by its cap keys`); + } + } + + if (!Array.isArray(terms.paymentOutputIndexes) || terms.paymentOutputIndexes.length < 1) { + throw new Error("paymentOutputIndexes must not be empty"); + } + const paymentIndexes = new Set(terms.paymentOutputIndexes); + if ( + paymentIndexes.size !== terms.paymentOutputIndexes.length || + terms.paymentOutputIndexes.some( + index => + !Number.isInteger(index) || + index < 0 || + index >= terms.outputs.length || + terms.outputs[index].owner.toLowerCase() !== terms.payTo.toLowerCase(), + ) + ) { + throw new Error("payment outputs must be unique outputs owned by payTo"); + } + + return { ...terms, asset: "BSV", inputs }; +}; + +const canonicalTerms = (terms: DecodedTerms): unknown[] => [ + terms.version, + terms.network, + terms.asset, + terms.payTo.toLowerCase(), + terms.senderIdentityKey.toLowerCase(), + terms.derivationPrefix, + terms.derivationSuffix, + terms.inputs.map(input => [ + input.owner, + input.kind, + input.source.id("hex"), + input.sourceOutputIndex, + String(input.sourceSatoshis), + input.sourceLockingScript.toLowerCase(), + input.publicKey.toLowerCase(), + ]), + terms.outputs.map(output => [ + output.owner, + output.lockingScript.toLowerCase(), + output.fixedAmount === undefined + ? null + : parseSatoshis(output.fixedAmount, "fixedAmount").toString(), + ]), + terms.chargedOwners, + terms.paymentOutputIndexes, + parseSatoshis(terms.fee, "fee").toString(), + terms.sequenceStart, + terms.validAfter, + terms.deadline, + terms.nLockTime, +]; + +export const uptoAuthorizationDigest = (terms: UptoBsvAuthorizationTerms): number[] => { + const decoded = decodeTerms(terms); + return Hash.sha256([ + ...Utils.toArray(AUTHORIZATION_DOMAIN, "utf8"), + 0, + ...Utils.toArray(JSON.stringify(canonicalTerms(decoded)), "utf8"), + ]); +}; + +export const uptoAuthorizationId = (terms: UptoBsvAuthorizationTerms): string => + Utils.toHex(uptoAuthorizationDigest(terms)); + +const sourceSatoshisByOwner = (terms: DecodedTerms): Map => { + const result = new Map(); + terms.inputs.forEach(input => { + result.set(input.owner, (result.get(input.owner) ?? 0n) + BigInt(input.sourceSatoshis)); + }); + return result; +}; + +const maximumByOwner = (terms: DecodedTerms): Map => { + const result = new Map(); + terms.inputs.forEach((input, index) => { + if (input.kind !== "cap") return; + const floor = parseSatoshis(terms.outputs[index].fixedAmount ?? "", `floor ${index}`); + result.set(input.owner, (result.get(input.owner) ?? 0n) + BigInt(input.sourceSatoshis) - floor); + }); + return result; +}; + +export const uptoMaximumAmount = (terms: UptoBsvAuthorizationTerms): string => { + const decoded = decodeTerms(terms); + const maxima = maximumByOwner(decoded); + return decoded.chargedOwners + .reduce((sum, owner) => sum + (maxima.get(owner) ?? 0n), 0n) + .toString(); +}; + +const signatureDigest = (transaction: Transaction, inputIndex: number, scope: number): number[] => + Hash.hash256(transaction.preimage(inputIndex, scope)); + +const unlockingScript = ( + signature: number[], + publicKey: string, + scope: number, +): UnlockingScript => { + const signatureWithScope = [...signature, scope & 0xff]; + const key = PublicKey.fromString(publicKey).encode(true) as number[]; + return new UnlockingScript([ + { op: signatureWithScope.length, data: signatureWithScope }, + { op: key.length, data: key }, + ]); +}; + +const buildTemplate = (terms: DecodedTerms, sequence: number): Transaction => { + const transaction = new Transaction(1, [], [], terms.nLockTime); + terms.inputs.forEach(input => { + transaction.addInput({ + sourceTransaction: input.source, + sourceOutputIndex: input.sourceOutputIndex, + unlockingScript: new UnlockingScript(), + sequence: input.kind === "cap" ? FINAL_SEQUENCE : sequence, + }); + }); + terms.outputs.forEach(output => { + transaction.addOutput({ + lockingScript: LockingScript.fromHex(output.lockingScript), + satoshis: toSafeSatoshis( + output.fixedAmount === undefined ? 0n : parseSatoshis(output.fixedAmount, "fixedAmount"), + "fixedAmount", + ), + }); + }); + return transaction; +}; + +const capIndexes = (terms: DecodedTerms): number[] => + terms.inputs.flatMap((input, index) => (input.kind === "cap" ? [index] : [])); + +const controlIndexes = (terms: DecodedTerms): number[] => + terms.inputs.flatMap((input, index) => (input.kind === "control" ? [index] : [])); + +const requireSigner = (signers: UptoBsvInputSigners, index: number): UptoBsvDigestSigner => { + const signer = signers[index]; + if (!signer) throw new Error(`missing signer for input ${index}`); + return signer; +}; + +const validateSpend = (transaction: Transaction, inputIndex: number): boolean => { + const input = transaction.inputs[inputIndex]; + const source = input.sourceTransaction; + const sourceOutput = source?.outputs[input.sourceOutputIndex]; + if (!source || !sourceOutput || !input.unlockingScript) return false; + try { + return new Spend({ + sourceTXID: input.sourceTXID ?? source.id("hex"), + sourceOutputIndex: input.sourceOutputIndex, + sourceSatoshis: sourceOutput.satoshis ?? 0, + lockingScript: sourceOutput.lockingScript, + transactionVersion: transaction.version, + otherInputs: transaction.inputs.filter((_, index) => index !== inputIndex), + outputs: transaction.outputs, + unlockingScript: input.unlockingScript, + inputSequence: input.sequence ?? FINAL_SEQUENCE, + inputIndex, + lockTime: transaction.lockTime, + }).validate(); + } catch { + return false; + } +}; + +const directSignatureValid = (digest: number[], encoded: string, publicKey: string): boolean => { + try { + return ECDSA.verify( + new BigNumber(digest), + Signature.fromDER(encoded, "base64"), + PublicKey.fromString(publicKey), + ); + } catch { + return false; + } +}; + +export const signUptoAuthorization = async ( + terms: UptoBsvAuthorizationTerms, + signers: UptoBsvInputSigners, +): Promise => { + const decoded = decodeTerms(terms); + const digest = uptoAuthorizationDigest(terms); + const template = buildTemplate(decoded, decoded.sequenceStart); + const capSignatures: UptoBsvCapSignature[] = []; + for (const index of capIndexes(decoded)) { + const signer = requireSigner(signers, index); + const transactionSignature = await signer(signatureDigest(template, index, CAP_SIGHASH)); + const authorizationSignature = await signer(digest); + capSignatures.push({ + inputIndex: index, + transactionSignature: Utils.toBase64(transactionSignature), + authorizationSignature: Utils.toBase64(authorizationSignature), + }); + } + return { authorizationId: Utils.toHex(digest), terms, capSignatures }; +}; + +export const verifyUptoAuthorization = ( + authorization: UptoBsvAuthorization, +): VerifiedUptoBsvAuthorization => { + const terms = decodeTerms(authorization.terms); + const digest = uptoAuthorizationDigest(authorization.terms); + if (authorization.authorizationId !== Utils.toHex(digest)) { + throw new Error("authorizationId does not match its canonical terms"); + } + const indexes = capIndexes(terms); + if (authorization.capSignatures.length !== indexes.length) { + throw new Error("authorization must contain one signature pair per cap input"); + } + const byIndex = new Map(); + authorization.capSignatures.forEach(signature => { + if (byIndex.has(signature.inputIndex)) throw new Error("duplicate cap signature"); + byIndex.set(signature.inputIndex, signature); + }); + const template = buildTemplate(terms, terms.sequenceStart); + indexes.forEach(index => { + const signature = byIndex.get(index); + if (!signature) throw new Error(`missing cap signature for input ${index}`); + if ( + !directSignatureValid(digest, signature.authorizationSignature, terms.inputs[index].publicKey) + ) { + throw new Error(`invalid authorization signature for input ${index}`); + } + template.inputs[index].unlockingScript = unlockingScript( + Utils.toArray(signature.transactionSignature, "base64"), + terms.inputs[index].publicKey, + CAP_SIGHASH, + ); + if (!validateSpend(template, index)) { + throw new Error(`invalid reusable cap signature for input ${index}`); + } + }); + return { + authorization, + terms, + maximumAmount: uptoMaximumAmount(authorization.terms), + }; +}; + +const computeDeltas = ( + terms: DecodedTerms, + outputAmounts: readonly bigint[], +): Map => { + const deltas = sourceSatoshisByOwner(terms); + terms.outputs.forEach((output, index) => { + deltas.set(output.owner, (deltas.get(output.owner) ?? 0n) - outputAmounts[index]); + }); + return deltas; +}; + +const validateAmounts = ( + terms: DecodedTerms, + outputAmounts: readonly string[], +): { parsed: bigint[]; deltas: Map; amount: bigint } => { + if (outputAmounts.length !== terms.outputs.length) { + throw new Error("outputAmounts length does not match the authorization"); + } + const parsed = outputAmounts.map((amount, index) => + parseSatoshis(amount, `outputAmounts[${index}]`), + ); + terms.outputs.forEach((output, index) => { + if (output.fixedAmount !== undefined && parsed[index] !== BigInt(output.fixedAmount)) { + throw new Error(`fixed output ${index} changed`); + } + }); + const inputTotal = terms.inputs.reduce((sum, input) => sum + BigInt(input.sourceSatoshis), 0n); + const outputTotal = parsed.reduce((sum, amount) => sum + amount, 0n); + if (outputTotal > BigInt(MAX_SATOSHIS)) { + throw new Error("transaction output total is outside the BSV satoshi range"); + } + if (inputTotal - outputTotal !== BigInt(terms.fee)) { + throw new Error("inputs minus outputs does not equal the authorized fee"); + } + const deltas = computeDeltas(terms, parsed); + const maxima = maximumByOwner(terms); + let amount = 0n; + terms.chargedOwners.forEach(owner => { + const delta = deltas.get(owner) ?? 0n; + const maximum = maxima.get(owner) ?? 0n; + if (delta < 0n || delta > maximum) { + throw new Error(`owner ${owner} net delta is outside its authorized maximum`); + } + amount += delta; + }); + return { parsed, deltas, amount }; +}; + +const validateControlSequence = (terms: DecodedTerms, nSequence: number): void => { + if ( + !Number.isSafeInteger(nSequence) || + nSequence < terms.sequenceStart || + nSequence > FINAL_SEQUENCE + ) { + throw new Error("control nSequence is outside the authorized range"); + } +}; + +const attachCapSignatures = ( + transaction: Transaction, + authorization: UptoBsvAuthorization, + terms: DecodedTerms, +): void => { + const signatures = new Map( + authorization.capSignatures.map(signature => [signature.inputIndex, signature]), + ); + capIndexes(terms).forEach(index => { + const signature = signatures.get(index); + if (!signature) throw new Error(`missing cap signature for input ${index}`); + transaction.inputs[index].unlockingScript = unlockingScript( + Utils.toArray(signature.transactionSignature, "base64"), + terms.inputs[index].publicKey, + CAP_SIGHASH, + ); + }); +}; + +export const buildUptoTransactionVersion = async ( + authorization: UptoBsvAuthorization, + args: BuildUptoBsvTransactionVersionArgs, + controlSigners: UptoBsvInputSigners, +): Promise => { + const verified = verifyUptoAuthorization(authorization); + const { terms } = verified; + validateControlSequence(terms, args.nSequence); + const { parsed } = validateAmounts(terms, args.outputAmounts); + const transaction = buildTemplate(terms, args.nSequence); + transaction.outputs.forEach((output, index) => { + output.satoshis = toSafeSatoshis(parsed[index], `output ${index}`); + }); + attachCapSignatures(transaction, authorization, terms); + for (const index of controlIndexes(terms)) { + const signature = await requireSigner( + controlSigners, + index, + )(signatureDigest(transaction, index, CONTROL_SIGHASH)); + transaction.inputs[index].unlockingScript = unlockingScript( + signature, + terms.inputs[index].publicKey, + CONTROL_SIGHASH, + ); + } + transaction.inputs.forEach((_, index) => { + if (!validateSpend(transaction, index)) { + throw new Error(`constructed transaction input ${index} failed validation`); + } + }); + return { + authorizationId: authorization.authorizationId, + transaction: Utils.toBase64(transaction.toAtomicBEEF()), + }; +}; + +export const verifyUptoTransactionVersion = ( + authorization: UptoBsvAuthorization, + version: UptoBsvTransactionVersion, +): VerifiedUptoBsvTransactionVersion => { + const verified = verifyUptoAuthorization(authorization); + const { terms } = verified; + if (version.authorizationId !== authorization.authorizationId) { + throw new Error("transaction version belongs to a different authorization"); + } + const transaction = decodeBeefSubject(version.transaction); + if ( + transaction.version !== 1 || + transaction.lockTime !== terms.nLockTime || + transaction.inputs.length !== terms.inputs.length || + transaction.outputs.length !== terms.outputs.length + ) { + throw new Error("signed transaction shape differs from its authorization"); + } + const sequences = controlIndexes(terms).map( + index => transaction.inputs[index].sequence ?? FINAL_SEQUENCE, + ); + const nSequence = sequences[0]; + if (nSequence === undefined || sequences.some(sequence => sequence !== nSequence)) { + throw new Error("control inputs must use one common nSequence"); + } + validateControlSequence(terms, nSequence); + transaction.inputs.forEach((input, index) => { + const expected = terms.inputs[index]; + input.sourceTransaction = expected.source; + const sourceTxid = input.sourceTXID ?? input.sourceTransaction.id("hex"); + const inputSequence = expected.kind === "cap" ? FINAL_SEQUENCE : nSequence; + if ( + sourceTxid !== expected.source.id("hex") || + input.sourceOutputIndex !== expected.sourceOutputIndex || + input.sequence !== inputSequence + ) { + throw new Error(`transaction input ${index} differs from its authorization`); + } + const scope = input.unlockingScript?.chunks[0]?.data?.at(-1); + const expectedScope = expected.kind === "cap" ? CAP_SIGHASH : CONTROL_SIGHASH; + if (scope !== (expectedScope & 0xff) || !validateSpend(transaction, index)) { + throw new Error(`transaction input ${index} has an invalid signature`); + } + }); + transaction.outputs.forEach((output, index) => { + if (output.lockingScript.toHex() !== terms.outputs[index].lockingScript.toLowerCase()) { + throw new Error(`transaction output ${index} script changed`); + } + }); + const outputAmounts = transaction.outputs.map(output => String(output.satoshis ?? 0)); + const { deltas, amount } = validateAmounts(terms, outputAmounts); + return { + version, + transaction, + txid: transaction.id("hex"), + nSequence, + cooperativeClose: nSequence === FINAL_SEQUENCE, + outputAmounts, + ownerDeltas: Object.fromEntries( + [...deltas.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([owner, delta]) => [owner, delta.toString()]), + ), + amount: amount.toString(), + }; +}; + +export const assertUptoVersionProgression = ( + authorization: UptoBsvAuthorization, + previous: UptoBsvTransactionVersion, + next: UptoBsvTransactionVersion, +): void => { + const prior = verifyUptoTransactionVersion(authorization, previous); + const current = verifyUptoTransactionVersion(authorization, next); + if (prior.cooperativeClose) { + throw new Error("a cooperatively closed transaction cannot advance"); + } + if (prior.nSequence === FINAL_SEQUENCE - 1 || current.nSequence !== prior.nSequence + 1) { + if (current.nSequence !== FINAL_SEQUENCE) { + throw new Error("control nSequence must advance by one or cooperatively close"); + } + } + if (BigInt(current.amount) < BigInt(prior.amount)) { + throw new Error("stream amount must not decrease"); + } +}; + +export const uptoP2pkhScript = (publicKey: string): string => { + assertPublicKey(publicKey, "publicKey"); + const hash = PublicKey.fromString(publicKey).toHash("hex") as string; + return `76a914${hash}88ac`; +}; + +export const findBeefOutput = ( + encoded: string, + lockingScript: string, + satoshis: number, +): { transaction: Transaction; outputIndex: number } => { + const transaction = decodeBeefSubject(encoded); + const indexes = transaction.outputs.flatMap((output, index) => + output.satoshis === satoshis && output.lockingScript.toHex() === lockingScript ? [index] : [], + ); + if (indexes.length !== 1) throw new Error("BEEF must contain one matching source output"); + return { transaction, outputIndex: indexes[0] }; +}; + +export const inspectUptoInput = ( + input: UptoBsvInput, +): { txid: string; satoshis: string; lockingScript: string } => { + const transaction = decodeBeefSubject(input.sourceTransaction); + const output = transaction.outputs[input.sourceOutputIndex]; + if (!output || output.satoshis === undefined) throw new Error("input source output is missing"); + return { + txid: transaction.id("hex"), + satoshis: String(output.satoshis), + lockingScript: output.lockingScript.toHex(), + }; +}; + +export const uptoInputSatoshis = (input: UptoBsvInput): string => { + return inspectUptoInput(input).satoshis; +}; diff --git a/typescript/packages/mechanisms/bsv/test/unit/constants.test.ts b/typescript/packages/mechanisms/bsv/test/unit/constants.test.ts index 798106cee8..33d10bfe2b 100644 --- a/typescript/packages/mechanisms/bsv/test/unit/constants.test.ts +++ b/typescript/packages/mechanisms/bsv/test/unit/constants.test.ts @@ -25,12 +25,7 @@ describe("constants", () => { expect(BSV_TTN_CAIP2).toBe("bsv:ttn"); expect(BSV_TSTN_CAIP2).toBe("bsv:tstn"); expect(BSV_WILDCARD_CAIP2).toBe("bsv:*"); - expect(BSV_NETWORKS).toEqual([ - "bsv:mainnet", - "bsv:testnet", - "bsv:ttn", - "bsv:tstn", - ]); + expect(BSV_NETWORKS).toEqual(["bsv:mainnet", "bsv:testnet", "bsv:ttn", "bsv:tstn"]); }); it("uses the BRC-29 protocol ID for key derivation", () => { diff --git a/typescript/packages/mechanisms/bsv/test/unit/upto-client.test.ts b/typescript/packages/mechanisms/bsv/test/unit/upto-client.test.ts new file mode 100644 index 0000000000..da5e05f4de --- /dev/null +++ b/typescript/packages/mechanisms/bsv/test/unit/upto-client.test.ts @@ -0,0 +1,323 @@ +import { Beef, BigNumber, ECDSA, LockingScript, PrivateKey, Transaction, Utils } from "@bsv/sdk"; +import type { WalletInterface } from "@bsv/sdk"; +import type { PaymentRequirements } from "@x402/core/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BRC29_PROTOCOL_ID, BSV_TESTNET_CAIP2 } from "../../src/constants"; +import type { + UptoBsvControlProposal, + UptoBsvControlRequest, + UptoBsvPayload, +} from "../../src/types"; +import { UptoBsvScheme, type UptoBsvControlProvider } from "../../src/upto/client/scheme"; +import { BSV_UPTO_PROTOCOL_ID, uptoControlKeyId, p2pkhLockingScript } from "../../src/upto/shared"; +import { buildUptoTransactionVersion, verifyUptoAuthorization } from "../../src/upto/transaction"; + +const NOW_SECONDS = 1_700_000_000; +const PAYER_KEY = PrivateKey.fromRandom(); +const PAYMENT_KEY = PrivateKey.fromRandom(); +const CAP_KEY = PrivateKey.fromRandom(); +const PAYEE_IDENTITY_KEY = PrivateKey.fromRandom(); +const CONTROL_KEY = PrivateKey.fromRandom(); + +const PAYER = PAYER_KEY.toPublicKey().toString(); +const PAY_TO = PAYEE_IDENTITY_KEY.toPublicKey().toString(); +const PAYMENT_PUBLIC_KEY = PAYMENT_KEY.toPublicKey().toString(); +const CAP_PUBLIC_KEY = CAP_KEY.toPublicKey().toString(); +const CONTROL_PUBLIC_KEY = CONTROL_KEY.toPublicKey().toString(); + +interface Fixture { + wallet: WalletInterface; + controlProvider: UptoBsvControlProvider; + createControlProposal: ReturnType; +} + +/** Builds one parseable BEEF source transaction paying a P2PKH output. */ +function sourceTransaction( + publicKey: string, + satoshis: number, + tag: number, +): { binary: number[]; encoded: string; txid: string } { + const transaction = new Transaction(1, [], [], tag); + transaction.addOutput({ + lockingScript: LockingScript.fromHex(p2pkhLockingScript(publicKey)), + satoshis, + }); + const beef = new Beef(); + beef.mergeTransaction(transaction); + const binary = beef.toBinary(); + return { + binary, + encoded: Utils.toBase64(binary), + txid: transaction.id("hex"), + }; +} + +/** Signs an already-computed digest with the cap key. */ +function signDigest(digest: number[]): number[] { + return ECDSA.sign(new BigNumber(digest), CAP_KEY, true).toDER() as number[]; +} + +/** Builds valid BSV upto payment requirements. */ +function requirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "upto", + network: BSV_TESTNET_CAIP2, + asset: "BSV", + amount: "1000", + payTo: PAY_TO, + maxTimeoutSeconds: 300, + extra: {}, + ...overrides, + }; +} + +/** Builds a valid recipient control proposal bound to the payment request. */ +function controlProposal( + request: UptoBsvControlRequest, + overrides: Partial = {}, +): UptoBsvControlProposal { + const source = sourceTransaction(CONTROL_PUBLIC_KEY, 2, 2); + return { + inputs: [ + { + owner: request.payTo, + kind: "control", + sourceTransaction: source.encoded, + sourceOutputIndex: 0, + publicKey: CONTROL_PUBLIC_KEY, + }, + ], + fee: "1", + sequenceStart: 1, + validAfter: NOW_SECONDS, + deadline: NOW_SECONDS + request.maxTimeoutSeconds, + nLockTime: NOW_SECONDS + 120, + ...overrides, + }; +} + +/** Creates a wallet and recipient-provider pair for the public client flow. */ +function fixture( + proposalFactory: (request: UptoBsvControlRequest) => UptoBsvControlProposal = controlProposal, +): Fixture { + const createControlProposal = vi.fn(async (request: UptoBsvControlRequest) => + proposalFactory(request), + ); + const wallet = { + getPublicKey: vi.fn( + async (args: { + identityKey?: boolean; + protocolID?: readonly [number, string]; + keyID?: string; + }) => { + if (args.identityKey) return { publicKey: PAYER }; + if (args.protocolID?.[1] === BSV_UPTO_PROTOCOL_ID[1]) { + return { publicKey: CAP_PUBLIC_KEY }; + } + if (args.keyID?.endsWith("upto-control-0")) { + return { publicKey: CONTROL_PUBLIC_KEY }; + } + return { publicKey: PAYMENT_PUBLIC_KEY }; + }, + ), + createAction: vi.fn( + async (args: { outputs?: Array<{ lockingScript: string; satoshis: number }> }) => { + const output = args.outputs?.[0]; + if (!output) throw new Error("test wallet expected one cap output"); + const source = sourceTransaction(CAP_PUBLIC_KEY, output.satoshis, 1); + expect(output.lockingScript).toBe(p2pkhLockingScript(CAP_PUBLIC_KEY)); + return { tx: source.binary, txid: source.txid }; + }, + ), + createSignature: vi.fn(async (args: { hashToDirectlySign?: number[] }) => { + if (!args.hashToDirectlySign) throw new Error("test wallet requires direct digest signing"); + return { signature: signDigest(args.hashToDirectlySign) }; + }), + } as unknown as WalletInterface; + return { + wallet, + controlProvider: { createControlProposal }, + createControlProposal, + }; +} + +describe("UptoBsvScheme (client)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW_SECONDS * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("creates a verifiable maximum authorization without selecting a transaction version", async () => { + const { wallet, controlProvider, createControlProposal } = fixture(); + const scheme = new UptoBsvScheme(wallet, { controlProvider }); + + const result = await scheme.createPaymentPayload(2, requirements()); + const payload = result.payload as unknown as UptoBsvPayload; + const verified = verifyUptoAuthorization(payload.authorization); + + expect(scheme.scheme).toBe("upto"); + expect(result.x402Version).toBe(2); + expect(payload).not.toHaveProperty("transaction"); + expect(payload.transactionVersion).toBeUndefined(); + expect(verified.maximumAmount).toBe("1000"); + expect(payload.authorization.terms.network).toBe(BSV_TESTNET_CAIP2); + expect(payload.authorization.terms.asset).toBe("BSV"); + expect(payload.authorization.terms.payTo).toBe(PAY_TO); + expect(payload.authorization.terms.senderIdentityKey).toBe(PAYER); + expect(payload.authorization.terms.inputs.map(input => input.kind)).toEqual(["cap", "control"]); + expect(payload.authorization.terms.inputs[1].owner).toBe(PAY_TO); + expect(payload.authorization.terms.paymentOutputIndexes).toEqual([2]); + expect(payload.outputIndex).toBe(2); + + expect(createControlProposal).toHaveBeenCalledOnce(); + expect(createControlProposal).toHaveBeenCalledWith({ + network: BSV_TESTNET_CAIP2, + payTo: PAY_TO, + senderIdentityKey: PAYER, + derivationPrefix: payload.derivationPrefix, + derivationSuffix: payload.derivationSuffix, + maxAmount: "1000", + maxTimeoutSeconds: 300, + }); + expect(wallet.getPublicKey).toHaveBeenCalledWith( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: `${payload.derivationPrefix} ${payload.derivationSuffix}`, + counterparty: PAY_TO, + }, + undefined, + ); + expect(wallet.getPublicKey).toHaveBeenCalledWith( + { + protocolID: BRC29_PROTOCOL_ID, + keyID: uptoControlKeyId(payload.derivationPrefix, payload.derivationSuffix, 0), + counterparty: PAY_TO, + }, + undefined, + ); + + const actionArgs = vi.mocked(wallet.createAction).mock.calls[0][0]; + expect(actionArgs.outputs?.[0].satoshis).toBe(1001); + expect(actionArgs.options).toEqual({ noSend: true, randomizeOutputs: false }); + expect(wallet.createSignature).toHaveBeenCalledTimes(2); + for (const [signatureArgs] of vi.mocked(wallet.createSignature).mock.calls) { + expect(signatureArgs).toMatchObject({ + protocolID: BSV_UPTO_PROTOCOL_ID, + keyID: `${payload.derivationPrefix} ${payload.derivationSuffix} cap-0`, + counterparty: "self", + }); + expect(signatureArgs.hashToDirectlySign).toHaveLength(32); + } + }); + + it("lets the payer verify each fully signed transaction and its progression", async () => { + const { wallet, controlProvider } = fixture(); + const scheme = new UptoBsvScheme(wallet, { controlProvider }); + const created = await scheme.createPaymentPayload(2, requirements()); + const payload = created.payload as unknown as UptoBsvPayload; + const controlSigner = async (digest: number[]): Promise => + ECDSA.sign(new BigNumber(digest), CONTROL_KEY, true).toDER() as number[]; + const first = await buildUptoTransactionVersion( + payload.authorization, + { nSequence: 1, outputAmounts: ["1", "600", "401"] }, + { 1: controlSigner }, + ); + const second = await buildUptoTransactionVersion( + payload.authorization, + { nSequence: 2, outputAmounts: ["1", "500", "501"] }, + { 1: controlSigner }, + ); + + expect(scheme.verifyTransactionVersion(payload, first)).toMatchObject({ + amount: "400", + nSequence: 1, + cooperativeClose: false, + ownerDeltas: { [PAYER]: "400", [PAY_TO]: "-399" }, + }); + expect(scheme.verifyTransactionVersion(payload, second, first)).toMatchObject({ + amount: "500", + nSequence: 2, + }); + expect(() => scheme.verifyTransactionVersion(payload, second)).toThrow( + /first non-final.*sequenceStart/i, + ); + expect(() => scheme.verifyTransactionVersion(payload, second, second)).toThrow( + /advance by one/i, + ); + }); + + it("accepts a minimal control input only when its value exceeds the fee", async () => { + const zeroFee = fixture(request => controlProposal(request, { fee: "0" })); + await expect( + new UptoBsvScheme(zeroFee.wallet, { + controlProvider: zeroFee.controlProvider, + }).createPaymentPayload(2, requirements()), + ).resolves.toBeDefined(); + + const valid = fixture(request => controlProposal(request)); + await expect( + new UptoBsvScheme(valid.wallet, { + controlProvider: valid.controlProvider, + }).createPaymentPayload(2, requirements()), + ).resolves.toBeDefined(); + + const equalToFee = fixture(request => { + const source = sourceTransaction(CONTROL_PUBLIC_KEY, 1, 3); + const proposal = controlProposal(request); + return { + ...proposal, + inputs: [ + { + ...proposal.inputs[0], + sourceTransaction: source.encoded, + }, + ], + }; + }); + await expect( + new UptoBsvScheme(equalToFee.wallet, { + controlProvider: equalToFee.controlProvider, + }).createPaymentPayload(2, requirements()), + ).rejects.toThrow(/must exceed.*fee/i); + expect(equalToFee.wallet.createAction).not.toHaveBeenCalled(); + expect(equalToFee.wallet.createSignature).not.toHaveBeenCalled(); + }); + + it("rejects a control input that is not bound to the recipient", async () => { + const unrelatedOwner = PrivateKey.fromRandom().toPublicKey().toString(); + const invalid = fixture(request => { + const proposal = controlProposal(request); + return { + ...proposal, + inputs: [{ ...proposal.inputs[0], owner: unrelatedOwner }], + }; + }); + + await expect( + new UptoBsvScheme(invalid.wallet, { + controlProvider: invalid.controlProvider, + }).createPaymentPayload(2, requirements()), + ).rejects.toThrow(/bound to payTo/i); + expect(invalid.wallet.createAction).not.toHaveBeenCalled(); + expect(invalid.wallet.createSignature).not.toHaveBeenCalled(); + }); + + it("rejects a zero maximum before consulting the recipient or wallet", async () => { + const { wallet, controlProvider, createControlProposal } = fixture(); + + await expect( + new UptoBsvScheme(wallet, { controlProvider }).createPaymentPayload( + 2, + requirements({ amount: "0" }), + ), + ).rejects.toThrow(/amount/i); + expect(createControlProposal).not.toHaveBeenCalled(); + expect(wallet.getPublicKey).not.toHaveBeenCalled(); + expect(wallet.createAction).not.toHaveBeenCalled(); + expect(wallet.createSignature).not.toHaveBeenCalled(); + }); +}); diff --git a/typescript/packages/mechanisms/bsv/test/unit/upto-facilitator.test.ts b/typescript/packages/mechanisms/bsv/test/unit/upto-facilitator.test.ts new file mode 100644 index 0000000000..7e443ee74b --- /dev/null +++ b/typescript/packages/mechanisms/bsv/test/unit/upto-facilitator.test.ts @@ -0,0 +1,839 @@ +import { Beef, BigNumber, ECDSA, LockingScript, PrivateKey, Transaction, Utils } from "@bsv/sdk"; +import type { WalletInterface } from "@bsv/sdk"; +import { x402ResourceServer, type FacilitatorClient, type SettleContext } from "@x402/core/server"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BSV_TESTNET_CAIP2 } from "../../src/constants"; +import { brc29PaymentKeyId, uptoControlKeyId } from "../../src/upto/shared"; +import type { + UptoBsvAuthorizationTerms, + UptoBsvPayload, + UptoBsvTransactionVersion, +} from "../../src/types"; +import { UptoBsvScheme, type UptoBsvSettlementStore } from "../../src/upto/facilitator/scheme"; +import { UptoBsvScheme as UptoBsvServerScheme } from "../../src/upto/server/scheme"; +import { + buildUptoTransactionVersion, + signUptoAuthorization, + uptoP2pkhScript, + verifyUptoTransactionVersion, + type UptoBsvDigestSigner, +} from "../../src/upto/transaction"; + +const NOW = 1_700_000_000_000; +const NOW_SECONDS = Math.floor(NOW / 1000); +const FINAL_SEQUENCE = 0xffffffff; +const MAXIMUM = "8000"; +const FEE = 1; +const CONTROL_SATOSHIS = 101; +const FLOOR = 2_000; +const NON_FINAL_DELAY_SECONDS = 120; + +interface FacilitatorFixture { + scheme: UptoBsvScheme; + wallet: WalletInterface; + payload: UptoBsvPayload; + maximumRequirements: PaymentRequirements; + senderIdentityKey: string; + capSigner: UptoBsvDigestSigner; + controlSigner: UptoBsvDigestSigner; +} + +interface TestSettlementBackend { + entries: Map; + nextToken: number; +} + +const testSettlementBackend = (): TestSettlementBackend => ({ + entries: new Map(), + nextToken: 0, +}); + +/** Test adapter whose backend can outlive an individual facilitator instance. */ +class TestSettlementStore implements UptoBsvSettlementStore { + constructor(private readonly backend = testSettlementBackend()) {} + + async tryClaim( + authorizationId: string, + txid: string, + deleteAfterMs: number, + ): Promise<{ claimed: true; token: string } | { claimed: false; txid: string }> { + const existing = this.backend.entries.get(authorizationId); + if (existing && existing.deleteAfterMs > Date.now()) { + return { claimed: false, txid: existing.txid }; + } + const token = `claim-${this.backend.nextToken++}`; + this.backend.entries.set(authorizationId, { txid, deleteAfterMs, token }); + return { claimed: true, token }; + } + + async release(authorizationId: string, token: string): Promise { + if (this.backend.entries.get(authorizationId)?.token === token) { + this.backend.entries.delete(authorizationId); + } + } +} + +/** Signs an already-computed digest without hashing it again. */ +const digestSigner = + (privateKey: PrivateKey): UptoBsvDigestSigner => + async digest => + ECDSA.sign(new BigNumber(digest), privateKey, true).toDER() as number[]; + +/** Creates a plain BEEF source transaction with one spendable P2PKH output. */ +const sourceTransaction = (privateKey: PrivateKey, satoshis: number, tag: number): string => { + const transaction = new Transaction(1, [], [], tag); + transaction.addOutput({ + lockingScript: LockingScript.fromHex(uptoP2pkhScript(privateKey.toPublicKey().toString())), + satoshis, + }); + const beef = new Beef(); + beef.mergeTransaction(transaction); + return Utils.toBase64(beef.toBinary()); +}; + +/** Builds actual-amount requirements while retaining the original maximum elsewhere. */ +const settlementRequirements = ( + fixture: FacilitatorFixture, + amount: string, +): PaymentRequirements => ({ + ...fixture.maximumRequirements, + amount, +}); + +/** Wraps an upto payload in the x402 envelope used by facilitator hooks. */ +const paymentEnvelope = ( + fixture: FacilitatorFixture, + transactionVersion?: UptoBsvTransactionVersion, + accepted: PaymentRequirements = fixture.maximumRequirements, +): PaymentPayload => ({ + x402Version: 2, + accepted, + payload: { + ...fixture.payload, + ...(transactionVersion ? { transactionVersion } : {}), + } as unknown as PaymentPayload["payload"], +}); + +/** Creates a valid maximum authorization and a wallet that signs control inputs for real. */ +const makeFixture = async ( + verifyBeef: (beefBytes: number[], subjectTxid: string) => Promise = vi + .fn() + .mockResolvedValue(true), + settlementStore: UptoBsvSettlementStore = new TestSettlementStore(), +): Promise => { + const capKey = PrivateKey.fromRandom(); + const recipientIdentityKey = PrivateKey.fromRandom(); + const paymentKey = PrivateKey.fromRandom(); + const controlKey = PrivateKey.fromRandom(); + const senderIdentityKey = capKey.toPublicKey().toString(); + const payTo = recipientIdentityKey.toPublicKey().toString(); + const derivationPrefix = Utils.toBase64([1, 2, 3, 4, 5, 6, 7, 8]); + const derivationSuffix = Utils.toBase64(Utils.toArray(String(NOW), "utf8")); + const paymentKeyId = brc29PaymentKeyId(derivationPrefix, derivationSuffix); + const controlKeyId = uptoControlKeyId(derivationPrefix, derivationSuffix, 0); + const terms: UptoBsvAuthorizationTerms = { + version: 1, + network: BSV_TESTNET_CAIP2, + asset: "BSV", + payTo, + senderIdentityKey, + derivationPrefix, + derivationSuffix, + inputs: [ + { + owner: senderIdentityKey, + kind: "cap", + sourceTransaction: sourceTransaction(capKey, 10_000, 1), + sourceOutputIndex: 0, + publicKey: senderIdentityKey, + }, + { + owner: payTo, + kind: "control", + sourceTransaction: sourceTransaction(controlKey, CONTROL_SATOSHIS, 2), + sourceOutputIndex: 0, + publicKey: controlKey.toPublicKey().toString(), + }, + ], + outputs: [ + { + owner: senderIdentityKey, + lockingScript: uptoP2pkhScript(senderIdentityKey), + fixedAmount: String(FLOOR), + }, + { + owner: senderIdentityKey, + lockingScript: uptoP2pkhScript(senderIdentityKey), + }, + { + owner: payTo, + lockingScript: uptoP2pkhScript(paymentKey.toPublicKey().toString()), + }, + ], + chargedOwners: [senderIdentityKey], + paymentOutputIndexes: [2], + fee: String(FEE), + sequenceStart: 1, + validAfter: NOW_SECONDS, + deadline: NOW_SECONDS + 300, + nLockTime: NOW_SECONDS + NON_FINAL_DELAY_SECONDS, + }; + const capSigner = digestSigner(capKey); + const authorization = await signUptoAuthorization(terms, { 0: capSigner }); + const internalizeAction = vi.fn().mockResolvedValue({ accepted: true }); + const getPublicKey = vi.fn().mockImplementation(async (args: { keyID?: string }) => { + if (args.keyID === paymentKeyId) return { publicKey: paymentKey.toPublicKey().toString() }; + if (args.keyID === controlKeyId) return { publicKey: controlKey.toPublicKey().toString() }; + throw new Error(`unexpected keyID ${args.keyID ?? "none"}`); + }); + const createSignature = vi + .fn() + .mockImplementation(async (args: { keyID: string; hashToDirectlySign: number[] }) => { + if (args.keyID !== controlKeyId) throw new Error(`unexpected keyID ${args.keyID}`); + return { signature: await digestSigner(controlKey)(Array.from(args.hashToDirectlySign)) }; + }); + const wallet = { + getNetwork: vi.fn().mockResolvedValue({ network: "testnet" }), + getPublicKey, + createSignature, + internalizeAction, + } as unknown as WalletInterface; + const scheme = new UptoBsvScheme({ + wallet, + identityKey: payTo, + feeSatoshis: FEE, + controlSatoshis: CONTROL_SATOSHIS, + nonFinalDelaySeconds: NON_FINAL_DELAY_SECONDS, + verifyBeef, + settlementStore, + }); + const maximumRequirements: PaymentRequirements = { + scheme: "upto", + network: BSV_TESTNET_CAIP2, + asset: "BSV", + amount: MAXIMUM, + payTo, + maxTimeoutSeconds: 300, + extra: {}, + }; + return { + scheme, + wallet, + maximumRequirements, + senderIdentityKey, + capSigner, + controlSigner: digestSigner(controlKey), + payload: { + derivationPrefix, + derivationSuffix, + senderIdentityKey, + outputIndex: 2, + authorization, + }, + }; +}; + +describe("UptoBsvScheme (facilitator)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("verifies the reusable authorization at its maximum amount", async () => { + const fixture = await makeFixture(); + + await expect( + fixture.scheme.verify(paymentEnvelope(fixture), fixture.maximumRequirements), + ).resolves.toEqual({ isValid: true, payer: fixture.senderIdentityKey }); + + const wrongMaximum = { ...fixture.maximumRequirements, amount: "7999" }; + const invalid = await fixture.scheme.verify( + paymentEnvelope(fixture, undefined, wrongMaximum), + wrongMaximum, + ); + expect(invalid).toMatchObject({ + isValid: false, + invalidReason: "invalid_upto_bsv_maximum_mismatch", + }); + }); + + it("rejects a client-owned settlement version before server enrichment", async () => { + const fixture = await makeFixture(); + const envelope = paymentEnvelope(fixture); + envelope.payload = { + ...envelope.payload, + transactionVersion: undefined, + } as unknown as PaymentPayload["payload"]; + + await expect( + fixture.scheme.verify(envelope, fixture.maximumRequirements), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: "unexpected_transaction_version", + }); + }); + + it("revalidates the recipient fee and timing policy before signing", async () => { + const fixture = await makeFixture(); + const feeAuthorization = await signUptoAuthorization( + { ...fixture.payload.authorization.terms, fee: "8000" }, + { 0: fixture.capSigner }, + ); + const timingAuthorization = await signUptoAuthorization( + { + ...fixture.payload.authorization.terms, + nLockTime: fixture.payload.authorization.terms.nLockTime + 1, + }, + { 0: fixture.capSigner }, + ); + + for (const authorization of [feeAuthorization, timingAuthorization]) { + const altered = { + ...fixture, + payload: { ...fixture.payload, authorization }, + }; + await expect( + fixture.scheme.verify(paymentEnvelope(altered), fixture.maximumRequirements), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: "invalid_upto_bsv_facilitator_policy", + }); + } + }); + + it("can reject an authorization whose source BEEF fails early SPV validation", async () => { + const verifyBeef = vi.fn().mockResolvedValue(false); + const fixture = await makeFixture(verifyBeef); + + await expect( + fixture.scheme.verify(paymentEnvelope(fixture), fixture.maximumRequirements), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: "invalid_upto_bsv_source_spv", + }); + expect(verifyBeef).toHaveBeenCalledOnce(); + }); + + it("creates the default zero and partial-amount output layouts", async () => { + const fixture = await makeFixture(); + + const zero = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "0" }, + ); + const partial = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500" }, + ); + + expect(verifyUptoTransactionVersion(fixture.payload.authorization, zero)).toMatchObject({ + amount: "0", + nSequence: 1, + outputAmounts: ["2000", "8000", "100"], + }); + expect(verifyUptoTransactionVersion(fixture.payload.authorization, partial)).toMatchObject({ + amount: "2500", + nSequence: 1, + outputAmounts: ["2000", "5500", "2600"], + }); + }); + + it("rejects a signed allocation whose owner labels overstate the recipient payment", async () => { + const fixture = await makeFixture(); + const misleadingTerms: UptoBsvAuthorizationTerms = { + ...fixture.payload.authorization.terms, + outputs: fixture.payload.authorization.terms.outputs.map((output, index) => + index === 1 ? { ...output, owner: "not-the-payer" } : output, + ), + }; + const authorization = await signUptoAuthorization(misleadingTerms, { + 0: fixture.capSigner, + }); + const misleadingPayload = { ...fixture.payload, authorization }; + const outputAmounts = [String(FLOOR), "8100", "0"]; + + await expect( + fixture.scheme.createTransactionVersion(misleadingPayload, fixture.maximumRequirements, { + amount: MAXIMUM, + outputAmounts, + }), + ).rejects.toThrow("invalid_upto_bsv_recipient_amount_shortfall"); + + const signed = await buildUptoTransactionVersion( + authorization, + { nSequence: FINAL_SEQUENCE, outputAmounts }, + { 1: fixture.controlSigner }, + ); + await expect( + fixture.scheme.settle( + paymentEnvelope({ ...fixture, payload: misleadingPayload }, signed), + settlementRequirements(fixture, MAXIMUM), + ), + ).resolves.toMatchObject({ + success: false, + errorReason: "invalid_upto_bsv_recipient_amount_shortfall", + }); + }); + + it("advances stream nSequence, rejects amount rollback, and cooperatively closes", async () => { + const fixture = await makeFixture(); + const first = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "1000" }, + ); + const second = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", previous: first }, + ); + + expect(verifyUptoTransactionVersion(fixture.payload.authorization, first).nSequence).toBe(1); + expect(verifyUptoTransactionVersion(fixture.payload.authorization, second)).toMatchObject({ + amount: "2500", + nSequence: 2, + cooperativeClose: false, + }); + await expect( + fixture.scheme.createTransactionVersion(fixture.payload, fixture.maximumRequirements, { + amount: "2499", + previous: second, + }), + ).rejects.toThrow(/amount must not decrease/i); + + const close = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", previous: second, cooperativeClose: true }, + ); + const verifiedClose = verifyUptoTransactionVersion(fixture.payload.authorization, close); + expect(verifiedClose).toMatchObject({ + amount: "2500", + nSequence: FINAL_SEQUENCE, + cooperativeClose: true, + }); + expect(verifiedClose.transaction.inputs[0].sequence).toBe(FINAL_SEQUENCE); + expect(verifiedClose.transaction.inputs[1].sequence).toBe(FINAL_SEQUENCE); + }); + + it("recomputes settlement amount, rejects a premature version, and rejects replay", async () => { + const fixture = await makeFixture(); + const intermediate = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500" }, + ); + + const premature = await fixture.scheme.settle( + paymentEnvelope(fixture, intermediate), + settlementRequirements(fixture, "2500"), + ); + expect(premature).toMatchObject({ success: false, errorReason: "transaction_non_final" }); + + const close = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", previous: intermediate, cooperativeClose: true }, + ); + const mismatch = await fixture.scheme.settle( + paymentEnvelope(fixture, close), + settlementRequirements(fixture, "2499"), + ); + expect(mismatch).toMatchObject({ + success: false, + errorReason: "invalid_upto_bsv_amount_mismatch", + }); + + const settlementPayload = paymentEnvelope(fixture, close); + const actualRequirements = settlementRequirements(fixture, "2500"); + const first = await fixture.scheme.settle(settlementPayload, actualRequirements); + const replay = await fixture.scheme.settle(settlementPayload, actualRequirements); + const verified = verifyUptoTransactionVersion(fixture.payload.authorization, close); + + expect(first).toMatchObject({ + success: true, + network: BSV_TESTNET_CAIP2, + transaction: verified.txid, + payer: fixture.senderIdentityKey, + amount: "2500", + }); + expect(replay).toMatchObject({ success: false, errorReason: "duplicate_settlement" }); + expect(fixture.wallet.internalizeAction).toHaveBeenCalledTimes(1); + expect(fixture.wallet.internalizeAction).toHaveBeenCalledWith( + { + tx: Utils.toArray(close.transaction, "base64"), + outputs: [ + { + outputIndex: 2, + protocol: "wallet payment", + paymentRemittance: { + derivationPrefix: fixture.payload.derivationPrefix, + derivationSuffix: fixture.payload.derivationSuffix, + senderIdentityKey: fixture.senderIdentityKey, + }, + }, + ], + description: "x402 upto payment", + }, + undefined, + ); + }); + + it("enforces validAfter, nLockTime, and deadline as distinct settlement bounds", async () => { + const fixture = await makeFixture(); + + vi.setSystemTime(new Date(NOW - 1_000)); + await expect( + fixture.scheme.verify(paymentEnvelope(fixture), fixture.maximumRequirements), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: "upto_authorization_out_of_window", + }); + + vi.setSystemTime(new Date(NOW)); + const intermediate = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500" }, + ); + vi.setSystemTime(new Date((NOW_SECONDS + NON_FINAL_DELAY_SECONDS) * 1000)); + await expect( + fixture.scheme.settle( + paymentEnvelope(fixture, intermediate), + settlementRequirements(fixture, "2500"), + ), + ).resolves.toMatchObject({ success: true, amount: "2500" }); + + const laterVersion = await makeFixture(); + await expect( + laterVersion.scheme.createTransactionVersion( + laterVersion.payload, + laterVersion.maximumRequirements, + { amount: "2500" }, + ), + ).resolves.toBeDefined(); + + const expired = await makeFixture(); + vi.setSystemTime(new Date(NOW)); + const close = await expired.scheme.createTransactionVersion( + expired.payload, + expired.maximumRequirements, + { amount: "2500", cooperativeClose: true }, + ); + vi.setSystemTime(new Date(expired.payload.authorization.terms.deadline * 1000)); + await expect( + expired.scheme.settle( + paymentEnvelope(expired, close), + settlementRequirements(expired, "2500"), + ), + ).resolves.toMatchObject({ + success: false, + errorReason: "upto_authorization_out_of_window", + }); + await expect( + expired.scheme.createTransactionVersion(expired.payload, expired.maximumRequirements, { + amount: "2500", + }), + ).rejects.toThrow(/out_of_window/); + + expect(() => verifyUptoTransactionVersion(expired.payload.authorization, close)).not.toThrow(); + }); + + it("rechecks the deadline after asynchronous transaction validation", async () => { + const verifyBeef = vi.fn().mockResolvedValue(true); + const fixture = await makeFixture(verifyBeef); + const close = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", cooperativeClose: true }, + ); + verifyBeef.mockImplementationOnce(async () => { + vi.setSystemTime(new Date(fixture.payload.authorization.terms.deadline * 1000)); + return true; + }); + + await expect( + fixture.scheme.settle( + paymentEnvelope(fixture, close), + settlementRequirements(fixture, "2500"), + ), + ).resolves.toMatchObject({ + success: false, + errorReason: "upto_authorization_out_of_window", + }); + expect(fixture.wallet.internalizeAction).not.toHaveBeenCalled(); + }); + + it("does not call the wallet when an atomic claim completes after the deadline", async () => { + const backingStore = new TestSettlementStore(); + const delayedStore: UptoBsvSettlementStore = { + tryClaim: async (authorizationId, txid, deleteAfterMs) => { + const claim = await backingStore.tryClaim(authorizationId, txid, deleteAfterMs); + vi.setSystemTime(new Date((NOW_SECONDS + 300) * 1000)); + return claim; + }, + release: (authorizationId, token) => backingStore.release(authorizationId, token), + }; + const fixture = await makeFixture(undefined, delayedStore); + const close = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", cooperativeClose: true }, + ); + + await expect( + fixture.scheme.settle( + paymentEnvelope(fixture, close), + settlementRequirements(fixture, "2500"), + ), + ).resolves.toMatchObject({ + success: false, + errorReason: "upto_authorization_out_of_window", + }); + expect(fixture.wallet.internalizeAction).not.toHaveBeenCalled(); + }); + + it("settles through resource-server enrichment with the selected transaction", async () => { + const fixture = await makeFixture(); + const selected = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", cooperativeClose: true }, + ); + const settle = vi.fn((payload: PaymentPayload, requirements: PaymentRequirements) => + fixture.scheme.settle(payload, requirements), + ); + const facilitatorClient: FacilitatorClient = { + getSupported: async () => ({ + kinds: [ + { + x402Version: 2, + scheme: "upto", + network: BSV_TESTNET_CAIP2, + }, + ], + extensions: [], + signers: { [BSV_TESTNET_CAIP2]: [fixture.maximumRequirements.payTo] }, + }), + verify: (payload, requirements) => fixture.scheme.verify(payload, requirements), + settle, + }; + const getTransactionVersion = vi.fn((_context: SettleContext) => selected); + const resourceServer = new x402ResourceServer(facilitatorClient).register( + BSV_TESTNET_CAIP2, + new UptoBsvServerScheme({ getTransactionVersion }), + ); + await resourceServer.initialize(); + const clientEnvelope = paymentEnvelope(fixture); + + const result = await resourceServer.settlePayment( + clientEnvelope, + fixture.maximumRequirements, + undefined, + undefined, + { amount: "2500" }, + ); + + expect(result).toMatchObject({ + success: true, + network: BSV_TESTNET_CAIP2, + amount: "2500", + payer: fixture.senderIdentityKey, + }); + expect(getTransactionVersion).toHaveBeenCalledOnce(); + expect(getTransactionVersion.mock.calls[0][0]).toMatchObject({ + paymentPayload: clientEnvelope, + requirements: { amount: "2500" }, + phase: "after-handler", + }); + expect(settle).toHaveBeenCalledOnce(); + expect(settle.mock.calls[0][0].payload).toEqual({ + ...fixture.payload, + transactionVersion: selected, + }); + expect(settle.mock.calls[0][1].amount).toBe("2500"); + expect(clientEnvelope.payload).toEqual(fixture.payload); + expect(fixture.wallet.internalizeAction).toHaveBeenCalledOnce(); + }); + + it("rejects a different terminal transaction after one authorization settles", async () => { + const fixture = await makeFixture(); + const first = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", cooperativeClose: true }, + ); + const competing = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "3000", cooperativeClose: true }, + ); + + await expect( + fixture.scheme.settle( + paymentEnvelope(fixture, first), + settlementRequirements(fixture, "2500"), + ), + ).resolves.toMatchObject({ success: true, amount: "2500" }); + await expect( + fixture.scheme.settle( + paymentEnvelope(fixture, competing), + settlementRequirements(fixture, "3000"), + ), + ).resolves.toMatchObject({ success: false, errorReason: "authorization_already_settled" }); + expect(fixture.wallet.internalizeAction).toHaveBeenCalledTimes(1); + }); + + it("consumes one authorization across facilitator replicas and restarts", async () => { + const durableBackend = testSettlementBackend(); + const fixture = await makeFixture(undefined, new TestSettlementStore(durableBackend)); + const restarted = new UptoBsvScheme({ + wallet: fixture.wallet, + identityKey: fixture.maximumRequirements.payTo, + feeSatoshis: FEE, + controlSatoshis: CONTROL_SATOSHIS, + nonFinalDelaySeconds: NON_FINAL_DELAY_SECONDS, + verifyBeef: vi.fn().mockResolvedValue(true), + settlementStore: new TestSettlementStore(durableBackend), + }); + const first = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2500", cooperativeClose: true }, + ); + const competing = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "3000", cooperativeClose: true }, + ); + + const settled = await fixture.scheme.settle( + paymentEnvelope(fixture, first), + settlementRequirements(fixture, "2500"), + ); + const afterRestart = await restarted.settle( + paymentEnvelope(fixture, competing), + settlementRequirements(fixture, "3000"), + ); + + expect(settled).toMatchObject({ success: true, amount: "2500" }); + expect(afterRestart).toMatchObject({ + success: false, + errorReason: "authorization_already_settled", + }); + expect(fixture.wallet.internalizeAction).toHaveBeenCalledTimes(1); + }); + + it("honors wallet rejection and merge replay outcomes", async () => { + const rejected = await makeFixture(); + vi.mocked(rejected.wallet.internalizeAction).mockResolvedValueOnce({ + accepted: false, + } as never); + const rejectedClose = await rejected.scheme.createTransactionVersion( + rejected.payload, + rejected.maximumRequirements, + { amount: "1000", cooperativeClose: true }, + ); + const rejectedRequirements = settlementRequirements(rejected, "1000"); + + await expect( + rejected.scheme.settle(paymentEnvelope(rejected, rejectedClose), rejectedRequirements), + ).resolves.toMatchObject({ + success: false, + errorReason: "settlement_rejected_by_wallet", + }); + await expect( + rejected.scheme.settle(paymentEnvelope(rejected, rejectedClose), rejectedRequirements), + ).resolves.toMatchObject({ success: true }); + + const replayed = await makeFixture(); + vi.mocked(replayed.wallet.internalizeAction).mockResolvedValueOnce({ + accepted: true, + isMerge: true, + satoshis: 0, + } as never); + const replayedClose = await replayed.scheme.createTransactionVersion( + replayed.payload, + replayed.maximumRequirements, + { amount: "1000", cooperativeClose: true }, + ); + const replayedPayload = paymentEnvelope(replayed, replayedClose); + const replayedRequirements = settlementRequirements(replayed, "1000"); + + await expect( + replayed.scheme.settle(replayedPayload, replayedRequirements), + ).resolves.toMatchObject({ success: false, errorReason: "duplicate_settlement" }); + await expect( + replayed.scheme.settle(replayedPayload, replayedRequirements), + ).resolves.toMatchObject({ success: false, errorReason: "duplicate_settlement" }); + expect(replayed.wallet.internalizeAction).toHaveBeenCalledTimes(1); + }); + + it("retains the authorization when the wallet result is not a definitive rejection", async () => { + for (const walletResult of [{}, { accepted: false, satoshis: 1 }]) { + const fixture = await makeFixture(); + vi.mocked(fixture.wallet.internalizeAction).mockResolvedValueOnce(walletResult as never); + const first = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "1000", cooperativeClose: true }, + ); + const competing = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "2000", cooperativeClose: true }, + ); + + await expect( + fixture.scheme.settle( + paymentEnvelope(fixture, first), + settlementRequirements(fixture, "1000"), + ), + ).resolves.toMatchObject({ + success: false, + errorReason: "settlement_indeterminate: invalid wallet result", + }); + await expect( + fixture.scheme.settle( + paymentEnvelope(fixture, competing), + settlementRequirements(fixture, "2000"), + ), + ).resolves.toMatchObject({ + success: false, + errorReason: "authorization_already_settled", + }); + expect(fixture.wallet.internalizeAction).toHaveBeenCalledTimes(1); + } + }); + + it("keeps the authorization guarded when the wallet outcome is indeterminate", async () => { + const fixture = await makeFixture(); + vi.mocked(fixture.wallet.internalizeAction).mockRejectedValueOnce( + new Error("transport timed out"), + ); + const close = await fixture.scheme.createTransactionVersion( + fixture.payload, + fixture.maximumRequirements, + { amount: "1000", cooperativeClose: true }, + ); + const payload = paymentEnvelope(fixture, close); + const actual = settlementRequirements(fixture, "1000"); + + await expect(fixture.scheme.settle(payload, actual)).resolves.toMatchObject({ + success: false, + errorReason: "settlement_indeterminate: transport timed out", + }); + await expect(fixture.scheme.settle(payload, actual)).resolves.toMatchObject({ + success: false, + errorReason: "duplicate_settlement", + }); + expect(fixture.wallet.internalizeAction).toHaveBeenCalledOnce(); + }); +}); diff --git a/typescript/packages/mechanisms/bsv/test/unit/upto-server.test.ts b/typescript/packages/mechanisms/bsv/test/unit/upto-server.test.ts new file mode 100644 index 0000000000..c30db90158 --- /dev/null +++ b/typescript/packages/mechanisms/bsv/test/unit/upto-server.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import type { SettleContext } from "@x402/core/server"; +import type { PaymentRequirements } from "@x402/core/types"; +import { BSV_TESTNET_CAIP2 } from "../../src/constants"; +import { ExactBsvScheme } from "../../src/exact/server/scheme"; +import { UptoBsvScheme } from "../../src/upto/server/scheme"; +import type { UptoBsvTransactionVersion } from "../../src/types"; + +const makeRequirements = (overrides: Partial = {}): PaymentRequirements => ({ + scheme: "upto", + network: BSV_TESTNET_CAIP2, + asset: "", + amount: "1000", + payTo: "02".padEnd(66, "a"), + maxTimeoutSeconds: 300, + extra: { keep: true }, + ...overrides, +}); + +const signedVersion: UptoBsvTransactionVersion = { + authorizationId: "ab".repeat(32), + transaction: "AQ==", +}; + +const makeScheme = (): UptoBsvScheme => + new UptoBsvScheme({ getTransactionVersion: () => signedVersion }); + +const makeSettleContext = (phase: SettleContext["phase"], amount = "750"): SettleContext => { + const requirements = makeRequirements({ amount }); + return { + paymentPayload: { + x402Version: 2, + accepted: requirements, + payload: { + authorization: { authorizationId: signedVersion.authorizationId }, + }, + }, + requirements, + declaredExtensions: {}, + phase, + }; +}; + +describe("UptoBsvScheme (server)", () => { + it("declares upto with the exact authorization flow", () => { + const upto = makeScheme(); + const exact = new ExactBsvScheme(); + + expect(upto.scheme).toBe("upto"); + expect(upto.defaultAssetTransferMethod).toBe(exact.defaultAssetTransferMethod); + expect(upto.paymentFlows).toEqual({ + default: { supported: ["authorization"], default: "authorization" }, + }); + }); + + it("uses the exact BSV price and asset semantics", async () => { + const upto = makeScheme(); + const exact = new ExactBsvScheme(); + const price = { amount: "1000", asset: "bsv", extra: { note: "cap" } }; + + await expect(upto.parsePrice(price, BSV_TESTNET_CAIP2)).resolves.toEqual( + await exact.parsePrice(price, BSV_TESTNET_CAIP2), + ); + await expect( + upto.parsePrice({ amount: "1000", asset: "USDC" }, BSV_TESTNET_CAIP2), + ).rejects.toThrow(/Unsupported asset/); + await expect(upto.parsePrice("$0.10", BSV_TESTNET_CAIP2)).rejects.toThrow( + /registerMoneyParser/, + ); + expect(upto.getAssetDecimals("BSV", BSV_TESTNET_CAIP2)).toBe( + exact.getAssetDecimals("BSV", BSV_TESTNET_CAIP2), + ); + }); + + it("supports a chainable exact-compatible money parser", async () => { + const upto = makeScheme(); + const registered = upto + .registerMoneyParser(async () => null) + .registerMoneyParser(async (amount, network) => ({ + amount: String(Math.round(Number(amount) * 2000)), + asset: "BSV", + extra: { network }, + })); + + expect(registered).toBe(upto); + await expect(upto.parsePrice(0.5, BSV_TESTNET_CAIP2)).resolves.toEqual({ + amount: "1000", + asset: "BSV", + extra: { network: BSV_TESTNET_CAIP2 }, + }); + }); + + it("enhances with exact rules while preserving upto", async () => { + const upto = makeScheme(); + const requirements = makeRequirements(); + const enhanced = await upto.enhancePaymentRequirements( + requirements, + { + x402Version: 2, + scheme: "upto", + network: BSV_TESTNET_CAIP2, + extra: { facilitator: "recipient" }, + }, + [], + ); + + expect(enhanced).toMatchObject({ + scheme: "upto", + asset: "BSV", + extra: { keep: true, facilitator: "recipient" }, + }); + expect(requirements).toMatchObject({ scheme: "upto", asset: "", extra: { keep: true } }); + }); + + it("does not change exact scheme behavior", async () => { + const exact = new ExactBsvScheme(); + const requirements = makeRequirements({ scheme: "exact" }); + const enhanced = await exact.enhancePaymentRequirements( + requirements, + { x402Version: 2, scheme: "exact", network: BSV_TESTNET_CAIP2 }, + [], + ); + + expect(exact.scheme).toBe("exact"); + expect(enhanced.scheme).toBe("exact"); + expect(enhanced.asset).toBe("BSV"); + }); + + describe("settlement payload enrichment", () => { + it("requires a transaction selector at construction", () => { + expect(() => new UptoBsvScheme(undefined as never)).toThrow(/getTransactionVersion/); + }); + + it.each(["before-handler", "after-handler", "cancel"] as const)( + "selects the signed transaction for the %s phase", + async phase => { + let receivedContext: SettleContext | undefined; + const upto = new UptoBsvScheme({ + getTransactionVersion: context => { + receivedContext = context; + return signedVersion; + }, + }); + const context = makeSettleContext(phase); + + await expect(upto.enrichSettlementPayload(context)).resolves.toEqual({ + transactionVersion: signedVersion, + }); + expect(receivedContext).toBe(context); + }, + ); + + it("fails explicitly when the configured selector has no signed transaction", async () => { + const upto = new UptoBsvScheme({ + getTransactionVersion: async () => undefined as unknown as UptoBsvTransactionVersion, + }); + + await expect( + upto.enrichSettlementPayload(makeSettleContext("after-handler")), + ).rejects.toThrow( + "No signed BSV upto transaction version is available for after-handler settlement", + ); + }); + }); +}); diff --git a/typescript/packages/mechanisms/bsv/test/unit/upto-transaction.test.ts b/typescript/packages/mechanisms/bsv/test/unit/upto-transaction.test.ts new file mode 100644 index 0000000000..38b0aab625 --- /dev/null +++ b/typescript/packages/mechanisms/bsv/test/unit/upto-transaction.test.ts @@ -0,0 +1,599 @@ +import { + Beef, + BigNumber, + ECDSA, + LockingScript, + PrivateKey, + Script, + Transaction, + Utils, +} from "@bsv/sdk"; +import { describe, expect, it } from "vitest"; +import type { UptoBsvAuthorizationTerms } from "../../src/types"; +import { + assertUptoVersionProgression, + buildUptoTransactionVersion, + signUptoAuthorization, + uptoAuthorizationDigest, + uptoAuthorizationId, + uptoP2pkhScript, + verifyUptoAuthorization, + verifyUptoTransactionVersion, + type UptoBsvDigestSigner, +} from "../../src/upto/transaction"; + +const FINAL_SEQUENCE = 0xffffffff; + +interface SingleFixture { + terms: UptoBsvAuthorizationTerms; + capSigner: UptoBsvDigestSigner; + controlKey: PrivateKey; + controlSigner: UptoBsvDigestSigner; + outputAmounts: (amount: number) => string[]; +} + +/** Signs an already-computed digest without hashing it again. */ +const digestSigner = + (privateKey: PrivateKey): UptoBsvDigestSigner => + async digest => + ECDSA.sign(new BigNumber(digest), privateKey, true).toDER() as number[]; + +/** Creates a plain BEEF source transaction with one spendable P2PKH output. */ +const sourceTransaction = (privateKey: PrivateKey, satoshis: number, tag: number): string => { + const transaction = new Transaction(1, [], [], tag); + transaction.addOutput({ + lockingScript: LockingScript.fromHex(uptoP2pkhScript(privateKey.toPublicKey().toString())), + satoshis, + }); + const beef = new Beef(); + beef.mergeTransaction(transaction); + return Utils.toBase64(beef.toBinary()); +}; + +/** Creates an Atomic BEEF source with one additional raw-transaction ancestor. */ +const sourceWithAncestry = ( + publicKey: string, + satoshis: number, + tag: number, +): { encoded: string; ancestorTxid: string } => { + const ancestor = new Transaction(1, [], [], tag); + ancestor.addOutput({ + lockingScript: LockingScript.fromHex("51"), + satoshis: satoshis + 1, + }); + const source = new Transaction(); + source.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex: 0, + unlockingScript: Script.fromHex("00"), + sequence: FINAL_SEQUENCE, + }); + source.addOutput({ + lockingScript: LockingScript.fromHex(uptoP2pkhScript(publicKey)), + satoshis, + }); + return { + encoded: Utils.toBase64(source.toAtomicBEEF()), + ancestorTxid: ancestor.id("hex"), + }; +}; + +/** Decodes the fully signed subject transaction from a transaction version. */ +const decodeTransaction = (encoded: string): Transaction => + Transaction.fromAtomicBEEF(Utils.toArray(encoded, "base64")); + +/** Returns the sighash byte appended to the first signature push. */ +const signatureScope = (transaction: Transaction, inputIndex: number): number | undefined => + transaction.inputs[inputIndex].unlockingScript?.chunks[0]?.data?.at(-1); + +/** Builds the smallest useful stream authorization: one cap and one control input. */ +const makeSingleFixture = (): SingleFixture => { + const capKey = PrivateKey.fromRandom(); + const controlKey = PrivateKey.fromRandom(); + const payerOwner = "payer"; + const payeeOwner = controlKey.toPublicKey().toString(); + const terms: UptoBsvAuthorizationTerms = { + version: 1, + network: "bsv:testnet", + asset: "BSV", + payTo: payeeOwner, + senderIdentityKey: capKey.toPublicKey().toString(), + derivationPrefix: "prefix", + derivationSuffix: "suffix", + inputs: [ + { + owner: payerOwner, + kind: "cap", + sourceTransaction: sourceTransaction(capKey, 10_000, 1), + sourceOutputIndex: 0, + publicKey: capKey.toPublicKey().toString(), + }, + { + owner: payeeOwner, + kind: "control", + sourceTransaction: sourceTransaction(controlKey, 101, 2), + sourceOutputIndex: 0, + publicKey: controlKey.toPublicKey().toString(), + }, + ], + outputs: [ + { + owner: payerOwner, + lockingScript: uptoP2pkhScript(capKey.toPublicKey().toString()), + fixedAmount: "2000", + }, + { + owner: payerOwner, + lockingScript: uptoP2pkhScript(capKey.toPublicKey().toString()), + }, + { + owner: payeeOwner, + lockingScript: uptoP2pkhScript(controlKey.toPublicKey().toString()), + }, + ], + chargedOwners: [payerOwner], + paymentOutputIndexes: [2], + fee: "1", + sequenceStart: 42, + validAfter: 1_700_000_000, + deadline: 1_700_000_300, + nLockTime: 1_700_000_120, + }; + + return { + terms, + capSigner: digestSigner(capKey), + controlKey, + controlSigner: digestSigner(controlKey), + outputAmounts: amount => ["2000", String(8_000 - amount), String(100 + amount)], + }; +}; + +/** Builds two cap inputs, two control inputs, and several outputs per owner. */ +const makeMultiFixture = () => { + const firstCapKey = PrivateKey.fromRandom(); + const secondCapKey = PrivateKey.fromRandom(); + const firstControlKey = PrivateKey.fromRandom(); + const secondControlKey = PrivateKey.fromRandom(); + const payerOwner = "payer"; + const payeeOwner = firstControlKey.toPublicKey().toString(); + const terms: UptoBsvAuthorizationTerms = { + version: 1, + network: "bsv:testnet", + asset: "BSV", + payTo: payeeOwner, + senderIdentityKey: firstCapKey.toPublicKey().toString(), + derivationPrefix: "multi-prefix", + derivationSuffix: "multi-suffix", + inputs: [ + { + owner: payerOwner, + kind: "cap", + sourceTransaction: sourceTransaction(firstCapKey, 6_000, 3), + sourceOutputIndex: 0, + publicKey: firstCapKey.toPublicKey().toString(), + }, + { + owner: payerOwner, + kind: "cap", + sourceTransaction: sourceTransaction(secondCapKey, 4_000, 4), + sourceOutputIndex: 0, + publicKey: secondCapKey.toPublicKey().toString(), + }, + { + owner: payeeOwner, + kind: "control", + sourceTransaction: sourceTransaction(firstControlKey, 101, 5), + sourceOutputIndex: 0, + publicKey: firstControlKey.toPublicKey().toString(), + }, + { + owner: payeeOwner, + kind: "control", + sourceTransaction: sourceTransaction(secondControlKey, 51, 6), + sourceOutputIndex: 0, + publicKey: secondControlKey.toPublicKey().toString(), + }, + ], + outputs: [ + { + owner: payerOwner, + lockingScript: uptoP2pkhScript(firstCapKey.toPublicKey().toString()), + fixedAmount: "1000", + }, + { + owner: payerOwner, + lockingScript: uptoP2pkhScript(secondCapKey.toPublicKey().toString()), + fixedAmount: "500", + }, + { + owner: payerOwner, + lockingScript: uptoP2pkhScript(firstCapKey.toPublicKey().toString()), + }, + { + owner: payerOwner, + lockingScript: uptoP2pkhScript(secondCapKey.toPublicKey().toString()), + }, + { + owner: payeeOwner, + lockingScript: uptoP2pkhScript(firstControlKey.toPublicKey().toString()), + }, + { + owner: payeeOwner, + lockingScript: uptoP2pkhScript(secondControlKey.toPublicKey().toString()), + }, + ], + chargedOwners: [payerOwner], + paymentOutputIndexes: [4, 5], + fee: "2", + sequenceStart: 7, + validAfter: 1_700_000_000, + deadline: 1_700_000_300, + nLockTime: 1_700_000_120, + }; + + return { + terms, + payerOwner, + payeeOwner, + capSigners: { 0: digestSigner(firstCapKey), 1: digestSigner(secondCapKey) }, + controlSigners: { 2: digestSigner(firstControlKey), 3: digestSigner(secondControlKey) }, + outputAmounts: ["1000", "500", "4500", "1000", "2000", "1150"], + }; +}; + +describe("BSV upto signed transaction versions", () => { + it("binds the transaction nLockTime independently from the authorization deadline", async () => { + const fixture = makeSingleFixture(); + const terms = { + ...fixture.terms, + nLockTime: 1_700_000_120, + deadline: 1_700_000_300, + }; + const authorization = await signUptoAuthorization(terms, { 0: fixture.capSigner }); + const version = await buildUptoTransactionVersion( + authorization, + { nSequence: terms.sequenceStart, outputAmounts: fixture.outputAmounts(1_000) }, + { 1: fixture.controlSigner }, + ); + + expect(decodeTransaction(version.transaction).lockTime).toBe(1_700_000_120); + }); + + it("keeps a stable authorization digest for the canonical tuple", () => { + const capKey = PrivateKey.fromHex("01".padStart(64, "0")); + const controlKey = PrivateKey.fromHex("02".padStart(64, "0")); + const payTo = controlKey.toPublicKey().toString(); + const terms: UptoBsvAuthorizationTerms = { + version: 1, + network: "bsv:testnet", + asset: "BSV", + payTo, + senderIdentityKey: capKey.toPublicKey().toString(), + derivationPrefix: "AQIDBAUGBwg=", + derivationSuffix: "MTcwMDAwMDAwMDAwMA==", + inputs: [ + { + owner: "payer", + kind: "cap", + sourceTransaction: sourceTransaction(capKey, 1_001, 101), + sourceOutputIndex: 0, + publicKey: capKey.toPublicKey().toString(), + }, + { + owner: payTo, + kind: "control", + sourceTransaction: sourceTransaction(controlKey, 2, 102), + sourceOutputIndex: 0, + publicKey: controlKey.toPublicKey().toString(), + }, + ], + outputs: [ + { + owner: "payer", + lockingScript: uptoP2pkhScript(capKey.toPublicKey().toString()), + fixedAmount: "1", + }, + { owner: "payer", lockingScript: uptoP2pkhScript(capKey.toPublicKey().toString()) }, + { owner: payTo, lockingScript: uptoP2pkhScript(payTo) }, + ], + chargedOwners: ["payer"], + paymentOutputIndexes: [2], + fee: "1", + sequenceStart: 1, + validAfter: 1_700_000_000, + deadline: 1_700_000_300, + nLockTime: 1_700_000_120, + }; + + expect(uptoAuthorizationId(terms)).toBe( + "7a9096b069b220dfc3158c41b816f453e98d53fbe7fdaa61c6137f23e5371a1a", + ); + }); + + it("preserves source ancestry when it builds the terminal Atomic BEEF", async () => { + const fixture = makeSingleFixture(); + const chained = sourceWithAncestry(fixture.terms.inputs[0].publicKey, 10_000, 100); + fixture.terms.inputs[0] = { + ...fixture.terms.inputs[0], + sourceTransaction: chained.encoded, + }; + const authorization = await signUptoAuthorization(fixture.terms, { 0: fixture.capSigner }); + const version = await buildUptoTransactionVersion( + authorization, + { nSequence: 42, outputAmounts: fixture.outputAmounts(1_000) }, + { 1: fixture.controlSigner }, + ); + + const beef = Beef.fromBinary(Utils.toArray(version.transaction, "base64")); + const terminal = Transaction.fromAtomicBEEF(Utils.toArray(version.transaction, "base64")); + expect(beef.findTxid(chained.ancestorTxid)?.tx).toBeDefined(); + expect(terminal.inputs[0].sourceTransaction?.inputs[0].sourceTransaction?.id("hex")).toBe( + chained.ancestorTxid, + ); + }); + + it("refuses an authorization with partial source ancestry", async () => { + const fixture = makeSingleFixture(); + const partial = new Transaction(); + partial.addInput({ + sourceTXID: "ff".repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromHex("00"), + sequence: FINAL_SEQUENCE, + }); + partial.addOutput({ + lockingScript: LockingScript.fromHex(uptoP2pkhScript(fixture.terms.inputs[0].publicKey)), + satoshis: 10_000, + }); + fixture.terms.inputs[0] = { + ...fixture.terms.inputs[0], + sourceTransaction: Utils.toBase64(partial.toAtomicBEEF(true)), + }; + await expect(signUptoAuthorization(fixture.terms, { 0: fixture.capSigner })).rejects.toThrow( + /complete source ancestry/i, + ); + }); + + it("rejects a source output outside the BSV satoshi range", async () => { + const fixture = makeSingleFixture(); + const oversized = new Transaction(1, [], [], 999); + oversized.addOutput({ + lockingScript: LockingScript.fromHex(uptoP2pkhScript(fixture.terms.inputs[0].publicKey)), + satoshis: 2_100_000_000_000_001, + }); + fixture.terms.inputs[0] = { + ...fixture.terms.inputs[0], + sourceTransaction: Utils.toBase64(oversized.toAtomicBEEF()), + }; + + await expect(signUptoAuthorization(fixture.terms, { 0: fixture.capSigner })).rejects.toThrow( + /source satoshis.*range/i, + ); + }); + + it("reuses the 0x43 cap signature while sequence changes and re-signs control with 0x41", async () => { + const fixture = makeSingleFixture(); + const authorization = await signUptoAuthorization(fixture.terms, { 0: fixture.capSigner }); + const first = await buildUptoTransactionVersion( + authorization, + { + nSequence: 42, + outputAmounts: fixture.outputAmounts(1_000), + }, + { 1: fixture.controlSigner }, + ); + const second = await buildUptoTransactionVersion( + authorization, + { + nSequence: 43, + outputAmounts: fixture.outputAmounts(2_000), + }, + { 1: fixture.controlSigner }, + ); + + const firstTransaction = decodeTransaction(first.transaction); + const secondTransaction = decodeTransaction(second.transaction); + + expect(firstTransaction.inputs[0].unlockingScript?.toHex()).toBe( + secondTransaction.inputs[0].unlockingScript?.toHex(), + ); + expect(signatureScope(firstTransaction, 0)).toBe(0x43); + expect(signatureScope(secondTransaction, 0)).toBe(0x43); + expect(firstTransaction.inputs[0].sequence).toBe(FINAL_SEQUENCE); + expect(secondTransaction.inputs[0].sequence).toBe(FINAL_SEQUENCE); + + expect(firstTransaction.inputs[1].unlockingScript?.toHex()).not.toBe( + secondTransaction.inputs[1].unlockingScript?.toHex(), + ); + expect(signatureScope(firstTransaction, 1)).toBe(0x41); + expect(signatureScope(secondTransaction, 1)).toBe(0x41); + expect(firstTransaction.inputs[1].sequence).toBe(42); + expect(secondTransaction.inputs[1].sequence).toBe(43); + expect(verifyUptoTransactionVersion(authorization, first).amount).toBe("1000"); + expect(verifyUptoTransactionVersion(authorization, second).amount).toBe("2000"); + }); + + it("cooperatively closes by finalizing and re-signing only the control input", async () => { + const fixture = makeSingleFixture(); + const authorization = await signUptoAuthorization(fixture.terms, { 0: fixture.capSigner }); + const intermediate = await buildUptoTransactionVersion( + authorization, + { + nSequence: 42, + outputAmounts: fixture.outputAmounts(1_500), + }, + { 1: fixture.controlSigner }, + ); + const close = await buildUptoTransactionVersion( + authorization, + { + nSequence: FINAL_SEQUENCE, + outputAmounts: fixture.outputAmounts(1_500), + }, + { 1: fixture.controlSigner }, + ); + + const intermediateTransaction = decodeTransaction(intermediate.transaction); + const closeTransaction = decodeTransaction(close.transaction); + + const verifiedClose = verifyUptoTransactionVersion(authorization, close); + expect(verifiedClose.cooperativeClose).toBe(true); + expect(verifiedClose.nSequence).toBe(FINAL_SEQUENCE); + expect(intermediateTransaction.inputs[0].sequence).toBe(FINAL_SEQUENCE); + expect(closeTransaction.inputs[0].sequence).toBe(FINAL_SEQUENCE); + expect(intermediateTransaction.inputs[0].unlockingScript?.toHex()).toBe( + closeTransaction.inputs[0].unlockingScript?.toHex(), + ); + expect(signatureScope(closeTransaction, 0)).toBe(0x43); + + expect(intermediateTransaction.inputs[1].sequence).toBe(42); + expect(closeTransaction.inputs[1].sequence).toBe(FINAL_SEQUENCE); + expect(intermediateTransaction.inputs[1].unlockingScript?.toHex()).not.toBe( + closeTransaction.inputs[1].unlockingScript?.toHex(), + ); + expect(signatureScope(closeTransaction, 1)).toBe(0x41); + expect(verifiedClose.amount).toBe("1500"); + }); + + it("rejects authorization terms changed after the cap owner signed", async () => { + const fixture = makeSingleFixture(); + const authorization = await signUptoAuthorization(fixture.terms, { 0: fixture.capSigner }); + const tamperedTerms: UptoBsvAuthorizationTerms = { + ...authorization.terms, + deadline: authorization.terms.deadline + 1, + }; + + expect(() => + verifyUptoAuthorization({ + ...authorization, + terms: tamperedTerms, + }), + ).toThrow(/authorizationId/i); + expect(() => + verifyUptoAuthorization({ + ...authorization, + authorizationId: uptoAuthorizationId(tamperedTerms), + terms: tamperedTerms, + }), + ).toThrow(/authorization signature/i); + }); + + it("does not count a charged-owner output unless a cap key controls it", async () => { + const fixture = makeSingleFixture(); + const redirected: UptoBsvAuthorizationTerms = { + ...fixture.terms, + outputs: fixture.terms.outputs.map((output, index) => + index === 1 + ? { + ...output, + lockingScript: uptoP2pkhScript(fixture.controlKey.toPublicKey().toString()), + } + : output, + ), + }; + + await expect(signUptoAuthorization(redirected, { 0: fixture.capSigner })).rejects.toThrow( + /not controlled by its cap keys/i, + ); + }); + + it("computes payment from owner net deltas across multiple inputs and outputs", async () => { + const fixture = makeMultiFixture(); + const authorization = await signUptoAuthorization(fixture.terms, fixture.capSigners); + const version = await buildUptoTransactionVersion( + authorization, + { + nSequence: 7, + outputAmounts: fixture.outputAmounts, + }, + fixture.controlSigners, + ); + + const verified = verifyUptoTransactionVersion(authorization, version); + + expect(verified.outputAmounts).toEqual(fixture.outputAmounts); + expect(verified.amount).toBe("3000"); + expect(verified.ownerDeltas).toEqual({ + [fixture.payeeOwner]: "-2998", + [fixture.payerOwner]: "3000", + }); + }); + + it("allows only contiguous stream sequences whose amount never decreases", async () => { + const fixture = makeSingleFixture(); + const authorization = await signUptoAuthorization(fixture.terms, { 0: fixture.capSigner }); + const first = await buildUptoTransactionVersion( + authorization, + { + nSequence: 42, + outputAmounts: fixture.outputAmounts(1_000), + }, + { 1: fixture.controlSigner }, + ); + const increased = await buildUptoTransactionVersion( + authorization, + { + nSequence: 43, + outputAmounts: fixture.outputAmounts(2_000), + }, + { 1: fixture.controlSigner }, + ); + const decreased = await buildUptoTransactionVersion( + authorization, + { + nSequence: 43, + outputAmounts: fixture.outputAmounts(500), + }, + { 1: fixture.controlSigner }, + ); + const skipped = await buildUptoTransactionVersion( + authorization, + { + nSequence: 44, + outputAmounts: fixture.outputAmounts(3_000), + }, + { 1: fixture.controlSigner }, + ); + + expect(() => assertUptoVersionProgression(authorization, first, increased)).not.toThrow(); + expect(() => assertUptoVersionProgression(authorization, first, decreased)).toThrow( + /must not decrease/i, + ); + expect(() => assertUptoVersionProgression(authorization, first, skipped)).toThrow( + /advance by one/i, + ); + }); + + it("rejects a reused cap signature when any input prevout is replaced", async () => { + const fixture = makeSingleFixture(); + const authorization = await signUptoAuthorization(fixture.terms, { 0: fixture.capSigner }); + const changedTerms: UptoBsvAuthorizationTerms = { + ...authorization.terms, + inputs: authorization.terms.inputs.map((input, index) => + index === 1 + ? { + ...input, + sourceTransaction: sourceTransaction(fixture.controlKey, 101, 99), + } + : input, + ), + }; + const changedDigest = uptoAuthorizationDigest(changedTerms); + const resignedAuthorization = { + ...authorization, + authorizationId: uptoAuthorizationId(changedTerms), + terms: changedTerms, + capSignatures: await Promise.all( + authorization.capSignatures.map(async signature => ({ + ...signature, + authorizationSignature: Utils.toBase64(await fixture.capSigner(changedDigest)), + })), + ), + }; + + expect(() => verifyUptoAuthorization(resignedAuthorization)).toThrow( + /invalid reusable cap signature/i, + ); + }); +}); diff --git a/typescript/packages/mechanisms/bsv/tsup.config.ts b/typescript/packages/mechanisms/bsv/tsup.config.ts index 1143bfeaf5..9c2b2627da 100644 --- a/typescript/packages/mechanisms/bsv/tsup.config.ts +++ b/typescript/packages/mechanisms/bsv/tsup.config.ts @@ -6,6 +6,9 @@ const baseConfig = { "exact/client/index": "src/exact/client/index.ts", "exact/server/index": "src/exact/server/index.ts", "exact/facilitator/index": "src/exact/facilitator/index.ts", + "upto/client/index": "src/upto/client/index.ts", + "upto/server/index": "src/upto/server/index.ts", + "upto/facilitator/index": "src/upto/facilitator/index.ts", }, dts: { resolve: true,