theseus/bundled-addons/aegis/lib/wc-sign.js
Local Dev 9e7e9d5e8b Aegis 0.31.0: chain adapters stop trusting what they should check
Start of the next batch; 0.30.0 is published.

- Tron panel sends signed whatever /wallet/createtransaction returned while
  the approval showed the local request. The returned bytes are now decoded
  and must be one transfer from this wallet, to that address, for that
  amount, with a matching txID - checked at plan time and again at signing.
- Importing a Solana wallet from a seed phrase threw on every attempt (a
  mis-parenthesised `new require("crypto").createHmac` plus a bare require
  of an ESM-only subpath). SLIP-0010 now uses Node's HMAC, as chain-sol does.
- Imported BCH wallets put token-bearing UTXOs into coin selection. They are
  excluded, as in the HD wallet, and the balance counts what can be spent.
- WizardConnect dropped the token from each spent output before signing, so
  under SIGHASH_UTXOS every signature of a token transaction was invalid.
- A wallet disposed while a refresh was in flight re-armed its poll timer.
- BCMR registry content is bounded before it reaches the panel: control and
  bidi characters stripped, lengths capped, decimals 0-18, icons https/ipfs
  only, registries https only.
2026-10-04 01:38:50 +02:00

147 lines
6.4 KiB
JavaScript

// WizardConnect transaction signing for Aegis.
//
// The dapp hands us a full BCH transaction plus its source outputs. Per the
// WC protocol, we must sign every input with SIGHASH_ALL | FORKID | UTXOS.
// Any other sighash flag combination MUST be rejected (protocol/security).
//
// This module supports P2PKH inputs only. Contract inputs (a source output
// carrying a `contract` field) are rejected with a clear error — they need
// script-aware signing that Aegis's BCH runtime doesn't do today.
// SIGHASH byte required for this protocol: SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS
// = 0x01 | 0x40 | 0x20 = 0x61.
const REQUIRED_SIGHASH = 0x61;
function toHex(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0"); return s; }
function fromHex(h) {
const s = String(h || "").replace(/^0x/i, "");
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}
function ensureTransaction(txOrHex, libauth) {
if (typeof txOrHex === "string") {
const dec = libauth.decodeTransactionCommon
? libauth.decodeTransactionCommon(fromHex(txOrHex))
: libauth.decodeTransaction(fromHex(txOrHex));
if (typeof dec === "string") throw new Error(`wc-sign: bad tx hex — ${dec}`);
return dec;
}
return txOrHex;
}
// libauth Output.token: { amount: bigint, category: Uint8Array,
// nft?: { capability, commitment: Uint8Array } }. Over the wire the byte
// fields may arrive as hex and the amount as a string or number.
function normalizeToken(t) {
if (!t || typeof t !== "object") return null;
const bytes = (v) => (v instanceof Uint8Array ? v : fromHex(String(v || "")));
const out = {
amount: typeof t.amount === "bigint" ? t.amount : BigInt(t.amount ?? 0),
category: bytes(t.category),
};
if (out.category.length !== 32) throw new Error("wc-sign: token category must be 32 bytes");
if (t.nft) {
out.nft = {
capability: String(t.nft.capability || "none"),
commitment: bytes(t.nft.commitment),
};
}
return out;
}
async function signTx({ request, account, branches, libauth, secp256k1 }) {
const {
generateSigningSerializationBCH,
hash256, encodeTransaction,
} = libauth;
// The WC message nests the whole WcSignTransactionRequest under
// `.transaction`, so the real shape is:
// request.transaction.transaction — the tx (object or hex)
// request.transaction.sourceOutputs — the spent outputs
// request.inputPaths / request.sequence — on the outer message
// Reading request.transaction as the tx (and request.sourceOutputs as
// the outputs) meant `tx.inputs` was undefined and signing threw on the
// first real request. index.js already read the nested
// request.transaction.userPrompt for the approval dialog, so only this
// module had it wrong. The flat shape is still accepted so a caller
// that hands us an already-unwrapped payload keeps working.
const inner = (request.transaction && (request.transaction.transaction !== undefined
|| request.transaction.sourceOutputs !== undefined))
? request.transaction
: request;
const tx = ensureTransaction(inner.transaction, libauth);
const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => {
if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`);
const out = {
lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode),
valueSatoshis: typeof o.valueSatoshis === "bigint" ? o.valueSatoshis : BigInt(o.valueSatoshis),
};
// The token rides along. SIGHASH_UTXOS commits every input's signature
// to ALL source outputs including their token prefix, so dropping it
// (as this did) made every signature of a token-spending transaction
// invalid.
const tok = normalizeToken(o.token);
if (tok) out.token = tok;
return out;
});
if (sourceOutputs.length !== tx.inputs.length) {
throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`);
}
const inputPathMap = new Map(); // inputIndex -> { branch, addressIndex }
for (const [inputIndex, pathName, addressIndex] of (request.inputPaths || [])) {
inputPathMap.set(Number(inputIndex), { pathName: String(pathName), addressIndex: Number(addressIndex) });
}
const signedInputs = tx.inputs.map((inp, i) => ({ ...inp }));
for (let i = 0; i < tx.inputs.length; i++) {
const hint = inputPathMap.get(i);
if (!hint) throw new Error(`wc-sign: no path for input ${i}`);
const branch = branches[hint.pathName];
if (!branch) throw new Error(`wc-sign: unknown path "${hint.pathName}"`);
const node = branch.deriveChild(hint.addressIndex);
// generateSigningSerializationBCH takes TWO positional arguments:
// (compilationContext, { coveredBytecode, signingSerializationType })
// Passing one merged object left coveredBytecode undefined, which threw
// "Cannot destructure property 'coveredBytecode' of 'undefined'".
// For P2PKH the covered bytecode is the spent output's locking script.
const preimage = generateSigningSerializationBCH(
{
inputIndex: i,
sourceOutputs,
transaction: { ...tx, inputs: signedInputs },
},
{
coveredBytecode: sourceOutputs[i].lockingBytecode,
signingSerializationType: new Uint8Array([REQUIRED_SIGHASH]),
},
);
const digest = hash256(preimage);
const sig = secp256k1.sign(digest, node.privateKey, { prehash: false, lowS: true, format: "der" });
// signature || sighashType byte
const sigWithHash = new Uint8Array(sig.length + 1);
sigWithHash.set(sig, 0); sigWithHash[sig.length] = REQUIRED_SIGHASH;
// P2PKH unlocking: <sig+hashtype> <pubkey>
const pushSig = new Uint8Array(1 + sigWithHash.length);
pushSig[0] = sigWithHash.length;
pushSig.set(sigWithHash, 1);
const pushPk = new Uint8Array(1 + node.publicKey.length);
pushPk[0] = node.publicKey.length;
pushPk.set(node.publicKey, 1);
const unlocking = new Uint8Array(pushSig.length + pushPk.length);
unlocking.set(pushSig, 0); unlocking.set(pushPk, pushSig.length);
signedInputs[i].unlockingBytecode = unlocking;
}
const encoded = encodeTransaction({ ...tx, inputs: signedInputs });
return { signedTransaction: toHex(encoded) };
}
module.exports = { signTx, REQUIRED_SIGHASH };