diff --git a/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts b/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts index 86067227d1c..922ba13f941 100644 --- a/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts @@ -42,12 +42,43 @@ export abstract class AbstractContractBuilder extends TransactionBuilder { this._contractName = txData.payload.contractName; this._functionName = txData.payload.functionName; this._functionArgs = txData.payload.functionArgs; + // Restore post-conditions from the deserialized tx — `toJson()` does not carry them, so + // without this a re-build (e.g. the fee re-build via `factory.from(hex)`) would drop them and + // a `Deny`-mode contract call would abort on-chain (SIP-005 post-condition check). + if (tx.stxTransaction) { + this._postConditionMode = tx.stxTransaction.postConditionMode; + this._postConditions = tx.stxTransaction.postConditions.values as PostCondition[]; + } super.initBuilder(tx); } else { throw new BuildTransactionError('Transaction should be contract call'); } } + /** + * Set the post-condition mode (`Allow` / `Deny`) for this contract call. + * + * @obligation callers building a contract call that moves assets MUST set a mode AND matching + * post-conditions, or leave this unset and let the caller attach explicit post-conditions — + * consequence: an unset mode defaults to `Deny` in `@stacks/transactions`, so any asset movement + * not covered by a post-condition aborts the tx on-chain + * @returns {AbstractContractBuilder} this builder + */ + postConditionMode(mode: PostConditionMode): this { + this._postConditionMode = mode; + return this; + } + + /** + * Set the post-conditions for this contract call. + * + * @returns {AbstractContractBuilder} this builder + */ + postConditions(postConditions: PostCondition[]): this { + this._postConditions = postConditions; + return this; + } + /** @inheritdoc */ protected async buildImplementation(): Promise { this._options = this.buildContractCallOptions(); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts index 67ddba11bae..8cda4695ce7 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -6,6 +6,9 @@ import { cvToString, cvToValue, deserializeCV, + FungibleConditionCode, + makeStandardSTXPostCondition, + PostConditionMode, } from '@stacks/transactions'; import { coins } from '@bitgo/statics'; import should from 'should'; @@ -115,6 +118,34 @@ describe('Stacks: PoX-5 Builder', function () { should.equal((await rebuilt.build()).toBroadcastFormat(), tx.toBroadcastFormat()); }); + it('carries post-condition mode/conditions and preserves them across the fee re-build (SI-1936)', async () => { + const functionArgs = pox5RegisterForBondArgsHex.map((hex) => + deserializeCV(Buffer.from(hex.replace(/^0x/, ''), 'hex')) + ); + const postCondition = makeStandardSTXPostCondition( + testData.TX_SENDER.address, + FungibleConditionCode.LessEqual, + 100000000 + ); + const builder = configure(factory.getPox5Builder()); + builder.functionName('register-for-bond'); + builder.functionArgs(functionArgs); + builder.postConditionMode(PostConditionMode.Deny); + builder.postConditions([postCondition]); + const tx = (await builder.build()) as StxLib.Transaction; + + should.equal(tx.stxTransaction.postConditionMode, PostConditionMode.Deny); + should.equal(tx.stxTransaction.postConditions.values.length, 1); + + // `buildTransactionWithFee` re-builds via `factory.from(hex)`; the post-conditions must survive + // (initBuilder restores them from the deserialized tx). + const rebuilt = factory.from(tx.toBroadcastFormat()); + rebuilt.fromPubKey(testData.TX_SENDER.pub); + const rebuiltTx = (await rebuilt.build()) as StxLib.Transaction; + should.equal(rebuiltTx.stxTransaction.postConditionMode, PostConditionMode.Deny); + should.equal(rebuiltTx.stxTransaction.postConditions.values.length, 1); + }); + it('validates the PoX-5 contract address during factory routing', async () => { const builder = configure(factory.getPox5Builder()); builder.registerForBond({