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..36789ce1 100644 --- a/packages/common/src/utils/utxo.ts +++ b/packages/common/src/utils/utxo.ts @@ -79,27 +79,33 @@ 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); - } + 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 (!ignoreSubtrahendLeftoverTokens && subtrahendTokens.size > 0) { + for (const [tokenId, amount] of subtrahendTokens) { + tokens.push({ tokenId, amount: -amount }); + } } return { nanoErgs, tokens }; diff --git a/packages/core/src/builder/selector/boxSelector.ts b/packages/core/src/builder/selector/boxSelector.ts index 801f557e..83acd3e7 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; + 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 = new Set(this._ensureInclusionBoxIds).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.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 a9fc47c1..6faf4bec 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -273,6 +273,15 @@ export class TransactionBuilder { let inputs = selector.select(target); if (isDefined(this._changeAddress)) { + const firstInputId = inputs[0].boxId; + const manualMintingTokenId = target.tokens.some((x) => x.tokenId === firstInputId) + ? firstInputId + : undefined; + + if (manualMintingTokenId) { + target.tokens = target.tokens.filter((x) => x.tokenId !== manualMintingTokenId); + } + let change = utxoDiff(utxoSum(inputs), target); const changeBoxes: OutputBuilder[] = []; @@ -331,9 +340,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 +359,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)) { @@ -365,19 +369,27 @@ export class TransactionBuilder { return unsignedTransaction; } - private _isMinting(): boolean { + private _getMintingOutput(): OutputBuilder | undefined { for (const output of this._outputs) { - if (output.minting) return true; + if (output.minting) return output; } - return false; + 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; } @@ -400,18 +412,6 @@ export class TransactionBuilder { return false; } - - private _getMintingTokenId(): string | undefined { - let tokenId = undefined; - for (const output of this._outputs) { - if (output.minting) { - tokenId = output.minting.tokenId; - break; - } - } - - return tokenId; - } } function isCollectionLike(obj: unknown): obj is CollectionLike { 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; 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: [