Repository navigation
feat(sdk-coin-stx): accept a configured PoX-5 staking contract pair (pair-scoped + expiring) - #10062
Conversation
|
|
3847eaf to
4028ca6
Compare
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
4028ca6 to
71b0880
Compare
|
@claude review |
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
71b0880 to
fa56ae9
Compare
|
Verify finding — addressed for the allowance; the public-keys gap is deliberately out of scope.
const explainedTx = await this.explainTransaction({
txHex: rawTx,
feeInfo: { fee: '' },
coinSpecific: (txPrebuild as { coinSpecific?: ExplainTransactionOptions['coinSpecific'] })?.coinSpecific,
});The test asserts the effect: with the prebuild allowance, verify now gets past parsing and fails only on the pre-existing gap ( We are deliberately not changing the public-keys handling.
So: the allowance is aligned across verify/explain/sign; the keys gap stands as-is and can be raised separately if hot-wallet contract-call verification is ever needed. The register-for-bond flow itself is unaffected — custody uses |
|
Addressed — pushed in
const explainedTx = await this.explainTransaction({
txHex: rawTx,
feeInfo: { fee: '' },
coinSpecific: (txPrebuild as { coinSpecific?: ExplainTransactionOptions['coinSpecific'] })?.coinSpecific,
});Tests cover it both ways:
The public-keys handling is deliberately unchanged — Current state: 1 commit, |
|
@claude review |
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Still open from earlier reviews (1):
- 🔴
modules/sdk-coin-stx/src/stx.ts:299—A compromised or spoofed build service can now get the SDK to sign a staking call to any contract, because the same pay…
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
fa56ae9 to
99f449c
Compare
|
Accepted — the finding is correct, and the PR is reworked to fix it. You're right that taking the allowlist from The address is now pinned in the SDK, not supplied by the transaction. It lives in the network statics and is the only non-canonical pair the SDK will accept: // modules/statics/src/networks.ts — mainnet Stx
allowedStakingContracts = [
{ name: 'pox-5', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY', expiresAt: '2026-11-02' },
];
Scope is deliberately global and time-bounded. The pin sits on the shared Only the register path uses it. wallet-platform resolves the register target only when it is a Tests: pair accepted (incl. If you'd rather have per-deployment scoping than a global time-bounded pin, that requires the vault to hold the allowlist — i.e. a vault change and release — since the signer's only no-change knowledge is its bundled SDK. Happy to take that route if you prefer it. |
99f449c to
f3b98e5
Compare
|
@claude review and share the approval status as comment. |
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Vijay-Jagannathan
left a comment
There was a problem hiding this comment.
Design is sound: the pair-scoped, SDK-pinned allowance fixes the self-certifying problem from the earlier coinSpecific approach, and isValidContractAddress staying address-only is the right call.
One blocking item: the PR says the shipped date can't break the tests, but three tests still read the real mainnet pin and the real clock. They fail from 2026-11-02T00:00Z with no code change. Details inline. Everything else is optional.
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
f3b98e5 to
95df8aa
Compare
|
All three blocking items fixed, plus the shipped-entry gap — pushed in 1. Time bomb in new StxLib.TransactionBuilderFactory(
withPairs([{ name: 'pox-5', address: CONFIGURED_ADDRESS, expiresAt: '2999-01-01' }])
).getContractBuilder();so the assertion stays the pair check ( 2. Time bomb in 3. Shipped-entry gap — closed. Added a test that asserts the shipped mainnet statics entry, so a typo in the pinned address is caught: should.deepEqual(
(mainnet.allowedStakingContracts ?? []).map((c) => ({ name: c.name, address: c.address })),
[{ name: 'pox-5', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }]
);It asserts only the name/address, so it doesn't depend on the 4. Optional — all pox-5 functions admitted. Noted in the statics comment: the pinned pair admits every function 5. Optional — statics comment. Updated to record the removal obligation (with the SI-2089 removal ticket), that the deployer |
Vijay-Jagannathan
left a comment
There was a problem hiding this comment.
Re-review of 95df8aa. Both blocking items from my earlier review are fixed:
- The
pox-4andsend-many-memorejection tests incontractBuilder.tsnow usewithPairs([... expiresAt: '2999-01-01']), so they still assert the pair check after the shipped pin expires. pox5Builder.tsnow buildsactiveConfigandexpiredConfigfrom injected pairs. Only the canonical assertion usescoins.get('stx'), and that doesn't depend on the pin.- A new test in
util.tspins the shipped name and address, so a typo innetworks.tswould fail. It deliberately ignoresexpiresAtand the clock. - The statics comment now documents the all-pox-5-functions scope and the removal obligation.
CI is green, including unit tests on Node 22, 24 and 26. I did not run the tests locally.
One non-blocking nit inline: a comment in networks.ts no longer matches the code.
Context
The Stacks Foundation PoX-5 test contract is deployed with the standard name
pox-5at a non-canonical boot address (SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY). The SDK'sUtils.isValidContractAddressis address-only, so aregister-for-bondtargeting it is rejected on every parse unless the caller supplies the staking allowlist.The offline vault signs custody wallets and re-parses the tx to explain/sign, so it needs to accept the address — but the allowlist must not come from the transaction, or the check would no longer constrain whoever produced it.
Change
Pin the allowance in the network statics (compiled into the SDK), as a pair with an expiry:
modules/statics/src/networks.tsStacksNetwork.allowedStakingContracts?: ReadonlyArray<{ name; address; expiresAt? }>; mainnetStxpins{ name: 'pox-5', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY', expiresAt: '2026-11-02' }modules/sdk-coin-stx/src/lib/utils.tsisExpired();isValidContractAddressstays address-onlymodules/sdk-coin-stx/src/lib/contractBuilder.tsisContractPairValid(andcontractAddress) consult the caller pairs plus the active pinned pairsmodules/sdk-coin-stx/src/lib/pox5Builder.tsisValidContractCallaccepts the canonical address or a matching non-expired pinned pairThe caller-supplied
allowedStakingContractsremains a working override.Why the address is pinned, not taken from the transaction
An earlier revision read the allowlist from
coinSpecificon the exported tx. That was rejected: the allowlist andtxHexthen arrive in the same object, so a compromised build service could point the tx at its own address and supply a matching allowlist — the check became self-certifying.Here the address is compiled into the SDK. A transaction can never supply or redirect it: the SDK only ever accepts the one pinned pair, so a spoofed export cannot make the vault sign a call to any other contract.
Scope, and why it is bounded by time
The pin is on the shared
Stxnetwork, so it applies to every Stx consumer until it expires. That is a deliberate, time-bounded trade: the address is pinned and self-expiring, and it only ever admits the Foundation's test contract — never an arbitrary one.After
2026-11-02the entry is ignored and the SDK reverts to canonical-only, failing closed:Only the register path uses it
The target resolution is register-only (wallet-platform):
So transfers, pox-4
stack-stx/delegate-stx, the canonical pox-5 register, send-many, sBTC withdraw and FT transfers all keep the existing contract and flow. The SDK change only makes the pinned address acceptable — it never redirects anything.Commits
95df8aa13aTests
pox-5at the pinned address → accepted (viaisContractPairValidandPox5Builder.isValidContractCall), includingfactory.from(hex)with no caller allowlistpox-4at the pinned address → rejected;send-many-memoat the pinned address → rejected/Invalid contract address/); future and absent → acceptedpox-5/pox-4→ still acceptedisValidContractAddressremains address-onlyany(realContractCallPayloads where possible, typed casts otherwise)The expiry tests inject their own pairs (
expiresAt: '2999-01-01'/'2020-01-01') rather than reading the shipped date, so they don't break when2026-11-02passes.Related
sdk-coin-stx+statics) — no code change