From 724af70721068719b7a4d0ee262e292a127e1ba2 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 4 Dec 2025 11:44:32 +0100 Subject: [PATCH 1/2] feat(utxo-lib): add getReplayProtectionPublicKey method to AcidTest Add a getter for the specific public key used for replay protection. This refactors the existing code to use this new method. Issue: BTC-2806 Co-authored-by: llm-git --- modules/utxo-lib/src/testutil/psbt.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/utxo-lib/src/testutil/psbt.ts b/modules/utxo-lib/src/testutil/psbt.ts index 7e4bbb185a..0a6285257b 100644 --- a/modules/utxo-lib/src/testutil/psbt.ts +++ b/modules/utxo-lib/src/testutil/psbt.ts @@ -340,8 +340,12 @@ export class AcidTest { return `${networkName} ${this.signStage} ${this.txFormat}`; } + getReplayProtectionPublicKey(): Buffer { + return this.rootWalletKeys.user.publicKey; + } + getReplayProtectionOutputScript(): Buffer { - const { scriptPubKey } = createOutputScriptP2shP2pk(this.rootWalletKeys.user.publicKey); + const { scriptPubKey } = createOutputScriptP2shP2pk(this.getReplayProtectionPublicKey()); assert(scriptPubKey); return scriptPubKey; } From 7565c1649abfb98983629293b37785dfcefe1bf9 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 4 Dec 2025 10:19:52 +0100 Subject: [PATCH 2/2] feat(abstract-utxo): replace replay protection output scripts with pubkeys Replace the implementation of replay protection to use pubkeys instead of output scripts directly. This allows more flexibility in how the scripts are created and addresses are encoded. Add tests to verify that the pubkeys correctly map to the expected addresses across different networks and address formats. Issue: BTC-2806 Co-authored-by: llm-git --- .../src/transaction/explainTransaction.ts | 4 +- .../fixedScript/explainPsbtWasm.ts | 2 +- .../fixedScript/replayProtection.ts | 45 +++++++++++--- .../transaction/fixedScript/explainPsbt.ts | 4 +- .../unit/transaction/fixedScript/parsePsbt.ts | 2 +- .../fixedScript/replayProtection.ts | 62 +++++++++++++++++++ 6 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 modules/abstract-utxo/test/unit/transaction/fixedScript/replayProtection.ts diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index 57c3b0b14b..35711b5651 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -6,7 +6,7 @@ import { getDescriptorMapFromWallet, isDescriptorWallet } from '../descriptor'; import { toBip32Triple } from '../keychains'; import { getPolicyForEnv } from '../descriptor/validatePolicy'; -import { getReplayProtectionOutputScripts } from './fixedScript/replayProtection'; +import { getReplayProtectionPubkeys } from './fixedScript/replayProtection'; import type { TransactionExplanationUtxolibLegacy, TransactionExplanationUtxolibPsbt, @@ -63,7 +63,7 @@ export function explainTx( } return fixedScript.explainPsbtWasm(tx, walletXpubs, { replayProtection: { - outputScripts: getReplayProtectionOutputScripts(network), + publicKeys: getReplayProtectionPubkeys(network), }, }); } else { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts index 0f884a3fd5..40f7a659c1 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts @@ -44,7 +44,7 @@ export function explainPsbtWasm( params: { replayProtection: { checkSignature?: boolean; - outputScripts: Buffer[]; + publicKeys: Buffer[]; }; customChangeWalletXpubs?: Triple; } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts b/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts index 96bf1c9113..b554ab001d 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts @@ -1,23 +1,48 @@ -import * as wasmUtxo from '@bitgo/wasm-utxo'; import * as utxolib from '@bitgo/utxo-lib'; +import { Descriptor, utxolibCompat } from '@bitgo/wasm-utxo'; -export function getReplayProtectionAddresses(network: utxolib.Network): string[] { +// 33p1q7mTGyeM5UnZERGiMcVUkY12SCsatA +// bitcoincash:pqt5x9w0m6z0f3znjkkx79wl3l7ywrszesemp8xgpf +const pubkeyProd = Buffer.from('0255b9f71ac2c78fffd83e3e37b9e17ae70d5437b7f56d0ed2e93b7de08015aa59', 'hex'); + +// 2MuMnPoSDgWEpNWH28X2nLtYMXQJCyT61eY +// bchtest:pqtjmnzwqffkrk2349g3cecfwwjwxusvnq87n07cal +const pubkeyTestnet = Buffer.from('0219da48412c2268865fe8c126327d1b12eee350a3b69eb09e3323cc9a11828945', 'hex'); + +export function getReplayProtectionPubkeys(network: utxolib.Network): Buffer[] { switch (network) { case utxolib.networks.bitcoincash: case utxolib.networks.bitcoinsv: - return ['33p1q7mTGyeM5UnZERGiMcVUkY12SCsatA']; - case utxolib.networks.bitcoincashTestnet: + return [pubkeyProd]; case utxolib.networks.bitcoinsvTestnet: - return ['2MuMnPoSDgWEpNWH28X2nLtYMXQJCyT61eY']; + case utxolib.networks.bitcoincashTestnet: + return [pubkeyTestnet]; } - return []; } -export function getReplayProtectionOutputScripts(network: utxolib.Network): Buffer[] { - return getReplayProtectionAddresses(network).map((address) => - Buffer.from(wasmUtxo.utxolibCompat.toOutputScript(address, network)) - ); +export function createReplayProtectionOutputScript(pubkey: Buffer): Buffer { + const descriptor = Descriptor.fromString(`sh(pk(${pubkey.toString('hex')}))`, 'definite'); + return Buffer.from(descriptor.scriptPubkey()); +} + +const replayProtectionScriptsProd = [createReplayProtectionOutputScript(pubkeyProd)]; +const replayProtectionScriptsTestnet = [createReplayProtectionOutputScript(pubkeyTestnet)]; + +export function getReplayProtectionAddresses( + network: utxolib.Network, + format: 'default' | 'cashaddr' = 'default' +): string[] { + switch (network) { + case utxolib.networks.bitcoincash: + case utxolib.networks.bitcoinsv: + return replayProtectionScriptsProd.map((script) => utxolibCompat.fromOutputScript(script, network, format)); + case utxolib.networks.bitcoinsvTestnet: + case utxolib.networks.bitcoincashTestnet: + return replayProtectionScriptsTestnet.map((script) => utxolibCompat.fromOutputScript(script, network, format)); + default: + return []; + } } export function isReplayProtectionUnspent( diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index d38467a81e..822ea0a7c6 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -65,7 +65,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { const wasmExplanation = explainPsbtWasm(wasmPsbt, walletXpubs, { replayProtection: { - outputScripts: [acidTest.getReplayProtectionOutputScript()], + publicKeys: [acidTest.getReplayProtectionPublicKey()], }, }); @@ -95,7 +95,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { it('returns custom change outputs when parameter is set', function () { const wasmExplanation = explainPsbtWasm(wasmPsbt, walletXpubs, { replayProtection: { - outputScripts: [acidTest.getReplayProtectionOutputScript()], + publicKeys: [acidTest.getReplayProtectionPublicKey()], }, customChangeWalletXpubs, }); diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts index c1404d2666..f5db82dccd 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts @@ -134,7 +134,7 @@ function describeParseTransactionWith( acidTest.rootWalletKeys.triple.map((k) => k.neutered().toBase58()) as Triple, { replayProtection: { - outputScripts: [acidTest.getReplayProtectionOutputScript()], + publicKeys: [acidTest.getReplayProtectionPublicKey()], }, } ); diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/replayProtection.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/replayProtection.ts new file mode 100644 index 0000000000..55545e2d0e --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/replayProtection.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; + +import * as utxolib from '@bitgo/utxo-lib'; + +import { + getReplayProtectionPubkeys, + getReplayProtectionAddresses, +} from '../../../../src/transaction/fixedScript/replayProtection'; + +describe('replayProtection', function () { + for (const network of utxolib.getNetworkList()) { + const networkName = utxolib.getNetworkName(network); + assert(networkName, 'network name is required'); + + describe(`${networkName}`, function () { + if ( + utxolib.getMainnet(network) === utxolib.networks.bitcoincash || + utxolib.getMainnet(network) === utxolib.networks.bitcoinsv + ) { + it('should have keys that correspond to addresses via p2shP2pk', function () { + const actualAddressesDefault = getReplayProtectionAddresses(network, 'default'); + + switch (network) { + case utxolib.networks.bitcoincash: + case utxolib.networks.bitcoinsv: + assert.deepStrictEqual(actualAddressesDefault, ['33p1q7mTGyeM5UnZERGiMcVUkY12SCsatA']); + break; + case utxolib.networks.bitcoincashTestnet: + case utxolib.networks.bitcoinsvTestnet: + assert.deepStrictEqual(actualAddressesDefault, ['2MuMnPoSDgWEpNWH28X2nLtYMXQJCyT61eY']); + break; + default: + throw new Error(`illegal state`); + } + + if (utxolib.getMainnet(network) !== utxolib.networks.bitcoincash) { + return; + } + + const actualAddressesCashaddr = getReplayProtectionAddresses(network, 'cashaddr'); + switch (network) { + case utxolib.networks.bitcoincash: + assert.deepStrictEqual(actualAddressesCashaddr, [ + 'bitcoincash:pqt5x9w0m6z0f3znjkkx79wl3l7ywrszesemp8xgpf', + ]); + break; + case utxolib.networks.bitcoincashTestnet: + assert.deepStrictEqual(actualAddressesCashaddr, ['bchtest:pqtjmnzwqffkrk2349g3cecfwwjwxusvnq87n07cal']); + break; + default: + throw new Error(`illegal state`); + } + }); + } else { + it('should have no replay protection', function () { + assert.deepEqual(getReplayProtectionPubkeys(network), []); + assert.deepEqual(getReplayProtectionAddresses(network), []); + }); + } + }); + } +});