From e8facf8453321e73d9dfeb957e6e7496dfc65c17 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 11:22:03 -0400 Subject: [PATCH 1/8] Add opt-in Casa Tookan signing adapter --- README.md | 1 + docs/casatookan.md | 59 +++++++ package.json | 4 + src/casatookan/provider.ts | 300 +++++++++++++++++++++++++++++++++ src/wallets/casatookan.ts | 30 ++++ src/wallets/descriptor.ts | 2 +- src/web.ts | 1 + tests/casatookan.test.ts | 307 ++++++++++++++++++++++++++++++++++ tests/compose-psbt.test.ts | 38 +++++ tests/session-wallets.test.ts | 8 +- tsup.config.ts | 1 + 11 files changed, 748 insertions(+), 3 deletions(-) create mode 100644 docs/casatookan.md create mode 100644 src/casatookan/provider.ts create mode 100644 src/wallets/casatookan.ts create mode 100644 tests/casatookan.test.ts 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.md b/docs/casatookan.md new file mode 100644 index 0000000..d37cba4 --- /dev/null +++ b/docs/casatookan.md @@ -0,0 +1,59 @@ +# 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 + +- Bitcoin mainnet, Legacy (`p2pkh`) and Native SegWit (`p2wpkh`). +- Passive account restore and explicit connection. Only the active account is + exposed; no paired-address permission is inferred. +- ECDSA Bitcoin Signed Message proofs, verified against the exact message and + account before returning them. The SDK declares the BIP-137 verification dialect. +- One PSBT per approval, with every input belonging to that account and every + signature using SIGHASH_ALL. Legacy inputs require full parent transactions. +- Raw compose signing returns `unsupported_method`, invoking the SDK's existing + PSBT fallback. Signing never broadcasts; explicit broadcast uses the SDK relay. +- Disconnect clears this adapter's local session. Revoke the site's permission in + Casa Tookan to revoke the underlying grant. + +Taproot, nested SegWit, selected-input signing, marketplace intents, reusable +authorizations, batches and commit/reveal are refused. This adapter does not +provide XCP Wallet's semantic intent validation. Sites must retain their own +transaction review. + +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. Confirm batch, partial-input, non-ALL and commit/reveal requests fail before + opening an approval. + +The native browser injects providers. No downloaded injection script, +development iframe bridge, CSP relaxation or CEID authentication is needed. 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..dc85468 --- /dev/null +++ b/src/casatookan/provider.ts @@ -0,0 +1,300 @@ +import { hex } from "@scure/base"; +import { p2pkh, p2wpkh, RawTx, type Transaction } from "@scure/btc-signer"; +import { getNetwork } from "@/config"; +import { broadcastSignedTransaction } from "@/counterparty/broadcast"; +import { verifyLegacyRecoverableMessage } from "@/crypto/bip322"; +import { WalletSdkError } from "@/errors"; +import type { ProviderPsbtSigningCapabilities } from "@/provider/capabilities"; +import type { ConnectionProof, WalletAddress, XcpProvider } from "@/provider/types"; +import { assertSameTransaction, readPsbt, readRawTransaction } from "@/transaction/verify"; + +export interface CasaTookanProvider { + requestAccounts(): Promise; + getAccounts(): Promise; + getPublicKey(): Promise; + getNetwork(): Promise; + signMessage(message: string, type: "ecdsa"): Promise; + signPsbt( + psbtHex: string, + options: { + autoFinalize: false; + toSignInputs: { index: number; address: string; sighashTypes: number[] }[]; + }, + ): 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; +} + +function signingCapabilities(): ProviderPsbtSigningCapabilities { + return { + intentValidation: "none", + psbt: { supported: true, inputScope: "all", sighashTypes: [1] }, + psbtBatch: { + supported: false, + inputScope: "all", + sighashTypes: [], + maxRequests: 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 fromCasaTookanError(error: unknown): WalletSdkError { + if (error instanceof WalletSdkError) return error; + const rpc = error as { code?: number; message?: string } | null; + const message = typeof rpc?.message === "string" ? rpc.message : "Casa Tookan request failed"; + const options = { cause: error, walletCode: rpc?.code }; + 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); +} + +function requireOwnedInputs(tx: Transaction, account: WalletAddress): void { + if (!tx.inputsLength || !tx.outputsLength) + throw new WalletSdkError("invalid_argument", "A transaction needs inputs and outputs"); + const key = hex.decode(account.publicKey); + const expected = account.type === "p2pkh" ? p2pkh(key).script : p2wpkh(key).script; + for (let index = 0; index < tx.inputsLength; index++) { + const input = tx.getInput(index); + let prevout = 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 output = parent.getOutput(input.index); + if (output.amount === undefined || !output.script) + throw new WalletSdkError("invalid_argument", "PSBT parent output is missing"); + if ( + prevout && + (prevout.amount !== output.amount || hex.encode(prevout.script) !== hex.encode(output.script)) + ) + throw new WalletSdkError("transaction_mismatch", "PSBT prevouts disagree"); + prevout = { amount: output.amount, script: output.script }; + } else if (account.type === "p2pkh") { + throw new WalletSdkError("invalid_argument", "Legacy signing requires the full parent transaction"); + } + if (!prevout || hex.encode(prevout.script) !== hex.encode(expected)) + throw new WalletSdkError("capability", "Casa Tookan must own every input in this transaction"); + if ((input.sighashType ?? 1) !== 1) + throw new WalletSdkError("capability", "Casa Tookan pilot supports SIGHASH_ALL only"); + } +} + +/** Opt-in pilot: mainnet Legacy/Native SegWit, one fully owned transaction per approval. */ +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; + + async function requireMainnet(): Promise { + if (getNetwork() !== "mainnet" || (await wallet.getNetwork()) !== "livenet") + throw new WalletSdkError("capability", "Casa Tookan pilot 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" : null; + if (!type) + throw new WalletSdkError("capability", "Select a Legacy or Native SegWit 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 { + 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 { + wallet.removeListener("accountsChanged", invalidate); + wallet.removeListener("networkChanged", invalidate); + wallet.removeListener("disconnect", invalidate); + } + } + + async function signPsbt(value: unknown): Promise<{ hex: string }> { + 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"].includes(key))) + throw new WalletSdkError("capability", "Casa Tookan pilot does not support intents or commit/reveal"); + if (typeof request.hex !== "string" || !/^(?:[0-9a-f]{2})+$/i.test(request.hex)) + throw new WalletSdkError("invalid_argument", "A hex PSBT is required"); + const psbtHex = request.hex; + const tx = readPsbt(psbtHex); + const indices = Array.from({ length: tx.inputsLength }, (_, index) => index); + if ( + request.sighashTypes !== undefined && + (!Array.isArray(request.sighashTypes) || + request.sighashTypes.length !== indices.length || + request.sighashTypes.some((type) => type !== 1)) + ) + throw new WalletSdkError("capability", "Use SIGHASH_ALL for every input"); + return withAccount(async (account) => { + if (request.signInputs !== undefined) { + const inputs = request.signInputs; + if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) + throw new WalletSdkError("invalid_argument", "Invalid signing inputs"); + const entries = Object.entries(inputs); + const selected = entries[0]?.[1] as unknown; + if ( + entries.length !== 1 || + entries[0]?.[0] !== account.address || + !Array.isArray(selected) || + selected.length !== indices.length || + new Set(selected).size !== indices.length || + selected.some((index) => !Number.isInteger(index) || !indices.includes(index)) + ) + throw new WalletSdkError("capability", "Casa Tookan must sign every input with the active account"); + } + requireOwnedInputs(tx, account); + // Casa Tookan's bridge spells this autoFinalize, unlike Unisat's autoFinalized. + const signed = await wallet.signPsbt(psbtHex, { + autoFinalize: false, + toSignInputs: indices.map((index) => ({ index, address: account.address, sighashTypes: [1] })), + }); + 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); + assertSameTransaction(tx, result); + requireOwnedInputs(result, account); + for (const index of indices) { + const before = tx.getInput(index); + const after = result.getInput(index); + if (before.witnessUtxo && after.witnessUtxo?.amount !== before.witnessUtxo.amount) + throw new WalletSdkError("transaction_mismatch", "Casa Tookan changed a prevout amount"); + } + if (!result.isFinal) result.finalize(); + result.extract(); + return { hex: signed }; + }); + } + + return { + async request({ method, params = [] }) { + try { + switch (method) { + case "xcp_requestAccounts": { + await requireMainnet(); + accountList(await wallet.requestAccounts()); + disconnected = false; + const active = await activeAccount(); + return { accounts: [active.address], proof: null }; + } + case "xcp_accounts": + return disconnected ? [] : accountList(await wallet.getAccounts()).slice(0, 1); + case "xcp_getAddresses": + if (disconnected || accountList(await wallet.getAccounts()).length === 0) return null; + return { active: await activeAccount(), signing: signingCapabilities() }; + 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"); + if ( + typeof signature !== "string" || + !verifyLegacyRecoverableMessage(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_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 pilot does not support ${method}`); + } + } catch (error) { + throw fromCasaTookanError(error); + } + }, + on: (event, handler) => wallet.on(event, handler), + removeListener: (event, handler) => wallet.removeListener(event, handler), + }; +} diff --git a/src/wallets/casatookan.ts b/src/wallets/casatookan.ts new file mode 100644 index 0000000..0de8217 --- /dev/null +++ b/src/wallets/casatookan.ts @@ -0,0 +1,30 @@ +import { + CASATOOKAN_MESSAGE_VERIFICATION, + 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, + 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/casatookan.test.ts b/tests/casatookan.test.ts new file mode 100644 index 0000000..e945ec6 --- /dev/null +++ b/tests/casatookan.test.ts @@ -0,0 +1,307 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { base64, hex } from "@scure/base"; +import { p2pkh, p2tr, p2wpkh, 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 { 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); + +function fixture(type: "p2wpkh" | "p2pkh" = "p2wpkh") { + const payment = type === "p2pkh" ? legacy : 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) => { + const tx = readPsbt(encoded); + 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 casa; +} + +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: "all", sighashTypes: [1] }, + psbtBatch: { supported: false }, + }, + }); + 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).toHaveBeenCalledOnce(); + }); + + 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("accepts an already-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)).resolves.toHaveProperty("hex"); + }); + + it.each(["p2pkh", "p2wpkh"] as const)( + "verifies %s message proofs against the requested message and account", + async (type) => { + const casa = fakeCasa(type === "p2pkh" ? legacy.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("connects with an independently verified session proof", async () => { + const casa = fakeCasa(); + 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(segwit.address); + expect(session.connectionProof?.verification?.method).toBe("BIP-137"); + session.stop(); + }); + + it.each(["xcp_signPsbts", "xcp_signTransaction", "xcp_switchAccount"])("refuses %s", async (method) => { + await expect(createCasaTookanProvider(fakeCasa()).request({ method })).rejects.toMatchObject({ + code: "unsupported_method", + }); + }); + + it.each([ + { intent: {} }, + { inscription: {} }, + { reveal: {} }, + { sighashTypes: [0x83] }, + { sighashTypes: [] }, + { signInputs: { [segwit.address]: [] } }, + { signInputs: { [other.address]: [0] } }, + { signInputs: { [segwit.address]: [0, 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: 0x83 }, + {}, + ]) { + 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 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, Taproot and non-mainnet before signing", async () => { + for (const address of [other.address, p2tr(pub.slice(1)).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("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); + }); +}); diff --git a/tests/compose-psbt.test.ts b/tests/compose-psbt.test.ts index fc1d28e..b6481e4 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,42 @@ 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) => walletSign(encoded)); + 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", From 49f8631a0ac5b04a311c26128d7c4a2c53f16ed3 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 11:38:14 -0400 Subject: [PATCH 2/8] Handle Casa Tookan restoration and account revocation --- src/casatookan/provider.ts | 40 +++++++++++++++++++++++++++++++++----- tests/casatookan.test.ts | 31 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/casatookan/provider.ts b/src/casatookan/provider.ts index dc85468..7ac896e 100644 --- a/src/casatookan/provider.ts +++ b/src/casatookan/provider.ts @@ -1,5 +1,5 @@ import { hex } from "@scure/base"; -import { p2pkh, p2wpkh, RawTx, type Transaction } from "@scure/btc-signer"; +import { Address, p2pkh, p2wpkh, RawTx, type Transaction } from "@scure/btc-signer"; import { getNetwork } from "@/config"; import { broadcastSignedTransaction } from "@/counterparty/broadcast"; import { verifyLegacyRecoverableMessage } from "@/crypto/bip322"; @@ -75,6 +75,15 @@ function accountList(value: unknown): string[] { return value; } +function supportsAddress(address: string): boolean { + try { + const type = Address().decode(address).type; + return type === "pkh" || type === "wpkh"; + } catch { + return false; + } +} + function fromCasaTookanError(error: unknown): WalletSdkError { if (error instanceof WalletSdkError) return error; const rpc = error as { code?: number; message?: string } | null; @@ -128,6 +137,7 @@ export function createCasaTookanProvider( const wallet = casa; let disconnected = false; let revision = 0; + const disconnectListeners = new Map<(...args: unknown[]) => void, (...args: unknown[]) => void>(); async function requireMainnet(): Promise { if (getNetwork() !== "mainnet" || (await wallet.getNetwork()) !== "livenet") @@ -243,13 +253,15 @@ export function createCasaTookanProvider( switch (method) { case "xcp_requestAccounts": { await requireMainnet(); - accountList(await wallet.requestAccounts()); + 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": - return disconnected ? [] : accountList(await wallet.getAccounts()).slice(0, 1); + if (disconnected || accountList(await wallet.getAccounts()).length === 0) return []; + return [(await activeAccount()).address]; case "xcp_getAddresses": if (disconnected || accountList(await wallet.getAccounts()).length === 0) return null; return { active: await activeAccount(), signing: signingCapabilities() }; @@ -294,7 +306,25 @@ export function createCasaTookanProvider( throw fromCasaTookanError(error); } }, - on: (event, handler) => wallet.on(event, handler), - removeListener: (event, handler) => wallet.removeListener(event, handler), + 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/tests/casatookan.test.ts b/tests/casatookan.test.ts index e945ec6..0a9dff2 100644 --- a/tests/casatookan.test.ts +++ b/tests/casatookan.test.ts @@ -127,6 +127,16 @@ describe("Casa Tookan pilot", () => { 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(); }); @@ -292,6 +302,27 @@ describe("Casa Tookan pilot", () => { 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(p2tr(pub.slice(1)).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", [p2tr(pub.slice(1)).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); From 28de11317251679cd98e3ddd3c171b2be636810d Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 12:26:42 -0400 Subject: [PATCH 3/8] fix: clear revoked Casa Tookan grants during passive restore --- src/casatookan/provider.ts | 35 +++++++++++++++++++++++++++++------ tests/casatookan.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/casatookan/provider.ts b/src/casatookan/provider.ts index 7ac896e..1f644b7 100644 --- a/src/casatookan/provider.ts +++ b/src/casatookan/provider.ts @@ -139,6 +139,27 @@ export function createCasaTookanProvider( let revision = 0; 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 pilot supports Bitcoin mainnet only"); @@ -259,12 +280,14 @@ export function createCasaTookanProvider( const active = await activeAccount(); return { accounts: [active.address], proof: null }; } - case "xcp_accounts": - if (disconnected || accountList(await wallet.getAccounts()).length === 0) return []; - return [(await activeAccount()).address]; - case "xcp_getAddresses": - if (disconnected || accountList(await wallet.getAccounts()).length === 0) return null; - return { active: await activeAccount(), signing: signingCapabilities() }; + case "xcp_accounts": { + const active = await passiveAccount(); + return active ? [active.address] : []; + } + case "xcp_getAddresses": { + const active = await passiveAccount(); + return active ? { active, signing: signingCapabilities() } : null; + } case "xcp_getNetwork": case "xcp_chainId": await requireMainnet(); diff --git a/tests/casatookan.test.ts b/tests/casatookan.test.ts index 0a9dff2..00f7a79 100644 --- a/tests/casatookan.test.ts +++ b/tests/casatookan.test.ts @@ -9,7 +9,7 @@ import { } from "@/casatookan/provider"; import { configureWalletSdk } from "@/config"; import { legacyMessageHash } from "@/crypto/bip322"; -import { WalletSession } from "@/session"; +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"; @@ -140,6 +140,33 @@ describe("Casa Tookan pilot", () => { expect(casa.requestAccounts).toHaveBeenCalledOnce(); }); + it.each([{ accounts: [] }, { accounts: [p2tr(pub.slice(1)).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(); + 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.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); From 25425b75142f832705d1c3611ab2cba2adc0f942 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 12:29:43 -0400 Subject: [PATCH 4/8] fix: restore wallet listeners when sessions restart --- src/session.ts | 6 ++++++ tests/casatookan.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) 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/tests/casatookan.test.ts b/tests/casatookan.test.ts index 00f7a79..a0c2cd1 100644 --- a/tests/casatookan.test.ts +++ b/tests/casatookan.test.ts @@ -155,6 +155,8 @@ describe("Casa Tookan pilot", () => { 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 }); @@ -167,6 +169,21 @@ describe("Casa Tookan pilot", () => { }, ); + 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); From 91b9887f3083d913376f3482aa110c6c93a5ed22 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 12:31:09 -0400 Subject: [PATCH 5/8] docs: record live Casa Tookan signer verification --- docs/casatookan.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/casatookan.md b/docs/casatookan.md index d37cba4..3f24e11 100644 --- a/docs/casatookan.md +++ b/docs/casatookan.md @@ -57,3 +57,30 @@ Before enabling a production site: 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. From 22ac1ad3aaf5d505c17cf1c0a8eab8941575439f Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 14:07:42 -0400 Subject: [PATCH 6/8] docs: specify verified Casa Tookan signing behavior --- docs/casatookan-dev-spec.md | 155 +++++ docs/casatookan.md | 5 + docs/qa/casatookan-2026-10-20.json | 1020 ++++++++++++++++++++++++++++ 3 files changed, 1180 insertions(+) create mode 100644 docs/casatookan-dev-spec.md create mode 100644 docs/qa/casatookan-2026-10-20.json diff --git a/docs/casatookan-dev-spec.md b/docs/casatookan-dev-spec.md new file mode 100644 index 0000000..b461fa6 --- /dev/null +++ b/docs/casatookan-dev-spec.md @@ -0,0 +1,155 @@ +# 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 [pilot adapter](casatookan.md) remains narrower than this contract. +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 | Rejected as not belonging to the casa. | Engine | +| Taproot script trees | Ownership matching recognizes the no-tree BIP-86 output, not arbitrary tree-bearing outputs. | Engine source; script-path UI not tested | +| 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. + +## Remaining external evidence + +Most integration questions are answered by this build. The concrete report to +the developers is the large-PSBT preparation overflow, with the ignored options +as compatibility details rather than missing signing features. + +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 index 3f24e11..1230926 100644 --- a/docs/casatookan.md +++ b/docs/casatookan.md @@ -14,6 +14,11 @@ Direct consumers can import `createCasaTookanProvider` from ## Supported boundary +This is the adapter's current boundary. The +[development-wallet contract](casatookan-dev-spec.md) records broader capabilities +verified directly against Casa, including selected inputs, batches, non-ALL +sighashes and Taproot. Those are not enabled by this pilot adapter. + - Bitcoin mainnet, Legacy (`p2pkh`) and Native SegWit (`p2wpkh`). - Passive account restore and explicit connection. Only the active account is exposed; no paired-address permission is inferred. 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." + ] +} From c04b34226bba26e8f9c8a757fa11aef31aab2bf3 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 14:29:18 -0400 Subject: [PATCH 7/8] feat: support verified Casa Tookan partial, Taproot and batch signing --- docs/casatookan-dev-spec.md | 2 +- docs/casatookan.md | 55 +++-- docs/qa/casatookan-adapter-2026-10-20.json | 83 +++++++ src/casatookan/provider.ts | 202 +++++++-------- src/casatookan/psbt.ts | 213 ++++++++++++++++ src/wallets/casatookan.ts | 2 + tests/casatookan.test.ts | 272 +++++++++++++++++---- tests/compose-psbt.test.ts | 7 +- 8 files changed, 651 insertions(+), 185 deletions(-) create mode 100644 docs/qa/casatookan-adapter-2026-10-20.json create mode 100644 src/casatookan/psbt.ts diff --git a/docs/casatookan-dev-spec.md b/docs/casatookan-dev-spec.md index b461fa6..b097097 100644 --- a/docs/casatookan-dev-spec.md +++ b/docs/casatookan-dev-spec.md @@ -16,7 +16,7 @@ Evidence has two scopes: - **Engine:** the exact downloaded engine run locally with a deterministic test key. These tests bypass provider preparation, permissions and UI. -The [pilot adapter](casatookan.md) remains narrower than this contract. +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. diff --git a/docs/casatookan.md b/docs/casatookan.md index 1230926..824b527 100644 --- a/docs/casatookan.md +++ b/docs/casatookan.md @@ -14,27 +14,27 @@ Direct consumers can import `createCasaTookanProvider` from ## Supported boundary -This is the adapter's current boundary. The -[development-wallet contract](casatookan-dev-spec.md) records broader capabilities -verified directly against Casa, including selected inputs, batches, non-ALL -sighashes and Taproot. Those are not enabled by this pilot adapter. - -- Bitcoin mainnet, Legacy (`p2pkh`) and Native SegWit (`p2wpkh`). -- Passive account restore and explicit connection. Only the active account is - exposed; no paired-address permission is inferred. -- ECDSA Bitcoin Signed Message proofs, verified against the exact message and - account before returning them. The SDK declares the BIP-137 verification dialect. -- One PSBT per approval, with every input belonging to that account and every - signature using SIGHASH_ALL. Legacy inputs require full parent transactions. -- Raw compose signing returns `unsupported_method`, invoking the SDK's existing - PSBT fallback. Signing never broadcasts; explicit broadcast uses the SDK relay. -- Disconnect clears this adapter's local session. Revoke the site's permission in - Casa Tookan to revoke the underlying grant. - -Taproot, nested SegWit, selected-input signing, marketplace intents, reusable -authorizations, batches and commit/reveal are refused. This adapter does not -provide XCP Wallet's semantic intent validation. Sites must retain their own -transaction review. +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. Detection requires Casa Tookan's marker plus its Unisat-compatible bridge; a standalone Unisat extension is not detected as Casa Tookan. @@ -57,8 +57,8 @@ Before enabling a production site: 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. Confirm batch, partial-input, non-ALL and commit/reveal requests fail before - opening an approval. +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. @@ -89,3 +89,12 @@ 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-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/src/casatookan/provider.ts b/src/casatookan/provider.ts index 1f644b7..80406ee 100644 --- a/src/casatookan/provider.ts +++ b/src/casatookan/provider.ts @@ -1,12 +1,18 @@ import { hex } from "@scure/base"; -import { Address, p2pkh, p2wpkh, RawTx, type Transaction } from "@scure/btc-signer"; +import { Address, p2pkh, p2tr, p2wpkh } from "@scure/btc-signer"; import { getNetwork } from "@/config"; import { broadcastSignedTransaction } from "@/counterparty/broadcast"; -import { verifyLegacyRecoverableMessage } from "@/crypto/bip322"; +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 { assertSameTransaction, readPsbt, readRawTransaction } from "@/transaction/verify"; +import { + CASA_BATCH_LIMIT, + CASA_SIGHASHES, + type CasaSigningOptions, + prepareCasaPsbt, + verifyCasaPsbt, +} from "./psbt"; export interface CasaTookanProvider { requestAccounts(): Promise; @@ -14,13 +20,8 @@ export interface CasaTookanProvider { getPublicKey(): Promise; getNetwork(): Promise; signMessage(message: string, type: "ecdsa"): Promise; - signPsbt( - psbtHex: string, - options: { - autoFinalize: false; - toSignInputs: { index: number; address: string; sighashTypes: number[] }[]; - }, - ): 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; } @@ -53,15 +54,27 @@ export function getCasaTookanProvider(): CasaTookanProvider | null { : null; } -function signingCapabilities(): ProviderPsbtSigningCapabilities { +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, + sighashTypes: [...CASA_SIGHASHES], + }; return { intentValidation: "none", - psbt: { supported: true, inputScope: "all", sighashTypes: [1] }, + psbt: { ...method }, psbtBatch: { - supported: false, - inputScope: "all", - sighashTypes: [], - maxRequests: 0, + ...method, + supported: batch, + approvalMode: "single", + maxRequests: batch ? CASA_BATCH_LIMIT : 0, maxPolicyOfferAlternatives: 0, marketplaceBundles: [], }, @@ -78,7 +91,7 @@ function accountList(value: unknown): string[] { function supportsAddress(address: string): boolean { try { const type = Address().decode(address).type; - return type === "pkh" || type === "wpkh"; + return type === "pkh" || type === "wpkh" || type === "tr"; } catch { return false; } @@ -86,9 +99,15 @@ function supportsAddress(address: string): boolean { function fromCasaTookanError(error: unknown): WalletSdkError { if (error instanceof WalletSdkError) return error; - const rpc = error as { code?: number; message?: string } | null; + 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: rpc?.code }; + 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); @@ -98,38 +117,6 @@ function fromCasaTookanError(error: unknown): WalletSdkError { return new WalletSdkError("invalid_response", message, options); } -function requireOwnedInputs(tx: Transaction, account: WalletAddress): void { - if (!tx.inputsLength || !tx.outputsLength) - throw new WalletSdkError("invalid_argument", "A transaction needs inputs and outputs"); - const key = hex.decode(account.publicKey); - const expected = account.type === "p2pkh" ? p2pkh(key).script : p2wpkh(key).script; - for (let index = 0; index < tx.inputsLength; index++) { - const input = tx.getInput(index); - let prevout = 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 output = parent.getOutput(input.index); - if (output.amount === undefined || !output.script) - throw new WalletSdkError("invalid_argument", "PSBT parent output is missing"); - if ( - prevout && - (prevout.amount !== output.amount || hex.encode(prevout.script) !== hex.encode(output.script)) - ) - throw new WalletSdkError("transaction_mismatch", "PSBT prevouts disagree"); - prevout = { amount: output.amount, script: output.script }; - } else if (account.type === "p2pkh") { - throw new WalletSdkError("invalid_argument", "Legacy signing requires the full parent transaction"); - } - if (!prevout || hex.encode(prevout.script) !== hex.encode(expected)) - throw new WalletSdkError("capability", "Casa Tookan must own every input in this transaction"); - if ((input.sighashType ?? 1) !== 1) - throw new WalletSdkError("capability", "Casa Tookan pilot supports SIGHASH_ALL only"); - } -} - -/** Opt-in pilot: mainnet Legacy/Native SegWit, one fully owned transaction per approval. */ export function createCasaTookanProvider( casa: CasaTookanProvider | null = getCasaTookanProvider(), ): XcpProvider { @@ -137,6 +124,7 @@ export function createCasaTookanProvider( 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 { @@ -162,7 +150,7 @@ export function createCasaTookanProvider( async function requireMainnet(): Promise { if (getNetwork() !== "mainnet" || (await wallet.getNetwork()) !== "livenet") - throw new WalletSdkError("capability", "Casa Tookan pilot supports Bitcoin mainnet only"); + throw new WalletSdkError("capability", "Casa Tookan supports Bitcoin mainnet only"); } async function activeAccount(): Promise { @@ -174,9 +162,19 @@ export function createCasaTookanProvider( 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" : null; + 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 or Native SegWit address in Casa Tookan"); + 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"); @@ -184,6 +182,12 @@ export function createCasaTookanProvider( } 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++; }; @@ -204,6 +208,7 @@ export function createCasaTookanProvider( 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); @@ -211,60 +216,28 @@ export function createCasaTookanProvider( } async function signPsbt(value: unknown): Promise<{ hex: string }> { - 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"].includes(key))) - throw new WalletSdkError("capability", "Casa Tookan pilot does not support intents or commit/reveal"); - if (typeof request.hex !== "string" || !/^(?:[0-9a-f]{2})+$/i.test(request.hex)) - throw new WalletSdkError("invalid_argument", "A hex PSBT is required"); - const psbtHex = request.hex; - const tx = readPsbt(psbtHex); - const indices = Array.from({ length: tx.inputsLength }, (_, index) => index); - if ( - request.sighashTypes !== undefined && - (!Array.isArray(request.sighashTypes) || - request.sighashTypes.length !== indices.length || - request.sighashTypes.some((type) => type !== 1)) - ) - throw new WalletSdkError("capability", "Use SIGHASH_ALL for every input"); return withAccount(async (account) => { - if (request.signInputs !== undefined) { - const inputs = request.signInputs; - if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) - throw new WalletSdkError("invalid_argument", "Invalid signing inputs"); - const entries = Object.entries(inputs); - const selected = entries[0]?.[1] as unknown; - if ( - entries.length !== 1 || - entries[0]?.[0] !== account.address || - !Array.isArray(selected) || - selected.length !== indices.length || - new Set(selected).size !== indices.length || - selected.some((index) => !Number.isInteger(index) || !indices.includes(index)) - ) - throw new WalletSdkError("capability", "Casa Tookan must sign every input with the active account"); - } - requireOwnedInputs(tx, account); - // Casa Tookan's bridge spells this autoFinalize, unlike Unisat's autoFinalized. - const signed = await wallet.signPsbt(psbtHex, { - autoFinalize: false, - toSignInputs: indices.map((index) => ({ index, address: account.address, sighashTypes: [1] })), - }); - 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); - assertSameTransaction(tx, result); - requireOwnedInputs(result, account); - for (const index of indices) { - const before = tx.getInput(index); - const after = result.getInput(index); - if (before.witnessUtxo && after.witnessUtxo?.amount !== before.witnessUtxo.amount) - throw new WalletSdkError("transaction_mismatch", "Casa Tookan changed a prevout amount"); - } - if (!result.isFinal) result.finalize(); - result.extract(); - return { hex: signed }; + 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])) }; }); } @@ -286,7 +259,9 @@ export function createCasaTookanProvider( } case "xcp_getAddresses": { const active = await passiveAccount(); - return active ? { active, signing: signingCapabilities() } : null; + return active + ? { active, signing: signingCapabilities(typeof wallet.signPsbts === "function") } + : null; } case "xcp_getNetwork": case "xcp_chainId": @@ -304,10 +279,9 @@ export function createCasaTookanProvider( if (address !== undefined && address !== account.address) throw new WalletSdkError("unauthorized", "Select the requested Casa Tookan account first"); const signature = await wallet.signMessage(message, "ecdsa"); - if ( - typeof signature !== "string" || - !verifyLegacyRecoverableMessage(message, signature, account.address).valid - ) + 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", @@ -317,13 +291,15 @@ export function createCasaTookanProvider( } 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 pilot does not support ${method}`); + throw new WalletSdkError("unsupported_method", `Casa Tookan does not support ${method}`); } } catch (error) { throw fromCasaTookanError(error); 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/wallets/casatookan.ts b/src/wallets/casatookan.ts index 0de8217..967963c 100644 --- a/src/wallets/casatookan.ts +++ b/src/wallets/casatookan.ts @@ -1,5 +1,6 @@ import { CASATOOKAN_MESSAGE_VERIFICATION, + casaTookanMessageVerification, createCasaTookanProvider, getCasaTookanProvider, } from "@/casatookan/provider"; @@ -12,6 +13,7 @@ export const CASATOOKAN_WALLET: WalletDescriptor = { installUrl: "https://casatookan.com", registryId: "CasaTookanBitcoinProvider", messageVerification: CASATOOKAN_MESSAGE_VERIFICATION, + messageVerificationForAddress: casaTookanMessageVerification, installed: () => getCasaTookanProvider() !== null, provider: () => createCasaTookanProvider(), onInjected: (listener) => { diff --git a/tests/casatookan.test.ts b/tests/casatookan.test.ts index a0c2cd1..e303f07 100644 --- a/tests/casatookan.test.ts +++ b/tests/casatookan.test.ts @@ -1,6 +1,6 @@ import { secp256k1 } from "@noble/curves/secp256k1"; import { base64, hex } from "@scure/base"; -import { p2pkh, p2tr, p2wpkh, Transaction } from "@scure/btc-signer"; +import { p2pkh, p2sh, p2tr, p2wpkh, Transaction } from "@scure/btc-signer"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type CasaTookanProvider, @@ -19,9 +19,11 @@ 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" = "p2wpkh") { - const payment = type === "p2pkh" ? legacy : 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") }); @@ -48,9 +50,10 @@ function fakeCasa(address = segwit.address) { const signature = secp256k1.sign(legacyMessageHash(message), key); return base64.encode(Uint8Array.from([31 + signature.recovery, ...signature.toCompactRawBytes()])); }), - signPsbt: vi.fn(async (encoded: string) => { + signPsbt: vi.fn(async (encoded: string, options?: Parameters[1]) => { const tx = readPsbt(encoded); - tx.sign(key); + 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) => { @@ -65,7 +68,11 @@ function fakeCasa(address = segwit.address) { }, listeners, } satisfies CasaTookanProvider & { emit: unknown; listeners: unknown }; - return casa; + 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 = {}) { @@ -116,8 +123,8 @@ describe("Casa Tookan pilot", () => { active: { address: segwit.address, type: "p2wpkh" }, signing: { intentValidation: "none", - psbt: { inputScope: "all", sighashTypes: [1] }, - psbtBatch: { supported: false }, + psbt: { inputScope: "selected", externalInputs: "any" }, + psbtBatch: { supported: true, maxRequests: 20, approvalMode: "single" }, }, }); expect(grant).not.toHaveProperty("legacy"); @@ -140,7 +147,7 @@ describe("Casa Tookan pilot", () => { expect(casa.requestAccounts).toHaveBeenCalledOnce(); }); - it.each([{ accounts: [] }, { accounts: [p2tr(pub.slice(1)).address] }])( + 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]]); @@ -202,7 +209,7 @@ describe("Casa Tookan pilot", () => { expect([...casa.listeners.values()].every((listeners) => listeners.size === 0)).toBe(true); }); - it("accepts an already-finalized response", async () => { + it("refuses an unexpectedly finalized response", async () => { const casa = fakeCasa(); casa.signPsbt.mockImplementation(async (encoded) => { const tx = readPsbt(encoded); @@ -210,13 +217,15 @@ describe("Casa Tookan pilot", () => { tx.finalize(); return hex.encode(tx.toPSBT()); }); - await expect(sign(casa)).resolves.toHaveProperty("hex"); + await expect(sign(casa)).rejects.toMatchObject({ code: "invalid_response" }); }); - it.each(["p2pkh", "p2wpkh"] as const)( + 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 : segwit.address); + 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); @@ -229,47 +238,45 @@ describe("Casa Tookan pilot", () => { }, ); - it("connects with an independently verified session proof", async () => { - const casa = fakeCasa(); - 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(segwit.address); - expect(session.connectionProof?.verification?.method).toBe("BIP-137"); - session.stop(); - }); + 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_signPsbts", "xcp_signTransaction", "xcp_switchAccount"])("refuses %s", async (method) => { + it.each(["xcp_signTransaction", "xcp_switchAccount"])("refuses %s", async (method) => { await expect(createCasaTookanProvider(fakeCasa()).request({ method })).rejects.toMatchObject({ code: "unsupported_method", }); }); - it.each([ - { intent: {} }, - { inscription: {} }, - { reveal: {} }, - { sighashTypes: [0x83] }, - { sighashTypes: [] }, - { signInputs: { [segwit.address]: [] } }, - { signInputs: { [other.address]: [0] } }, - { signInputs: { [segwit.address]: [0, 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.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: 0x83 }, - {}, + { witnessUtxo: { script: segwit.script, amount: 20_000n }, sighashType: 4 }, ]) { const tx = new Transaction(); tx.addInput({ txid: "aa".repeat(32), index: 0, ...input }); @@ -332,8 +339,8 @@ describe("Casa Tookan pilot", () => { await expect(sign(casa)).rejects.toMatchObject({ code: "unauthorized" }); }); - it("rejects mismatched keys, Taproot and non-mainnet before signing", async () => { - for (const address of [other.address, p2tr(pub.slice(1)).address]) { + 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(); @@ -347,7 +354,7 @@ describe("Casa Tookan pilot", () => { }); it("does not restore an unsupported account through the passive connect fallback", async () => { - const provider = createCasaTookanProvider(fakeCasa(p2tr(pub.slice(1)).address)); + const provider = createCasaTookanProvider(fakeCasa(nested.address)); await expect(provider.request({ method: "xcp_accounts" })).rejects.toMatchObject({ code: "capability" }); }); @@ -359,7 +366,7 @@ describe("Casa Tookan pilot", () => { casa.emit("accountsChanged", [other.address]); expect(disconnected).not.toHaveBeenCalled(); casa.emit("accountsChanged", []); - casa.emit("accountsChanged", [p2tr(pub.slice(1)).address]); + casa.emit("accountsChanged", [nested.address]); expect(disconnected).toHaveBeenCalledTimes(2); provider.removeListener("disconnect", disconnected); casa.emit("accountsChanged", []); @@ -379,4 +386,175 @@ describe("Casa Tookan pilot", () => { 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 b6481e4..204a529 100644 --- a/tests/compose-psbt.test.ts +++ b/tests/compose-psbt.test.ts @@ -84,7 +84,12 @@ 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) => walletSign(encoded)); + 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], From 089ca36869ab72440292c3064143e7dc0ea8e75c Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 20 Oct 2026 16:12:52 -0400 Subject: [PATCH 8/8] Report Casa custom-output recovery limits from live signer tests --- docs/casatookan-dev-spec.md | 47 +++++- docs/casatookan.md | 5 +- .../casatookan-custom-scripts-2026-10-20.json | 134 ++++++++++++++++++ src/casatookan/provider.ts | 1 + src/provider/capabilities.ts | 9 +- tests/capabilities.test.ts | 10 ++ tests/casatookan.test.ts | 24 +++- 7 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 docs/qa/casatookan-custom-scripts-2026-10-20.json diff --git a/docs/casatookan-dev-spec.md b/docs/casatookan-dev-spec.md index b097097..0a33c0d 100644 --- a/docs/casatookan-dev-spec.md +++ b/docs/casatookan-dev-spec.md @@ -27,8 +27,9 @@ The [recorded results](qa/casatookan-2026-10-20.json) include 28 wallet scenario | 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 | Rejected as not belonging to the casa. | Engine | -| Taproot script trees | Ownership matching recognizes the no-tree BIP-86 output, not arbitrary tree-bearing outputs. | Engine source; script-path UI not tested | +| 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 | @@ -142,11 +143,47 @@ 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 -Most integration questions are answered by this build. The concrete report to -the developers is the large-PSBT preparation overflow, with the ignored options -as compatibility details rather than missing signing features. +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. diff --git a/docs/casatookan.md b/docs/casatookan.md index 824b527..069bb2b 100644 --- a/docs/casatookan.md +++ b/docs/casatookan.md @@ -35,6 +35,9 @@ Application `intent` metadata is accepted but **not validated or displayed by Ca 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. @@ -90,7 +93,7 @@ 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 +## 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), 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/src/casatookan/provider.ts b/src/casatookan/provider.ts index 80406ee..e244968 100644 --- a/src/casatookan/provider.ts +++ b/src/casatookan/provider.ts @@ -65,6 +65,7 @@ function signingCapabilities(batch: boolean): ProviderPsbtSigningCapabilities { supported: true, inputScope: "selected" as const, externalInputs: "any" as const, + taprootTreeKeyPath: false, sighashTypes: [...CASA_SIGHASHES], }; return { 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/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 index e303f07..fd936d7 100644 --- a/tests/casatookan.test.ts +++ b/tests/casatookan.test.ts @@ -1,6 +1,6 @@ import { secp256k1 } from "@noble/curves/secp256k1"; import { base64, hex } from "@scure/base"; -import { p2pkh, p2sh, p2tr, p2wpkh, Transaction } from "@scure/btc-signer"; +import { p2pkh, p2sh, p2tr, p2wpkh, Script, Transaction } from "@scure/btc-signer"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type CasaTookanProvider, @@ -123,7 +123,7 @@ describe("Casa Tookan pilot", () => { active: { address: segwit.address, type: "p2wpkh" }, signing: { intentValidation: "none", - psbt: { inputScope: "selected", externalInputs: "any" }, + psbt: { inputScope: "selected", externalInputs: "any", taprootTreeKeyPath: false }, psbtBatch: { supported: true, maxRequests: 20, approvalMode: "single" }, }, }); @@ -286,6 +286,26 @@ describe("Casa Tookan pilot", () => { 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 () => {