diff --git a/modules/sdk-coin-stx/src/lib/contractBuilder.ts b/modules/sdk-coin-stx/src/lib/contractBuilder.ts index ea225bd73e..436dacf1d1 100644 --- a/modules/sdk-coin-stx/src/lib/contractBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/contractBuilder.ts @@ -15,7 +15,7 @@ import { } from '@stacks/transactions'; import { InvalidParameterValueError } from '@bitgo/sdk-core'; import { Transaction } from './transaction'; -import { contractPrincipalCVFromString, isValidAddress, standardPrincipalCVFromString } from './utils'; +import { contractPrincipalCVFromString, isExpired, isValidAddress, standardPrincipalCVFromString } from './utils'; import { ClarityValueJson } from './iface'; import { Utils } from '.'; import { CONTRACT_NAME_SENDMANY, VALID_POX5_CONTRACT_FUNCTION_NAMES, VALID_STAKING_CONTRACT_NAMES } from './constants'; @@ -61,6 +61,15 @@ export class ContractBuilder extends AbstractContractBuilder { return this; } + /** + * Caller-supplied pairs plus this network's active (non-expired) pairs. Network pairs are + * pair-scoped (name + address), so a canonical name never binds to a foreign address. + */ + private getAllowedStakingContracts(): ReadonlyArray<{ name: string; address: string }> { + const networkPairs = (this._coinConfig.network as BitgoStacksNetwork).allowedStakingContracts ?? []; + return [...this._allowedStakingContracts, ...networkPairs.filter((pair) => !isExpired(pair.expiresAt))]; + } + /** True when the current (name, address) is a canonical pair or an explicitly allowlisted pair. */ private isContractPairValid(): boolean { const isCanonicalName = @@ -72,7 +81,7 @@ export class ContractBuilder extends AbstractContractBuilder { if (isCanonicalName && isCanonicalAddress) { return true; } - return this._allowedStakingContracts.some( + return this.getAllowedStakingContracts().some( (contract) => contract.name === this._contractName && contract.address === this._contractAddress ); } @@ -106,7 +115,7 @@ export class ContractBuilder extends AbstractContractBuilder { throw new InvalidParameterValueError('Invalid address'); } const isCanonicalAddress = Utils.isValidContractAddress(address, this._coinConfig.network as BitgoStacksNetwork); - const isAllowedAddress = this._allowedStakingContracts.some((contract) => contract.address === address); + const isAllowedAddress = this.getAllowedStakingContracts().some((contract) => contract.address === address); if (!isCanonicalAddress && !isAllowedAddress) { throw new InvalidParameterValueError('Invalid contract address'); } diff --git a/modules/sdk-coin-stx/src/lib/pox5Builder.ts b/modules/sdk-coin-stx/src/lib/pox5Builder.ts index 1a9c9a9977..ce600db39d 100644 --- a/modules/sdk-coin-stx/src/lib/pox5Builder.ts +++ b/modules/sdk-coin-stx/src/lib/pox5Builder.ts @@ -26,7 +26,7 @@ import { FUNCTION_NAME_UNSTAKE, FUNCTION_NAME_UPDATE_BOND_REGISTRATION, } from './constants'; -import { contractPrincipalCVFromString, standardPrincipalCVFromString } from './utils'; +import { contractPrincipalCVFromString, isExpired, standardPrincipalCVFromString } from './utils'; type Integer = bigint | number | string; type ByteValue = Buffer | Uint8Array | string; @@ -167,10 +167,14 @@ 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); + const isCanonical = network.stakingContractAddress === address; + // A configured (non-expired) network pair may point pox-5 at a different deployer. + const isConfiguredPair = (network.allowedStakingContracts ?? []).some( + (pair) => pair.name === CONTRACT_NAME_POX5 && pair.address === address && !isExpired(pair.expiresAt) ); + return (isCanonical || isConfiguredPair) && 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 9a108ef7c7..cfae45b7e9 100644 --- a/modules/sdk-coin-stx/src/lib/utils.ts +++ b/modules/sdk-coin-stx/src/lib/utils.ts @@ -292,6 +292,24 @@ export function isValidContractAddress(addr: string, network: BitgoStacksNetwork ); } +/** + * Whether a configured staking-contract allowance has expired. + * + * @param {string} [expiresAt] - ISO-8601 date (e.g. `2027-03-31`). Undefined ⇒ never expires. + * @returns {boolean} - true when the entry is in the past and must no longer be honoured; an + * unparseable expiry fails closed (treated as expired) + */ +export function isExpired(expiresAt?: string): boolean { + if (!expiresAt) { + return false; + } + const expiry = Date.parse(expiresAt); + if (Number.isNaN(expiry)) { + return true; + } + return Date.now() > expiry; +} + /** * Check if the name is one of valid contract names * diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts index 01a2f1ecac..ed60bc81c2 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts @@ -3,10 +3,19 @@ import should from 'should'; import BigNum from 'bn.js'; import { STACKS_TESTNET, STACKS_MAINNET } from '@stacks/network'; import { InvalidParameterValueError, ParseTransactionError, TransactionType } from '@bitgo/sdk-core'; -import { bufferCV, noneCV, someCV, standardPrincipalCV, tupleCV, uintCV, intCV } from '@stacks/transactions'; +import { + bufferCV, + ContractCallPayload, + noneCV, + someCV, + standardPrincipalCV, + tupleCV, + uintCV, + intCV, +} from '@stacks/transactions'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; -import { coins } from '@bitgo/statics'; +import { coins, BaseCoin as CoinConfig } from '@bitgo/statics'; import { Stx, Tstx, StxLib } from '../../../src'; import * as testData from '../resources'; @@ -171,17 +180,15 @@ describe('Stacks: Contract Builder', function () { it('an unsigned caller-allowlisted non-canonical pox-5 (dummy) contract call transaction', async () => { // The caller supplies the staking contract it is configured for — no SDK change needed. const builder = factoryProd.getContractBuilder(); - builder.allowedStakingContracts([ - { name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }, - ]); + builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]); builder.fee({ fee: '180' }); builder.nonce(0); builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); - builder.contractName('pox-5-dummy-v1'); + builder.contractName('pox-5-test'); builder.functionName('register-for-bond'); builder.functionArgs([ { type: 'uint128', val: '210' }, - { type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-dummy-v1' }, + { type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-test' }, { type: 'uint128', val: '1005000' }, { type: 'optional' }, { type: 'optional' }, @@ -191,7 +198,7 @@ describe('Stacks: Contract Builder', function () { const tx = await builder.build(); const txJson = tx.toJson(); - should.deepEqual(txJson.payload.contractName, 'pox-5-dummy-v1'); + should.deepEqual(txJson.payload.contractName, 'pox-5-test'); should.deepEqual(txJson.payload.contractAddress, 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); should.deepEqual(txJson.payload.functionName, 'register-for-bond'); }); @@ -199,17 +206,17 @@ describe('Stacks: Contract Builder', function () { it('rehydrates an allowlisted non-canonical contract call (sign / explain / fee-rebuild path)', async () => { // `signTransaction` / `explainTransaction` / the fee re-build all go through `factory.from(hex)`, // which re-creates the builder — re-parsing must not reject the allowlisted contract. - const allowlist = [{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]; + const allowlist = [{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]; const builder = factoryProd.getContractBuilder(); builder.allowedStakingContracts(allowlist); builder.fee({ fee: '180' }); builder.nonce(0); builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); - builder.contractName('pox-5-dummy-v1'); + builder.contractName('pox-5-test'); builder.functionName('register-for-bond'); builder.functionArgs([ { type: 'uint128', val: '210' }, - { type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-dummy-v1' }, + { type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-test' }, { type: 'uint128', val: '1005000' }, { type: 'optional' }, { type: 'optional' }, @@ -223,7 +230,7 @@ describe('Stacks: Contract Builder', function () { reparsed.numberSignatures(1); const payload = (await reparsed.build()).toJson().payload; should.deepEqual(payload.contractAddress, 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); - should.deepEqual(payload.contractName, 'pox-5-dummy-v1'); + should.deepEqual(payload.contractName, 'pox-5-test'); // Without the allowlist the rebuild is rejected — canonical validation is preserved (no relaxation). (() => factoryProd.from(tx.toBroadcastFormat())).should.throw(ParseTransactionError); @@ -231,47 +238,39 @@ describe('Stacks: Contract Builder', function () { it('rejects an allowlisted name followed by a canonical address (order-independent pair check)', function () { const builder = factoryProd.getContractBuilder(); - builder.allowedStakingContracts([ - { name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }, - ]); - builder.contractName('pox-5-dummy-v1'); // allowlisted name first + builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]); + builder.contractName('pox-5-test'); // allowlisted name first (() => builder.contractAddress('SP000000000000000000002Q6VF78')).should.throw(/not an allowlisted pair/); }); 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.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]); builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); // allowlisted address first - (() => builder.contractName('pox-5')).should.throw(/not an allowlisted pair/); + (() => builder.contractName('pox-4')).should.throw(/not an allowlisted pair/); }); it('rejects duplicate allowlisted contract names', function () { const builder = factoryProd.getContractBuilder(); (() => builder.allowedStakingContracts([ - { name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }, - { name: 'pox-5-dummy-v1', address: 'SP000000000000000000002Q6VF78' }, + { name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }, + { name: 'pox-5-test', address: 'SP000000000000000000002Q6VF78' }, ])).should.throw(/Duplicate staking contract name/); }); it('rejects an allowlisted name paired with a different address', function () { const builder = factoryProd.getContractBuilder(); - builder.allowedStakingContracts([ - { name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }, - ]); + builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]); builder.contractAddress('SP000000000000000000002Q6VF78'); // canonical address, wrong pair - (() => builder.contractName('pox-5-dummy-v1')).should.throw(/not an allowlisted pair/); + (() => builder.contractName('pox-5-test')).should.throw(/not an allowlisted pair/); }); it('rejects a function that is not in the pox-5 set for an allowlisted contract', function () { const builder = factoryProd.getContractBuilder(); - builder.allowedStakingContracts([ - { name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }, - ]); + builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]); builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); - builder.contractName('pox-5-dummy-v1'); + builder.contractName('pox-5-test'); // `stack-stx` is a pox-4 function, not part of the pox-5 ABI the dummy mirrors. (() => builder.functionName('stack-stx')).should.throw(/is not supported contract function name/); }); @@ -288,6 +287,148 @@ describe('Stacks: Contract Builder', function () { ); }); + describe('network-configured staking contract pairs (SI-2089)', function () { + // The mainnet Stx network declares this as a pair-scoped, self-expiring pox-5 allowance. + const CONFIGURED_ADDRESS = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'; + const CANONICAL_ADDRESS = 'SP000000000000000000002Q6VF78'; + + const withPairs = (pairs: ReadonlyArray<{ name: string; address: string; expiresAt?: string }>) => + ({ + ...coins.get(coinName), + network: { ...coins.get(coinName).network, allowedStakingContracts: pairs }, + } as unknown as Readonly); + + const buildRegisterForBond = (factoryInstance: StxLib.TransactionBuilderFactory) => { + const builder = factoryInstance.getContractBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress(CONFIGURED_ADDRESS); + builder.contractName('pox-5'); + builder.functionName('register-for-bond'); + builder.functionArgs([ + { type: 'uint128', val: '210' }, + { type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager' }, + { type: 'uint128', val: '1005000' }, + { type: 'optional' }, + { type: 'optional' }, + ]); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + return builder; + }; + + it('accepts pox-5 at the configured network address', async function () { + // Injected pair with a far-future expiry so the test exercises the pair logic and does not + // depend on the shipped `expiresAt` in the mainnet statics (which would break CI once past). + const builder = buildRegisterForBond( + new StxLib.TransactionBuilderFactory( + withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }]) + ) + ); + const tx = await builder.build(); + should.deepEqual(tx.toJson().payload.contractAddress, CONFIGURED_ADDRESS); + should.deepEqual(tx.toJson().payload.contractName, 'pox-5'); + }); + + it('parses a configured-address register-for-bond via from(hex) with NO caller allowlist', async function () { + const factory = new StxLib.TransactionBuilderFactory( + withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }]) + ); + const tx = await buildRegisterForBond(factory).build(); + const reparsed = factory.from(tx.toBroadcastFormat()); + reparsed.fromPubKey(testData.TX_SENDER.pub); + reparsed.numberSignatures(1); + const payload = (await reparsed.build()).toJson().payload as unknown as ContractCallPayload; + should.deepEqual(payload.contractAddress, CONFIGURED_ADDRESS); + should.deepEqual(payload.contractName, 'pox-5'); + }); + + it('rejects pox-4 at the configured network address (reviewer regression)', function () { + // Injected non-expired pair: the rejection must be the PAIR check, not the address check — + // reading the shipped pin would flip this to "Invalid contract address" once it expires. + const builder = new StxLib.TransactionBuilderFactory( + withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }]) + ).getContractBuilder(); + builder.contractName('pox-4'); + (() => builder.contractAddress(CONFIGURED_ADDRESS)).should.throw(/not an allowlisted pair/); + }); + + it('rejects send-many-memo at the configured network address', function () { + const builder = new StxLib.TransactionBuilderFactory( + withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }]) + ).getContractBuilder(); + builder.contractName('send-many-memo'); + (() => builder.contractAddress(CONFIGURED_ADDRESS)).should.throw(/not an allowlisted pair/); + }); + + it('rejects an unknown address', function () { + const builder = factoryProd.getContractBuilder(); + (() => builder.contractAddress('SP2TYC2AVC4GSB01F78H0XSVTH8WRGV95BXFG4BBR')).should.throw( + /Invalid contract address/ + ); + }); + + it('still accepts canonical pox-5 and pox-4 at the canonical address', function () { + const pox5 = factoryProd.getContractBuilder(); + pox5.contractName('pox-5'); + (() => pox5.contractAddress(CANONICAL_ADDRESS)).should.not.throw(); + const pox4 = factoryProd.getContractBuilder(); + pox4.contractName('pox-4'); + (() => pox4.contractAddress(CANONICAL_ADDRESS)).should.not.throw(); + }); + + it('honours the expiry: expired entry rejected, non-expired and absent accepted', function () { + const expired = new StxLib.TransactionBuilderFactory( + withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2020-01-01' }]) + ); + const expiredBuilder = expired.getContractBuilder(); + expiredBuilder.contractName('pox-5'); + (() => expiredBuilder.contractAddress(CONFIGURED_ADDRESS)).should.throw(/Invalid contract address/); + + const active = new StxLib.TransactionBuilderFactory( + withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }]) + ); + const activeBuilder = active.getContractBuilder(); + activeBuilder.contractName('pox-5'); + (() => activeBuilder.contractAddress(CONFIGURED_ADDRESS)).should.not.throw(); + + const noExpiry = new StxLib.TransactionBuilderFactory( + withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS }]) + ); + const noExpiryBuilder = noExpiry.getContractBuilder(); + noExpiryBuilder.contractName('pox-5'); + (() => noExpiryBuilder.contractAddress(CONFIGURED_ADDRESS)).should.not.throw(); + }); + + it('keeps the caller-supplied allowlist as a working override', async function () { + // A caller-only pair (not declared on the network) still works when passed explicitly. + const DUMMY_ADDRESS = 'SP2T758K6T2YRKG9Q0TJ16B6FP5QQREWZSESRS0PY'; + const builder = factoryProd.getContractBuilder(); + builder.allowedStakingContracts([{ name: 'pox-5-test', address: DUMMY_ADDRESS }]); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress(DUMMY_ADDRESS); + builder.contractName('pox-5-test'); + builder.functionName('register-for-bond'); + builder.functionArgs([ + { type: 'uint128', val: '210' }, + { type: 'principal', val: `${DUMMY_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(); + should.deepEqual(tx.toJson().payload.contractAddress, DUMMY_ADDRESS); + + // Without the caller allowlist the same address is not accepted by the network. + (() => factoryProd.getContractBuilder().contractAddress(DUMMY_ADDRESS)).should.throw( + /Invalid contract address/ + ); + }); + }); + 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 149594270b..a3ccbc3319 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -4,6 +4,7 @@ import { ClarityValue, ContractCallPayload, createAddress, + createContractCallPayload, cvToString, cvToValue, deserializeCV, @@ -11,7 +12,7 @@ import { PostConditionMode, PostConditionType, } from '@stacks/transactions'; -import { coins } from '@bitgo/statics'; +import { coins, BaseCoin as CoinConfig } from '@bitgo/statics'; import should from 'should'; import { BaseTransaction } from '@bitgo/sdk-core'; @@ -196,6 +197,47 @@ describe('Stacks: PoX-5 Builder', function () { ); }); + it('accepts a configured (non-expired) network pox-5 pair and rejects other names/expired (SI-2089)', () => { + const CONFIGURED_ADDRESS = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'; + const payloadFor = (name: string): ContractCallPayload => + createContractCallPayload(CONFIGURED_ADDRESS, name, 'register-for-bond', []); + + // Injected non-expired config: the assertions below must not depend on the shipped pin in + // `networks.ts`, whose `expiresAt` would otherwise flip them to `false` once it passes. + const activeConfig = { + ...coins.get('stx'), + network: { + ...coins.get('stx').network, + allowedStakingContracts: [{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }], + }, + } as unknown as Readonly; + + // Canonical pox-5 address, canonical name — unchanged. + should.equal( + StxLib.Pox5Builder.isValidContractCall( + coins.get('stx'), + createContractCallPayload('SP000000000000000000002Q6VF78', 'pox-5', 'register-for-bond', []) + ), + true + ); + + // Configured pair: pox-5 at the pinned address is accepted. + should.equal(StxLib.Pox5Builder.isValidContractCall(activeConfig, payloadFor('pox-5')), true); + // Other canonical names at that address must NOT be accepted (reviewer regression). + should.equal(StxLib.Pox5Builder.isValidContractCall(activeConfig, payloadFor('pox-4')), false); + should.equal(StxLib.Pox5Builder.isValidContractCall(activeConfig, payloadFor('send-many-memo')), false); + + // Expired pair is rejected. + const expiredConfig = { + ...coins.get('stx'), + network: { + ...coins.get('stx').network, + allowedStakingContracts: [{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2020-01-01' }], + }, + } as unknown as Readonly; + should.equal(StxLib.Pox5Builder.isValidContractCall(expiredConfig, payloadFor('pox-5')), false); + }); + 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 6434d279c7..007d808460 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, StacksNetwork } from '@bitgo/statics'; import * as testData from './resources'; import { StxLib } from '../../src'; @@ -123,6 +124,47 @@ describe('Stx util library', function () { }); }); + describe('isValidContractAddress', function () { + it('accepts only canonical contract addresses (address-only)', function () { + const mainnet = coins.get('stx').network as StacksNetwork; + Utils.isValidContractAddress(mainnet.stakingContractAddress, mainnet).should.be.true(); + Utils.isValidContractAddress(mainnet.sendmanymemoContractAddress, mainnet).should.be.true(); + Utils.isValidContractAddress(mainnet.sbtcWithdrawalContractAddress, mainnet).should.be.true(); + // The network-configured test address is NOT accepted by the shared address check. + Utils.isValidContractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY', mainnet).should.be.false(); + Utils.isValidContractAddress('SP2TYC2AVC4GSB01F78H0XSVTH8WRGV95BXFG4BBR', mainnet).should.be.false(); + }); + + it('ships the pinned mainnet pox-5 pair (name + address)', function () { + // Guards the SHIPPED statics entry — a typo in the pinned address would otherwise go unnoticed + // because the other tests inject their own pairs. Deliberately asserts only the name/address, so + // it does not depend on the `expiresAt` value or the clock. + const mainnet = coins.get('stx').network as StacksNetwork; + should.deepEqual( + (mainnet.allowedStakingContracts ?? []).map((contract) => ({ + name: contract.name, + address: contract.address, + })), + [{ name: 'pox-5', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }] + ); + }); + }); + + describe('isExpired', function () { + it('treats an absent expiry as never expiring', function () { + Utils.isExpired(undefined).should.be.false(); + }); + + it('detects past and future expiries', function () { + Utils.isExpired('2020-01-01').should.be.true(); + Utils.isExpired('2999-01-01').should.be.false(); + }); + + it('fails closed on an unparseable expiry', function () { + Utils.isExpired('not-a-date').should.be.true(); + }); + }); + 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 00a2777de2..6deba319f8 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -209,6 +209,13 @@ export interface StacksNetwork extends AccountNetwork { readonly chainId?: number; /** Transaction version byte. Omitted for the stock Stacks mainnet/testnet deployments. */ readonly transactionVersion?: number; + /** + * Non-canonical staking contract pairs (name + address) this network accepts **in addition to** + * the canonical `stakingContractAddress`. Pair-scoped on purpose: a bare address would let a + * canonical name (e.g. `pox-4`) bind to a third-party deployer. Temporary test-contract + * allowance, self-expiring via `expiresAt`; remove together with the test contract. + */ + readonly allowedStakingContracts?: ReadonlyArray<{ name: string; address: string; expiresAt?: string }>; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -1362,6 +1369,23 @@ class Stx extends Mainnet implements StacksNetwork { sendmanymemoContractAddress = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE'; stakingContractAddress = 'SP000000000000000000002Q6VF78'; sbtcWithdrawalContractAddress = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4'; + // Temporary test-contract allowance: pair-scoped (name + address) and self-expiring, so a + // configured PoX-5 test deployment can be parsed without relaxing canonical validation for other + // names (pox-4 / send-many-memo) or past the expiry. + // + // The pinned pair admits every PoX-5 function `Pox5Builder` supports at this address (stake, + // unstake, update-bond-registration, ...), not only `register-for-bond` — the register-only + // restriction lives in wallet-platform, not here. Intentionally allowed for this test deployment. + // + // `expiresAt` is a full timestamp: `'2026-11-02'` parses as 00:00Z, so the allowance ends at the + // START of Nov 2, not after it. + // + // @obligation remove this entry (and its tests) together with the test contract — see the removal + // ticket on SI-2089. The deployer `SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY` is the Stacks + // Foundation's test deployment. + allowedStakingContracts = [ + { name: 'pox-5', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY', expiresAt: '2026-11-02' }, + ]; } class StxTestnet extends Testnet implements StacksNetwork {