diff --git a/.changeset/tidy-executions-keep.md b/.changeset/tidy-executions-keep.md new file mode 100644 index 00000000..f4399eca --- /dev/null +++ b/.changeset/tidy-executions-keep.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/mock-chain": patch +--- + +Fix `MockChain.execute()` mutating the caller's transaction object: outputs were distributed to parties by splicing the transaction's own `outputs` array, so a transaction passed as an EIP-12 object lost every output that matched a party (typically all of them). Outputs are now distributed over a copy, leaving the caller's object unchanged. diff --git a/packages/mock-chain/src/mockChain.spec.ts b/packages/mock-chain/src/mockChain.spec.ts index 63ab9d45..3672985b 100644 --- a/packages/mock-chain/src/mockChain.spec.ts +++ b/packages/mock-chain/src/mockChain.spec.ts @@ -296,6 +296,52 @@ describe("Contract execution and chain mocking", () => { expect(() => chain.execute(unsignedTransaction)).not.to.throw(); }); + it("Should not mutate the caller's transaction object on execution", () => { + const chain = new MockChain(); + + const bob = chain.newParty().withBalance({ + nanoergs: 1000000000n, + tokens: [{ tokenId: SIGUSD_TOKEN_ID, amount: 1000n }] + }); + const alice = chain.newParty(); + + const unsignedTransaction = new TransactionBuilder(38479) + .from(bob.utxos.toArray()) + .to( + new OutputBuilder(SAFE_MIN_BOX_VALUE, alice.address).addTokens({ + tokenId: SIGUSD_TOKEN_ID, + amount: 100n + }) + ) + .sendChangeTo(bob.address) + .payMinFee() + .build() + .toEIP12Object(); + + const outputsBefore = [...unsignedTransaction.outputs]; + const inputsBefore = [...unsignedTransaction.inputs]; + + expect(chain.execute(unsignedTransaction)).to.be.true; + + // the caller's object must be left exactly as it was passed in + expect(unsignedTransaction.outputs).to.have.length(outputsBefore.length); + expect(unsignedTransaction.outputs).to.deep.equal(outputsBefore); + for (let i = 0; i < outputsBefore.length; i++) { + expect(unsignedTransaction.outputs[i]).to.be.equal(outputsBefore[i]); + } + expect(unsignedTransaction.inputs).to.deep.equal(inputsBefore); + + // outputs must still be delivered to the parties + expect(alice.balance).to.be.deep.equal({ + nanoergs: SAFE_MIN_BOX_VALUE, + tokens: [{ tokenId: SIGUSD_TOKEN_ID, amount: 100n }] + }); + expect(bob.balance).to.be.deep.equal({ + nanoergs: 1000000000n - RECOMMENDED_MIN_FEE_VALUE - SAFE_MIN_BOX_VALUE, + tokens: [{ tokenId: SIGUSD_TOKEN_ID, amount: 900n }] + }); + }); + it("Should execute burning transaction", () => { const chain = new MockChain(); diff --git a/packages/mock-chain/src/mockChain.ts b/packages/mock-chain/src/mockChain.ts index 30d65220..3fa5dc7c 100644 --- a/packages/mock-chain/src/mockChain.ts +++ b/packages/mock-chain/src/mockChain.ts @@ -199,7 +199,10 @@ export class MockChain { this.#pushMetadata(txObject); - const { inputs, outputs } = txObject; + const { inputs } = txObject; + // distribute outputs over a copy: splicing the transaction's own array + // would mutate the caller's transaction object + const outputs = [...txObject.outputs]; for (const party of this.#parties) { for (let i = inputs.length - 1; i >= 0; i--) { if (party.utxos.exists(inputs[i].boxId)) {