Skip to content

Commit fa56ae9

Browse files
committed
feat(sdk-coin-stx): read the staking allowance from the tx prebuild
The vault rehydrates a transaction to explain and sign, so a register targeting a non-canonical PoX-5 test contract is rejected unless the allowlist reaches the SDK there. Rather than a shared network allowance (which would apply to every SDK consumer), read the pair the exporting deployment declares on the transaction's coinSpecific. signTransaction falls back to params.txPrebuild.coinSpecific.allowedStakingContracts and explainTransaction to params.coinSpecific.allowedStakingContracts, so the offline vault works unchanged - it already passes the whole exported transaction as the prebuild. The allowance is an explicit, per-deployment declaration, never derived from the transaction. Absent means canonical-only and fails closed. The caller-supplied allowedStakingContracts option still takes precedence. verifyTransaction forwards the prebuild's coinSpecific to its internal explain call too, so verify resolves the allowance the same way explain and sign do. SI-2089 TICKET: SI-2089
1 parent 54f0ff4 commit fa56ae9

5 files changed

Lines changed: 236 additions & 33 deletions

File tree

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,14 @@ export class Stx extends BaseCoin {
120120
return this.verifySbtcWithdrawTransaction(rawTx, txParams.sbtcWithdrawParams);
121121
}
122122

