deviant/Parameters/WalletConnect/wallet-side/wc-handler.ts
walerikus 35076606c7 chore: checkpoint in-flight work across shell, contracts, and docs
Snapshot of the shared working tree's outstanding changes (multiple
concurrent workstreams), excluding generated runtime state files
(.football-tote-state.json, .tote-platform-state.json, .usdm-historical-chart.html).

Spans: wallet shell (connect/, keystore MultiKeyStore+vault, screens,
currency picker, sign-message), verify artifacts, new Potidaea/DRA/tote
contracts + regtest suites, cross-chain/T6 + WalletConnect Parameters,
a telegram-orderbook subproject, design mockups, and docs. See
shells/app/WALLET-COORDINATION.md for the per-file ownership split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 02:02:05 +02:00

122 lines
5.5 KiB
TypeScript

/**
* wc-handler.ts — wc2-bch-bcr method dispatch (the WalletConnect path).
*
* Methods (per ADR-0041 §3 and snapshot 0057 — device-validated against Cashonize):
* - `bch_getAddresses` → returns the wallet's cashaddr(s)
* - `bch_signMessage` → ECDSA-DER over sha256(message) ⚠ format TBD on-device
* - `bch_signTransaction` → libauth pass-through co-sign (delegated to signer.ts)
*
* The signTransaction handler is the load-bearing one for Deviant covenant flows
* (tote `bet()`, `GamePoolEscrowFlat.fund()`, checkers stake) per ADR-0043 §2.3:
* CashConnect V0 cannot carry dApp-compiled covenant unlockers, so all covenant
* co-signs route through this handler.
*
* Signing discipline (ADR-0041 §5): the signer NEVER signs an input it doesn't
* own — even if asked. See signer.ts `isOwnedInput`.
*
* The wire formatting (libauth `stringify` / `parseExtendedJson`) is the
* responder's responsibility. By the time we get here, requests are decoded
* objects with `Uint8Array` bytes and `bigint` values.
*/
import { sha256, ripemd160, secp256k1, binToHex } from '@bitauth/libauth';
import { coSignTransaction } from './signer';
import type {
BchSignMessageRequest,
BchSignTxRequest,
SignedMessageResponse,
SignedTxResponse,
} from './index';
import type { KeyProvider } from './responder';
import { p2pkhLockingBytecode, isOwnedInput } from './signer';
/** Result of `bch_getAddresses`. The wc2-bch-bcr spec returns a string list. */
export type GetAddressesResponse = readonly string[];
/** `bch_getAddresses` — the wallet exposes the cashaddr(s) it controls. We surface
* only the active account's `cashaddr` (singular) at v1; multi-account is a future
* enhancement (the KeyStore is single-account today — `wiki/wallet-ui-design.md`).
*/
export function handleGetAddresses(key: KeyProvider): GetAddressesResponse {
const id = key.identity();
return [id.cashaddr];
}
/** `bch_signMessage` — ECDSA-DER signature over `sha256(message)`.
*
* ⚠ Wire format pending on-device confirmation against Cashonize (ADR-0041 "What
* this ADR does NOT resolve" §2). Two candidates exist on BCH:
* (a) **Bitcoin Cash Signed-Message format** (classical): recoverable ECDSA over
* `hash256("\x18Bitcoin Signed Message:\n" || varint(len(msg)) || msg)`,
* base64-encoded with a 1-byte recovery header. Used by historical wallets.
* (b) **Simple `ecdsa(sha256(msg))` DER** — what most modern WC2 BCH wallets
* appear to do; matches CashConnect's `signMessage` (Schnorr) in shape.
*
* We ship (b) since the proposer in `spike/wc-connect/connect.mjs` issues a plain
* message and Cashonize returned a sig (snapshot 0055) — and the simpler path
* keeps the responder unblocked. On-device validation will confirm; if Cashonize
* actually expects (a), switch this function (NOT the wider responder) and bump
* the wire-format note in ADR-0041.
*
* SECURITY: we always sign with the active account's key — there is no
* per-address routing yet, since the wallet is single-account at v1. */
export function handleSignMessage(
request: BchSignMessageRequest,
key: KeyProvider,
): SignedMessageResponse {
// request.address: the dApp may name an address; we don't currently disambiguate
// between accounts. If/when multi-account lands, look it up against the
// KeyStore's account list and fail closed if the address isn't ours.
const digest = sha256.hash(request.message);
const sig = secp256k1.signMessageHashDER(key.privateKey(), digest);
if (typeof sig === 'string') {
throw new Error(`bch_signMessage failed: ${sig}`);
}
// The "signedMessage" field carries a UTF-8 echo of what was actually signed,
// so the dApp can verify it presented the right text to the user.
const printable = isProbablyUtf8(request.message);
return {
signedMessage: printable ? new TextDecoder('utf-8', { fatal: false }).decode(request.message) : binToHex(request.message),
signature: binToHex(sig),
};
}
/** `bch_signTransaction` — libauth pass-through co-sign. Fills in the unlocking
* bytecode for every input owned by this wallet (sourceOutput.lockingBytecode
* matches our P2PKH); leaves all other inputs untouched.
*
* This is the path snapshot 0057 device-validated; it's also the ONLY path for
* Deviant covenant co-sign (ADR-0043 §2.3). */
export async function handleSignTransaction(
request: BchSignTxRequest,
key: KeyProvider,
): Promise<SignedTxResponse> {
const id = key.identity();
return coSignTransaction({
request,
ownPubkeyHash: hash160OfPubkey(id.publicKey),
ownPublicKey: id.publicKey,
privateKey: key.privateKey(),
network: id.network,
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
function isProbablyUtf8(bin: Uint8Array): boolean {
try {
new TextDecoder('utf-8', { fatal: true }).decode(bin);
return true;
} catch {
return false;
}
}
/** RIPEMD160(SHA256(pubkey)) — the 20-byte pubkey hash used as the P2PKH key.
* Two-primitive form (not the helper alias) to stay portable across libauth
* versions. The responder caches the result per-session via `KeyProvider.pubkeyHash()`. */
function hash160OfPubkey(publicKey: Uint8Array): Uint8Array {
return ripemd160.hash(sha256.hash(publicKey));
}
// Re-export so the signing-discipline check is callable from tests + responder.
export { isOwnedInput, p2pkhLockingBytecode };