Skip to content

Commit

Permalink
Merge pull request #141 from fleet-sdk/fix-utxo-diff
Browse files Browse the repository at this point in the history
Fix `utxoDiff` calculation
  • Loading branch information
arobsn authored Sep 10, 2024
2 parents 7352e17 + 14c4a04 commit cca6f6d
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 50 deletions.
5 changes: 5 additions & 0 deletions .changeset/flat-swans-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fleet-sdk/common": patch
---

Fix `utxoDiff` miscalculation when tokens are present in subtrahend but not in minuend
32 changes: 32 additions & 0 deletions packages/common/src/utils/utxo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 17 additions & 11 deletions packages/common/src/utils/utxo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,27 +79,33 @@ export function utxoSum(boxes: readonly BoxAmounts[], tokenId?: TokenId) {
*/
export function utxoDiff(
minuend: BoxSummary | Box<Amount>[],
subtrahend: BoxSummary | Box<Amount>[]
subtrahend: BoxSummary | Box<Amount>[],
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<bigint>[] = [];
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 };
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/builder/selector/boxSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ export class BoxSelector<T extends Box<bigint>> {
let selected: Box<bigint>[] = [];

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) {
Expand Down Expand Up @@ -144,6 +149,7 @@ export class BoxSelector<T extends Box<bigint>> {
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;
}

Expand Down
29 changes: 16 additions & 13 deletions packages/core/src/builder/transactionBuilder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
);
});
});

Expand Down
46 changes: 23 additions & 23 deletions packages/core/src/builder/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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(
Expand All @@ -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)) {
Expand All @@ -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;
}
Expand All @@ -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<T>(obj: unknown): obj is CollectionLike<T> {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/models/ergoUnsignedTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ export default defineConfig({
plugins: [viteTsConfigPaths()],
test: {
coverage: {
all: true,
thresholds: {
"100": true
},
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
Expand Down

0 comments on commit cca6f6d

Please sign in to comment.