From 504974e46d22515456110608672b0c0f6b4352b8 Mon Sep 17 00:00:00 2001 From: arobsn <87387688+arobsn@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:07:07 -0300 Subject: [PATCH 1/6] fix `utxoDiff` --- .changeset/flat-swans-cross.md | 5 ++++ packages/common/src/utils/utxo.spec.ts | 32 ++++++++++++++++++++++++++ packages/common/src/utils/utxo.ts | 25 ++++++++++++-------- 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 .changeset/flat-swans-cross.md diff --git a/.changeset/flat-swans-cross.md b/.changeset/flat-swans-cross.md new file mode 100644 index 00000000..7e46fccd --- /dev/null +++ b/.changeset/flat-swans-cross.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/common": patch +--- + +Fix `utxoDiff` miscalculation when tokens are present in subtrahend but not in minuend diff --git a/packages/common/src/utils/utxo.spec.ts b/packages/common/src/utils/utxo.spec.ts index 6dd42885..e11c1ad3 100644 --- a/packages/common/src/utils/utxo.spec.ts +++ b/packages/common/src/utils/utxo.spec.ts @@ -125,6 +125,38 @@ describe("utxoDiff()", () => { }); }); + it("Should calculate the difference between two summaries and take subtrahend tokens, that are not present in minuend, into consideration", () => { + const sumA: BoxSummary = { + nanoErgs: 100n, + tokens: [ + { tokenId: "token_id_1", amount: 5n }, + { tokenId: "token_id_2", amount: 875n }, + { tokenId: "token_id_3", amount: 100n }, + { tokenId: "token_id_4", amount: 200n } + ] + }; + + const sumB: BoxSummary = { + nanoErgs: 10n, + tokens: [ + { tokenId: "token_id_1", amount: 2n }, + { tokenId: "token_id_2", amount: 880n }, + { tokenId: "token_id_3", amount: 100n }, + { tokenId: "token_id_5", amount: 200n } // not present in sumA + ] + }; + + expect(utxoDiff(sumA, sumB)).toEqual({ + nanoErgs: 90n, + tokens: [ + { tokenId: "token_id_1", amount: 3n }, + { tokenId: "token_id_2", amount: -5n }, + { tokenId: "token_id_4", amount: 200n }, + { tokenId: "token_id_5", amount: -200n } + ] + }); + }); + it("Should calculate the difference between two box arrays", () => { const boxes1 = regularBoxes; const boxes2 = regularBoxes.slice(1); diff --git a/packages/common/src/utils/utxo.ts b/packages/common/src/utils/utxo.ts index 6c58d80d..0e636a31 100644 --- a/packages/common/src/utils/utxo.ts +++ b/packages/common/src/utils/utxo.ts @@ -81,25 +81,30 @@ export function utxoDiff( minuend: BoxSummary | Box[], subtrahend: BoxSummary | Box[] ): BoxSummary { - if (Array.isArray(minuend)) { - minuend = utxoSum(minuend); - } - - if (Array.isArray(subtrahend)) { - subtrahend = utxoSum(subtrahend); - } + if (Array.isArray(minuend)) minuend = utxoSum(minuend); + if (Array.isArray(subtrahend)) subtrahend = utxoSum(subtrahend); const tokens: TokenAmount[] = []; const nanoErgs = minuend.nanoErgs - subtrahend.nanoErgs; + const subtrahendTokens = new Map(subtrahend.tokens.map((t) => [t.tokenId, t.amount])); for (const token of minuend.tokens) { - const balance = - token.amount - - (subtrahend.tokens.find((t) => t.tokenId === token.tokenId)?.amount || _0n); + const subtrahendAmount = subtrahendTokens.get(token.tokenId) || _0n; + const balance = token.amount - (subtrahendAmount || _0n); if (balance !== _0n) { tokens.push({ tokenId: token.tokenId, amount: balance }); } + + if (subtrahendAmount > _0n) { + subtrahendTokens.delete(token.tokenId); + } + } + + if (subtrahendTokens.size > 0) { + for (const [tokenId, amount] of subtrahendTokens) { + tokens.push({ tokenId, amount: -amount }); + } } return { nanoErgs, tokens }; From c332bb321011a5a7115893b811dbd030d082fb07 Mon Sep 17 00:00:00 2001 From: arobsn <87387688+arobsn@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:22:03 -0300 Subject: [PATCH 2/6] fix manual minting --- packages/common/src/utils/utxo.ts | 5 ++-- .../core/src/builder/selector/boxSelector.ts | 8 +++++- .../core/src/builder/transactionBuilder.ts | 25 +++++++++---------- .../src/models/ergoUnsignedTransaction.ts | 7 +++++- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/common/src/utils/utxo.ts b/packages/common/src/utils/utxo.ts index 0e636a31..36789ce1 100644 --- a/packages/common/src/utils/utxo.ts +++ b/packages/common/src/utils/utxo.ts @@ -79,7 +79,8 @@ export function utxoSum(boxes: readonly BoxAmounts[], tokenId?: TokenId) { */ export function utxoDiff( minuend: BoxSummary | Box[], - subtrahend: BoxSummary | Box[] + subtrahend: BoxSummary | Box[], + ignoreSubtrahendLeftoverTokens = false ): BoxSummary { if (Array.isArray(minuend)) minuend = utxoSum(minuend); if (Array.isArray(subtrahend)) subtrahend = utxoSum(subtrahend); @@ -101,7 +102,7 @@ export function utxoDiff( } } - if (subtrahendTokens.size > 0) { + if (!ignoreSubtrahendLeftoverTokens && subtrahendTokens.size > 0) { for (const [tokenId, amount] of subtrahendTokens) { tokens.push({ tokenId, amount: -amount }); } diff --git a/packages/core/src/builder/selector/boxSelector.ts b/packages/core/src/builder/selector/boxSelector.ts index 801f557e..5b619c31 100644 --- a/packages/core/src/builder/selector/boxSelector.ts +++ b/packages/core/src/builder/selector/boxSelector.ts @@ -65,7 +65,12 @@ export class BoxSelector> { let selected: Box[] = []; const predicate = this._ensureFilterPredicate; - const inclusion = this._ensureInclusionBoxIds; + const inclusion = new Set(this._ensureInclusionBoxIds); + + // if the target has a token that is being minted, then the first input should be included + if (target.tokens?.some((x) => x.tokenId === unselected[0].boxId)) { + inclusion.add(unselected[0].boxId); + } if (predicate) { if (inclusion) { @@ -144,6 +149,7 @@ export class BoxSelector> { const totalSelected = utxoSum(inputs, tokenTarget.tokenId); if (tokenTarget.amount && tokenTarget.amount > totalSelected) { if (tokenTarget.tokenId === first(inputs).boxId) { + // if the token is the same as the first input, then it is being minted continue; } diff --git a/packages/core/src/builder/transactionBuilder.ts b/packages/core/src/builder/transactionBuilder.ts index a9fc47c1..d8a0e581 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -273,6 +273,14 @@ export class TransactionBuilder { let inputs = selector.select(target); if (isDefined(this._changeAddress)) { + const manualMinting = target.tokens.some((x) => x.tokenId === inputs[0].boxId) + ? inputs[0].boxId + : undefined; + + if (manualMinting) { + target.tokens = target.tokens.filter((x) => x.tokenId !== manualMinting); + } + let change = utxoDiff(utxoSum(inputs), target); const changeBoxes: OutputBuilder[] = []; @@ -331,9 +339,7 @@ export class TransactionBuilder { } for (const input of inputs) { - if (!input.isValid()) { - throw new InvalidInput(input.boxId); - } + if (!input.isValid()) throw new InvalidInput(input.boxId); } const unsignedTransaction = new ErgoUnsignedTransaction( @@ -352,10 +358,7 @@ export class TransactionBuilder { } if (some(burning.tokens) && some(this._burning)) { - burning = utxoDiff(burning, { - nanoErgs: _0n, - tokens: this._burning.toArray() - }); + burning = utxoDiff(burning, { nanoErgs: _0n, tokens: this._burning.toArray() }); } if (!this._settings.canBurnTokens && some(burning.tokens)) { @@ -402,15 +405,11 @@ export class TransactionBuilder { } private _getMintingTokenId(): string | undefined { - let tokenId = undefined; for (const output of this._outputs) { - if (output.minting) { - tokenId = output.minting.tokenId; - break; - } + if (output.minting) return output.minting.tokenId; } - return tokenId; + return; } } diff --git a/packages/core/src/models/ergoUnsignedTransaction.ts b/packages/core/src/models/ergoUnsignedTransaction.ts index 2e1d2dea..bff6613f 100644 --- a/packages/core/src/models/ergoUnsignedTransaction.ts +++ b/packages/core/src/models/ergoUnsignedTransaction.ts @@ -48,7 +48,12 @@ export class ErgoUnsignedTransaction { } get burning(): BoxSummary { - return utxoDiff(utxoSum(this.inputs), utxoSum(this.outputs)); + const diff = utxoDiff(utxoSum(this.inputs), utxoSum(this.outputs)); + if (diff.tokens.length > 0) { + diff.tokens = diff.tokens.filter((x) => x.tokenId !== this.inputs[0].boxId); + } + + return diff; } toPlainObject(): UnsignedTransaction; From 40c73dad810b799bacdfc36007f6b92a88188248 Mon Sep 17 00:00:00 2001 From: arobsn <87387688+arobsn@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:27:28 -0300 Subject: [PATCH 3/6] fix vitest thresholds --- vitest.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index b6d73221..6ff4eedf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,9 @@ export default defineConfig({ plugins: [viteTsConfigPaths()], test: { coverage: { - all: true, + thresholds: { + "100": true + }, provider: "v8", reporter: ["text", "json", "html"], exclude: [ From cc4a9f459e4e0beb9078ed402079b0b97604e471 Mon Sep 17 00:00:00 2001 From: arobsn <87387688+arobsn@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:35:55 -0300 Subject: [PATCH 4/6] increase coverage --- .../core/src/builder/selector/boxSelector.ts | 4 ++-- packages/core/src/builder/transactionBuilder.ts | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/core/src/builder/selector/boxSelector.ts b/packages/core/src/builder/selector/boxSelector.ts index 5b619c31..83acd3e7 100644 --- a/packages/core/src/builder/selector/boxSelector.ts +++ b/packages/core/src/builder/selector/boxSelector.ts @@ -65,11 +65,11 @@ export class BoxSelector> { let selected: Box[] = []; const predicate = this._ensureFilterPredicate; - const inclusion = new Set(this._ensureInclusionBoxIds); + let inclusion = this._ensureInclusionBoxIds; // if the target has a token that is being minted, then the first input should be included if (target.tokens?.some((x) => x.tokenId === unselected[0].boxId)) { - inclusion.add(unselected[0].boxId); + inclusion = new Set(this._ensureInclusionBoxIds).add(unselected[0].boxId); } if (predicate) { diff --git a/packages/core/src/builder/transactionBuilder.ts b/packages/core/src/builder/transactionBuilder.ts index d8a0e581..19ed64e0 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -369,11 +369,15 @@ export class TransactionBuilder { } private _isMinting(): boolean { + return this._getMintingTokenId() !== undefined; + } + + private _getMintingTokenId(): string | undefined { for (const output of this._outputs) { - if (output.minting) return true; + if (output.minting) return output.minting.tokenId; } - return false; + return; } private _isMoreThanOneTokenBeingMinted(): boolean { @@ -403,14 +407,6 @@ export class TransactionBuilder { return false; } - - private _getMintingTokenId(): string | undefined { - for (const output of this._outputs) { - if (output.minting) return output.minting.tokenId; - } - - return; - } } function isCollectionLike(obj: unknown): obj is CollectionLike { From 9fc8086a7f64f4d8d3db9f4669bc7fc74e81c26b Mon Sep 17 00:00:00 2001 From: arobsn <87387688+arobsn@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:43:26 -0300 Subject: [PATCH 5/6] fix double minting check and test --- .../src/builder/transactionBuilder.spec.ts | 29 ++++++++++--------- .../core/src/builder/transactionBuilder.ts | 18 +++++++----- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/core/src/builder/transactionBuilder.spec.ts b/packages/core/src/builder/transactionBuilder.spec.ts index 93757b5c..a3766bca 100644 --- a/packages/core/src/builder/transactionBuilder.spec.ts +++ b/packages/core/src/builder/transactionBuilder.spec.ts @@ -1005,20 +1005,23 @@ describe("Token minting", () => { }); it("Should fail if trying to mint more than one token", () => { - const builder = new TransactionBuilder(height).from(regularBoxes).to([ - new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address).mintToken({ - name: "token1", - amount: 1n - }), - new OutputBuilder(SAFE_MIN_BOX_VALUE, a2.address).mintToken({ - name: "token2", - amount: 1n - }) - ]); + const builder = new TransactionBuilder(height) + .from(regularBoxes) + .to([ + new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address).mintToken({ + name: "token1", + amount: 1n + }), + new OutputBuilder(SAFE_MIN_BOX_VALUE, a2.address).mintToken({ + name: "token2", + amount: 1n + }) + ]) + .sendChangeTo(a1.address); - expect(() => { - builder.build(); - }).toThrow(MalformedTransaction); + expect(() => builder.build()).to.throw( + "Malformed transaction: only one token can be minted per transaction." + ); }); }); diff --git a/packages/core/src/builder/transactionBuilder.ts b/packages/core/src/builder/transactionBuilder.ts index 19ed64e0..ede06417 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -368,23 +368,27 @@ export class TransactionBuilder { return unsignedTransaction; } - private _isMinting(): boolean { - return this._getMintingTokenId() !== undefined; - } - - private _getMintingTokenId(): string | undefined { + private _getMintingOutput(): OutputBuilder | undefined { for (const output of this._outputs) { - if (output.minting) return output.minting.tokenId; + if (output.minting) return output; } return; } + private _isMinting(): boolean { + return this._getMintingOutput() !== undefined; + } + + private _getMintingTokenId(): string | undefined { + return this._getMintingOutput()?.minting?.tokenId; + } + private _isMoreThanOneTokenBeingMinted(): boolean { let mintingCount = 0; for (const output of this._outputs) { - if (isDefined(output.minting)) { + if (output.minting) { mintingCount++; if (mintingCount > 1) return true; } From 14c4a04129dcf2da55a0e8082801b47f17fc5085 Mon Sep 17 00:00:00 2001 From: arobsn <87387688+arobsn@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:05:15 -0300 Subject: [PATCH 6/6] refactor --- packages/core/src/builder/transactionBuilder.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/builder/transactionBuilder.ts b/packages/core/src/builder/transactionBuilder.ts index ede06417..6faf4bec 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -273,12 +273,13 @@ export class TransactionBuilder { let inputs = selector.select(target); if (isDefined(this._changeAddress)) { - const manualMinting = target.tokens.some((x) => x.tokenId === inputs[0].boxId) - ? inputs[0].boxId + const firstInputId = inputs[0].boxId; + const manualMintingTokenId = target.tokens.some((x) => x.tokenId === firstInputId) + ? firstInputId : undefined; - if (manualMinting) { - target.tokens = target.tokens.filter((x) => x.tokenId !== manualMinting); + if (manualMintingTokenId) { + target.tokens = target.tokens.filter((x) => x.tokenId !== manualMintingTokenId); } let change = utxoDiff(utxoSum(inputs), target);