Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
192 changes: 192 additions & 0 deletions docs/casatookan-dev-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Casa Tookan development-wallet contract

Observed on 2026-10-20 against [the development wallet](https://devapp.casatookan.com/),
version 2.1.0, build `bfbcddd-dirty` (08:06 AM). This is a working integration
contract for this build, not a guarantee about other builds.

The [published signing engine](https://devapp.casatookan.com/signer-core.js) has
SHA-256 `7e49e42a354014604718f49b18a341b40c057ece816e91716e2d3017d74eb579`.
That matches the hash pinned in the development app. Its header says the native
modules also pin a core hash; installed native binaries were not inspected.

Evidence has two scopes:

- **Wallet:** real provider calls through the official web bridge and approval UI,
using an unfunded wallet and synthetic prevouts. No broadcast.
- **Engine:** the exact downloaded engine run locally with a deterministic test
key. These tests bypass provider preparation, permissions and UI.

The [adapter contract](casatookan.md) enables the verified signing mechanics with conservative size and batch limits.
The [recorded results](qa/casatookan-2026-10-20.json) include 28 wallet scenarios,
69 engine probes, and the earlier selected-input/preservation checks.

## Signing capabilities

| Capability | Observation | Evidence |
| --- | --- | --- |
| P2PKH and P2WPKH | Selected inputs, preserved foreign signatures, non-ALL sighashes and batches work. Shared transactions finalize. | Wallet |
| BIP-86 Taproot key path | Schnorr signatures verify; missing `tapInternalKey` is populated; finalization works. | Wallet, engine |
| Nested P2SH-P2WPKH | Rejected as not belonging to the casa, including when the underlying key belongs to it. | Wallet, engine |
| P2WSH multisig | Even a 1-of-1 script with the wallet's key is rejected as not belonging to the casa. | Wallet, engine |
| Taproot script paths | Both untweaked and BIP-86-tweaked leaf keys are rejected as not belonging to the casa. The alternative Bitcoin provider also refuses. | Wallet, engine |
| Taproot tree key-path recovery | A tree-bearing output with the wallet's own internal key and `tapMerkleRoot` is rejected. No-tree BIP-86 control succeeds. | Wallet, engine |
| PSBT v0 and v2 | Accepted; v2 remains v2. | Wallet: SegWit/Taproot; engine: all three supported types |
| ECDSA sighashes | `1`, `2`, `3`, `129`, `130`, `131` verify. | Wallet: SegWit; engine: Legacy/SegWit, v0/v2 |
| Taproot sighashes | `0`, `1`, `2`, `3`, `129`, `130`, `131` verify. | Wallet, engine v0/v2 |
| Foreign signatures | Retained byte-for-byte and still verify after signing another input. | Wallet: Legacy/SegWit |
| Unknown PSBT fields | A supplied proprietary input field was stripped. Do not assume arbitrary metadata survives. | Engine |

## Provider options

`window.unisat.signPsbt(psbtHex, options)` returns PSBT hex.

```ts
{
autoFinalize: false,
toSignInputs: [
{ index: 0, address: selectedAddress, sighashTypes: [131] }
]
}
```

- `autoFinalize` defaults to true. **`autoFinalized` is ignored**, including
`autoFinalized: false`; the resulting PSBT is finalized.
- `toSignInputs` omitted means all recognized inputs in the current casa.
An empty array fails with `E_NOT_OWN_INPUT`.
- `index` selects the actual input. `sighashTypes` is an allowlist, not a request
to change the PSBT's sighash. Defaults are ALL for Legacy/SegWit and
DEFAULT/ALL for Taproot. A mismatch fails with `E_SIGHASH` at signing.
- `address` must belong to the current casa, but does **not** bind the selected
input to that address. A Taproot connection with its Taproot address in the
options successfully signed the same casa's Legacy input.
- `publicKey` is ignored. Supplying another key did not prevent the real input's
owner from signing.
- Duplicate input indices are accepted by the engine. Normalize and validate
selections in our adapter.
- Finalization attempts to finalize the transaction, not just the selected
inputs. Use `autoFinalize: false` for incomplete collaborative PSBTs.

Therefore, validate each selected prevout against the intended account locally;
neither the connected address nor an options field substitutes for that check.

## Batches and failures

`signPsbts(psbtHexs, options)` accepts one shared options object or an array of
per-PSBT options. Two different sighash policies in one batch were verified.
Results are ordered. The UI presents the requests together for one approval.

| Scenario | Result |
| --- | --- |
| 20-item SegWit batch | All returned signatures verified. |
| 20 distinct Taproot transactions | Returned in order; all signatures verified against their respective transactions. |
| Empty batch | `E_NO_KEY`, before approval. |
| Valid first PSBT, malformed second | Entire request rejected before approval. |
| Valid first PSBT, disallowed sighash on second | Approval shown; entire promise rejects with `E_SIGHASH`, no results returned. |
| Cancel batch | `4001`, no results returned. |
| Two simultaneous signing calls | One succeeds; the other returns `E_BUSY`. |

The web signer prepares every item before approval, then signs sequentially and
returns only after all succeed. A later failure can occur after an earlier item
was signed internally, but that earlier result is not returned to the caller.
There is no tested partial-success response. Serialize independent approvals.

## Sizes and preparation

- A 65,780-byte PSBT passed through the real wallet and its signature verified.
- A 262,388-byte PSBT failed before approval with `-32603`,
`Maximum call stack size exceeded`.
- A separate 100-input fixture, carrying full parent transactions, reproduced
the same failure. The issue is not specific to the large OP_RETURN fixture.
- The local engine accepted a 324,053-byte, 100-input SegWit PSBT, signed every
input, and passed signature verification. It also accepted the 262,388-byte
data fixture and batches of 100.
- A larger local 500-input run was stopped after prolonged execution. It does
not establish either support or a hard limit.

The failure is in provider preparation. `hydratePsbt` removes empty
`finalScriptSig` fields and re-encodes with
`btoa(String.fromCharCode(...o.toPSBT()))`. Spreading the entire byte array into
one call hits the JavaScript argument/stack limit. The earlier hex-to-base64
conversion already uses 32,768-byte chunks.

There is no explicit batch-count or byte-size cap in the inspected paths.
The successful sizes above are tested examples, not advertised maxima.
Do not infer unlimited capacity or the same runtime limit on phones.

Include prevouts in requests. When both UTXO fields are absent, preparation
fetches parent transactions sequentially. The inspected transaction fetch path
has no deduplication cache; asset divisibility lookups do have a cache.

## Message signatures

- Legacy/SegWit use recoverable Bitcoin Signed Message ECDSA, verifiable through
the SDK's existing BIP-137 path.
- Taproot also returns recoverable ECDSA under its untweaked internal key.
The real signature passes `verifyBip86RecoverableMessage` and fails the
BIP-137 and BIP-322 paths. Use the explicit `ECDSA-BIP86` proof dialect already
supported for Horizon; never silently fall back from BIP-322.
- `signMessage(message, "bip322-simple")` is rejected as unsupported.

## Transaction checks still belong in our integration

The engine verifies full parent transaction IDs and rejects contradictory
witness/non-witness prevout data. It also permits witness-only Legacy signing
with an unverified-fee warning. Retain our full-parent requirement for Legacy.

With finalization disabled, engine tests returned signatures for negative-fee,
dust-output, zero-fee and future-locktime transactions. These are observations
about signing policy, not claims that those transactions can relay or confirm.
Retain our transaction validity, fee, asset and marketplace-intent checks.

The approval modal prevented normal in-page account switching in the web UI;
the test was cancelled. This does not establish behavior for native
interruptions or external revocation during approval. Retain the adapter's
account/event-revision checks before returning a signature.

## Custom outputs and recovery

The [additional probes](qa/casatookan-custom-scripts-2026-10-20.json) bypass our
adapter to test Casa directly. Each rejected engine fixture first signed,
finalized and extracted with its known test key through `@scure/btc-signer`.
The real wallet independently refused the same five script shapes.

Casa's `ownScripts` and `analyzePsbt` recognize only its three standard output
scripts. `signingPlan` refuses other scripts before the underlying signer runs.
Changing provider interfaces or supplying a merkle root does not bypass that
ownership check. This needs upstream ownership and approval support, not a
different sighash or an adapter option.

This affects more than inscriptions. DigiRare policy offers can create custom
Taproot outputs. Casa can sign the ordinary funding inputs, but cannot reclaim
those outputs if funding confirms without a fill. The adapter reports
`psbt.taprootTreeKeyPath: false`; sites must refuse creation of those offers.
Accepting someone else's offer still signs ordinary seller inputs. Invalidating
an unconfirmed offer by spending its original standard funding coins also
remains possible. That invalidation is not recovery of a confirmed custom output.

## Alternative providers and account discovery

`CasaTookanBitcoinProvider.getInfo()` exposes addresses, messages, PSBT signing
and transfers, but no additional signing capabilities. With Taproot selected,
`getAddresses({ purposes: ['payment', 'ordinals', 'counterparty'] })` returned
the same address three times; payment and counterparty incorrectly reported
`addressType: 'p2pkh'`. The Unisat interface returned the single active account.
The Bitcoin provider's event methods are stubs in this build. It is not a
drop-in replacement for account lifecycle handling or a verified paired
Legacy/SegWit grant.

Casa does decode some Counterparty messages (send, order and cancel). That is
distinct from validating our marketplace intent claims or linked bundles.

## Remaining external evidence

The concrete upstream gaps are custom-script ownership/recovery, large-PSBT
preparation overflow, and alternative-provider address-type metadata. Marketplace
intent validation and paired grants would be new capabilities. Ignored Unisat
options are compatibility details covered by the adapter.

What this environment cannot establish is which signer-core hash ships in each
iOS/Android release, native lifecycle behavior, or practical mobile size limits.
Those need installed-device tests. End-to-end marketplace offer/fill and
Counterparty asset semantics remain our integration work, not questions about
whether Casa can produce these signatures.
103 changes: 103 additions & 0 deletions docs/casatookan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Casa Tookan pilot

The adapter is opt-in and is not included in `KNOWN_WALLETS`:

```ts
import { CASATOOKAN_WALLET, discoverWallets, KNOWN_WALLETS } from "@xcp/wallet-sdk/web";

const wallets = discoverWallets([...KNOWN_WALLETS, CASATOOKAN_WALLET]);
// Pass wallets to WalletProvider or WalletSession.
```

Direct consumers can import `createCasaTookanProvider` from
`@xcp/wallet-sdk/casatookan`.

## Supported boundary

The [development-wallet contract](casatookan-dev-spec.md) records the tested provider behavior.

- Mainnet P2PKH, Native SegWit and BIP-86 Taproot key-path signing.
- Active-account-only grants, passive restore, revocation detection and account-change checks.
- Verified message proofs: BIP-137 for Legacy/SegWit, explicitly declared ECDSA-BIP86 for Taproot.
- Selected inputs, unsigned foreign inputs, and preserved foreign signatures. ECDSA sighashes
1/2/3/129/130/131; Taproot additionally supports DEFAULT (0).
- One approval for up to 20 PSBTs, with per-request selections and sighashes. A failed or
incomplete batch returns no results. Overlapping approvals are refused until the first finishes.
- A conservative 64 KiB per-PSBT integration limit avoids the dev wallet's preparation overflow.
Every input must include prevout data; selected Legacy inputs require full parent transactions.
- Returned signatures are checked against the original transaction, key, prevouts and sighash.
Existing signatures cannot disappear or change. Original PSBT metadata is retained, including
proprietary fields stripped by Casa. Responses must remain partially signed as requested.
- Raw compose signing uses the SDK's PSBT fallback. Signing never broadcasts; explicit broadcast
uses the SDK relay. Disconnect clears the local session; revoke the underlying grant in Casa.

Application `intent` metadata is accepted but **not validated or displayed by Casa**. The capability
report declares `intentValidation: "none"` and no wallet-validated marketplace bundles. Sites must
retain their own transaction and asset validation. Nested SegWit, multisig, Taproot script paths,
inscription/commit-reveal requests and paired-address grants remain unsupported.
Tree-bearing Taproot outputs cannot be spent through their key path either:
`psbt.taprootTreeKeyPath` is explicitly false. Do not create offers that require
this recovery path, even if Casa can sign their initial funding transaction.

Detection requires Casa Tookan's marker plus its Unisat-compatible bridge; a
standalone Unisat extension is not detected as Casa Tookan.

## Evidence and remaining native QA

Implementation follows the [integration guide](https://docs.casatookan.com/integrate.html)
and the public `https://devapp.casatookan.com/` bridge inspected on 2026-10-20.
Its `signPsbt` handler reads `autoFinalize`, not Unisat's `autoFinalized`.
Unit tests use deterministic test keys and a bridge double. They are not evidence
that the installed iOS/Android signer behaves identically.

Before enabling a production site:

1. Record the installed Casa Tookan version and platform. Connect each supported
address type, reload, disconnect, reconnect and switch profiles.
2. Verify a message signature through the site's normal server verifier.
3. Sign an ordinary PSBT in each address type. Check returned prevouts, signatures,
outputs, fee and finalization against the original request. Use an unfunded
test fixture first; broadcasting is a separate funded test.
4. Reject a request, revoke permission during approval and switch away and back.
No stale result should reach the site.
5. Exercise selected inputs and batch approvals, and confirm unsupported script-path
and oversized requests fail before approval.

The native browser injects providers. No downloaded injection script,
development iframe bridge, CSP relaxation or CEID authentication is needed.

## Development signer verification — 2026-10-20

Tested against `devapp.casatookan.com`, version 2.1.0, build
`bfbcddd-dirty`, with a newly created, unfunded wallet and the official web
bridge. A temporary HTTPS preview hosted XCPDEX's production build and a
signing fixture using this adapter.

- Legacy and Native SegWit accounts connected through XCPDEX.
- Both accounts signed the same test message; this SDK verified each signature.
Changing the SegWit message made verification fail.
- Both accounts signed and finalized a one-input, one-output SIGHASH_ALL PSBT.
Each synthetic parent paid 10,000 sats to the active account; its child returned
9,000 sats to that account. The returned signatures were also verified against
their transaction digests with `@noble/curves`. Nothing was broadcast.
- Rejecting a message returned `user_rejected`; retrying succeeded.
- XCPDEX displayed a connection rejection and supported disconnect, reconnect,
and passive restoration without another approval.

Revoking permission while the page was closed exposed stale optimistic identity.
The adapter now invalidates it on an empty or unsupported passive account.
The browser regression also exposed missing listeners after React's development
effect restart; session restart now reattaches them.

This establishes compatibility with the web development signer. The installed
iOS/Android signer, funded DEX composition, and changes during a pending native
approval still need the native checks above.

## Expanded adapter verification — 2026-10-20

The real dev-wallet UI also passed Taproot connection and verified message signing,
a PSBT v2 shared transaction retaining a foreign SegWit signature (finalized and extracted),
a reusable SINGLE|ANYONECANPAY authorization, and twenty ordered transactions with mixed
ALL/131 sighashes in one approval. These calls used the expanded SDK adapter, not direct
bridge calls. All prevouts were synthetic and no transaction was broadcast.
Results: [adapter QA](qa/casatookan-adapter-2026-10-20.json).
Loading
Loading