From 6542d8b4ef6b2212b4014e119960030fcc21c7c6 Mon Sep 17 00:00:00 2001 From: Doddanna17 Date: Mon, 26 Oct 2026 10:44:00 +0530 Subject: [PATCH] feat(sdk-coin-stx): accept a configured PoX-5 staking contract address The Stacks Foundation PoX-5 test contract uses the standard name `pox-5` at a non-canonical boot address. `isValidContractAddress` is address-only, so a register targeting it is rejected on every parse unless the caller supplies an allowlist. Allow the address to come from the network instead: add `allowedStakingContractAddresses` to the StacksNetwork statics (set on mainnet to the test address only) and accept it in both `isValidContractAddress` and `Pox5Builder.isValidContractCall`. A consumer can then parse and sign a configured address with no caller changes. Default-safe: unset on testnet/signet, so behaviour there is unchanged. The caller-supplied allowlist remains a working override. SI-2089 TICKET: SI-2089 --- modules/sdk-coin-stx/src/lib/pox5Builder.ts | 11 ++- modules/sdk-coin-stx/src/lib/utils.ts | 6 +- .../transactionBuilder/contractBuilder.ts | 75 ++++++++++++++++++- .../unit/transactionBuilder/pox5Builder.ts | 34 +++++++++ modules/sdk-coin-stx/test/unit/util.ts | 21 ++++++ modules/statics/src/networks.ts | 14 ++++ 6 files changed, 152 insertions(+), 9 deletions(-) diff --git a/modules/sdk-coin-stx/src/lib/pox5Builder.ts b/modules/sdk-coin-stx/src/lib/pox5Builder.ts index 1a9c9a99770..cda10f63e91 100644 --- a/modules/sdk-coin-stx/src/lib/pox5Builder.ts +++ b/modules/sdk-coin-stx/src/lib/pox5Builder.ts @@ -167,10 +167,13 @@ export class Pox5Builder extends ContractBuilder { } public static isValidContractCall(coinConfig: Readonly, payload: ContractCallPayload): boolean { - return ( - (coinConfig.network as BitgoStacksNetwork).stakingContractAddress === addressToString(payload.contractAddress) && - payload.contractName.content === CONTRACT_NAME_POX5 - ); + const network = coinConfig.network as BitgoStacksNetwork; + const address = addressToString(payload.contractAddress); + // Canonical pox-5, or a deployment-configured non-canonical pox-5 (same standard name), so the + // tx routes to this builder (and keeps its pox-5-specific behaviour) without a caller allowlist. + const isPox5Address = + network.stakingContractAddress === address || (network.allowedStakingContractAddresses ?? []).includes(address); + return isPox5Address && payload.contractName.content === CONTRACT_NAME_POX5; } registerForBond(params: Pox5RegisterForBondParams): this { diff --git a/modules/sdk-coin-stx/src/lib/utils.ts b/modules/sdk-coin-stx/src/lib/utils.ts index 9a108ef7c73..00a60460d53 100644 --- a/modules/sdk-coin-stx/src/lib/utils.ts +++ b/modules/sdk-coin-stx/src/lib/utils.ts @@ -280,6 +280,9 @@ export function isValidMemo(memo: string): boolean { /** * Checks for valid contract address * + * Accepts the canonical staking / send-many-memo / sbtc-withdrawal addresses plus any + * deployment-configured `network.allowedStakingContractAddresses` (e.g. a non-canonical pox-5). + * * @param {string} addr - contract deployer address * @param {BitgoStacksNetwork} network - network object * @returns {boolean} - the validation result @@ -288,7 +291,8 @@ export function isValidContractAddress(addr: string, network: BitgoStacksNetwork return ( addr === network.stakingContractAddress || addr === network.sendmanymemoContractAddress || - addr === network.sbtcWithdrawalContractAddress + addr === network.sbtcWithdrawalContractAddress || + (network.allowedStakingContractAddresses ?? []).includes(addr) ); } diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts index 01a2f1ecacb..264ed309f1a 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts @@ -30,6 +30,9 @@ describe('Stacks: Contract Builder', function () { describe('Stx Contract call Builder', () => { const factory = new StxLib.TransactionBuilderFactory(coins.get(coinNameTest)); const factoryProd = new StxLib.TransactionBuilderFactory(coins.get(coinName)); + // Deployment-configured non-canonical pox-5 address (Stacks Foundation test contract) set on the + // mainnet `Stx` statics network as `allowedStakingContractAddresses`. + const CONFIGURED_STAKING_ADDRESS = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'; const initTxBuilder = () => { const txBuilder = factory.getContractBuilder(); @@ -240,10 +243,11 @@ describe('Stacks: Contract Builder', function () { it('rejects an allowlisted address followed by a canonical name (order-independent pair check)', function () { const builder = factoryProd.getContractBuilder(); - builder.allowedStakingContracts([ - { name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }, - ]); - builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); // allowlisted address first + // A dummy address that is NOT the network-configured pox-5 (otherwise the canonical `pox-5` + // name would legitimately pair with it) — keeps the allowlist pair check under test. + const dummyAddress = 'SP2T758K6T2YRKG9Q0TJ16B6FP5QQREWZSESRS0PY'; + builder.allowedStakingContracts([{ name: 'pox-5-dummy-v1', address: dummyAddress }]); + builder.contractAddress(dummyAddress); // allowlisted address first (() => builder.contractName('pox-5')).should.throw(/not an allowlisted pair/); }); @@ -288,6 +292,69 @@ describe('Stacks: Contract Builder', function () { ); }); + it('accepts a deployment-configured staking address on the canonical pox-5 name (no allowlist)', async () => { + // The network statics carry the configured address, so a canonical pox-5 call targeting it + // builds/parses with no caller-supplied allowlist. + const builder = factoryProd.getContractBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress(CONFIGURED_STAKING_ADDRESS); + builder.contractName('pox-5'); + builder.functionName('register-for-bond'); + builder.functionArgs([ + { type: 'uint128', val: '210' }, + { type: 'principal', val: `${CONFIGURED_STAKING_ADDRESS}.signer-manager` }, + { type: 'uint128', val: '1005000' }, + { type: 'optional' }, + { type: 'optional' }, + ]); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + + const tx = await builder.build(); + tx.toJson().payload.contractAddress.should.equal(CONFIGURED_STAKING_ADDRESS); + tx.toJson().payload.contractName.should.equal('pox-5'); + }); + + it('rehydrates a configured-address pox-5 call with NO allowlist (OVC path)', async () => { + // The key behaviour: `factory.from(hex)` — used by sign / explain / fee-rebuild — must parse + // a register-for-bond targeting the configured address without any allowlist, so the offline + // vault console needs no code change. + const builder = factoryProd.getContractBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress(CONFIGURED_STAKING_ADDRESS); + builder.contractName('pox-5'); + builder.functionName('register-for-bond'); + builder.functionArgs([ + { type: 'uint128', val: '210' }, + { type: 'principal', val: `${CONFIGURED_STAKING_ADDRESS}.signer-manager` }, + { type: 'uint128', val: '1005000' }, + { type: 'optional' }, + { type: 'optional' }, + ]); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + const tx = await builder.build(); + + // Routes to the pox-5 builder (parity) with no allowlist supplied. + should.equal( + StxLib.Pox5Builder.isValidContractCall( + coins.get(coinName), + (tx as StxLib.Transaction).stxTransaction.payload as any + ), + true + ); + + const reparsed = factoryProd.from(tx.toBroadcastFormat()); + reparsed.should.be.an.instanceOf(StxLib.Pox5Builder); + reparsed.fromPubKey(testData.TX_SENDER.pub); + reparsed.numberSignatures(1); + const payload = (await reparsed.build()).toJson().payload as any; + should.deepEqual(payload.contractAddress, CONFIGURED_STAKING_ADDRESS); + should.deepEqual(payload.contractName, 'pox-5'); + }); + it('a signed contract call with args', async () => { const builder = initTxBuilder(); builder.functionArgs([ diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts index 149594270be..e2377234525 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -196,6 +196,40 @@ describe('Stacks: PoX-5 Builder', function () { ); }); + it('accepts a deployment-configured non-canonical pox-5 address during factory routing', async () => { + // Mainnet `Stx` statics carry the Stacks Foundation test contract under + // `allowedStakingContractAddresses`, so a pox-5 call to it routes to the PoX-5 builder with no + // caller allowlist. + const factoryProd = new StxLib.TransactionBuilderFactory(coins.get('stx')); + const configuredAddress = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'; + const builder = configure(factoryProd.getPox5Builder()); + builder.contractAddress(configuredAddress); + builder.registerForBond({ + bondIndex: 210, + signerManager, + amountUstx: '1005000', + lockup: { kind: 'btc', unlockBytes: '00', outputs: [validLockupOutput()] }, + }); + const tx = await builder.build(); + const payload = (tx as StxLib.Transaction).stxTransaction.payload as any; + + should.equal(StxLib.Pox5Builder.isValidContractCall(coins.get('stx'), payload), true); + // an unknown address is still rejected + should.equal( + StxLib.Pox5Builder.isValidContractCall(coins.get('stx'), { + ...payload, + contractAddress: createAddress(testData.ACCOUNT_1.address), + }), + false + ); + + // No allowlist: the raw tx rehydrates back through the PoX-5 builder. + const reparsed = factoryProd.from(tx.toBroadcastFormat()); + reparsed.should.be.an.instanceOf(StxLib.Pox5Builder); + reparsed.fromPubKey(testData.TX_SENDER.pub); + should.equal((await reparsed.build()).toBroadcastFormat(), tx.toBroadcastFormat()); + }); + it('builds sBTC registration using response error', async () => { const builder = configure(factory.getPox5Builder()); builder.registerForBond({ diff --git a/modules/sdk-coin-stx/test/unit/util.ts b/modules/sdk-coin-stx/test/unit/util.ts index 6434d279c7b..0257eaa543b 100644 --- a/modules/sdk-coin-stx/test/unit/util.ts +++ b/modules/sdk-coin-stx/test/unit/util.ts @@ -18,6 +18,7 @@ import { uintCV, } from '@stacks/transactions'; import BigNum from 'bn.js'; +import { coins } from '@bitgo/statics'; import * as testData from './resources'; import { StxLib } from '../../src'; @@ -123,6 +124,26 @@ describe('Stx util library', function () { }); }); + describe('isValidContractAddress', function () { + const network = coins.get('stx').network as any; + const configuredAddress = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'; + + it('accepts the canonical staking address and a deployment-configured address', function () { + Utils.isValidContractAddress(network.stakingContractAddress, network).should.be.true(); + Utils.isValidContractAddress(network.sendmanymemoContractAddress, network).should.be.true(); + Utils.isValidContractAddress(configuredAddress, network).should.be.true(); + }); + + it('rejects an address that is neither canonical nor configured', function () { + Utils.isValidContractAddress('SP2T758K6T2YRKG9Q0TJ16B6FP5QQREWZSESRS0PY', network).should.be.false(); + }); + + it('does not accept the configured address on a network that does not carry it', function () { + const testnet = coins.get('tstx').network as any; + Utils.isValidContractAddress(configuredAddress, testnet).should.be.false(); + }); + }); + describe('amount', function () { it('valid amount', function () { Utils.isValidAmount('10').should.be.true(); diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 00a2777de27..aedc3d43331 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -209,6 +209,16 @@ export interface StacksNetwork extends AccountNetwork { readonly chainId?: number; /** Transaction version byte. Omitted for the stock Stacks mainnet/testnet deployments. */ readonly transactionVersion?: number; + /** + * Additional staking contract deployer addresses this network accepts, in addition to the + * canonical `stakingContractAddress`. Deployment-specific and temporary: used to allow a + * non-canonical pox-5 deployment (e.g. the Stacks Foundation test contract) without the caller + * passing an allowlist. Empty/omitted by default, so behaviour is unchanged unless configured. + * + * @obligation remove once the test deployment is retired — an address listed here is trusted for + * every Stacks transaction on this network + */ + readonly allowedStakingContractAddresses?: ReadonlyArray; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -1362,6 +1372,10 @@ class Stx extends Mainnet implements StacksNetwork { sendmanymemoContractAddress = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE'; stakingContractAddress = 'SP000000000000000000002Q6VF78'; sbtcWithdrawalContractAddress = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4'; + // Temporary: the Stacks Foundation pox-5 test contract (standard name `pox-5`, non-canonical boot + // address). Allows transactions targeting it to parse/build without a caller-supplied allowlist. + // Remove with the test. + allowedStakingContractAddresses = ['SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY']; } class StxTestnet extends Testnet implements StacksNetwork {