123-
const explainedTx = await this.explainTransaction({ txHex: rawTx, feeInfo: { fee: '' } });
123+
const explainedTx = await this.explainTransaction({
124+
txHex: rawTx,
125+
feeInfo: { fee: '' },
126+
// Resolve the staking allowance the same way explainTransaction/signTransaction do, so a prebuild
127+
// declaring `coinSpecific.allowedStakingContracts` verifies instead of throwing ParseTransactionError.
128+
// `VerifyTransactionOptions.txPrebuild` is the wider sdk-core type, hence the cast.
129+
coinSpecific: (txPrebuild as { coinSpecific?: ExplainTransactionOptions['coinSpecific'] })?.coinSpecific,
130+
});
124131
const recipient = txParams.recipients?.[0];
125132
if (recipient !== undefined && explainedTx) {
126133
const txOutput = explainedTx.outputs[0];
@@ -283,9 +290,13 @@ export class Stx extends BaseCoin {
283290
*/
284291
async signTransaction(params: StxSignTransactionOptions): Promise<SignedTransaction> {
285292
const factory = new StxLib.TransactionBuilderFactory(coins.get(this.getChain()));
286-
const txBuilder = factory.from(params.txPrebuild.txHex, {
287-
allowedStakingContracts: params.allowedStakingContracts,
288-
});
293+
// The staking allowance is an explicit, per-deployment declaration emitted by wallet-platform in
294+
// the exported transaction (`coinSpecific.allowedStakingContracts`). It is deliberately NOT
295+
// derived from the transaction itself (that would defeat the check). When absent, validation is
296+
// canonical-only and fails closed.
297+
const allowedStakingContracts =
298+
params.allowedStakingContracts ?? params.txPrebuild?.coinSpecific?.allowedStakingContracts;
299+
const txBuilder = factory.from(params.txPrebuild.txHex, { allowedStakingContracts });
289300
const prvKeys = params.prv instanceof Array ? params.prv : [params.prv];
290301
prvKeys.forEach((prv) => txBuilder.sign({ key: prv }));
291302
if (params.pubKeys) txBuilder.fromPubKey(params.pubKeys);
@@ -318,7 +329,11 @@ export class Stx extends BaseCoin {
318329
}
319330

320331
const factory = new StxLib.TransactionBuilderFactory(coins.get(this.getChain()));
321-
const txBuilder = factory.from(txHex, { allowedStakingContracts: params.allowedStakingContracts });
332+
// Same rule as signTransaction: the allowance is an explicit, per-deployment declaration carried
333+
// on the transaction's `coinSpecific`, never derived from the transaction. Absent ⇒ canonical-only
334+
// (fail-closed).
335+
const allowedStakingContracts = params.allowedStakingContracts ?? params.coinSpecific?.allowedStakingContracts;
336+
const txBuilder = factory.from(txHex, { allowedStakingContracts });
322337

323338
if (params.publicKeys !== undefined) {
324339
txBuilder.fromPubKey(params.publicKeys);

‎modules/sdk-coin-stx/src/types.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export interface ExplainTransactionOptions {
3434
* explicitly so no shared/instance state is involved. Empty/omitted ⇒ canonical-only.
3535
*/
3636
allowedStakingContracts?: ReadonlyArray<{ name: string; address: string }>;
37+
/**
38+
* Explicit, per-deployment staking allowance emitted by wallet-platform on the exported
39+
* transaction. Used as a fallback when no top-level `allowedStakingContracts` is supplied; it is
40+
* never derived from the transaction itself. Absent ⇒ canonical-only (fail-closed).
41+
*/
42+
coinSpecific?: { allowedStakingContracts?: ReadonlyArray<{ name: string; address: string }> };
3743
}
3844

3945
export interface StxSignTransactionOptions extends SignTransactionOptions {
@@ -48,4 +54,6 @@ export interface StxSignTransactionOptions extends SignTransactionOptions {
4854
export interface TransactionPrebuild extends BaseTransactionPrebuild {
4955
txHex: string;
5056
source: string;
57+
/** @see ExplainTransactionOptions.coinSpecific */
58+
coinSpecific?: { allowedStakingContracts?: ReadonlyArray<{ name: string; address: string }> };
5159
}

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,131 @@ describe('STX:', function () {
222222
assert.doesNotThrow(() => JSON.stringify(explain.contractFunctionArgs));
223223
});
224224

225+
describe('PoX-5 non-canonical staking allowance from the tx prebuild (SI-2089)', () => {
226+
const TEST_ADDRESS = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY';
227+
const testPair = [{ name: 'pox-5', address: TEST_ADDRESS }];
228+
229+
// Build requires the caller allowlist (the builder is canonical-only); the produced hex targets
230+
// the non-canonical test address under the canonical `pox-5` name.
231+
const buildConfiguredRegisterForBond = async () => {
232+
const factory = new StxLib.TransactionBuilderFactory(coins.get(coinNameTest));
233+
const builder = factory.getContractBuilder();
234+
builder.allowedStakingContracts(testPair);
235+
builder.fee({ fee: '180' });
236+
builder.nonce(0);
237+
builder.contractAddress(TEST_ADDRESS);
238+
builder.contractName('pox-5');
239+
builder.functionName('register-for-bond');
240+
builder.functionArgs([
241+
{ type: 'uint128', val: '210' },
242+
{ type: 'principal', val: `${TEST_ADDRESS}.signer-manager` },
243+
{ type: 'uint128', val: '1005000' },
244+
{ type: 'optional' },
245+
{ type: 'optional' },
246+
]);
247+
builder.fromPubKey(resources.TX_SENDER.pub);
248+
builder.numberSignatures(1);
249+
return await builder.build();
250+
};
251+
252+
it('signs a prebuild carrying coinSpecific.allowedStakingContracts with no top-level allowlist', async function () {
253+
const tx = await buildConfiguredRegisterForBond();
254+
const signed = await basecoin.signTransaction({
255+
prv: resources.TX_SENDER.prv,
256+
pubKeys: [resources.TX_SENDER.pub],
257+
txPrebuild: {
258+
txHex: tx.toBroadcastFormat(),
259+
coinSpecific: { allowedStakingContracts: testPair },
260+
},
261+
});
262+
const signedHex = signed.halfSigned?.txHex ?? signed.txHex;
263+
assert.ok(signedHex, 'expected a signed transaction hex');
264+
265+
// The signed hex round-trips through the same prebuild-declared allowance.
266+
const reparsed = new StxLib.TransactionBuilderFactory(coins.get(coinNameTest)).from(signedHex, {
267+
allowedStakingContracts: testPair,
268+
});
269+
const payload = (await reparsed.build()).toJson().payload as any;
270+
payload.contractAddress.should.equal(TEST_ADDRESS);
271+
payload.contractName.should.equal('pox-5');
272+
});
273+
274+
it('rejects the same prebuild when the coinSpecific allowance is absent (fail-closed)', async function () {
275+
const tx = await buildConfiguredRegisterForBond();
276+
await assert.rejects(
277+
basecoin.signTransaction({
278+
prv: resources.TX_SENDER.prv,
279+
pubKeys: [resources.TX_SENDER.pub],
280+
txPrebuild: { txHex: tx.toBroadcastFormat() },
281+
}),
282+
/error parsing the raw transaction/
283+
);
284+
});
285+
286+
it('lets a top-level allowedStakingContracts take precedence over coinSpecific', async function () {
287+
const tx = await buildConfiguredRegisterForBond();
288+
// The top-level pair matches while the coinSpecific pair does not — the top-level must win.
289+
const signed = await basecoin.signTransaction({
290+
prv: resources.TX_SENDER.prv,
291+
pubKeys: [resources.TX_SENDER.pub],
292+
allowedStakingContracts: testPair,
293+
txPrebuild: {
294+
txHex: tx.toBroadcastFormat(),
295+
coinSpecific: {
296+
allowedStakingContracts: [{ name: 'pox-5', address: 'SP2T758K6T2YRKG9Q0TJ16B6FP5QQREWZSESRS0PY' }],
297+
},
298+
},
299+
});
300+
assert.ok(signed.halfSigned?.txHex ?? signed.txHex, 'expected a signed transaction hex');
301+
});
302+
303+
it('explains a prebuild carrying coinSpecific.allowedStakingContracts with no top-level allowlist', async function () {
304+
const tx = await buildConfiguredRegisterForBond();
305+
const explain = await basecoin.explainTransaction({
306+
txHex: tx.toBroadcastFormat(),
307+
publicKeys: [resources.TX_SENDER.pub],
308+
feeInfo: { fee: '' },
309+
coinSpecific: { allowedStakingContracts: testPair },
310+
});
311+
explain.contractAddress.should.equal(TEST_ADDRESS);
312+
explain.contractName.should.equal('pox-5');
313+
});
314+
315+
it('rejects explain when the coinSpecific allowance is absent (fail-closed)', async function () {
316+
const tx = await buildConfiguredRegisterForBond();
317+
await assert.rejects(
318+
basecoin.explainTransaction({ txHex: tx.toBroadcastFormat(), feeInfo: { fee: '' } }),
319+
/error parsing the raw transaction/
320+
);
321+
});
322+
323+
it('verify resolves the prebuild allowance: it gets past parsing and fails only on the pre-existing public-keys gap', async function () {
324+
// `verifyTransaction` forwards `coinSpecific` to its internal explain call, so the non-canonical
325+
// address is now admitted. It then fails on the PRE-EXISTING gap that it never supplies public
326+
// keys to explain (a contract-call build requires them) — unrelated to the allowance, and
327+
// deliberately not changed here.
328+
const tx = await buildConfiguredRegisterForBond();
329+
await assert.rejects(
330+
basecoin.verifyTransaction({
331+
txPrebuild: { txHex: tx.toBroadcastFormat(), coinSpecific: { allowedStakingContracts: testPair } },
332+
txParams: { recipients: [] },
333+
} as any),
334+
/supply at least 1 public key/
335+
);
336+
});
337+
338+
it('verify still fails at parsing when the prebuild allowance is absent (fail-closed)', async function () {
339+
const tx = await buildConfiguredRegisterForBond();
340+
await assert.rejects(
341+
basecoin.verifyTransaction({
342+
txPrebuild: { txHex: tx.toBroadcastFormat() },
343+
txParams: { recipients: [] },
344+
} as any),
345+
/error parsing the raw transaction/
346+
);
347+
});
348+
});
349+
225350
it('should explain a fungible token transfer transaction with memo', async function () {
226351
const explain = await basecoin.explainTransaction({
227352
txHex: testData.txForExplainFungibleTokenTransfer,

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

Lines changed: 63 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,51 @@ describe('Stacks: Contract Builder', function () {
288278
);
289279
});
290280

281+
describe('canonical-only validation and the caller-supplied allowlist (SI-2089)', function () {
282+
// The PoX-5 test contract lives at a non-canonical address; the SDK has no network-level
283+
// allowance, so it is only accepted when the caller supplies it explicitly.
284+
const TEST_ADDRESS = 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY';
285+
const CANONICAL_ADDRESS = 'SP000000000000000000002Q6VF78';
286+
287+
it('still accepts canonical pox-5 and pox-4 at the canonical address', function () {
288+
const pox5 = factoryProd.getContractBuilder();
289+
pox5.contractName('pox-5');
290+
(() => pox5.contractAddress(CANONICAL_ADDRESS)).should.not.throw();
291+
const pox4 = factoryProd.getContractBuilder();
292+
pox4.contractName('pox-4');
293+
(() => pox4.contractAddress(CANONICAL_ADDRESS)).should.not.throw();
294+
});
295+
296+
it('rejects the test address without a caller allowlist (fail-closed)', function () {
297+
const builder = factoryProd.getContractBuilder();
298+
builder.contractName('pox-5');
299+
(() => builder.contractAddress(TEST_ADDRESS)).should.throw(/Invalid contract address/);
300+
});
301+
302+
it('rejects pox-4 and send-many-memo at the test address (no network allowance)', function () {
303+
const pox4 = factoryProd.getContractBuilder();
304+
pox4.contractName('pox-4');
305+
(() => pox4.contractAddress(TEST_ADDRESS)).should.throw(/Invalid contract address/);
306+
const sendMany = factoryProd.getContractBuilder();
307+
sendMany.contractName('send-many-memo');
308+
(() => sendMany.contractAddress(TEST_ADDRESS)).should.throw(/Invalid contract address/);
309+
});
310+
311+
it('scopes a caller allowlist to the exact (name, address) pair', function () {
312+
const builder = factoryProd.getContractBuilder();
313+
builder.allowedStakingContracts([{ name: 'pox-5', address: TEST_ADDRESS }]);
314+
builder.contractName('pox-4');
315+
(() => builder.contractAddress(TEST_ADDRESS)).should.throw(/not an allowlisted pair/);
316+
});
317+
318+
it('rejects an unknown address', function () {
319+
const builder = factoryProd.getContractBuilder();
320+
(() => builder.contractAddress('SP2TYC2AVC4GSB01F78H0XSVTH8WRGV95BXFG4BBR')).should.throw(
321+
/Invalid contract address/
322+
);
323+
});
324+
});
325+
291326
it('a signed contract call with args', async () => {
292327
const builder = initTxBuilder();
293328
builder.functionArgs([

0 commit comments

Comments
 (0)