Skip to content

Commit 3847eaf

Browse files
committed
feat(sdk-coin-stx): accept a configured PoX-5 staking contract pair
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. Accept it as a PAIR instead: add `allowedStakingContracts` (name, address and optional expiry) to the StacksNetwork statics, seeded on mainnet with the test pair, and consult it in `isContractPairValid` and `Pox5Builder.isValidContractCall`. `isValidContractAddress` stays address-only, so pox-4 and send-many-memo at that address are still rejected. The entry is pair-scoped and self-expiring, so the trust cannot outlive the test or leak to other contract names. The caller-supplied allowlist remains a working override. SI-2089 TICKET: SI-2089
1 parent 54f0ff4 commit 3847eaf

7 files changed

Lines changed: 265 additions & 35 deletions

File tree

‎modules/sdk-coin-stx/src/lib/contractBuilder.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '@stacks/transactions';
1616
import { InvalidParameterValueError } from '@bitgo/sdk-core';
1717
import { Transaction } from './transaction';
18-
import { contractPrincipalCVFromString, isValidAddress, standardPrincipalCVFromString } from './utils';
18+
import { contractPrincipalCVFromString, isExpired, isValidAddress, standardPrincipalCVFromString } from './utils';
1919
import { ClarityValueJson } from './iface';
2020
import { Utils } from '.';
2121
import { CONTRACT_NAME_SENDMANY, VALID_POX5_CONTRACT_FUNCTION_NAMES, VALID_STAKING_CONTRACT_NAMES } from './constants';
@@ -61,6 +61,15 @@ export class ContractBuilder extends AbstractContractBuilder {
6161
return this;
6262
}
6363

