diff --git a/README.md b/README.md index 5ebe78a..6f4e884 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ extension and the mobile app. | `@xcp/wallet-sdk/amounts/vectors` | Versioned, language-neutral JSON regression vectors. | | `@xcp/wallet-sdk/web` | Wallet discovery (`discoverWallets`, `XCP_WALLET`, `HORIZON_WALLET`) and page-level signals (`webSessionOptions`). Browser only. | | `@xcp/wallet-sdk/horizon` | Horizon Wallet as an `XcpProvider`: `detectHorizonProvider`, `createHorizonProvider`. | +| `@xcp/wallet-sdk/casatookan` | Opt-in [Casa Tookan pilot](docs/casatookan.md): mainnet Legacy/Native SegWit, fully owned SIGHASH_ALL PSBTs. | | `@xcp/wallet-sdk/react` | `WalletProvider`, `useWallet`, `useWalletChooser`, `WalletChooser`, `useCompose`, `usePending`. | | `@xcp/wallet-sdk/react/leader-polling` | The SWR middleware; `swr` is needed only here and below. | | `@xcp/wallet-sdk/react/use-spendable-balance` | SWR-backed balance with pending debits. | diff --git a/docs/casatookan-dev-spec.md b/docs/casatookan-dev-spec.md new file mode 100644 index 0000000..0a33c0d --- /dev/null +++ b/docs/casatookan-dev-spec.md @@ -0,0 +1,192 @@ +# Casa Tookan development-wallet contract + +Observed on 2026-10-20 against [the development wallet](https://devapp.casatookan.com/), +version 2.1.0, build `bfbcddd-dirty` (08:06 AM). This is a working integration +contract for this build, not a guarantee about other builds. + +The [published signing engine](https://devapp.casatookan.com/signer-core.js) has +SHA-256 `7e49e42a354014604718f49b18a341b40c057ece816e91716e2d3017d74eb579`. +That matches the hash pinned in the development app. Its header says the native +modules also pin a core hash; installed native binaries were not inspected. + +Evidence has two scopes: + +- **Wallet:** real provider calls through the official web bridge and approval UI, + using an unfunded wallet and synthetic prevouts. No broadcast. +- **Engine:** the exact downloaded engine run locally with a deterministic test + key. These tests bypass provider preparation, permissions and UI. + +The [adapter contract](casatookan.md) enables the verified signing mechanics with conservative size and batch limits. +The [recorded results](qa/casatookan-2026-10-20.json) include 28 wallet scenarios, +69 engine probes, and the earlier selected-input/preservation checks. + +## Signing capabilities + +| Capability | Observation | Evidence | +| --- | --- | --- | +| P2PKH and P2WPKH | Selected inputs, preserved foreign signatures, non-ALL sighashes and batches work. Shared transactions finalize. | Wallet | +| BIP-86 Taproot key path | Schnorr signatures verify; missing `tapInternalKey` is populated; finalization works. | Wallet, engine | +| Nested P2SH-P2WPKH | Rejected as not belonging to the casa, including when the underlying key belongs to it. | Wallet, engine | +| P2WSH multisig | Even a 1-of-1 script with the wallet's key is rejected as not belonging to the casa. | Wallet, engine | +| Taproot script paths | Both untweaked and BIP-86-tweaked leaf keys are rejected as not belonging to the casa. The alternative Bitcoin provider also refuses. | Wallet, engine | +| Taproot tree key-path recovery | A tree-bearing output with the wallet's own internal key and `tapMerkleRoot` is rejected. No-tree BIP-86 control succeeds. | Wallet, engine | +| PSBT v0 and v2 | Accepted; v2 remains v2. | Wallet: SegWit/Taproot; engine: all three supported types | +| ECDSA sighashes | `1`, `2`, `3`, `129`, `130`, `131` verify. | Wallet: SegWit; engine: Legacy/SegWit, v0/v2 | +| Taproot sighashes | `0`, `1`, `2`, `3`, `129`, `130`, `131` verify. | Wallet, engine v0/v2 | +| Foreign signatures | Retained byte-for-byte and still verify after signing another input. | Wallet: Legacy/SegWit | +| Unknown PSBT fields | A supplied proprietary input field was stripped. Do not assume arbitrary metadata survives. | Engine | + +## Provider options + +`window.unisat.signPsbt(psbtHex, options)` returns PSBT hex. + +```ts +{ + autoFinalize: false, + toSignInputs: [ + { index: 0, address: selectedAddress, sighashTypes: [131] } + ] +} +``` + +- `autoFinalize` defaults to true. **`autoFinalized` is ignored**, including + `autoFinalized: false`; the resulting PSBT is finalized. +- `toSignInputs` omitted means all recognized inputs in the current casa. + An empty array fails with `E_NOT_OWN_INPUT`. +- `index` selects the actual input. `sighashTypes` is an allowlist, not a request + to change the PSBT's sighash. Defaults are ALL for Legacy/SegWit and + DEFAULT/ALL for Taproot. A mismatch fails with `E_SIGHASH` at signing. +- `address` must belong to the current casa, but does **not** bind the selected + input to that address. A Taproot connection with its Taproot address in the + options successfully signed the same casa's Legacy input. +- `publicKey` is ignored. Supplying another key did not prevent the real input's + owner from signing. +- Duplicate input indices are accepted by the engine. Normalize and validate + selections in our adapter. +- Finalization attempts to finalize the transaction, not just the selected + inputs. Use `autoFinalize: false` for incomplete collaborative PSBTs. + +Therefore, validate each selected prevout against the intended account locally; +neither the connected address nor an options field substitutes for that check. + +## Batches and failures + +`signPsbts(psbtHexs, options)` accepts one shared options object or an array of +per-PSBT options. Two different sighash policies in one batch were verified. +Results are ordered. The UI presents the requests together for one approval. + +| Scenario | Result | +| --- | --- | +| 20-item SegWit batch | All returned signatures verified. | +| 20 distinct Taproot transactions | Returned in order; all signatures verified against their respective transactions. | +| Empty batch | `E_NO_KEY`, before approval. | +| Valid first PSBT, malformed second | Entire request rejected before approval. | +| Valid first PSBT, disallowed sighash on second | Approval shown; entire promise rejects with `E_SIGHASH`, no results returned. | +| Cancel batch | `4001`, no results returned. | +| Two simultaneous signing calls | One succeeds; the other returns `E_BUSY`. | + +The web signer prepares every item before approval, then signs sequentially and +returns only after all succeed. A later failure can occur after an earlier item +was signed internally, but that earlier result is not returned to the caller. +There is no tested partial-success response. Serialize independent approvals. + +## Sizes and preparation + +- A 65,780-byte PSBT passed through the real wallet and its signature verified. +- A 262,388-byte PSBT failed before approval with `-32603`, + `Maximum call stack size exceeded`. +- A separate 100-input fixture, carrying full parent transactions, reproduced + the same failure. The issue is not specific to the large OP_RETURN fixture. +- The local engine accepted a 324,053-byte, 100-input SegWit PSBT, signed every + input, and passed signature verification. It also accepted the 262,388-byte + data fixture and batches of 100. +- A larger local 500-input run was stopped after prolonged execution. It does + not establish either support or a hard limit. + +The failure is in provider preparation. `hydratePsbt` removes empty +`finalScriptSig` fields and re-encodes with +`btoa(String.fromCharCode(...o.toPSBT()))`. Spreading the entire byte array into +one call hits the JavaScript argument/stack limit. The earlier hex-to-base64 +conversion already uses 32,768-byte chunks. + +There is no explicit batch-count or byte-size cap in the inspected paths. +The successful sizes above are tested examples, not advertised maxima. +Do not infer unlimited capacity or the same runtime limit on phones. + +Include prevouts in requests. When both UTXO fields are absent, preparation +fetches parent transactions sequentially. The inspected transaction fetch path +has no deduplication cache; asset divisibility lookups do have a cache. + +## Message signatures + +- Legacy/SegWit use recoverable Bitcoin Signed Message ECDSA, verifiable through + the SDK's existing BIP-137 path. +- Taproot also returns recoverable ECDSA under its untweaked internal key. + The real signature passes `verifyBip86RecoverableMessage` and fails the + BIP-137 and BIP-322 paths. Use the explicit `ECDSA-BIP86` proof dialect already + supported for Horizon; never silently fall back from BIP-322. +- `signMessage(message, "bip322-simple")` is rejected as unsupported. + +## Transaction checks still belong in our integration + +The engine verifies full parent transaction IDs and rejects contradictory +witness/non-witness prevout data. It also permits witness-only Legacy signing +with an unverified-fee warning. Retain our full-parent requirement for Legacy. + +With finalization disabled, engine tests returned signatures for negative-fee, +dust-output, zero-fee and future-locktime transactions. These are observations +about signing policy, not claims that those transactions can relay or confirm. +Retain our transaction validity, fee, asset and marketplace-intent checks. + +The approval modal prevented normal in-page account switching in the web UI; +the test was cancelled. This does not establish behavior for native +interruptions or external revocation during approval. Retain the adapter's +account/event-revision checks before returning a signature. + +## Custom outputs and recovery + +The [additional probes](qa/casatookan-custom-scripts-2026-10-20.json) bypass our +adapter to test Casa directly. Each rejected engine fixture first signed, +finalized and extracted with its known test key through `@scure/btc-signer`. +The real wallet independently refused the same five script shapes. + +Casa's `ownScripts` and `analyzePsbt` recognize only its three standard output +scripts. `signingPlan` refuses other scripts before the underlying signer runs. +Changing provider interfaces or supplying a merkle root does not bypass that +ownership check. This needs upstream ownership and approval support, not a +different sighash or an adapter option. + +This affects more than inscriptions. DigiRare policy offers can create custom +Taproot outputs. Casa can sign the ordinary funding inputs, but cannot reclaim +those outputs if funding confirms without a fill. The adapter reports +`psbt.taprootTreeKeyPath: false`; sites must refuse creation of those offers. +Accepting someone else's offer still signs ordinary seller inputs. Invalidating +an unconfirmed offer by spending its original standard funding coins also +remains possible. That invalidation is not recovery of a confirmed custom output. + +## Alternative providers and account discovery + +`CasaTookanBitcoinProvider.getInfo()` exposes addresses, messages, PSBT signing +and transfers, but no additional signing capabilities. With Taproot selected, +`getAddresses({ purposes: ['payment', 'ordinals', 'counterparty'] })` returned +the same address three times; payment and counterparty incorrectly reported +`addressType: 'p2pkh'`. The Unisat interface returned the single active account. +The Bitcoin provider's event methods are stubs in this build. It is not a +drop-in replacement for account lifecycle handling or a verified paired +Legacy/SegWit grant. + +Casa does decode some Counterparty messages (send, order and cancel). That is +distinct from validating our marketplace intent claims or linked bundles. + +## Remaining external evidence + +The concrete upstream gaps are custom-script ownership/recovery, large-PSBT +preparation overflow, and alternative-provider address-type metadata. Marketplace +intent validation and paired grants would be new capabilities. Ignored Unisat +options are compatibility details covered by the adapter. + +What this environment cannot establish is which signer-core hash ships in each +iOS/Android release, native lifecycle behavior, or practical mobile size limits. +Those need installed-device tests. End-to-end marketplace offer/fill and +Counterparty asset semantics remain our integration work, not questions about +whether Casa can produce these signatures. diff --git a/docs/casatookan.md b/docs/casatookan.md new file mode 100644 index 0000000..069bb2b --- /dev/null +++ b/docs/casatookan.md @@ -0,0 +1,103 @@ +# Casa Tookan pilot + +The adapter is opt-in and is not included in `KNOWN_WALLETS`: + +```ts +import { CASATOOKAN_WALLET, discoverWallets, KNOWN_WALLETS } from "@xcp/wallet-sdk/web"; + +const wallets = discoverWallets([...KNOWN_WALLETS, CASATOOKAN_WALLET]); +// Pass wallets to WalletProvider or WalletSession. +``` + +Direct consumers can import `createCasaTookanProvider` from +`@xcp/wallet-sdk/casatookan`. + +## Supported boundary + +The [development-wallet contract](casatookan-dev-spec.md) records the tested provider behavior. + +- Mainnet P2PKH, Native SegWit and BIP-86 Taproot key-path signing. +- Active-account-only grants, passive restore, revocation detection and account-change checks. +- Verified message proofs: BIP-137 for Legacy/SegWit, explicitly declared ECDSA-BIP86 for Taproot. +- Selected inputs, unsigned foreign inputs, and preserved foreign signatures. ECDSA sighashes + 1/2/3/129/130/131; Taproot additionally supports DEFAULT (0). +- One approval for up to 20 PSBTs, with per-request selections and sighashes. A failed or + incomplete batch returns no results. Overlapping approvals are refused until the first finishes. +- A conservative 64 KiB per-PSBT integration limit avoids the dev wallet's preparation overflow. + Every input must include prevout data; selected Legacy inputs require full parent transactions. +- Returned signatures are checked against the original transaction, key, prevouts and sighash. + Existing signatures cannot disappear or change. Original PSBT metadata is retained, including + proprietary fields stripped by Casa. Responses must remain partially signed as requested. +- Raw compose signing uses the SDK's PSBT fallback. Signing never broadcasts; explicit broadcast + uses the SDK relay. Disconnect clears the local session; revoke the underlying grant in Casa. + +Application `intent` metadata is accepted but **not validated or displayed by Casa**. The capability +report declares `intentValidation: "none"` and no wallet-validated marketplace bundles. Sites must +retain their own transaction and asset validation. Nested SegWit, multisig, Taproot script paths, +inscription/commit-reveal requests and paired-address grants remain unsupported. +Tree-bearing Taproot outputs cannot be spent through their key path either: +`psbt.taprootTreeKeyPath` is explicitly false. Do not create offers that require +this recovery path, even if Casa can sign their initial funding transaction. + +Detection requires Casa Tookan's marker plus its Unisat-compatible bridge; a +standalone Unisat extension is not detected as Casa Tookan. + +## Evidence and remaining native QA + +Implementation follows the [integration guide](https://docs.casatookan.com/integrate.html) +and the public `https://devapp.casatookan.com/` bridge inspected on 2026-10-20. +Its `signPsbt` handler reads `autoFinalize`, not Unisat's `autoFinalized`. +Unit tests use deterministic test keys and a bridge double. They are not evidence +that the installed iOS/Android signer behaves identically. + +Before enabling a production site: + +1. Record the installed Casa Tookan version and platform. Connect each supported + address type, reload, disconnect, reconnect and switch profiles. +2. Verify a message signature through the site's normal server verifier. +3. Sign an ordinary PSBT in each address type. Check returned prevouts, signatures, + outputs, fee and finalization against the original request. Use an unfunded + test fixture first; broadcasting is a separate funded test. +4. Reject a request, revoke permission during approval and switch away and back. + No stale result should reach the site. +5. Exercise selected inputs and batch approvals, and confirm unsupported script-path + and oversized requests fail before approval. + +The native browser injects providers. No downloaded injection script, +development iframe bridge, CSP relaxation or CEID authentication is needed. + +## Development signer verification — 2026-10-20 + +Tested against `devapp.casatookan.com`, version 2.1.0, build +`bfbcddd-dirty`, with a newly created, unfunded wallet and the official web +bridge. A temporary HTTPS preview hosted XCPDEX's production build and a +signing fixture using this adapter. + +- Legacy and Native SegWit accounts connected through XCPDEX. +- Both accounts signed the same test message; this SDK verified each signature. + Changing the SegWit message made verification fail. +- Both accounts signed and finalized a one-input, one-output SIGHASH_ALL PSBT. + Each synthetic parent paid 10,000 sats to the active account; its child returned + 9,000 sats to that account. The returned signatures were also verified against + their transaction digests with `@noble/curves`. Nothing was broadcast. +- Rejecting a message returned `user_rejected`; retrying succeeded. +- XCPDEX displayed a connection rejection and supported disconnect, reconnect, + and passive restoration without another approval. + +Revoking permission while the page was closed exposed stale optimistic identity. +The adapter now invalidates it on an empty or unsupported passive account. +The browser regression also exposed missing listeners after React's development +effect restart; session restart now reattaches them. + +This establishes compatibility with the web development signer. The installed +iOS/Android signer, funded DEX composition, and changes during a pending native +approval still need the native checks above. + +## Expanded adapter verification — 2026-10-20 + +The real dev-wallet UI also passed Taproot connection and verified message signing, +a PSBT v2 shared transaction retaining a foreign SegWit signature (finalized and extracted), +a reusable SINGLE|ANYONECANPAY authorization, and twenty ordered transactions with mixed +ALL/131 sighashes in one approval. These calls used the expanded SDK adapter, not direct +bridge calls. All prevouts were synthetic and no transaction was broadcast. +Results: [adapter QA](qa/casatookan-adapter-2026-10-20.json). diff --git a/docs/qa/casatookan-2026-10-20.json b/docs/qa/casatookan-2026-10-20.json new file mode 100644 index 0000000..2aa69cb --- /dev/null +++ b/docs/qa/casatookan-2026-10-20.json @@ -0,0 +1,1020 @@ +{ + "date": "2026-10-20", + "build": "bfbcddd-dirty", + "coreSha256": "7e49e42a354014604718f49b18a341b40c057ece816e91716e2d3017d74eb579", + "unfunded": true, + "broadcast": false, + "previousWalletTests": { + "testedAt": "2026-10-20", + "runtime": "https://devapp.casatookan.com/", + "build": "bfbcddd-dirty, 2026-10-20 08:06 AM", + "provider": "window.unisat via official casa-tookan-web.js bridge", + "scope": "Direct provider tests, bypassing our deliberately restricted SDK adapter", + "accounts": [ + { + "type": "P2WPKH", + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "selectedInput": "PASS", + "preserveOtherSignature": "PASS", + "singleAnyoneCanPay": "PASS", + "batchTwoMixedSighashes": "PASS" + }, + { + "type": "P2PKH", + "address": "1B4rXVM5a3y4H3PnghbQzovfKKPBSTVnse", + "selectedInput": "PASS", + "preserveOtherSignature": "PASS", + "singleAnyoneCanPay": "PASS", + "batchTwoMixedSighashes": "PASS" + } + ], + "assertions": [ + "Unsigned transaction bytes unchanged", + "Requested input signature verifies against independent digest using noble secp256k1", + "Exact requested sighash byte (1 or 131)", + "Unselected owned input remains unsigned", + "autoFinalize:false preserves partial signatures", + "Foreign signature preserved byte-for-byte and verifies", + "Shared transaction finalizes with both signatures", + "Batch returns two results in order with each request's sighash and input selection" + ], + "approvalObservations": [ + "Shared input warning displayed", + "Partial-signature warning displayed for sighash 131", + "Batch displays both requests in one approval" + ], + "broadcast": false, + "realFunds": false, + "notTested": [ + "Native iOS or Android", + "Live marketplace offer and fill", + "Asset decoding", + "Account switch during pending approval", + "Batch failure atomicity", + "Taproot", + "Nested SegWit" + ] + }, + "wallet": [ + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "connect", + "outcome": "returned", + "pub": "020c5f819d6a8bfd267d1e8c6be082b91e8f9671735600904fbab2cb7426c20a98", + "ms": 7394 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "batch-malformed", + "outcome": "rejected", + "error": "Reader(magic): bytes: cannot find terminator", + "code": -32603, + "ms": 19 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "empty-batch", + "outcome": "rejected", + "error": "Requests must name one known estate", + "code": "E_NO_KEY", + "ms": 9 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "empty-inputs", + "outcome": "rejected", + "error": "No input of this casa to sign", + "code": "E_NOT_OWN_INPUT", + "ms": 114 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "bad-index", + "outcome": "rejected", + "error": "No input #99", + "code": "E_BAD_REQUEST", + "ms": 87 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "wrong-address", + "outcome": "rejected", + "error": "bc1ql3e9…lsvsd5 is not this casa's address", + "code": 4100, + "ms": 30 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "nested", + "outcome": "rejected", + "error": "Input #0 is not from this casa", + "code": "E_NOT_OWN_INPUT", + "ms": 82 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "v2", + "outcome": "returned", + "version": 2, + "verified": true, + "ms": 6636 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "all-sighashes", + "outcome": "returned", + "hashes": [ + 1, + 2, + 3, + 129, + 130, + 131 + ], + "verified": 6, + "ms": 4810 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "batch-late-failure", + "outcome": "rejected", + "error": "Input #0: Input with not allowed sigHash=131. Allowed: 1", + "code": "E_SIGHASH", + "ms": 4411 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "cancel-batch", + "outcome": "rejected", + "error": "User rejected the request", + "code": 4001, + "ms": 5637 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "concurrent", + "outcome": "returned", + "results": [ + { + "status": "fulfilled", + "verified": true + }, + { + "status": "rejected", + "error": "Another signature request is open", + "code": "E_BUSY" + } + ], + "ms": 5846 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "batch20", + "outcome": "returned", + "count": 20, + "verified": true, + "ms": 10732 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "public-key-only", + "outcome": "returned", + "verified": true, + "psbtBytes": 220, + "finalized": false, + "accountAfter": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "ms": 7698 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "default-finalize", + "outcome": "returned", + "finalized": true, + "extractBytes": 191, + "ms": 4497 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "wrong-spelling", + "outcome": "returned", + "finalized": true, + "ms": 4784 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "large-psbt", + "outcome": "returned", + "verified": true, + "psbtBytes": 65780, + "finalized": false, + "accountAfter": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "ms": 7872 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "large-262k", + "outcome": "rejected", + "error": "Maximum call stack size exceeded", + "code": -32603, + "ms": 154 + }, + { + "address": "bc1qtdulksxgz7u0z8pm77hes7g95kskg4vmtnkxvm", + "type": "segwit", + "case": "pending-account", + "outcome": "rejected", + "error": "User rejected the request", + "code": 4001, + "ms": 24321 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "connect", + "outcome": "returned", + "pub": "020c4ad1e5dcfea07885b4e4868ee607e16590221d695b27961dcdbd3b2f68d660", + "ms": 12498 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "inputs100", + "outcome": "rejected", + "error": "Maximum call stack size exceeded", + "code": -32603, + "ms": 2891 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "message-bip322", + "outcome": "rejected", + "error": "Unsupported signature type bip322-simple", + "code": -32603, + "ms": 10 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "message-ecdsa", + "outcome": "returned", + "bip137": false, + "bip86": true, + "bip322": false, + "ms": 7642 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "all-sighashes", + "outcome": "returned", + "hashes": [ + 0, + 1, + 2, + 3, + 129, + 130, + 131 + ], + "verified": 7, + "ms": 9435 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "v2", + "outcome": "returned", + "version": 2, + "verified": true, + "ms": 6685 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "default-finalize", + "outcome": "returned", + "finalized": true, + "extractBytes": 162, + "ms": 10806 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "cross-purpose", + "outcome": "returned", + "connectedAddress": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "signedInputAddress": "1B4rXVM5a3y4H3PnghbQzovfKKPBSTVnse", + "verified": true, + "ms": 8243 + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "type": "taproot", + "case": "batch20-distinct", + "outcome": "returned", + "count": 20, + "verified": true, + "ms": 12841 + } + ], + "engine": [ + { + "name": "legacy/v0/sighash-1", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 69, + "observed": "returned" + }, + { + "name": "legacy/v0/sighash-2", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 41, + "observed": "returned" + }, + { + "name": "legacy/v0/sighash-3", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 38, + "observed": "returned" + }, + { + "name": "legacy/v0/sighash-129", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 36, + "observed": "returned" + }, + { + "name": "legacy/v0/sighash-130", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 35, + "observed": "returned" + }, + { + "name": "legacy/v0/sighash-131", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 27, + "observed": "returned" + }, + { + "name": "legacy/v2/sighash-1", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 26, + "observed": "returned" + }, + { + "name": "legacy/v2/sighash-2", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 24, + "observed": "returned" + }, + { + "name": "legacy/v2/sighash-3", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 25, + "observed": "returned" + }, + { + "name": "legacy/v2/sighash-129", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 27, + "observed": "returned" + }, + { + "name": "legacy/v2/sighash-130", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 25, + "observed": "returned" + }, + { + "name": "legacy/v2/sighash-131", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 31, + "observed": "returned" + }, + { + "name": "segwit/v0/sighash-1", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 23, + "observed": "returned" + }, + { + "name": "segwit/v0/sighash-2", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 22, + "observed": "returned" + }, + { + "name": "segwit/v0/sighash-3", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 21, + "observed": "returned" + }, + { + "name": "segwit/v0/sighash-129", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 25, + "observed": "returned" + }, + { + "name": "segwit/v0/sighash-130", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 21, + "observed": "returned" + }, + { + "name": "segwit/v0/sighash-131", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 28, + "observed": "returned" + }, + { + "name": "segwit/v2/sighash-1", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 36, + "observed": "returned" + }, + { + "name": "segwit/v2/sighash-2", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 26, + "observed": "returned" + }, + { + "name": "segwit/v2/sighash-3", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 37, + "observed": "returned" + }, + { + "name": "segwit/v2/sighash-129", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 37, + "observed": "returned" + }, + { + "name": "segwit/v2/sighash-130", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 33, + "observed": "returned" + }, + { + "name": "segwit/v2/sighash-131", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 32, + "observed": "returned" + }, + { + "name": "taproot/v0/sighash-0", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 53, + "observed": "returned" + }, + { + "name": "taproot/v0/sighash-1", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 56, + "observed": "returned" + }, + { + "name": "taproot/v0/sighash-2", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 43, + "observed": "returned" + }, + { + "name": "taproot/v0/sighash-3", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 46, + "observed": "returned" + }, + { + "name": "taproot/v0/sighash-129", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 45, + "observed": "returned" + }, + { + "name": "taproot/v0/sighash-130", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 39, + "observed": "returned" + }, + { + "name": "taproot/v0/sighash-131", + "outputVersion": 0, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 43, + "observed": "returned" + }, + { + "name": "taproot/v2/sighash-0", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 49, + "observed": "returned" + }, + { + "name": "taproot/v2/sighash-1", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [], + "ms": 51, + "observed": "returned" + }, + { + "name": "taproot/v2/sighash-2", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 54, + "observed": "returned" + }, + { + "name": "taproot/v2/sighash-3", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 44, + "observed": "returned" + }, + { + "name": "taproot/v2/sighash-129", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 42, + "observed": "returned" + }, + { + "name": "taproot/v2/sighash-130", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 41, + "observed": "returned" + }, + { + "name": "taproot/v2/sighash-131", + "outputVersion": 2, + "signedInputs": [ + 0 + ], + "warnings": [ + "Partial signature (sighash #0): other inputs or outputs may still change." + ], + "ms": 43, + "observed": "returned" + }, + { + "name": "legacy/witness-only", + "warnings": [ + "Input amounts are claimed by the PSBT, not proven: the fee is unverified." + ], + "ms": 19, + "observed": "returned" + }, + { + "name": "segwit/witness-only", + "warnings": [ + "Input amounts are claimed by the PSBT, not proven: the fee is unverified." + ], + "ms": 16, + "observed": "returned" + }, + { + "name": "taproot/witness-only", + "warnings": [], + "ms": 33, + "observed": "returned" + }, + { + "name": "nested/ownership", + "error": "E_NOT_OWN_INPUT: No input of this casa to sign", + "ms": 10, + "observed": "rejected" + }, + { + "name": "wsh/ownership", + "error": "E_NOT_OWN_INPUT: No input of this casa to sign", + "ms": 9, + "observed": "rejected" + }, + { + "name": "empty-selection", + "error": "E_NOT_OWN_INPUT: No input of this casa to sign", + "ms": 5, + "observed": "rejected" + }, + { + "name": "out-of-range", + "error": "E_BAD_REQUEST: No input #99", + "ms": 5, + "observed": "rejected" + }, + { + "name": "non-ALL-without-allowlist", + "error": "E_SIGHASH: Input #0: Input with not allowed sigHash=131. Allowed: 1", + "ms": 14, + "observed": "rejected" + }, + { + "name": "wrong-allowlist", + "error": "E_SIGHASH: Input #0: Input with not allowed sigHash=131. Allowed: 1", + "ms": 14, + "observed": "rejected" + }, + { + "name": "duplicate-selection", + "signedInputs": [ + 0, + 0 + ], + "isFinal": false, + "warnings": [], + "ms": 30, + "observed": "returned" + }, + { + "name": "missing-prevout", + "error": "E_NOT_OWN_INPUT: No input of this casa to sign", + "ms": 4, + "observed": "rejected" + }, + { + "name": "wrong-parent", + "error": "E_BAD_REQUEST: Unreadable PSBT: nonWitnessUtxo: wrong txid, exp=cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd got=78278b746d01d9866e0d28bd35c56cd919abb62ba22b425a6217ea05a58b4403", + "ms": 5, + "observed": "rejected" + }, + { + "name": "conflicting-prevout", + "error": "E_BAD_REQUEST: Unreadable PSBT: validateInput: witnessUtxo different from nonWitnessUtxo", + "ms": 6, + "observed": "rejected" + }, + { + "name": "finalize", + "signedInputs": [ + 0 + ], + "isFinal": true, + "warnings": [], + "ms": 17, + "observed": "returned" + }, + { + "name": "already-finalized", + "signedInputs": [ + 0 + ], + "isFinal": true, + "warnings": [], + "ms": 23, + "observed": "returned" + }, + { + "name": "negative-fee", + "signedInputs": [ + 0 + ], + "isFinal": false, + "warnings": [], + "ms": 18, + "observed": "returned" + }, + { + "name": "dust", + "signedInputs": [ + 0 + ], + "isFinal": false, + "warnings": [], + "ms": 19, + "observed": "returned" + }, + { + "name": "future-locktime", + "signedInputs": [ + 0 + ], + "isFinal": false, + "warnings": [], + "ms": 18, + "observed": "returned" + }, + { + "name": "zero-fee", + "signedInputs": [ + 0 + ], + "isFinal": false, + "warnings": [], + "ms": 23, + "observed": "returned" + }, + { + "name": "unknown-fields", + "signedInputs": [ + 0 + ], + "isFinal": false, + "warnings": [], + "ms": 20, + "observed": "returned" + }, + { + "name": "foreign-unsigned", + "signedInputs": [ + 0 + ], + "isFinal": false, + "warnings": [ + "Input amounts are claimed by the PSBT, not proven: the fee is unverified.", + "1 input is not from this casa (shared transaction)." + ], + "ms": 15, + "observed": "returned" + }, + { + "name": "size/inputs-1", + "psbtBytes": 220, + "signedCount": 1, + "ms": 22, + "observed": "returned" + }, + { + "name": "size/inputs-10", + "psbtBytes": 4553, + "signedCount": 10, + "ms": 220, + "observed": "returned" + }, + { + "name": "size/inputs-100", + "psbtBytes": 324053, + "signedCount": 100, + "ms": 9877, + "observed": "returned" + }, + { + "name": "size/data-1024", + "psbtBytes": 1262, + "ms": 33, + "observed": "returned" + }, + { + "name": "size/data-65536", + "psbtBytes": 65780, + "ms": 185, + "observed": "returned" + }, + { + "name": "size/data-262144", + "psbtBytes": 262388, + "ms": 734, + "observed": "returned" + }, + { + "name": "batch/count-0", + "count": 0, + "ms": 0, + "observed": "returned" + }, + { + "name": "batch/count-2", + "count": 2, + "ms": 39, + "observed": "returned" + }, + { + "name": "batch/count-20", + "count": 20, + "ms": 394, + "observed": "returned" + }, + { + "name": "batch/count-100", + "count": 100, + "ms": 2422, + "observed": "returned" + } + ], + "limitations": [ + "Engine probes bypass the provider and approval UI.", + "Timing includes interaction delays in wallet tests; do not use as a benchmark.", + "The 500-input engine attempt was stopped after prolonged execution; result unknown.", + "Pending account switch was prevented by modal; final cancellation is not evidence of successful account-change handling." + ] +} diff --git a/docs/qa/casatookan-adapter-2026-10-20.json b/docs/qa/casatookan-adapter-2026-10-20.json new file mode 100644 index 0000000..2996ebe --- /dev/null +++ b/docs/qa/casatookan-adapter-2026-10-20.json @@ -0,0 +1,83 @@ +{ + "scope": "Official dev wallet 2.1.0 bfbcddd-dirty; SDK adapter; unfunded synthetic prevouts; no broadcast", + "results": [ + { + "ready": true, + "noBroadcastCode": true + }, + { + "case": "connect", + "outcome": "passed", + "account": { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "publicKey": "020c4ad1e5dcfea07885b4e4868ee607e16590221d695b27961dcdbd3b2f68d660", + "type": "p2tr" + }, + "signing": { + "intentValidation": "none", + "psbt": { + "supported": true, + "inputScope": "selected", + "externalInputs": "any", + "sighashTypes": [ + 0, + 1, + 2, + 3, + 129, + 130, + 131 + ] + }, + "psbtBatch": { + "supported": true, + "inputScope": "selected", + "externalInputs": "any", + "sighashTypes": [ + 0, + 1, + 2, + 3, + 129, + 130, + 131 + ], + "approvalMode": "single", + "maxRequests": 20, + "maxPolicyOfferAlternatives": 0, + "marketplaceBundles": [] + } + } + }, + { + "case": "message", + "outcome": "passed", + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "verifiedByAdapter": true, + "signatureLength": 88 + }, + { + "case": "shared", + "outcome": "passed", + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "verifiedByAdapter": true, + "finalized": true, + "bytes": 583 + }, + { + "case": "listing", + "outcome": "passed", + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "verifiedByAdapter": true, + "finalized": false, + "bytes": 392 + }, + { + "case": "batch20", + "outcome": "passed", + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "count": 20, + "verifiedByAdapter": true + } + ] +} diff --git a/docs/qa/casatookan-custom-scripts-2026-10-20.json b/docs/qa/casatookan-custom-scripts-2026-10-20.json new file mode 100644 index 0000000..6e43827 --- /dev/null +++ b/docs/qa/casatookan-custom-scripts-2026-10-20.json @@ -0,0 +1,134 @@ +{ + "date": "2026-10-20", + "walletVersion": "2.1.0 bfbcddd-dirty", + "wallet": [ + { + "case": "accounts", + "result": { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "info": { + "version": "1.0.0", + "methods": [ + "getInfo", + "getAddresses", + "getAccounts", + "signMessage", + "signPsbt", + "sendTransfer" + ], + "supports": [] + }, + "addresses": { + "addresses": [ + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "publicKey": "020c4ad1e5dcfea07885b4e4868ee607e16590221d695b27961dcdbd3b2f68d660", + "purpose": "payment", + "addressType": "p2pkh" + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "publicKey": "0c4ad1e5dcfea07885b4e4868ee607e16590221d695b27961dcdbd3b2f68d660", + "purpose": "ordinals", + "addressType": "p2tr" + }, + { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "publicKey": "020c4ad1e5dcfea07885b4e4868ee607e16590221d695b27961dcdbd3b2f68d660", + "purpose": "counterparty", + "addressType": "p2pkh" + } + ] + }, + "passive": [ + "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8" + ] + } + }, + { + "case": "script-untweaked", + "error": "Input #0 is not from this casa", + "code": "E_NOT_OWN_INPUT" + }, + { + "case": "script-tweaked", + "error": "Input #0 is not from this casa", + "code": "E_NOT_OWN_INPUT" + }, + { + "case": "tree-keypath", + "error": "Input #0 is not from this casa", + "code": "E_NOT_OWN_INPUT" + }, + { + "case": "nested", + "error": "Input #0 is not from this casa", + "code": "E_NOT_OWN_INPUT" + }, + { + "case": "multisig", + "error": "Input #0 is not from this casa", + "code": "E_NOT_OWN_INPUT" + }, + { + "case": "alternate/script-untweaked", + "error": "Input #0 is not from this casa", + "code": "E_NOT_OWN_INPUT" + }, + { + "case": "baseline", + "result": { + "address": "bc1p2quahgfx3sltmzwwxj6fxdgu9n55w3fvck5dgdwdu3wa8angcdzqhlndl8", + "kind": "baseline", + "unsignedUnchanged": true, + "tapKeySig": true, + "tapScriptSigs": 0, + "partialSigs": 0 + } + } + ], + "noBroadcast": true, + "engine": { + "scope": "exact published engine, synthetic inputs", + "sha256": "7e49e42a354014604718f49b18a341b40c057ece816e91716e2d3017d74eb579", + "results": [ + { + "name": "baseline", + "stage": "signed", + "signedInputs": [ + 0 + ] + }, + { + "name": "script-untweaked", + "stage": "refused", + "fixtureFinalizesWithKnownKey": true, + "error": "E_NOT_OWN_INPUT: Input #0 is not from this casa" + }, + { + "name": "script-tweaked", + "stage": "refused", + "fixtureFinalizesWithKnownKey": true, + "error": "E_NOT_OWN_INPUT: Input #0 is not from this casa" + }, + { + "name": "tree-keypath", + "stage": "refused", + "fixtureFinalizesWithKnownKey": true, + "error": "E_NOT_OWN_INPUT: Input #0 is not from this casa" + }, + { + "name": "nested", + "stage": "refused", + "fixtureFinalizesWithKnownKey": true, + "error": "E_NOT_OWN_INPUT: Input #0 is not from this casa" + }, + { + "name": "multisig", + "stage": "refused", + "fixtureFinalizesWithKnownKey": true, + "error": "E_NOT_OWN_INPUT: Input #0 is not from this casa" + } + ] + } +} diff --git a/package.json b/package.json index 81784c5..243706b 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "types": "./dist/horizon.d.ts", "import": "./dist/horizon.js" }, + "./casatookan": { + "types": "./dist/casatookan.d.ts", + "import": "./dist/casatookan.js" + }, "./amounts": { "import": { "types": "./dist/amounts.d.ts", diff --git a/src/casatookan/provider.ts b/src/casatookan/provider.ts new file mode 100644 index 0000000..e244968 --- /dev/null +++ b/src/casatookan/provider.ts @@ -0,0 +1,330 @@ +import { hex } from "@scure/base"; +import { Address, p2pkh, p2tr, p2wpkh } from "@scure/btc-signer"; +import { getNetwork } from "@/config"; +import { broadcastSignedTransaction } from "@/counterparty/broadcast"; +import { verifyBip86RecoverableMessage, verifyLegacyRecoverableMessage } from "@/crypto/bip322"; +import { WalletSdkError } from "@/errors"; +import type { ProviderPsbtSigningCapabilities } from "@/provider/capabilities"; +import type { ConnectionProof, WalletAddress, XcpProvider } from "@/provider/types"; +import { + CASA_BATCH_LIMIT, + CASA_SIGHASHES, + type CasaSigningOptions, + prepareCasaPsbt, + verifyCasaPsbt, +} from "./psbt"; + +export interface CasaTookanProvider { + requestAccounts(): Promise; + getAccounts(): Promise; + getPublicKey(): Promise; + getNetwork(): Promise; + signMessage(message: string, type: "ecdsa"): Promise; + signPsbt(psbtHex: string, options: CasaSigningOptions): Promise; + signPsbts?(psbtHexes: string[], options: CasaSigningOptions[]): Promise; + on(event: string, listener: (...args: unknown[]) => void): void; + removeListener(event: string, listener: (...args: unknown[]) => void): void; +} + +export const CASATOOKAN_MESSAGE_VERIFICATION: NonNullable = { + method: "BIP-137", + format: "legacy_recoverable", +}; + +export function getCasaTookanProvider(): CasaTookanProvider | null { + if (typeof window === "undefined") return null; + const injected = window as unknown as { + casaTookan?: { isCasaTookan?: boolean }; + unisat?: Partial; + }; + const provider = injected.unisat; + if (injected.casaTookan?.isCasaTookan !== true || !provider) return null; + const methods: (keyof CasaTookanProvider)[] = [ + "requestAccounts", + "getAccounts", + "getPublicKey", + "getNetwork", + "signMessage", + "signPsbt", + "on", + "removeListener", + ]; + return methods.every((method) => typeof provider[method] === "function") + ? (provider as CasaTookanProvider) + : null; +} + +export function casaTookanMessageVerification(address: string): NonNullable { + return address.startsWith("bc1p") + ? { method: "ECDSA-BIP86", format: "legacy_recoverable" } + : CASATOOKAN_MESSAGE_VERIFICATION; +} + +function signingCapabilities(batch: boolean): ProviderPsbtSigningCapabilities { + const method = { + supported: true, + inputScope: "selected" as const, + externalInputs: "any" as const, + taprootTreeKeyPath: false, + sighashTypes: [...CASA_SIGHASHES], + }; + return { + intentValidation: "none", + psbt: { ...method }, + psbtBatch: { + ...method, + supported: batch, + approvalMode: "single", + maxRequests: batch ? CASA_BATCH_LIMIT : 0, + maxPolicyOfferAlternatives: 0, + marketplaceBundles: [], + }, + }; +} + +function accountList(value: unknown): string[] { + if (!Array.isArray(value) || !value.every((address) => typeof address === "string")) { + throw new WalletSdkError("invalid_response", "Casa Tookan returned invalid accounts"); + } + return value; +} + +function supportsAddress(address: string): boolean { + try { + const type = Address().decode(address).type; + return type === "pkh" || type === "wpkh" || type === "tr"; + } catch { + return false; + } +} + +function fromCasaTookanError(error: unknown): WalletSdkError { + if (error instanceof WalletSdkError) return error; + const rpc = error as { code?: number | string; message?: string } | null; + const message = typeof rpc?.message === "string" ? rpc.message : "Casa Tookan request failed"; + const options = { cause: error, walletCode: typeof rpc?.code === "number" ? rpc.code : undefined }; + if (rpc?.code === "E_BUSY" || /\bE_BUSY\b/.test(message)) + return new WalletSdkError( + "invalid_argument", + "Finish the pending Casa Tookan approval before signing again", + options, + ); + if (rpc?.code === 4001 || /reject|cancel|denied/i.test(message)) + return new WalletSdkError("user_rejected", message, options); + if (rpc?.code === 4100) return new WalletSdkError("unauthorized", message, options); + if (rpc?.code === 4900) return new WalletSdkError("disconnected", message, options); + if (rpc?.code === 4200 || rpc?.code === -32601) + return new WalletSdkError("unsupported_method", message, options); + return new WalletSdkError("invalid_response", message, options); +} + +export function createCasaTookanProvider( + casa: CasaTookanProvider | null = getCasaTookanProvider(), +): XcpProvider { + if (!casa) throw new WalletSdkError("wallet_missing", "Casa Tookan not detected"); + const wallet = casa; + let disconnected = false; + let revision = 0; + let signing = false; + const disconnectListeners = new Map<(...args: unknown[]) => void, (...args: unknown[]) => void>(); + + function endSession(): void { + revision++; + for (const handler of disconnectListeners.keys()) handler({}); + } + + async function passiveAccount(): Promise { + if (disconnected) return null; + if (accountList(await wallet.getAccounts()).length === 0) { + // Casa has no cold-worker state: an empty grant invalidates cached identity. + endSession(); + return null; + } + try { + return await activeAccount(); + } catch (error) { + if (error instanceof WalletSdkError && (error.code === "capability" || error.code === "unauthorized")) + endSession(); + throw error; + } + } + + async function requireMainnet(): Promise { + if (getNetwork() !== "mainnet" || (await wallet.getNetwork()) !== "livenet") + throw new WalletSdkError("capability", "Casa Tookan supports Bitcoin mainnet only"); + } + + async function activeAccount(): Promise { + if (disconnected) throw new WalletSdkError("disconnected", "Reconnect Casa Tookan before signing"); + await requireMainnet(); + const [address] = accountList(await wallet.getAccounts()); + if (!address) throw new WalletSdkError("unauthorized", "Connect Casa Tookan before signing"); + const publicKey = await wallet.getPublicKey(); + if (typeof publicKey !== "string" || !/^(02|03)[0-9a-f]{64}$/i.test(publicKey)) + throw new WalletSdkError("invalid_response", "Casa Tookan returned an invalid public key"); + const key = hex.decode(publicKey); + const type = + p2pkh(key).address === address + ? "p2pkh" + : p2wpkh(key).address === address + ? "p2wpkh" + : p2tr(key.slice(1)).address === address + ? "p2tr" + : null; + if (!type) + throw new WalletSdkError( + "capability", + "Select a Legacy, Native SegWit or BIP-86 Taproot address in Casa Tookan", + ); + const [current] = accountList(await wallet.getAccounts()); + if (current !== address) + throw new WalletSdkError("disconnected", "Casa Tookan account changed; reconnect and retry"); + return { address, publicKey, type }; + } + + async function withAccount(action: (account: WalletAddress) => Promise): Promise { + if (signing) + throw new WalletSdkError( + "invalid_argument", + "Finish the pending Casa Tookan approval before signing again", + ); + signing = true; + const invalidate = () => { + revision++; + }; + wallet.on("accountsChanged", invalidate); + wallet.on("networkChanged", invalidate); + wallet.on("disconnect", invalidate); + const start = revision; + try { + const account = await activeAccount(); + if (revision !== start) throw new WalletSdkError("disconnected", "Casa Tookan account changed"); + const result = await action(account); + const current = await activeAccount(); + if ( + revision !== start || + current.address !== account.address || + current.publicKey !== account.publicKey + ) + throw new WalletSdkError("disconnected", "Casa Tookan account changed; reconnect and retry"); + return result; + } finally { + signing = false; + wallet.removeListener("accountsChanged", invalidate); + wallet.removeListener("networkChanged", invalidate); + wallet.removeListener("disconnect", invalidate); + } + } + + async function signPsbt(value: unknown): Promise<{ hex: string }> { + return withAccount(async (account) => { + const prepared = prepareCasaPsbt(value, account); + const signed = await wallet.signPsbt(prepared.hex, prepared.options); + return { hex: verifyCasaPsbt(prepared, signed) }; + }); + } + + async function signPsbts(value: unknown): Promise<{ hexes: string[] }> { + if (!wallet.signPsbts) + throw new WalletSdkError("unsupported_method", "This Casa Tookan version does not support batches"); + const requests = (value as { requests?: unknown } | null)?.requests; + if (!Array.isArray(requests) || requests.length < 1 || requests.length > CASA_BATCH_LIMIT) + throw new WalletSdkError("invalid_argument", `Provide between 1 and ${CASA_BATCH_LIMIT} PSBTs`); + return withAccount(async (account) => { + const prepared = requests.map((request) => prepareCasaPsbt(request, account)); + const signed = await wallet.signPsbts!( + prepared.map((p) => p.hex), + prepared.map((p) => p.options), + ); + if (!Array.isArray(signed) || signed.length !== prepared.length) + throw new WalletSdkError("invalid_response", "Casa Tookan returned an incomplete PSBT batch"); + return { hexes: prepared.map((p, index) => verifyCasaPsbt(p, signed[index])) }; + }); + } + + return { + async request({ method, params = [] }) { + try { + switch (method) { + case "xcp_requestAccounts": { + await requireMainnet(); + if (accountList(await wallet.getAccounts()).length === 0) + accountList(await wallet.requestAccounts()); + disconnected = false; + const active = await activeAccount(); + return { accounts: [active.address], proof: null }; + } + case "xcp_accounts": { + const active = await passiveAccount(); + return active ? [active.address] : []; + } + case "xcp_getAddresses": { + const active = await passiveAccount(); + return active + ? { active, signing: signingCapabilities(typeof wallet.signPsbts === "function") } + : null; + } + case "xcp_getNetwork": + case "xcp_chainId": + await requireMainnet(); + return method === "xcp_getNetwork" ? "mainnet" : "0x0"; + case "xcp_disconnect": + disconnected = true; + revision++; + return null; + case "xcp_signMessage": { + const [message, address] = params; + if (typeof message !== "string") + throw new WalletSdkError("invalid_argument", "A message is required"); + return await withAccount(async (account) => { + if (address !== undefined && address !== account.address) + throw new WalletSdkError("unauthorized", "Select the requested Casa Tookan account first"); + const signature = await wallet.signMessage(message, "ecdsa"); + const verify = + account.type === "p2tr" ? verifyBip86RecoverableMessage : verifyLegacyRecoverableMessage; + if (typeof signature !== "string" || !verify(message, signature, account.address).valid) + throw new WalletSdkError( + "invalid_response", + "Casa Tookan signature does not match the message and account", + ); + return signature; + }); + } + case "xcp_signPsbt": + return await signPsbt(params[0]); + case "xcp_signPsbts": + return await signPsbts(params[0]); + case "xcp_broadcastTransaction": + await activeAccount(); + if (typeof params[0] !== "string") + throw new WalletSdkError("invalid_argument", "A signed transaction is required"); + return { txid: await broadcastSignedTransaction(params[0]) }; + default: + throw new WalletSdkError("unsupported_method", `Casa Tookan does not support ${method}`); + } + } catch (error) { + throw fromCasaTookanError(error); + } + }, + on: (event, handler) => { + if (event === "disconnect" && !disconnectListeners.has(handler)) { + const onAccounts = (value: unknown) => { + const accounts = accountList(value); + // XCP uses [] for a locked wallet; Casa Tookan has lost the usable account. + if (accounts.length === 0 || !supportsAddress(accounts[0]!)) handler({}); + }; + disconnectListeners.set(handler, onAccounts); + wallet.on("accountsChanged", onAccounts); + } + wallet.on(event, handler); + }, + removeListener: (event, handler) => { + const onAccounts = event === "disconnect" ? disconnectListeners.get(handler) : undefined; + if (onAccounts) { + wallet.removeListener("accountsChanged", onAccounts); + disconnectListeners.delete(handler); + } + wallet.removeListener(event, handler); + }, + }; +} diff --git a/src/casatookan/psbt.ts b/src/casatookan/psbt.ts new file mode 100644 index 0000000..dd06256 --- /dev/null +++ b/src/casatookan/psbt.ts @@ -0,0 +1,213 @@ +import { schnorr, secp256k1 } from "@noble/curves/secp256k1"; +import { sha256 } from "@noble/hashes/sha256"; +import { hex } from "@scure/base"; +import { p2pkh, p2tr, p2wpkh, RawTx, Transaction } from "@scure/btc-signer"; +import { WalletSdkError } from "@/errors"; +import type { WalletAddress } from "@/provider/types"; +import { assertSameTransaction, readPsbt, readRawTransaction, VERIFY_TX_OPTIONS } from "@/transaction/verify"; + +export const CASA_BATCH_LIMIT = 20; +// Larger requests overflow the dev wallet's PSBT preparation, before signing. +export const CASA_PSBT_BYTES = 65_536; +export const CASA_SIGHASHES = [0, 1, 2, 3, 129, 130, 131]; + +export interface CasaSigningOptions { + autoFinalize: false; + toSignInputs: { index: number; address: string; sighashTypes: number[] }[]; +} + +const sameBytes = (a: Uint8Array | undefined, b: Uint8Array | undefined) => + a === undefined || b === undefined ? a === b : hex.encode(a) === hex.encode(b); + +function prevout(tx: Transaction, index: number) { + const input = tx.getInput(index); + let output = input.witnessUtxo; + if (input.nonWitnessUtxo) { + const parent = readRawTransaction(hex.encode(RawTx.encode(input.nonWitnessUtxo))); + if (!input.txid || parent.id !== hex.encode(input.txid) || input.index === undefined) + throw new WalletSdkError("transaction_mismatch", "PSBT parent does not match its input"); + const candidate = parent.getOutput(input.index); + if (candidate.amount === undefined || !candidate.script) + throw new WalletSdkError("invalid_argument", "PSBT parent output is missing"); + if (output && (output.amount !== candidate.amount || !sameBytes(output.script, candidate.script))) + throw new WalletSdkError("transaction_mismatch", "PSBT prevouts disagree"); + output = { amount: candidate.amount, script: candidate.script }; + } + if (!output) throw new WalletSdkError("invalid_argument", "Include prevout data for every input"); + return output; +} + +function payment(account: WalletAddress) { + const key = hex.decode(account.publicKey); + return account.type === "p2tr" ? p2tr(key.slice(1)) : account.type === "p2pkh" ? p2pkh(key) : p2wpkh(key); +} + +export function prepareCasaPsbt(value: unknown, account: WalletAddress) { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new WalletSdkError("invalid_argument", "A PSBT request is required"); + const request = value as Record; + if (Object.keys(request).some((key) => !["hex", "signInputs", "sighashTypes", "intent"].includes(key))) + throw new WalletSdkError("capability", "Casa Tookan does not support inscription or reveal signing"); + if (typeof request.hex !== "string" || !/^(?:[0-9a-f]{2})+$/i.test(request.hex)) + throw new WalletSdkError("invalid_argument", "A hex PSBT is required"); + if (request.hex.length / 2 > CASA_PSBT_BYTES) + throw new WalletSdkError("capability", "Casa Tookan supports PSBTs up to 64 KiB in this integration"); + const tx = Transaction.fromPSBT(hex.decode(request.hex), { + ...VERIFY_TX_OPTIONS, + unknown: "ignore", + proprietary: "ignore", + }); + if (!tx.inputsLength || !tx.outputsLength) + throw new WalletSdkError("invalid_argument", "A transaction needs inputs and outputs"); + let indices = Array.from({ length: tx.inputsLength }, (_, index) => index); + if (request.signInputs !== undefined) { + if (!request.signInputs || typeof request.signInputs !== "object" || Array.isArray(request.signInputs)) + throw new WalletSdkError("invalid_argument", "Invalid signing inputs"); + const entries = Object.entries(request.signInputs); + const selected: unknown = entries[0]?.[1]; + if (entries.length !== 1 || entries[0]?.[0] !== account.address || !Array.isArray(selected)) + throw new WalletSdkError("capability", "Select inputs belonging to the active Casa Tookan account"); + if ( + selected.length === 0 || + new Set(selected).size !== selected.length || + selected.some((i) => !Number.isSafeInteger(i) || i < 0 || i >= tx.inputsLength) + ) + throw new WalletSdkError("invalid_argument", "Select distinct, valid input indices"); + indices = [...selected]; + } + if ( + request.sighashTypes !== undefined && + (!Array.isArray(request.sighashTypes) || request.sighashTypes.length !== tx.inputsLength) + ) + throw new WalletSdkError("invalid_argument", "Sighash types must be indexed by transaction input"); + const prevouts = Array.from({ length: tx.inputsLength }, (_, index) => prevout(tx, index)); + const expected = payment(account).script; + const options: CasaSigningOptions = { + autoFinalize: false, + toSignInputs: indices.map((index) => { + const input = tx.getInput(index); + if (!sameBytes(prevouts[index]!.script, expected)) + throw new WalletSdkError( + "capability", + "Selected input does not belong to the active Casa Tookan account", + ); + if (account.type === "p2pkh" && !input.nonWitnessUtxo) + throw new WalletSdkError("invalid_argument", "Legacy signing requires the full parent transaction"); + if (input.tapLeafScript?.length || input.tapMerkleRoot || input.redeemScript || input.witnessScript) + throw new WalletSdkError( + "capability", + "Casa Tookan supports single-key inputs and BIP-86 key-path signing", + ); + if (input.finalScriptSig?.length || input.finalScriptWitness?.length) + throw new WalletSdkError("invalid_argument", "Selected input is already finalized"); + const sighash = input.sighashType ?? (account.type === "p2tr" ? 0 : 1); + if (!CASA_SIGHASHES.includes(sighash) || (sighash === 0 && account.type !== "p2tr")) + throw new WalletSdkError("capability", "Unsupported Casa Tookan sighash"); + if (request.sighashTypes !== undefined && (request.sighashTypes as unknown[])[index] !== sighash) + throw new WalletSdkError("invalid_argument", "Requested sighash differs from the PSBT"); + if ((sighash & 0x1f) === 3 && index >= tx.outputsLength) + throw new WalletSdkError("invalid_argument", "SIGHASH_SINGLE requires a matching output"); + return { index, address: account.address, sighashTypes: [sighash] }; + }), + }; + return { hex: request.hex, tx, options, account, prevouts }; +} + +function legacyDigest(tx: Transaction, index: number, script: Uint8Array, hashType: number) { + const raw = RawTx.decode(tx.unsignedTx); + const mode = hashType & 0x1f; + raw.inputs = raw.inputs.map((input, i) => ({ + ...input, + finalScriptSig: i === index ? script : new Uint8Array(), + sequence: i !== index && mode !== 1 ? 0 : input.sequence, + })); + if (hashType & 0x80) raw.inputs = [raw.inputs[index]!]; + if (mode === 2) raw.outputs = []; + if (mode === 3) + raw.outputs = raw.outputs + .slice(0, index + 1) + .map((output, i) => (i === index ? output : { amount: 0xffffffffffffffffn, script: new Uint8Array() })); + return sha256(sha256(Uint8Array.from([...RawTx.encode(raw), hashType, 0, 0, 0]))); +} + +export function verifyCasaPsbt(prepared: ReturnType, signed: unknown): string { + if (typeof signed !== "string" || !/^(?:[0-9a-f]{2})+$/i.test(signed)) + throw new WalletSdkError("invalid_response", "Casa Tookan returned an invalid PSBT"); + const result = readPsbt(signed); + const { tx, options, account, prevouts } = prepared; + assertSameTransaction(tx, result); + const selected = new Set(options.toSignInputs.map(({ index }) => index)); + for (let index = 0; index < tx.inputsLength; index++) { + const before = tx.getInput(index); + const after = result.getInput(index); + const output = prevout(result, index); + if ( + output.amount !== prevouts[index]!.amount || + !sameBytes(output.script, prevouts[index]!.script) || + before.sighashType !== after.sighashType + ) + throw new WalletSdkError("transaction_mismatch", "Casa Tookan changed input signing data"); + for (const [key, signature] of before.partialSig ?? []) { + if (!after.partialSig?.some(([k, sig]) => sameBytes(k, key) && sameBytes(sig, signature))) + throw new WalletSdkError("transaction_mismatch", "Casa Tookan changed an existing signature"); + } + for (const field of ["tapKeySig", "tapScriptSig", "finalScriptSig", "finalScriptWitness"] as const) { + if ( + (!selected.has(index) || before[field] !== undefined) && + JSON.stringify(before[field]) !== JSON.stringify(after[field]) + ) + throw new WalletSdkError( + "transaction_mismatch", + "Casa Tookan changed an existing or unselected signature", + ); + } + if (!selected.has(index) && (before.partialSig?.length ?? 0) !== (after.partialSig?.length ?? 0)) + throw new WalletSdkError("transaction_mismatch", "Casa Tookan signed an unselected input"); + } + const publicKey = hex.decode(account.publicKey); + for (const { index, sighashTypes } of options.toSignInputs) { + const input = result.getInput(index); + const hashType = sighashTypes[0]!; + let valid = false; + try { + if (account.type === "p2tr") { + const sig = input.tapKeySig; + const expectedLength = hashType === 0 ? 64 : 65; + if (sig?.length === expectedLength && (hashType === 0 || sig[64] === hashType)) { + const digest = tx.preimageWitnessV1( + index, + prevouts.map((p) => p.script), + hashType, + prevouts.map((p) => p.amount), + ); + valid = schnorr.verify(sig.slice(0, 64), digest, payment(account).script.slice(2)); + if (valid) tx.updateInput(index, { tapKeySig: sig, tapInternalKey: publicKey.slice(1) }, true); + } + } else { + const sig = input.partialSig?.find(([key]) => sameBytes(key, publicKey))?.[1]; + if (sig && sig[sig.length - 1] === hashType) { + const script = p2pkh(publicKey).script; + const digest = + account.type === "p2pkh" + ? legacyDigest(tx, index, script, hashType) + : tx.preimageWitnessV0(index, script, hashType, prevouts[index]!.amount); + valid = secp256k1.verify( + secp256k1.Signature.fromDER(sig.slice(0, -1)).toCompactRawBytes(), + digest, + publicKey, + ); + if (valid) tx.updateInput(index, { partialSig: [[publicKey, sig]] }, true); + } + } + } catch { + valid = false; + } + if (!valid) + throw new WalletSdkError( + "invalid_response", + "Casa Tookan returned a missing or invalid input signature", + ); + } + // Return the original metadata with verified signatures; Casa strips proprietary fields. + return hex.encode(tx.toPSBT()); +} diff --git a/src/provider/capabilities.ts b/src/provider/capabilities.ts index 583d8f2..d2578a7 100644 --- a/src/provider/capabilities.ts +++ b/src/provider/capabilities.ts @@ -12,6 +12,8 @@ import { isRevealIntent } from "@/provider/commit-reveal"; export interface ProviderPsbtSigningMethodCapabilities { /** Generic script-path signatures use the account's untweaked key (Horizon). */ taprootScriptPath?: "untweaked-key"; + /** Key-path spending of a Taproot output that commits to a script tree. */ + taprootTreeKeyPath?: boolean; supported: boolean; sighashTypes: number[]; inputScope: "selected" | "all"; @@ -108,10 +110,8 @@ const PSBT_OPTS = { const methodCapabilities = (value: unknown): ProviderPsbtSigningMethodCapabilities | null => { if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const { supported, sighashTypes, inputScope, externalInputs, taprootScriptPath } = value as Record< - string, - unknown - >; + const { supported, sighashTypes, inputScope, externalInputs, taprootScriptPath, taprootTreeKeyPath } = + value as Record; if ( typeof supported !== "boolean" || !Array.isArray(sighashTypes) || @@ -123,6 +123,7 @@ const methodCapabilities = (value: unknown): ProviderPsbtSigningMethodCapabiliti } return { ...(taprootScriptPath === "untweaked-key" ? { taprootScriptPath } : {}), + ...(typeof taprootTreeKeyPath === "boolean" ? { taprootTreeKeyPath } : {}), supported, sighashTypes: [...sighashTypes] as number[], inputScope, diff --git a/src/session.ts b/src/session.ts index 43d6bd9..1a3970d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -275,6 +275,12 @@ export class WalletSession { this.started = true; this.stopped = false; + if (this.wallet) { + this.wallet.on("accountsChanged", this.onAccountsChanged); + this.wallet.on("disconnect", this.onDisconnect); + void this.reconcile(); + } + const key = WALLET_CONNECTED_STORAGE_KEY; // The storage event is the only cross-tab connect signal; the provider emits nothing on connect. if (this.options.subscribeStorage) { diff --git a/src/wallets/casatookan.ts b/src/wallets/casatookan.ts new file mode 100644 index 0000000..967963c --- /dev/null +++ b/src/wallets/casatookan.ts @@ -0,0 +1,32 @@ +import { + CASATOOKAN_MESSAGE_VERIFICATION, + casaTookanMessageVerification, + createCasaTookanProvider, + getCasaTookanProvider, +} from "@/casatookan/provider"; +import type { WalletDescriptor } from "@/wallets/descriptor"; + +export const CASATOOKAN_WALLET: WalletDescriptor = { + id: "casatookan", + name: "Casa Tookan", + icon: null, + installUrl: "https://casatookan.com", + registryId: "CasaTookanBitcoinProvider", + messageVerification: CASATOOKAN_MESSAGE_VERIFICATION, + messageVerificationForAddress: casaTookanMessageVerification, + installed: () => getCasaTookanProvider() !== null, + provider: () => createCasaTookanProvider(), + onInjected: (listener) => { + if (typeof window === "undefined" || getCasaTookanProvider()) return () => {}; + const started = Date.now(); + const timer = setInterval(() => { + if (getCasaTookanProvider()) { + clearInterval(timer); + listener(); + } else if (Date.now() - started >= 5_000) { + clearInterval(timer); + } + }, 250); + return () => clearInterval(timer); + }, +}; diff --git a/src/wallets/descriptor.ts b/src/wallets/descriptor.ts index bf5c5be..5362b74 100644 --- a/src/wallets/descriptor.ts +++ b/src/wallets/descriptor.ts @@ -1,7 +1,7 @@ import type { ConnectionProof, XcpProvider } from "@/provider/types"; /** The wallets this SDK has an adapter for. Anything else in the page is ignored. */ -export type WalletId = "xcp" | "horizon"; +export type WalletId = "xcp" | "horizon" | "casatookan"; /** How one supported wallet is found, described and wrapped. */ export interface WalletDescriptor { diff --git a/src/web.ts b/src/web.ts index 55967f1..630016d 100644 --- a/src/web.ts +++ b/src/web.ts @@ -2,6 +2,7 @@ import { discoverWallets } from "@/wallets/discovery"; +export { CASATOOKAN_WALLET } from "@/wallets/casatookan"; export { discoverWallets, KNOWN_WALLETS, diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts index 0228cb0..c9d90fe 100644 --- a/tests/capabilities.test.ts +++ b/tests/capabilities.test.ts @@ -15,6 +15,16 @@ const report = { }; describe("parseProviderPsbtSigningCapabilities", () => { + it.each([true, false, undefined, "true"])("preserves explicit tree-recovery support: %s", (value) => { + const parsed = parseProviderPsbtSigningCapabilities({ + ...report, + psbt: { ...report.psbt, taprootTreeKeyPath: value }, + psbtBatch: { ...report.psbtBatch, taprootTreeKeyPath: value }, + }); + expect(parsed?.psbt.taprootTreeKeyPath).toBe(typeof value === "boolean" ? value : undefined); + expect(parsed?.psbtBatch.taprootTreeKeyPath).toBe(typeof value === "boolean" ? value : undefined); + }); + it("accepts a well-formed report", () => { const parsed = parseProviderPsbtSigningCapabilities(report); expect(parsed?.psbt.sighashTypes).toEqual([1, 131]); diff --git a/tests/casatookan.test.ts b/tests/casatookan.test.ts new file mode 100644 index 0000000..fd936d7 --- /dev/null +++ b/tests/casatookan.test.ts @@ -0,0 +1,580 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { base64, hex } from "@scure/base"; +import { p2pkh, p2sh, p2tr, p2wpkh, Script, Transaction } from "@scure/btc-signer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type CasaTookanProvider, + createCasaTookanProvider, + getCasaTookanProvider, +} from "@/casatookan/provider"; +import { configureWalletSdk } from "@/config"; +import { legacyMessageHash } from "@/crypto/bip322"; +import { WALLET_CONNECTED_STORAGE_KEY, WalletSession } from "@/session"; +import { readPsbt } from "@/transaction/verify"; +import { CASATOOKAN_WALLET } from "@/wallets/casatookan"; +import { discoverWallets, KNOWN_WALLETS } from "@/wallets/discovery"; + +const key = new Uint8Array(32).fill(9); +const pub = secp256k1.getPublicKey(key, true); +const other = p2wpkh(secp256k1.getPublicKey(new Uint8Array(32).fill(10), true)); +const segwit = p2wpkh(pub); +const legacy = p2pkh(pub); +const taproot = p2tr(pub.slice(1)); +const nested = p2sh(segwit); + +function fixture(type: "p2wpkh" | "p2pkh" | "p2tr" = "p2wpkh") { + const payment = type === "p2pkh" ? legacy : type === "p2tr" ? taproot : segwit; + const parent = new Transaction({ allowUnknownInputs: true }); + parent.addOutputAddress(payment.address, 20_000n); + parent.addInput({ txid: hex.decode("cc".repeat(32)), index: 0, finalScriptSig: hex.decode("51") }); + const tx = new Transaction(); + tx.addInput({ + txid: parent.id, + index: 0, + ...(type === "p2pkh" + ? { nonWitnessUtxo: parent.toBytes(true, true) } + : { witnessUtxo: { script: payment.script, amount: 20_000n } }), + }); + tx.addOutputAddress(other.address, 19_000n); + return tx; +} + +function fakeCasa(address = segwit.address) { + const listeners = new Map void>>(); + const casa = { + requestAccounts: vi.fn(async () => [address]), + getAccounts: vi.fn(async (): Promise => [address]), + getPublicKey: vi.fn(async () => hex.encode(pub)), + getNetwork: vi.fn(async () => "livenet"), + signMessage: vi.fn(async (message: string) => { + const signature = secp256k1.sign(legacyMessageHash(message), key); + return base64.encode(Uint8Array.from([31 + signature.recovery, ...signature.toCompactRawBytes()])); + }), + signPsbt: vi.fn(async (encoded: string, options?: Parameters[1]) => { + const tx = readPsbt(encoded); + if (options) for (const input of options.toSignInputs) tx.signIdx(key, input.index, input.sighashTypes); + else tx.sign(key); + return hex.encode(tx.toPSBT()); + }), + on: (event: string, listener: (...args: unknown[]) => void) => { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(listener); + }, + removeListener: (event: string, listener: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(listener); + }, + emit: (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }, + listeners, + } satisfies CasaTookanProvider & { emit: unknown; listeners: unknown }; + return Object.assign(casa, { + signPsbts: vi.fn(async (hexes: string[], options: Parameters[1][]) => + Promise.all(hexes.map((h, i) => casa.signPsbt(h, options[i]))), + ), + }); +} + +function sign(casa: CasaTookanProvider, tx = fixture(), extra: Record = {}) { + return createCasaTookanProvider(casa).request({ + method: "xcp_signPsbt", + params: [{ hex: hex.encode(tx.toPSBT()), ...extra }], + }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + configureWalletSdk({ network: "mainnet", storage: null }); +}); + +describe("Casa Tookan pilot", () => { + it("requires Casa Tookan's marker and a complete bridge, not just window.unisat", () => { + const casa = fakeCasa(); + vi.stubGlobal("window", { unisat: casa }); + expect(getCasaTookanProvider()).toBeNull(); + vi.stubGlobal("window", { casaTookan: { isCasaTookan: true }, unisat: casa }); + expect(getCasaTookanProvider()).toBe(casa); + vi.stubGlobal("window", { casaTookan: { isCasaTookan: true }, unisat: {} }); + expect(getCasaTookanProvider()).toBeNull(); + }); + + it("is opt-in and discovers late injection", () => { + vi.useFakeTimers(); + vi.stubGlobal("window", {}); + expect(KNOWN_WALLETS.some((wallet) => wallet.id === "casatookan")).toBe(false); + const discovery = discoverWallets([CASATOOKAN_WALLET]); + const changed = vi.fn(); + const unsubscribe = discovery.subscribe(changed); + vi.stubGlobal("window", { casaTookan: { isCasaTookan: true }, unisat: fakeCasa() }); + vi.advanceTimersByTime(250); + expect(changed).toHaveBeenCalledWith([expect.objectContaining({ id: "casatookan", installed: true })]); + unsubscribe(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("restores passively, disconnects locally and requires explicit reconnect", async () => { + const casa = fakeCasa(); + const provider = createCasaTookanProvider(casa); + expect(await provider.request({ method: "xcp_accounts" })).toEqual([segwit.address]); + expect(casa.requestAccounts).not.toHaveBeenCalled(); + const grant = await provider.request({ method: "xcp_getAddresses" }); + expect(grant).toMatchObject({ + active: { address: segwit.address, type: "p2wpkh" }, + signing: { + intentValidation: "none", + psbt: { inputScope: "selected", externalInputs: "any", taprootTreeKeyPath: false }, + psbtBatch: { supported: true, maxRequests: 20, approvalMode: "single" }, + }, + }); + expect(grant).not.toHaveProperty("legacy"); + await provider.request({ method: "xcp_disconnect" }); + expect(await provider.request({ method: "xcp_accounts" })).toEqual([]); + await expect(provider.request({ method: "xcp_signMessage", params: ["hello"] })).rejects.toMatchObject({ + code: "disconnected", + }); + await provider.request({ method: "xcp_requestAccounts" }); + expect(casa.requestAccounts).not.toHaveBeenCalled(); + }); + + it("asks for a connection only when Casa Tookan has no existing grant", async () => { + const casa = fakeCasa(); + casa.getAccounts.mockResolvedValueOnce([]); + const provider = createCasaTookanProvider(casa); + await expect(provider.request({ method: "xcp_requestAccounts" })).resolves.toMatchObject({ + accounts: [segwit.address], + }); + expect(casa.requestAccounts).toHaveBeenCalledOnce(); + }); + + it.each([{ accounts: [] }, { accounts: [nested.address] }])( + "clears restored identity when the passive grant is unusable: %j", + async ({ accounts }) => { + const stored = new Map([[WALLET_CONNECTED_STORAGE_KEY, segwit.address]]); + configureWalletSdk({ + storage: { + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => void stored.set(key, value), + removeItem: (key) => void stored.delete(key), + }, + }); + const casa = fakeCasa(); + casa.getAccounts.mockResolvedValue(accounts); + const session = new WalletSession({ provider: createCasaTookanProvider(casa) }); + session.start(); + session.stop(); + session.start(); + try { + await vi.waitFor(() => { + expect(session.getState()).toMatchObject({ readyState: "disconnected", address: null }); + }); + expect(stored.has(WALLET_CONNECTED_STORAGE_KEY)).toBe(false); + expect(casa.requestAccounts).not.toHaveBeenCalled(); + } finally { + session.stop(); + } + }, + ); + + it("reattaches account and revocation listeners when a session restarts", async () => { + const casa = fakeCasa(); + const session = new WalletSession({ provider: createCasaTookanProvider(casa) }); + session.start(); + await session.connect(); + session.stop(); + session.start(); + session.start(); + expect(casa.listeners.get("accountsChanged")?.size).toBe(2); + casa.emit("accountsChanged", []); + expect(session.getState()).toMatchObject({ readyState: "disconnected", address: null }); + session.stop(); + expect([...casa.listeners.values()].every((listeners) => listeners.size === 0)).toBe(true); + }); + + it.each(["p2pkh", "p2wpkh"] as const)("signs and finalizes a complete %s transaction", async (type) => { + const address = type === "p2pkh" ? legacy.address : segwit.address; + const casa = fakeCasa(address); + const tx = fixture(type); + const signed = (await sign(casa, tx, { signInputs: { [address]: [0] }, sighashTypes: [1] })) as { + hex: string; + }; + expect(casa.signPsbt).toHaveBeenCalledWith(hex.encode(tx.toPSBT()), { + autoFinalize: false, + toSignInputs: [{ index: 0, address, sighashTypes: [1] }], + }); + const result = readPsbt(signed.hex); + expect(hex.encode(result.unsignedTx)).toBe(hex.encode(tx.unsignedTx)); + result.finalize(); + expect(result.extract().length).toBeGreaterThan(0); + expect([...casa.listeners.values()].every((listeners) => listeners.size === 0)).toBe(true); + }); + + it("refuses an unexpectedly finalized response", async () => { + const casa = fakeCasa(); + casa.signPsbt.mockImplementation(async (encoded) => { + const tx = readPsbt(encoded); + tx.sign(key); + tx.finalize(); + return hex.encode(tx.toPSBT()); + }); + await expect(sign(casa)).rejects.toMatchObject({ code: "invalid_response" }); + }); + + it.each(["p2pkh", "p2wpkh", "p2tr"] as const)( + "verifies %s message proofs against the requested message and account", + async (type) => { + const casa = fakeCasa( + type === "p2pkh" ? legacy.address : type === "p2tr" ? taproot.address : segwit.address, + ); + const provider = createCasaTookanProvider(casa); + const signature = await provider.request({ method: "xcp_signMessage", params: ["exact message"] }); + casa.signMessage.mockResolvedValueOnce(signature as string); + await expect( + provider.request({ method: "xcp_signMessage", params: ["different"] }), + ).rejects.toMatchObject({ code: "invalid_response" }); + await expect( + provider.request({ method: "xcp_signMessage", params: ["hello", other.address] }), + ).rejects.toMatchObject({ code: "unauthorized" }); + }, + ); + + it.each([segwit.address, taproot.address])( + "connects with an independently verified session proof: %s", + async (address) => { + const casa = fakeCasa(address); + vi.stubGlobal("window", { casaTookan: { isCasaTookan: true }, unisat: casa }); + const session = new WalletSession({ + wallets: discoverWallets([CASATOOKAN_WALLET]), + proofOnConnect: true, + origin: "https://xcpdex.com", + }); + await session.connect("casatookan"); + expect(session.address).toBe(address); + expect(session.connectionProof?.verification?.method).toBe( + address === taproot.address ? "ECDSA-BIP86" : "BIP-137", + ); + session.stop(); + }, + ); + + it.each(["xcp_signTransaction", "xcp_switchAccount"])("refuses %s", async (method) => { + await expect(createCasaTookanProvider(fakeCasa()).request({ method })).rejects.toMatchObject({ + code: "unsupported_method", + }); + }); + + it.each([{ inscription: {} }, { reveal: {} }, { signInputs: { [other.address]: [0] } }])( + "refuses unsupported request %j before prompting", + async (extra) => { + const casa = fakeCasa(); + await expect(sign(casa, fixture(), extra)).rejects.toMatchObject({ code: "capability" }); + expect(casa.signPsbt).not.toHaveBeenCalled(); + }, + ); + + it("refuses foreign inputs, missing prevouts and unsafe PSBT sighash flags", async () => { + const casa = fakeCasa(); + for (const input of [ + { witnessUtxo: { script: other.script, amount: 20_000n } }, + { witnessUtxo: { script: segwit.script, amount: 20_000n }, sighashType: 4 }, + ]) { + const tx = new Transaction(); + tx.addInput({ txid: "aa".repeat(32), index: 0, ...input }); + tx.addOutputAddress(other.address, 19_000n); + await expect(sign(casa, tx)).rejects.toMatchObject({ code: "capability" }); + } + expect(casa.signPsbt).not.toHaveBeenCalled(); + }); + + it("refuses tree-bearing Taproot recovery even when the internal key is the account key", async () => { + const casa = fakeCasa(taproot.address); + const tree = p2tr(pub.slice(1), { script: Script.encode([taproot.script.slice(2), "CHECKSIG"]) }); + const tx = new Transaction(); + tx.addInput({ + txid: "ab".repeat(32), + index: 0, + witnessUtxo: { script: tree.script, amount: 20_000n }, + tapInternalKey: pub.slice(1), + tapMerkleRoot: tree.tapMerkleRoot, + }); + tx.addOutputAddress(taproot.address, 19_000n); + const control = tx.clone(); + control.sign(key); + control.finalize(); + expect(control.extract().length).toBeGreaterThan(0); + await expect(sign(casa, tx)).rejects.toMatchObject({ code: "capability" }); + expect(casa.signPsbt).not.toHaveBeenCalled(); + }); + + it("refuses changed transactions and unsigned responses", async () => { + const casa = fakeCasa(); + casa.signPsbt.mockImplementationOnce(async () => { + const tx = fixture(); + tx.updateOutput(0, { amount: 18_000n }); + tx.sign(key); + return hex.encode(tx.toPSBT()); + }); + await expect(sign(casa)).rejects.toMatchObject({ code: "transaction_mismatch" }); + casa.signPsbt.mockImplementationOnce(async (encoded) => encoded); + await expect(sign(casa)).rejects.toMatchObject({ code: "invalid_response" }); + }); + + it("refuses changed prevout amounts even when the unsigned transaction is unchanged", async () => { + const casa = fakeCasa(); + casa.signPsbt.mockImplementationOnce(async (encoded) => { + const tx = readPsbt(encoded); + tx.updateInput(0, { witnessUtxo: { script: segwit.script, amount: 30_000n } }); + tx.sign(key); + return hex.encode(tx.toPSBT()); + }); + await expect(sign(casa)).rejects.toMatchObject({ code: "transaction_mismatch" }); + }); + + it.each(["psbt", "message"])("discards %s results after switching away and back", async (kind) => { + const casa = fakeCasa(); + const method = kind === "psbt" ? "signPsbt" : "signMessage"; + const original = casa[method].getMockImplementation()!; + casa[method].mockImplementationOnce(async (value: string) => { + const result = await original(value); + casa.emit("accountsChanged", [other.address]); + casa.emit("accountsChanged", [segwit.address]); + return result; + }); + const request = + kind === "psbt" + ? sign(casa) + : createCasaTookanProvider(casa).request({ method: "xcp_signMessage", params: ["hello"] }); + await expect(request).rejects.toMatchObject({ code: "disconnected" }); + expect([...casa.listeners.values()].every((listeners) => listeners.size === 0)).toBe(true); + }); + + it("discards signing results after silent revocation", async () => { + const casa = fakeCasa(); + const original = casa.signPsbt.getMockImplementation()!; + casa.signPsbt.mockImplementationOnce(async (encoded) => { + const result = await original(encoded); + casa.getAccounts.mockResolvedValue([]); + return result; + }); + await expect(sign(casa)).rejects.toMatchObject({ code: "unauthorized" }); + }); + + it("rejects mismatched keys, nested SegWit and non-mainnet before signing", async () => { + for (const address of [other.address, nested.address]) { + const casa = fakeCasa(address); + await expect(sign(casa)).rejects.toMatchObject({ code: "capability" }); + expect(casa.signPsbt).not.toHaveBeenCalled(); + } + const casa = fakeCasa(); + casa.getNetwork.mockResolvedValue("testnet"); + await expect(sign(casa)).rejects.toMatchObject({ code: "capability" }); + casa.getNetwork.mockResolvedValue("livenet"); + configureWalletSdk({ network: "testnet" }); + await expect(sign(casa)).rejects.toMatchObject({ code: "capability" }); + }); + + it("does not restore an unsupported account through the passive connect fallback", async () => { + const provider = createCasaTookanProvider(fakeCasa(nested.address)); + await expect(provider.request({ method: "xcp_accounts" })).rejects.toMatchObject({ code: "capability" }); + }); + + it("ends the SDK session on revoked or unsupported accounts and removes the event bridge", () => { + const casa = fakeCasa(); + const provider = createCasaTookanProvider(casa); + const disconnected = vi.fn(); + provider.on("disconnect", disconnected); + casa.emit("accountsChanged", [other.address]); + expect(disconnected).not.toHaveBeenCalled(); + casa.emit("accountsChanged", []); + casa.emit("accountsChanged", [nested.address]); + expect(disconnected).toHaveBeenCalledTimes(2); + provider.removeListener("disconnect", disconnected); + casa.emit("accountsChanged", []); + expect(disconnected).toHaveBeenCalledTimes(2); + expect(casa.listeners.get("accountsChanged")?.size).toBe(0); + }); + + it("preserves rejection errors and removes only its own listeners", async () => { + const casa = fakeCasa(); + const provider = createCasaTookanProvider(casa); + const listener = vi.fn(); + provider.on("accountsChanged", listener); + casa.signPsbt.mockRejectedValueOnce({ code: 4001, message: "User rejected" }); + await expect(sign(casa)).rejects.toMatchObject({ code: "user_rejected" }); + casa.emit("accountsChanged", []); + expect(listener).toHaveBeenCalledWith([]); + provider.removeListener("accountsChanged", listener); + expect(casa.listeners.get("accountsChanged")?.size).toBe(0); + }); + + for (const type of ["p2pkh", "p2wpkh", "p2tr"] as const) { + for (const version of [0, 2]) { + it.each(type === "p2tr" ? [0, 1, 2, 3, 129, 130, 131] : [1, 2, 3, 129, 130, 131])( + `verifies ${type} PSBT v${version} with sighash %d`, + async (sighashType) => { + const address = + type === "p2pkh" ? legacy.address : type === "p2tr" ? taproot.address : segwit.address; + const tx = fixture(type); + tx.updateInput(0, { + sighashType, + ...(type === "p2tr" ? { tapInternalKey: pub.slice(1) } : {}), + }); + const provider = createCasaTookanProvider(fakeCasa(address)); + const result = (await provider.request({ + method: "xcp_signPsbt", + params: [{ hex: hex.encode(tx.toPSBT(version)), sighashTypes: [sighashType] }], + })) as { hex: string }; + const signed = readPsbt(result.hex); + signed.finalize(); + expect(signed.extract().length).toBeGreaterThan(0); + expect(hex.encode(readPsbt(result.hex).toPSBT(version))).toBe(result.hex); + }, + ); + } + } + + it("signs a selected input, preserves a foreign signature, and finalizes the shared transaction", async () => { + const tx = fixture(); + const foreignKey = new Uint8Array(32).fill(10); + tx.addInput({ txid: "dd".repeat(32), index: 1, witnessUtxo: { script: other.script, amount: 5_000n } }); + tx.signIdx(foreignKey, 1); + const foreignSignature = tx.getInput(1).partialSig; + const casa = fakeCasa(); + const result = (await sign(casa, tx, { + signInputs: { [segwit.address]: [0] }, + intent: { action: "trade" }, + })) as { hex: string }; + const signed = readPsbt(result.hex); + expect(signed.getInput(1).partialSig).toEqual(foreignSignature); + signed.finalize(); + expect(signed.extract().length).toBeGreaterThan(0); + expect(casa.signPsbt.mock.calls[0]?.[1]?.toSignInputs).toEqual([ + { index: 0, address: segwit.address, sighashTypes: [1] }, + ]); + }); + + it("leaves unselected owned and unsigned foreign inputs unsigned", async () => { + const tx = fixture(); + for (const [index, payment] of [segwit, other].entries()) + tx.addInput({ txid: "dd".repeat(32), index, witnessUtxo: { script: payment.script, amount: 5_000n } }); + const signed = (await sign(fakeCasa(), tx, { signInputs: { [segwit.address]: [0] } })) as { hex: string }; + expect(readPsbt(signed.hex).getInput(1).partialSig).toBeUndefined(); + expect(readPsbt(signed.hex).getInput(2).partialSig).toBeUndefined(); + }); + + it("refuses another address family from the same key before invoking the wallet", async () => { + const casa = fakeCasa(taproot.address); + await expect(sign(casa, fixture(), { signInputs: { [taproot.address]: [0] } })).rejects.toMatchObject({ + code: "capability", + }); + expect(casa.signPsbt).not.toHaveBeenCalled(); + }); + + it.each([ + { sighashTypes: [131] }, + { sighashTypes: [] }, + { signInputs: { [segwit.address]: [] } }, + { signInputs: { [segwit.address]: [0, 0] } }, + { signInputs: { [segwit.address]: [-1] } }, + ])("validates input selection and exact sighash before approval: %j", async (extra) => { + const casa = fakeCasa(); + await expect(sign(casa, fixture(), extra)).rejects.toMatchObject({ code: "invalid_argument" }); + expect(casa.signPsbt).not.toHaveBeenCalled(); + }); + + it("rejects signatures produced for a different prevout amount even if metadata is restored", async () => { + const casa = fakeCasa(); + casa.signPsbt.mockImplementationOnce(async (encoded) => { + const tx = readPsbt(encoded); + tx.updateInput(0, { witnessUtxo: { script: segwit.script, amount: 30_000n } }); + tx.sign(key); + tx.updateInput(0, { witnessUtxo: { script: segwit.script, amount: 20_000n } }, true); + return hex.encode(tx.toPSBT()); + }); + await expect(sign(casa)).rejects.toMatchObject({ code: "invalid_response" }); + }); + + it("restores proprietary metadata stripped by the signing engine", async () => { + const original = fixture(); + const tx = Transaction.fromPSBT(original.toPSBT(), { unknown: "ignore", proprietary: "ignore" }); + tx.updateInput(0, { unknown: [[{ type: 252, key: new Uint8Array([1, 65, 0]) }, new Uint8Array([42])]] }); + const result = (await sign(fakeCasa(), tx)) as { hex: string }; + const signed = Transaction.fromPSBT(hex.decode(result.hex), { unknown: "ignore", proprietary: "ignore" }); + expect(signed.getInput(0).proprietary).toEqual([[new Uint8Array([1, 65, 0]), new Uint8Array([42])]]); + }); + + it("signs 20 ordered requests through one batch call with per-item policies", async () => { + const casa = fakeCasa(); + const provider = createCasaTookanProvider(casa); + const requests = Array.from({ length: 20 }, (_, index) => { + const tx = fixture(); + tx.updateOutput(0, { amount: BigInt(19_000 - index) }); + tx.updateInput(0, { sighashType: index % 2 ? 131 : 1 }); + return { hex: hex.encode(tx.toPSBT()), sighashTypes: [index % 2 ? 131 : 1] }; + }); + const result = (await provider.request({ method: "xcp_signPsbts", params: [{ requests }] })) as { + hexes: string[]; + }; + expect(casa.signPsbts).toHaveBeenCalledOnce(); + expect(result.hexes).toHaveLength(20); + result.hexes.forEach((encoded, index) => { + const tx = readPsbt(encoded); + expect(tx.getOutput(0).amount).toBe(BigInt(19_000 - index)); + tx.finalize(); + expect(tx.extract().length).toBeGreaterThan(0); + }); + }); + + it.each(["truncated", "reordered", "unsigned", "rejected", "revoked"])( + "returns no partial batch for %s results", + async (failure) => { + const casa = fakeCasa(); + const original = casa.signPsbts.getMockImplementation()!; + casa.signPsbts.mockImplementationOnce(async (hexes, options) => { + const signed = await original(hexes, options); + if (failure === "truncated") return signed.slice(0, 1); + if (failure === "reordered") return signed.reverse(); + if (failure === "unsigned") return [signed[0]!, hexes[1]!]; + if (failure === "rejected") throw { code: 4001, message: "Cancelled" }; + casa.emit("accountsChanged", []); + return signed; + }); + const tx = fixture(); + const second = fixture(); + second.updateOutput(0, { amount: 18_999n }); + await expect( + createCasaTookanProvider(casa).request({ + method: "xcp_signPsbts", + params: [{ requests: [tx, second].map((t) => ({ hex: hex.encode(t.toPSBT()) })) }], + }), + ).rejects.toBeInstanceOf(Error); + }, + ); + + it("rejects excessive batches, oversized PSBTs and invalid later requests before prompting", async () => { + const casa = fakeCasa(); + const provider = createCasaTookanProvider(casa); + const request = { hex: hex.encode(fixture().toPSBT()) }; + for (const requests of [[], Array(21).fill(request), [request, { hex: "00" }]]) + await expect( + provider.request({ method: "xcp_signPsbts", params: [{ requests }] }), + ).rejects.toBeInstanceOf(Error); + await expect( + provider.request({ method: "xcp_signPsbt", params: [{ hex: "00".repeat(65_537) }] }), + ).rejects.toMatchObject({ code: "capability" }); + expect(casa.signPsbts).not.toHaveBeenCalled(); + expect(casa.signPsbt).not.toHaveBeenCalled(); + }); + + it("refuses overlapping approvals and allows the next request after completion", async () => { + const casa = fakeCasa(); + const provider = createCasaTookanProvider(casa); + const params = [{ hex: hex.encode(fixture().toPSBT()) }]; + const pending = provider.request({ method: "xcp_signPsbt", params }); + await expect(provider.request({ method: "xcp_signPsbt", params })).rejects.toMatchObject({ + code: "invalid_argument", + }); + await pending; + await expect(provider.request({ method: "xcp_signPsbt", params })).resolves.toHaveProperty("hex"); + }); +}); diff --git a/tests/compose-psbt.test.ts b/tests/compose-psbt.test.ts index fc1d28e..204a529 100644 --- a/tests/compose-psbt.test.ts +++ b/tests/compose-psbt.test.ts @@ -2,8 +2,10 @@ import { secp256k1 } from "@noble/curves/secp256k1"; import { base64, hex } from "@scure/base"; import { Address, OutScript, p2wpkh, Transaction } from "@scure/btc-signer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCasaTookanProvider } from "@/casatookan/provider"; import { configureWalletSdk } from "@/config"; import { WalletSdkError } from "@/errors"; +import { XcpWallet } from "@/provider/wallet"; import { type ComposeSigner, composeAndBroadcast } from "@/transaction/compose"; /** @@ -80,6 +82,47 @@ afterEach(() => { }); describe("compose PSBT path", () => { + it("routes Casa Tookan through PSBT fallback and finalizes before broadcast", async () => { + stubNode(coreCompose()); + const signPsbt = vi.fn(async (encoded: string, options: { autoFinalize: false }) => { + expect(options.autoFinalize).toBe(false); + const tx = Transaction.fromPSBT(hex.decode(encoded)); + tx.sign(PRIV); + return hex.encode(tx.toPSBT()); + }); + const wallet = new XcpWallet( + createCasaTookanProvider({ + getAccounts: async () => [ADDR], + requestAccounts: async () => [ADDR], + getPublicKey: async () => hex.encode(PUB), + getNetwork: async () => "livenet", + signMessage: async () => { + throw new Error("No message expected"); + }, + signPsbt, + on: () => {}, + removeListener: () => {}, + }), + ); + const broadcastTransaction = vi.fn(async () => TXID); + const receipt = await composeAndBroadcast( + { + address: ADDR, + publicKey: hex.encode(PUB), + connectionProof: null, + signTransaction: (encoded) => wallet.signTransaction(encoded), + signPsbt: (encoded, inputs, types) => wallet.signPsbt(encoded, inputs, types), + broadcastTransaction, + }, + "send", + { asset: "XCP" }, + { feeRate: 1 }, + ); + expect(receipt.txid).toBe(TXID); + expect(signPsbt).toHaveBeenCalledOnce(); + expect(broadcastTransaction).toHaveBeenCalledOnce(); + expect(Transaction.fromRaw(hex.decode(receipt.signedHex)).getInput(0).finalScriptWitness).toHaveLength(2); + }); it("decodes Core's base64, fills prevouts from the node, signs, finalizes and broadcasts", async () => { const urls = stubNode(coreCompose()); const psbtCalls: unknown[][] = []; diff --git a/tests/session-wallets.test.ts b/tests/session-wallets.test.ts index 61d7484..1934ac1 100644 --- a/tests/session-wallets.test.ts +++ b/tests/session-wallets.test.ts @@ -45,8 +45,12 @@ function fakeProvider(address: string) { /** Two wallets whose presence the test flips; no window involved. */ function fakeDiscovery() { - const installed: Record = { xcp: false, horizon: false }; - const providers = { xcp: fakeProvider(XCP_ADDR), horizon: fakeProvider(HORIZON_ADDR) }; + const installed: Record = { xcp: false, horizon: false, casatookan: false }; + const providers = { + xcp: fakeProvider(XCP_ADDR), + horizon: fakeProvider(HORIZON_ADDR), + casatookan: fakeProvider(XCP_ADDR), + }; const listeners = new Set<(c: WalletCandidate[]) => void>(); const nudged: WalletId[] = []; const describe = (id: WalletId, extra: Partial = {}): WalletDescriptor => ({ diff --git a/tsup.config.ts b/tsup.config.ts index 326a29b..d9d8da0 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ amounts: "src/amounts.ts", web: "src/web.ts", horizon: "src/horizon/provider.ts", + casatookan: "src/casatookan/provider.ts", "react/index": "src/react/index.ts", "react/leader-polling": "src/react/leader-polling.ts", "react/use-spendable-balance": "src/react/use-spendable-balance.ts",