From 69cbffae70468c443631ef26015caced2d631a37 Mon Sep 17 00:00:00 2001 From: Doddanna17 Date: Wed, 14 Oct 2026 16:55:02 +0530 Subject: [PATCH] fix(sdk-coin-stx): stringify Response Clarity args to JSON-safe values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stringifyCv normalised only Int/UInt/OptionalSome/Tuple/List, so the ResponseOk/ResponseErr wrapping register-for-bond's lockup reached toJson() with native BigInt values. JSON.stringify then threw, and the policy service, which receives this via fast-safe-stringify, rejected the whole payload and returned 400 — blocking the PoX-5 register leg. Handle ResponseOk/ResponseErr by recursing into the wrapped value, as is already done for OptionalSome. Tests pin the real staging register-for-bond args (btc ResponseOk / sbtc ResponseErr): the built tx toJson() and explainTransaction().contractFunctionArgs are JSON-serialisable, and the fee re-build stays byte-identical. STX-only; no change for other txs. Ticket: SI-1922 --- modules/sdk-coin-stx/src/lib/utils.ts | 6 +++ .../test/unit/registerForBondRealArgs.ts | 18 ++++++++ modules/sdk-coin-stx/test/unit/stx.ts | 28 +++++++++++- .../unit/transactionBuilder/pox5Builder.ts | 43 ++++++++++++++++++- modules/sdk-coin-stx/test/unit/util.ts | 28 +++++++++++- 5 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 modules/sdk-coin-stx/test/unit/registerForBondRealArgs.ts diff --git a/modules/sdk-coin-stx/src/lib/utils.ts b/modules/sdk-coin-stx/src/lib/utils.ts index 1d723e12da4..c29ed79ac3a 100644 --- a/modules/sdk-coin-stx/src/lib/utils.ts +++ b/modules/sdk-coin-stx/src/lib/utils.ts @@ -473,6 +473,12 @@ export function stringifyCv(cv: ClarityValue): any { return { type: cv.type, value: cv.value.toString() }; case ClarityType.OptionalSome: return { type: cv.type, value: stringifyCv(cv.value) }; + // ResponseOk/ResponseErr wrap a ClarityValue (e.g. register-for-bond's lockup tuple/list). + // Without these cases the inner ints reach `toJson()` as native BigInt, which JSON.stringify + // rejects — making the payload unserialisable for consumers such as the policy service (SI-1922). + case ClarityType.ResponseOk: + case ClarityType.ResponseErr: + return { type: cv.type, value: stringifyCv(cv.value) }; case ClarityType.Tuple: return { type: cv.type, diff --git a/modules/sdk-coin-stx/test/unit/registerForBondRealArgs.ts b/modules/sdk-coin-stx/test/unit/registerForBondRealArgs.ts new file mode 100644 index 00000000000..aab1a3a52ee --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/registerForBondRealArgs.ts @@ -0,0 +1,18 @@ +// Real `register-for-bond` Clarity args captured from staking-service on staging (SI-1922). +// +// Five hex-encoded Clarity values, in contract order: +// 1. bond-index (uint) +// 2. signer-manager (contract principal) +// 3. amount-ustx (uint) +// 4. btc-lockup — ResponseOk(tuple { outputs (list …), staker-unlock-bytes }) +// 5. signer-calldata — OptionalSome(buff) +// +// Arg 4 previously leaked its nested uints as native BigInt through `stringifyCv`, making +// `toJson()` (and `explainTransaction().contractFunctionArgs`) non-JSON-serialisable. +export const pox5RegisterForBondArgsHex: string[] = [ + '0x0100000000000000000000000000000156', + '0x061ad540a8a654c4c0f54f910212ff3b119cb2257bb80e7369676e65722d6d616e61676572', + '0x0100000000000000000000000005f5e100', + '0x070c00000002076f7574707574730b000000010c0000000906616d6f756e740100000000000000000000000005f5e1000668656164657202000000500000002097197b45436447b05fce01087c7e00d647ccfe96d709217f17fa1ab2000000000559689a4b0fc9383dc22fec3d477fa32f11b8efc7bd07d1dc3b0938a9197617134ecf6aebdd001db76b228706686569676874010000000000000000000000000000366c0b6c6561662d6861736865730b000000020200000020ba24cab9d1cca4f2d36603adbb0bd277c2396c6f6949acb4775dbf7232cd389f0200000020fdb16797c86264f28c53a7022ac9511838ab018b5b362030e4633085f35b1a220c6f75747075742d696e646578010000000000000000000000000000000002747802000003d5010000000001036d2667e568a7e45e36ea5f020d44e013255a100495f8e5fc0e6dcdb952864a7c0000000000fdffffff5e1bb42e7ea184d6c2df73cd12f4bbde8bd4dc26bff724d6f5ff376a9c6155ad0000000000fdffffff13b7a0017d2ed5f512871529655abdb29fbb6bbaaf4b0fda8f3128a0231812e80000000000fdffffff0200e1f50500000000220020b429ff60e345066883a1ec05bdeab240bce4788194b90763e6c8bb4ea2f8a54230ddfa0200000000220020a606117c1e4c37dffb31ec1ae28d81b192035e9f33b01c6c14c99d886ff4119b04004730440220134758278602aafe39266e236df96d34f91efdb1d5759a2477cc6eb5d84e0b7a02200916b7017bb4fa7806366b64a46ed06e5088bf2782852c77e69c7e76516ace5101483045022100dca9449c15181ba5cb99a4732cc00fb556ddd67f7aab6d1d01da5827f6d49ae8022007686588d3fb2978172989f129af3fa49451cf78336284f890b30bfa0aa5ba6f016952210330e26147bd8e7caff599299593b7a775dd71f651dfec452df745601ad37856da21031ac72b388de801b5bdc741436b7129dae65a75c4d2bf719976e9febc7e150177210253b816cf66a086bc49a2b23994e6d8672100f29f0e763814cb76006c12958c1053ae0400483045022100ef7a5fd52a85a90c6855ec3ca1fe84b5e1055aed5cc54af82cac2482eda39aca02207f1495943f8d3c9d451862c5d5d995abdf58f1ea087e2387bd6386fc3b4419e801483045022100aefc23feab383e12a88874c4171045b2f7e1b75b48cd382d6daf58b567cfe7d202200a6aebb83924acbff2add02aefd2cfc567b2b0a2fb30a7c45bf7265c2a0e8229016952210330e26147bd8e7caff599299593b7a775dd71f651dfec452df745601ad37856da21031ac72b388de801b5bdc741436b7129dae65a75c4d2bf719976e9febc7e150177210253b816cf66a086bc49a2b23994e6d8672100f29f0e763814cb76006c12958c1053ae040047304402200ac37c752d55c17ac01256893fc20d752b059926f887931912d9f59f083e62e302206c8bd733fd2d9175d66833009534d5cbc36b5cfe4641be9396318f5c17fd35fd01483045022100d0ca25c6c8f0632684ba8edcafa50785315204567611b509e96e4b11981aa11a02207ba500a6db5bb2a1437a8fd8c7871d99154010d293d8116f9c7799346701839f016952210330e26147bd8e7caff599299593b7a775dd71f651dfec452df745601ad37856da21031ac72b388de801b5bdc741436b7129dae65a75c4d2bf719976e9febc7e150177210253b816cf66a086bc49a2b23994e6d8672100f29f0e763814cb76006c12958c1053ae000000000874782d636f756e7401000000000000000000000000000000040874782d696e646578010000000000000000000000000000000112756e6c6f636b2d6275726e2d686569676874010000000000000000000000000000376e137374616b65722d756e6c6f636b2d6279746573020000006952210379bbe125f6554f3fb835ada018017c692d6a64a1bd40c8b6ccd129267e0b994d21028a64e3ea928cf8abe941a29ecbe21956bb08ffaea9c26f4af74b069fa449b8082102b0a5c91db10c5bb2b3e7f8c1924c33333769e8cf34ddbf9ca6872796bd614e5e53ae', + '0x0a02000000690c00000002076d61782d66656501000000000000000000000000000f424008706f782d616464720c0000000209686173686279746573020000002010af3de7d61f26b4fdf06276f7fde3c7298e2407c3b05ea1d94b687d6fcc9c8d0776657273696f6e020000000105', +]; diff --git a/modules/sdk-coin-stx/test/unit/stx.ts b/modules/sdk-coin-stx/test/unit/stx.ts index 982233555e6..96545663537 100644 --- a/modules/sdk-coin-stx/test/unit/stx.ts +++ b/modules/sdk-coin-stx/test/unit/stx.ts @@ -5,10 +5,11 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { ErrorNoInputToRecover, RecoveryProviderError, Wallet } from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; -import { cvToString, pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; +import { cvToString, deserializeCV, pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; import * as testData from '../fixtures'; import * as resources from './resources'; +import { pox5RegisterForBondArgsHex } from './registerForBondRealArgs'; import { Stx, StxLib, Tstx } from '../../src'; import { RecoveryInfo, RecoveryOptions, RecoveryTransaction } from '../../src/lib/iface'; @@ -196,6 +197,31 @@ describe('STX:', function () { explain.contractFunctionArgs[0].value.toString().should.equal(testData.txExplainedContract.functionArgs[0].value); }); + it('explains a register-for-bond transaction with JSON-serialisable contractFunctionArgs (SI-1922)', async function () { + const factory = new StxLib.TransactionBuilderFactory(coins.get(coinNameTest)); + const functionArgs = pox5RegisterForBondArgsHex.map((hex) => + deserializeCV(Buffer.from(hex.replace(/^0x/, ''), 'hex')) + ); + const builder = factory.getPox5Builder(); + builder.fee({ fee: '18810' }); + builder.nonce(0); + builder.fromPubKey(resources.TX_SENDER.pub); + builder.numberSignatures(1); + builder.functionName('register-for-bond'); + builder.functionArgs(functionArgs); + const tx = await builder.build(); + + const explain = await basecoin.explainTransaction({ + txHex: tx.toBroadcastFormat(), + publicKeys: [resources.TX_SENDER.pub], + feeInfo: { fee: '' }, + }); + explain.contractName.should.equal('pox-5'); + explain.contractFunction.should.equal('register-for-bond'); + // the second consumer of these args (explainTransaction) must also be JSON-serialisable + assert.doesNotThrow(() => JSON.stringify(explain.contractFunctionArgs)); + }); + it('should explain a fungible token transfer transaction with memo', async function () { const explain = await basecoin.explainTransaction({ txHex: testData.txForExplainFungibleTokenTransfer, diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts index b6814b08017..67ddba11bae 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -1,11 +1,19 @@ import assert from 'assert'; -import { ClarityType, ContractCallPayload, createAddress, cvToString, cvToValue } from '@stacks/transactions'; +import { + ClarityType, + ContractCallPayload, + createAddress, + cvToString, + cvToValue, + deserializeCV, +} from '@stacks/transactions'; import { coins } from '@bitgo/statics'; import should from 'should'; import { BaseTransaction } from '@bitgo/sdk-core'; import { StxLib } from '../../../src'; import * as testData from '../resources'; +import { pox5RegisterForBondArgsHex } from '../registerForBondRealArgs'; describe('Stacks: PoX-5 Builder', function () { const factory = new StxLib.TransactionBuilderFactory(coins.get('tstx')); @@ -69,12 +77,44 @@ describe('Stacks: PoX-5 Builder', function () { should.equal(payload.functionArgs[3].type, ClarityType.ResponseOk); should.equal(payload.functionArgs[4].type, ClarityType.OptionalSome); + // SI-1922: the register args must be JSON-serialisable (no BigInt leaks) — this object is sent + // to the policy service, where fast-safe-stringify otherwise replaces the whole body. + should.doesNotThrow(() => JSON.stringify(tx.toJson())); + should.equal(typeof payload.functionArgs[3].value.data.outputs.list[0].data.amount.value, 'string'); + const rebuiltBuilder = factory.from(tx.toBroadcastFormat()); rebuiltBuilder.fromPubKey(testData.TX_SENDER.pub); const rebuilt = await rebuiltBuilder.build(); should.equal(rebuilt.toBroadcastFormat(), tx.toBroadcastFormat()); }); + it('pins the real staging register-for-bond args and keeps them JSON-serialisable (SI-1922)', async () => { + const functionArgs = pox5RegisterForBondArgsHex.map((hex) => + deserializeCV(Buffer.from(hex.replace(/^0x/, ''), 'hex')) + ); + const builder = configure(factory.getPox5Builder()); + builder.functionName('register-for-bond'); + builder.functionArgs(functionArgs); + const tx = await builder.build(); + const payload = tx.toJson().payload as any; + + should.equal(payload.functionArgs.length, 5); + should.equal(payload.functionArgs[0].type, ClarityType.UInt); + should.equal(payload.functionArgs[1].type, ClarityType.PrincipalContract); + should.equal(payload.functionArgs[3].type, ClarityType.ResponseOk); + should.equal(payload.functionArgs[4].type, ClarityType.OptionalSome); + + // the btc-lockup (arg 4) nested uints must be stringified — no BigInt leaks into the payload + should.doesNotThrow(() => JSON.stringify(tx.toJson())); + should.equal(typeof payload.functionArgs[3].value.data.outputs.list[0].data.amount.value, 'string'); + should.equal(typeof payload.functionArgs[3].value.data.outputs.list[0].data['unlock-burn-height'].value, 'string'); + + // the fee re-build (factory.from → initBuilder) must stay byte-identical + const rebuilt = factory.from(tx.toBroadcastFormat()); + rebuilt.fromPubKey(testData.TX_SENDER.pub); + should.equal((await rebuilt.build()).toBroadcastFormat(), tx.toBroadcastFormat()); + }); + it('validates the PoX-5 contract address during factory routing', async () => { const builder = configure(factory.getPox5Builder()); builder.registerForBond({ @@ -108,6 +148,7 @@ describe('Stacks: PoX-5 Builder', function () { const payload = tx.toJson().payload as any; should.equal(payload.functionArgs[3].type, ClarityType.ResponseErr); should.equal(cvToValue(payload.functionArgs[3].value).toString(), '10000'); + should.doesNotThrow(() => JSON.stringify(tx.toJson())); }); it('builds validator update, early exit, and reward calls', async () => { diff --git a/modules/sdk-coin-stx/test/unit/util.ts b/modules/sdk-coin-stx/test/unit/util.ts index f26fdff1eaa..70fccc22542 100644 --- a/modules/sdk-coin-stx/test/unit/util.ts +++ b/modules/sdk-coin-stx/test/unit/util.ts @@ -1,6 +1,16 @@ import assert from 'assert'; import should from 'should'; -import { AddressVersion, ClarityType, IntCV, NoneCV, SomeCV, UIntCV, ListCV } from '@stacks/transactions'; +import { + AddressVersion, + ClarityType, + IntCV, + NoneCV, + ResponseErrorCV, + ResponseOkCV, + SomeCV, + UIntCV, + ListCV, +} from '@stacks/transactions'; import BigNum from 'bn.js'; import * as testData from './resources'; import { StxLib } from '../../src'; @@ -305,6 +315,22 @@ describe('Stx util library', function () { const input: ListCV = { type: ClarityType.List, list: [] }; Utils.stringifyCv(input).should.deepEqual({ type: 11, list: [] }); }); + + it('ResponseOk type with uint value', function () { + const input: ResponseOkCV = { + type: ClarityType.ResponseOk, + value: { type: ClarityType.UInt, value: BigInt('100000') }, + }; + Utils.stringifyCv(input).should.deepEqual({ type: 7, value: { type: 1, value: '100000' } }); + }); + + it('ResponseErr type with uint value', function () { + const input: ResponseErrorCV = { + type: ClarityType.ResponseErr, + value: { type: ClarityType.UInt, value: BigInt('10000') }, + }; + Utils.stringifyCv(input).should.deepEqual({ type: 8, value: { type: 1, value: '10000' } }); + }); }); describe('getAddressVersion', function () {