64+
/**
65+
* Caller-supplied pairs plus this network's active (non-expired) pairs. Network pairs are
66+
* pair-scoped (name + address), so a canonical name never binds to a foreign address.
67+
*/
68+
private getAllowedStakingContracts(): ReadonlyArray<{ name: string; address: string }> {
69+
const networkPairs = (this._coinConfig.network as BitgoStacksNetwork).allowedStakingContracts ?? [];
70+
return [...this._allowedStakingContracts, ...networkPairs.filter((pair) => !isExpired(pair.expiresAt))];
71+
}
72+
6473
/** True when the current (name, address) is a canonical pair or an explicitly allowlisted pair. */
6574
private isContractPairValid(): boolean {
6675
const isCanonicalName =
@@ -72,7 +81,7 @@ export class ContractBuilder extends AbstractContractBuilder {
7281
if (isCanonicalName && isCanonicalAddress) {
7382
return true;
7483
}
75-
return this._allowedStakingContracts.some(
84+
return this.getAllowedStakingContracts().some(
7685
(contract) => contract.name === this._contractName && contract.address === this._contractAddress
7786
);
7887
}
@@ -106,7 +115,7 @@ export class ContractBuilder extends AbstractContractBuilder {
106115
throw new InvalidParameterValueError('Invalid address');
107116
}
108117
const isCanonicalAddress = Utils.isValidContractAddress(address, this._coinConfig.network as BitgoStacksNetwork);
109-
const isAllowedAddress = this._allowedStakingContracts.some((contract) => contract.address === address);
118+
const isAllowedAddress = this.getAllowedStakingContracts().some((contract) => contract.address === address);
110119
if (!isCanonicalAddress && !isAllowedAddress) {
111120
throw new InvalidParameterValueError('Invalid contract address');
112121
}

‎modules/sdk-coin-stx/src/lib/pox5Builder.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
FUNCTION_NAME_UNSTAKE,
2727
FUNCTION_NAME_UPDATE_BOND_REGISTRATION,
2828
} from './constants';
29-
import { contractPrincipalCVFromString, standardPrincipalCVFromString } from './utils';
29+
import { contractPrincipalCVFromString, isExpired, standardPrincipalCVFromString } from './utils';
3030

3131
type Integer = bigint | number | string;
3232
type ByteValue = Buffer | Uint8Array | string;
@@ -167,10 +167,14 @@ export class Pox5Builder extends ContractBuilder {
167167
}
168168

169169
public static isValidContractCall(coinConfig: Readonly<CoinConfig>, payload: ContractCallPayload): boolean {
170-
return (
171-
(coinConfig.network as BitgoStacksNetwork).stakingContractAddress === addressToString(payload.contractAddress) &&
172-
payload.contractName.content === CONTRACT_NAME_POX5
170+
const network = coinConfig.network as BitgoStacksNetwork;
171+
const address = addressToString(payload.contractAddress);
172+
const isCanonical = network.stakingContractAddress === address;
173+
// A configured (non-expired) network pair may point pox-5 at a different deployer.
174+
const isConfiguredPair = (network.allowedStakingContracts ?? []).some(
175+
(pair) => pair.name === CONTRACT_NAME_POX5 && pair.address === address && !isExpired(pair.expiresAt)
173176
);
177+
return (isCanonical || isConfiguredPair) && payload.contractName.content === CONTRACT_NAME_POX5;
174178
}
175179

176180
registerForBond(params: Pox5RegisterForBondParams): this {

‎modules/sdk-coin-stx/src/lib/utils.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,24 @@ export function isValidContractAddress(addr: string, network: BitgoStacksNetwork
292292
);
293293
}
294294

295+
/**
296+
* Whether a configured staking-contract allowance has expired.
297+
*
298+
* @param {string} [expiresAt] - ISO-8601 date (e.g. `2027-03-31`). Undefined ⇒ never expires.
299+
* @returns {boolean} - true when the entry is in the past and must no longer be honoured; an
300+
* unparseable expiry fails closed (treated as expired)
301+
*/
302+
export function isExpired(expiresAt?: string): boolean {
303+
if (!expiresAt) {
304+
return false;
305+
}
306+
const expiry = Date.parse(expiresAt);
307+
if (Number.isNaN(expiry)) {
308+
return true;
309+
}
310+
return Date.now() > expiry;
311+
}
312+
295313
/**
296314
* Check if the name is one of valid contract names
297315
*

‎modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts‎

Lines changed: 145 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -171,17 +171,15 @@ describe('Stacks: Contract Builder', function () {
171171
it('an unsigned caller-allowlisted non-canonical pox-5 (dummy) contract call transaction', async () => {
172172
// The caller supplies the staking contract it is configured for — no SDK change needed.
173173
const builder = factoryProd.getContractBuilder();
174-
builder.allowedStakingContracts([
175-
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
176-
]);
174+
builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]);
177175
builder.fee({ fee: '180' });
178176
builder.nonce(0);
179177
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
180-
builder.contractName('pox-5-dummy-v1');
178+
builder.contractName('pox-5-test');
181179
builder.functionName('register-for-bond');
182180
builder.functionArgs([
183181
{ type: 'uint128', val: '210' },
184-
{ type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-dummy-v1' },
182+
{ type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-test' },
185183
{ type: 'uint128', val: '1005000' },
186184
{ type: 'optional' },
187185
{ type: 'optional' },
@@ -191,25 +189,25 @@ describe('Stacks: Contract Builder', function () {
191189

192190
const tx = await builder.build();
193191
const txJson = tx.toJson();
194-
should.deepEqual(txJson.payload.contractName, 'pox-5-dummy-v1');
192+
should.deepEqual(txJson.payload.contractName, 'pox-5-test');
195193
should.deepEqual(txJson.payload.contractAddress, 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
196194
should.deepEqual(txJson.payload.functionName, 'register-for-bond');
197195
});
198196

199197
it('rehydrates an allowlisted non-canonical contract call (sign / explain / fee-rebuild path)', async () => {
200198
// `signTransaction` / `explainTransaction` / the fee re-build all go through `factory.from(hex)`,
201199
// which re-creates the builder — re-parsing must not reject the allowlisted contract.
202-
const allowlist = [{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }];
200+
const allowlist = [{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }];
203201
const builder = factoryProd.getContractBuilder();
204202
builder.allowedStakingContracts(allowlist);
205203
builder.fee({ fee: '180' });
206204
builder.nonce(0);
207205
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
208-
builder.contractName('pox-5-dummy-v1');
206+
builder.contractName('pox-5-test');
209207
builder.functionName('register-for-bond');
210208
builder.functionArgs([
211209
{ type: 'uint128', val: '210' },
212-
{ type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-dummy-v1' },
210+
{ type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-test' },
213211
{ type: 'uint128', val: '1005000' },
214212
{ type: 'optional' },
215213
{ type: 'optional' },
@@ -223,55 +221,47 @@ describe('Stacks: Contract Builder', function () {
223221
reparsed.numberSignatures(1);
224222
const payload = (await reparsed.build()).toJson().payload;
225223
should.deepEqual(payload.contractAddress, 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
226-
should.deepEqual(payload.contractName, 'pox-5-dummy-v1');
224+
should.deepEqual(payload.contractName, 'pox-5-test');
227225

228226
// Without the allowlist the rebuild is rejected — canonical validation is preserved (no relaxation).
229227
(() => factoryProd.from(tx.toBroadcastFormat())).should.throw(ParseTransactionError);
230228
});
231229

232230
it('rejects an allowlisted name followed by a canonical address (order-independent pair check)', function () {
233231
const builder = factoryProd.getContractBuilder();
234-
builder.allowedStakingContracts([
235-
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
236-
]);
237-
builder.contractName('pox-5-dummy-v1'); // allowlisted name first
232+
builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]);
233+
builder.contractName('pox-5-test'); // allowlisted name first
238234
(() => builder.contractAddress('SP000000000000000000002Q6VF78')).should.throw(/not an allowlisted pair/);
239235
});
240236

241237
it('rejects an allowlisted address followed by a canonical name (order-independent pair check)', function () {
242238
const builder = factoryProd.getContractBuilder();
243-
builder.allowedStakingContracts([
244-
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
245-
]);
239+
builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]);
246240
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); // allowlisted address first
247-
(() => builder.contractName('pox-5')).should.throw(/not an allowlisted pair/);
241+
(() => builder.contractName('pox-4')).should.throw(/not an allowlisted pair/);
248242
});
249243

250244
it('rejects duplicate allowlisted contract names', function () {
251245
const builder = factoryProd.getContractBuilder();
252246
(() =>
253247
builder.allowedStakingContracts([
254-
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
255-
{ name: 'pox-5-dummy-v1', address: 'SP000000000000000000002Q6VF78' },
248+
{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
249+
{ name: 'pox-5-test', address: 'SP000000000000000000002Q6VF78' },
256250
])).should.throw(/Duplicate staking contract name/);
257251
});
258252

259253
it('rejects an allowlisted name paired with a different address', function () {
260254
const builder = factoryProd.getContractBuilder();
261-
builder.allowedStakingContracts([
262-
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
263-
]);
255+
builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]);
264256
builder.contractAddress('SP000000000000000000002Q6VF78'); // canonical address, wrong pair
265-
(() => builder.contractName('pox-5-dummy-v1')).should.throw(/not an allowlisted pair/);
257+
(() => builder.contractName('pox-5-test')).should.throw(/not an allowlisted pair/);
266258
});
267259

268260
it('rejects a function that is not in the pox-5 set for an allowlisted contract', function () {
269261
const builder = factoryProd.getContractBuilder();
270-
builder.allowedStakingContracts([
271-
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
272-
]);
262+
builder.allowedStakingContracts([{ name: 'pox-5-test', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]);
273263
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
274-
builder.contractName('pox-5-dummy-v1');
264+
builder.contractName('pox-5-test');
275265
// `stack-stx` is a pox-4 function, not part of the pox-5 ABI the dummy mirrors.
276266
(() => builder.functionName('stack-stx')).should.throw(/is not supported contract function name/);
277267
});
@@ -288,6 +278,133 @@ describe('Stacks: Contract Builder', function () {
288278
);
289279
});
290280

281+
describe('network-configured staking contract pairs (SI-2089)', function () {
282+
// The mainnet Stx network declares this as a pair-scoped, self-expiring pox-5 allowance.
283+
const CONFIGURED_ADDRESS = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY';
284+
const CANONICAL_ADDRESS = 'SP000000000000000000002Q6VF78';
285+
286+
const withPairs = (pairs: ReadonlyArray<{ name: string; address: string; expiresAt?: string }>) =>
287+
({
288+
...coins.get(coinName),
289+
network: { ...coins.get(coinName).network, allowedStakingContracts: pairs },
290+
} as any);
291+
292+
const buildRegisterForBond = (factoryInstance: StxLib.TransactionBuilderFactory) => {
293+
const builder = factoryInstance.getContractBuilder();
294+
builder.fee({ fee: '180' });
295+
builder.nonce(0);
296+
builder.contractAddress(CONFIGURED_ADDRESS);
297+
builder.contractName('pox-5');
298+
builder.functionName('register-for-bond');
299+
builder.functionArgs([
300+
{ type: 'uint128', val: '210' },
301+
{ type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager' },
302+
{ type: 'uint128', val: '1005000' },
303+
{ type: 'optional' },
304+
{ type: 'optional' },
305+
]);
306+
builder.fromPubKey(testData.TX_SENDER.pub);
307+
builder.numberSignatures(1);
308+
return builder;
309+
};
310+
311+
it('accepts pox-5 at the configured network address', async function () {
312+
const builder = buildRegisterForBond(factoryProd);
313+
const tx = await builder.build();
314+
should.deepEqual(tx.toJson().payload.contractAddress, CONFIGURED_ADDRESS);
315+
should.deepEqual(tx.toJson().payload.contractName, 'pox-5');
316+
});
317+
318+
it('parses a configured-address register-for-bond via from(hex) with NO caller allowlist', async function () {
319+
const tx = await buildRegisterForBond(factoryProd).build();
320+
const reparsed = factoryProd.from(tx.toBroadcastFormat());
321+
reparsed.fromPubKey(testData.TX_SENDER.pub);
322+
reparsed.numberSignatures(1);
323+
const payload = (await reparsed.build()).toJson().payload as any;
324+
should.deepEqual(payload.contractAddress, CONFIGURED_ADDRESS);
325+
should.deepEqual(payload.contractName, 'pox-5');
326+
});
327+
328+
it('rejects pox-4 at the configured network address (reviewer regression)', function () {
329+
const builder = factoryProd.getContractBuilder();
330+
builder.contractName('pox-4');
331+
(() => builder.contractAddress(CONFIGURED_ADDRESS)).should.throw(/not an allowlisted pair/);
332+
});
333+
334+
it('rejects send-many-memo at the configured network address', function () {
335+
const builder = factoryProd.getContractBuilder();
336+
builder.contractName('send-many-memo');
337+
(() => builder.contractAddress(CONFIGURED_ADDRESS)).should.throw(/not an allowlisted pair/);
338+
});
339+
340+
it('rejects an unknown address', function () {
341+
const builder = factoryProd.getContractBuilder();
342+
(() => builder.contractAddress('SP2TYC2AVC4GSB01F78H0XSVTH8WRGV95BXFG4BBR')).should.throw(
343+
/Invalid contract address/
344+
);
345+
});
346+
347+
it('still accepts canonical pox-5 and pox-4 at the canonical address', function () {
348+
const pox5 = factoryProd.getContractBuilder();
349+
pox5.contractName('pox-5');
350+
(() => pox5.contractAddress(CANONICAL_ADDRESS)).should.not.throw();
351+
const pox4 = factoryProd.getContractBuilder();
352+
pox4.contractName('pox-4');
353+
(() => pox4.contractAddress(CANONICAL_ADDRESS)).should.not.throw();
354+
});
355+
356+
it('honours the expiry: expired entry rejected, non-expired and absent accepted', function () {
357+
const expired = new StxLib.TransactionBuilderFactory(
358+
withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2020-01-01' }])
359+
);
360+
const expiredBuilder = expired.getContractBuilder();
361+
expiredBuilder.contractName('pox-5');
362+
(() => expiredBuilder.contractAddress(CONFIGURED_ADDRESS)).should.throw(/Invalid contract address/);
363+
364+
const active = new StxLib.TransactionBuilderFactory(
365+
withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }])
366+
);
367+
const activeBuilder = active.getContractBuilder();
368+
activeBuilder.contractName('pox-5');
369+
(() => activeBuilder.contractAddress(CONFIGURED_ADDRESS)).should.not.throw();
370+
371+
const noExpiry = new StxLib.TransactionBuilderFactory(
372+
withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS }])
373+
);
374+
const noExpiryBuilder = noExpiry.getContractBuilder();
375+
noExpiryBuilder.contractName('pox-5');
376+
(() => noExpiryBuilder.contractAddress(CONFIGURED_ADDRESS)).should.not.throw();
377+
});
378+
379+
it('keeps the caller-supplied allowlist as a working override', async function () {
380+
// A caller-only pair (not declared on the network) still works when passed explicitly.
381+
const DUMMY_ADDRESS = 'SP2T758K6T2YRKG9Q0TJ16B6FP5QQREWZSESRS0PY';
382+
const builder = factoryProd.getContractBuilder();
383+
builder.allowedStakingContracts([{ name: 'pox-5-test', address: DUMMY_ADDRESS }]);
384+
builder.fee({ fee: '180' });
385+
builder.nonce(0);
386+
builder.contractAddress(DUMMY_ADDRESS);
387+
builder.contractName('pox-5-test');
388+
builder.functionName('register-for-bond');
389+
builder.functionArgs([
390+
{ type: 'uint128', val: '210' },
391+
{ type: 'principal', val: `${DUMMY_ADDRESS}.signer-manager` },
392+
{ type: 'uint128', val: '1005000' },
393+
{ type: 'optional' },
394+
{ type: 'optional' },
395+
]);
396+
builder.fromPubKey(testData.TX_SENDER.pub);
397+
builder.numberSignatures(1);
398+
const tx = await builder.build();
399+
should.deepEqual(tx.toJson().payload.contractAddress, DUMMY_ADDRESS);
400+
401+
// Without the caller allowlist the same address is not accepted by the network.
402+
(() => factoryProd.getContractBuilder().contractAddress(DUMMY_ADDRESS)).should.throw(
403+
/Invalid contract address/
404+
);
405+
});
406+
});
407+
291408
it('a signed contract call with args', async () => {
292409
const builder = initTxBuilder();
293410
builder.functionArgs([

0 commit comments

Comments
 (0)