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 b2c8c470d13000cdcea0c5eb4944655486dac7d1 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): refactor replay protection to use pubkeys Refactor replay protection to use the public keys directly instead of output scripts. The output scripts are now derived from the public keys, making the code more maintainable and easier to understand. Add tests for replay protection to verify that the output scripts match those computed from descriptors. Issue: BTC-2806 Co-authored-by: llm-git --- .../src/transaction/explainTransaction.ts | 4 +- .../fixedScript/explainPsbtWasm.ts | 2 +- .../fixedScript/replayProtection.ts | 42 ++++++++++++++----- .../transaction/fixedScript/explainPsbt.ts | 4 +- .../unit/transaction/fixedScript/parsePsbt.ts | 2 +- .../fixedScript/replayProtection.ts | 28 +++++++++++++ 6 files changed, 66 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..58b61d23d6 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts @@ -1,23 +1,45 @@ -import * as wasmUtxo from '@bitgo/wasm-utxo'; import * as utxolib from '@bitgo/utxo-lib'; +import { utxolibCompat } from '@bitgo/wasm-utxo'; -export function getReplayProtectionAddresses(network: utxolib.Network): string[] { +export const pubkeyProd = Buffer.from('0255b9f71ac2c78fffd83e3e37b9e17ae70d5437b7f56d0ed2e93b7de08015aa59', 'hex'); + +export 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)) - ); +// sh(pk(pubkeyProd)) +// 33p1q7mTGyeM5UnZERGiMcVUkY12SCsatA +// bitcoincash:pqt5x9w0m6z0f3znjkkx79wl3l7ywrszesemp8xgpf +const replayProtectionScriptsProd = [Buffer.from('a914174315cfde84f4c45395ac6f15df8ffc470e02cc87', 'hex')]; +// sh(pk(pubkeyTestnet)) +// 2MuMnPoSDgWEpNWH28X2nLtYMXQJCyT61eY +// bchtest:pqtjmnzwqffkrk2349g3cecfwwjwxusvnq87n07cal +const replayProtectionScriptsTestnet = [Buffer.from('a914172dcc4e025361d951a9511c670973a4e3720c9887', 'hex')]; + +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..f8f2a62b83 --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/replayProtection.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; + +import * as utxolib from '@bitgo/utxo-lib'; +import { Descriptor, utxolibCompat } from '@bitgo/wasm-utxo'; + +import { + getReplayProtectionAddresses, + pubkeyProd, + pubkeyTestnet, +} from '../../../../src/transaction/fixedScript/replayProtection'; + +function createReplayProtectionOutputScript(pubkey: Buffer): Buffer { + const descriptor = Descriptor.fromString(`sh(pk(${pubkey.toString('hex')}))`, 'definite'); + return Buffer.from(descriptor.scriptPubkey()); +} + +describe('replayProtection', function () { + it('should have scriptPubKeys that match descriptor computation', function () { + for (const pubkey of [pubkeyProd, pubkeyTestnet]) { + const network = pubkey === pubkeyProd ? utxolib.networks.bitcoincash : utxolib.networks.bitcoincashTestnet; + const expectedScript = createReplayProtectionOutputScript(pubkey); + const actualAddresses = getReplayProtectionAddresses(network); + assert.equal(actualAddresses.length, 1); + const actualScript = Buffer.from(utxolibCompat.toOutputScript(actualAddresses[0], network)); + assert.deepStrictEqual(actualScript, expectedScript); + } + }); +});