Skip to content

Commit

Permalink
feat(core): add min box value estimation
Browse files Browse the repository at this point in the history
  • Loading branch information
capt-nemo429 committed Apr 7, 2023
1 parent 5931453 commit 03fcb9e
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 66 deletions.
90 changes: 68 additions & 22 deletions packages/core/src/builder/outputBuilder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { UndefinedCreationHeight } from "../errors/undefinedCreationHeight";
import { UndefinedMintingContext } from "../errors/undefinedMintingContext";
import { ErgoAddress, TokensCollection } from "../models";
import { regularBoxesMock } from "../tests/mocks/mockBoxes";
import { OutputBuilder, SAFE_MIN_BOX_VALUE } from "./outputBuilder";
import { estimateMinBoxValue, OutputBuilder, SAFE_MIN_BOX_VALUE } from "./outputBuilder";

const address = "9fMPy1XY3GW4T6t3LjYofqmzER6x9cV21n5UVJTWmma4Y9mAW6c";
const ergoTree = "0008cd026dc059d64a50d0dbf07755c2c4a4e557e3df8afa7141868b3ab200643d437ee7";
Expand Down Expand Up @@ -113,8 +113,8 @@ describe("Token handling", () => {
it("Should add distinct tokens", () => {
builder.addTokens({ tokenId: tokenA, amount: 50n }).addTokens({ tokenId: tokenB, amount: 10n });

expect(builder.tokens).toHaveLength(2);
const tokens = builder.tokens.toArray();
expect(builder.assets).toHaveLength(2);
const tokens = builder.assets.toArray();
expect(tokens.find((x) => x.tokenId === tokenA)?.amount).toBe(50n);
expect(tokens.find((x) => x.tokenId === tokenB)?.amount).toBe(10n);
});
Expand All @@ -127,8 +127,8 @@ describe("Token handling", () => {
])
);

expect(builder.tokens).toHaveLength(2);
const tokens = builder.tokens.toArray();
expect(builder.assets).toHaveLength(2);
const tokens = builder.assets.toArray();
expect(tokens.find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);
expect(tokens.find((x) => x.tokenId === tokenB)?.amount).toEqual(10n);
});
Expand All @@ -141,8 +141,8 @@ describe("Token handling", () => {

builder.addTokens(collection);

expect(builder.tokens).toHaveLength(2);
const tokens = builder.tokens.toArray();
expect(builder.assets).toHaveLength(2);
const tokens = builder.assets.toArray();
expect(tokens.find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);
expect(tokens.find((x) => x.tokenId === tokenB)?.amount).toEqual(10n);
});
Expand All @@ -151,35 +151,35 @@ describe("Token handling", () => {
builder
.addTokens({ tokenId: tokenA, amount: "50" })
.addTokens({ tokenId: tokenB, amount: 10n });
expect(builder.tokens).toHaveLength(2);
expect(builder.tokens.toArray().find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);
expect(builder.assets).toHaveLength(2);
expect(builder.assets.toArray().find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);

builder.addTokens({ tokenId: tokenA, amount: 100n });
expect(builder.tokens).toHaveLength(2);
const tokens = builder.tokens.toArray();
expect(builder.assets).toHaveLength(2);
const tokens = builder.assets.toArray();
expect(tokens.find((x) => x.tokenId === tokenA)?.amount).toEqual(150n);
expect(tokens.find((x) => x.tokenId === tokenB)?.amount).toEqual(10n);
});

it("Should add multiple tokens and sum if the same tokenId is added more than one time", () => {
builder.addTokens({ tokenId: tokenA, amount: "50" });
expect(builder.tokens.toArray().find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);
expect(builder.tokens).toHaveLength(1);
expect(builder.assets.toArray().find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);
expect(builder.assets).toHaveLength(1);

builder.addTokens([
{ tokenId: tokenA, amount: 100n },
{ tokenId: tokenB, amount: "10" }
]);
expect(builder.tokens).toHaveLength(2);
const tokens = builder.tokens.toArray();
expect(builder.assets).toHaveLength(2);
const tokens = builder.assets.toArray();
expect(tokens.find((x) => x.tokenId === tokenA)?.amount).toEqual(150n);
expect(tokens.find((x) => x.tokenId === tokenB)?.amount).toEqual(10n);
});

it("Should not sum if the same tokenId is added more than one time", () => {
builder.addTokens({ tokenId: tokenA, amount: "50" });
expect(builder.tokens.toArray().find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);
expect(builder.tokens).toHaveLength(1);
expect(builder.assets.toArray().find((x) => x.tokenId === tokenA)?.amount).toEqual(50n);
expect(builder.assets).toHaveLength(1);

builder.addTokens(
[
Expand All @@ -188,21 +188,21 @@ describe("Token handling", () => {
],
{ sum: false }
);
expect(builder.tokens).toHaveLength(3);
const tokens = builder.tokens.toArray();
expect(builder.assets).toHaveLength(3);
const tokens = builder.assets.toArray();
expect(tokens.find((x) => x.tokenId === tokenA && x.amount === 50n)).not.toBeFalsy();
expect(tokens.find((x) => x.tokenId === tokenB && x.amount === 10n)).not.toBeFalsy();
expect(tokens.find((x) => x.tokenId === tokenA && x.amount === 110n)).not.toBeFalsy();
});

it("Should remove tokens from the list using context ejector", () => {
builder.addTokens({ tokenId: tokenA, amount: 50n }).addTokens({ tokenId: tokenB, amount: 10n });
expect(builder.tokens).toHaveLength(2);
expect(builder.assets).toHaveLength(2);

builder.eject(({ tokens }) => tokens.remove(tokenA));

expect(builder.tokens).toHaveLength(1);
expect(builder.tokens.toArray().find((x) => x.tokenId === tokenA)).toBeFalsy();
expect(builder.assets).toHaveLength(1);
expect(builder.assets.toArray().find((x) => x.tokenId === tokenA)).toBeFalsy();
});
});

Expand Down Expand Up @@ -434,4 +434,50 @@ describe("Building", () => {
builder.build();
}).toThrow(UndefinedCreationHeight);
});

it("Should set min box value", () => {
const output = new OutputBuilder(SAFE_MIN_BOX_VALUE, address);
expect(output.value).toBe(SAFE_MIN_BOX_VALUE);

output.setValue(SAFE_MIN_BOX_VALUE * 2n);
expect(output.value).toBe(SAFE_MIN_BOX_VALUE * 2n);
});

it("Should estimate min box value", () => {
const output = new OutputBuilder(estimateMinBoxValue(), address, height);
expect(output.value).toBe(28440n);

output.addTokens({ tokenId: tokenA, amount: 10n });
expect(output.value).toBe(40320n);

output.addTokens({ tokenId: tokenB, amount: 90n });
expect(output.value).toBe(52200n);
});

it("Should estimate min box value with custom value per byte", () => {
const output = new OutputBuilder(estimateMinBoxValue(200n), address, height);
expect(output.value).toBe(15800n);

output.addTokens({ tokenId: tokenA, amount: 10n });
expect(output.value).toBe(22400n);

output.addTokens({ tokenId: tokenB, amount: 90n });
expect(output.value).toBe(29000n);
});

it("Should replace dynamic value estimation by fixed value", () => {
const output = new OutputBuilder(estimateMinBoxValue(), address, height);
expect(output.value).toBe(28440n);

output.setValue(SAFE_MIN_BOX_VALUE);
expect(output.value).toBe(SAFE_MIN_BOX_VALUE);
});

it("Should replace fixed value by dynamic value estimation", () => {
const output = new OutputBuilder(SAFE_MIN_BOX_VALUE, address, height);
expect(output.value).toBe(SAFE_MIN_BOX_VALUE);

output.setValue(estimateMinBoxValue());
expect(output.value).toBe(28440n);
});
});
46 changes: 35 additions & 11 deletions packages/core/src/builder/outputBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Box,
BoxCandidate,
ErgoTree,
isDefined,
NewToken,
NonMandatoryRegisters,
OneOrMore,
Expand All @@ -26,33 +27,41 @@ import { UndefinedCreationHeight } from "../errors/undefinedCreationHeight";
import { UndefinedMintingContext } from "../errors/undefinedMintingContext";
import { ErgoAddress } from "../models";
import { TokenAddOptions, TokensCollection } from "../models/collections/tokensCollection";
import { estimateBoxSize } from "../serializer/sigma/boxSerializer";
import { SConstant } from "../serializer/sigma/constantSerializer";
import { SByte, SColl } from "../serializer/sigma/sigmaTypes";

export const BOX_VALUE_PER_BYTE = BigInt(360);
export const SAFE_MIN_BOX_VALUE = BigInt(1000000);

export type BoxValueEstimationCallback = (outputBuilder: OutputBuilder) => bigint;

export function estimateMinBoxValue(valuePerByte = BOX_VALUE_PER_BYTE): BoxValueEstimationCallback {
return (output: OutputBuilder) => {
return BigInt(estimateBoxSize(output, SAFE_MIN_BOX_VALUE)) * valuePerByte;
};
}

export class OutputBuilder {
private readonly _value: bigint;
private readonly _address: ErgoAddress;
private readonly _tokens: TokensCollection;
private _value!: bigint;
private _valueEstimator?: BoxValueEstimationCallback;
private _creationHeight?: number;
private _registers: NonMandatoryRegisters;
private _minting?: NewToken<bigint>;

constructor(
value: Amount,
value: Amount | BoxValueEstimationCallback,
recipient: Base58String | ErgoTree | ErgoAddress,
creationHeight?: number
) {
this._value = ensureBigInt(value);
this.setValue(value);

this._creationHeight = creationHeight;
this._tokens = new TokensCollection();
this._registers = {};

if (this._value <= _0n) {
throw new Error("An UTxO cannot be created without a minimum required amount.");
}

if (typeof recipient === "string") {
this._address = isHex(recipient)
? ErgoAddress.fromErgoTree(recipient)
Expand All @@ -63,7 +72,7 @@ export class OutputBuilder {
}

public get value(): bigint {
return this._value;
return isDefined(this._valueEstimator) ? this._valueEstimator(this) : this._value;
}

public get address(): ErgoAddress {
Expand All @@ -78,7 +87,7 @@ export class OutputBuilder {
return this._creationHeight;
}

public get tokens(): TokensCollection {
public get assets(): TokensCollection {
return this._tokens;
}

Expand All @@ -90,10 +99,25 @@ export class OutputBuilder {
return this._minting;
}

public setValue(value: Amount | BoxValueEstimationCallback): OutputBuilder {
if (typeof value === "function") {
this._valueEstimator = value;
} else {
this._value = ensureBigInt(value);
this._valueEstimator = undefined;

if (this._value <= _0n) {
throw new Error("An UTxO cannot be created without a minimum required amount.");
}
}

return this;
}

public addTokens(
tokens: OneOrMore<TokenAmount<Amount>> | TokensCollection,
options?: TokenAddOptions
) {
): OutputBuilder {
if (tokens instanceof TokensCollection) {
this._tokens.add(tokens.toArray(), options);
} else {
Expand Down Expand Up @@ -138,7 +162,7 @@ export class OutputBuilder {
}

public build(transactionInputs?: UnsignedInput[] | Box<Amount>[]): BoxCandidate<bigint> {
let tokens = this.tokens.toArray();
let tokens = this.assets.toArray();

if (this.minting) {
if (isEmpty(transactionInputs)) {
Expand Down
68 changes: 63 additions & 5 deletions packages/core/src/builder/transactionBuilder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Network } from "@fleet-sdk/common";
import { Amount, BoxCandidate, Network } from "@fleet-sdk/common";
import { ensureBigInt, first, some, sumBy, utxoSum } from "@fleet-sdk/common";
import { stringToBytes } from "@scure/base";
import { InvalidInput } from "../errors";
Expand All @@ -7,8 +7,14 @@ import { NonStandardizedMinting } from "../errors/nonStandardizedMinting";
import { NotAllowedTokenBurning } from "../errors/notAllowedTokenBurning";
import { ErgoAddress, ErgoUnsignedInput, MAX_TOKENS_PER_BOX } from "../models";
import { SByte, SColl, SConstant } from "../serializer";
import { estimateBoxSize } from "../serializer/sigma/boxSerializer";
import { invalidBoxesMock, manyTokensBoxesMock, regularBoxesMock } from "../tests/mocks/mockBoxes";
import { OutputBuilder, SAFE_MIN_BOX_VALUE } from "./outputBuilder";
import {
BOX_VALUE_PER_BYTE,
estimateMinBoxValue,
OutputBuilder,
SAFE_MIN_BOX_VALUE
} from "./outputBuilder";
import { FEE_CONTRACT, RECOMMENDED_MIN_FEE_VALUE, TransactionBuilder } from "./transactionBuilder";

const height = 844540;
Expand Down Expand Up @@ -617,6 +623,27 @@ describe("Building", () => {
expect(changeOutput.additionalRegisters).toEqual({});
});

it("Should build with min box value estimation", () => {
const transaction = new TransactionBuilder(height)
.from(manyTokensBoxesMock)
.to(
new OutputBuilder(estimateMinBoxValue(), a2.address)
.addTokens({
tokenId: "31d6f93435540f52f067efe2c5888b8d4c4418a4fd28156dd834102c8336a804",
amount: 1n
})
.addTokens({
tokenId: "8565b6d9b72d0cb8ca052f7e5b8cdf32905333b9e026162e3a6d585ae78e697b",
amount: 1n
})
)
.payFee(RECOMMENDED_MIN_FEE_VALUE)
.sendChangeTo(a1.address)
.build();

expect(first(transaction.outputs).value).toBe(52200n);
});

it("Should produce multiple change boxes and run multiple input selections if necessary", () => {
const transaction = new TransactionBuilder(height)
.from(manyTokensBoxesMock)
Expand Down Expand Up @@ -664,23 +691,27 @@ describe("Building", () => {

expect(change1.ergoTree).toBe(a1.ergoTree);
expect(change1.creationHeight).toBe(height);
expect(change1.value).toBe(3465648n);
expect(change1.value).toBe(3595808n);
expect(change1.assets).toHaveLength(MAX_TOKENS_PER_BOX);
expect(change1.additionalRegisters).toEqual({});

expect(change2.ergoTree).toBe(a1.ergoTree);
expect(change2.creationHeight).toBe(height);
expect(change2.value).toBe(SAFE_MIN_BOX_VALUE);
expect(change2.value).toBe(_estimateBoxValue(change2));
expect(change2.assets).toHaveLength(MAX_TOKENS_PER_BOX);
expect(change2.additionalRegisters).toEqual({});

expect(change3.ergoTree).toBe(a1.ergoTree);
expect(change3.creationHeight).toBe(height);
expect(change3.value).toBe(SAFE_MIN_BOX_VALUE);
expect(change3.value).toBe(_estimateBoxValue(change3));
expect(change3.assets).toHaveLength(32);
expect(change3.additionalRegisters).toEqual({});
});

function _estimateBoxValue(box: BoxCandidate<Amount>) {
return BigInt(estimateBoxSize(box)) * BOX_VALUE_PER_BYTE;
}

it("Should produce multiple change boxes based on maxTokensPerChangeBox param", () => {
const tokensPerBox = 2;

Expand All @@ -701,6 +732,33 @@ describe("Building", () => {
} else {
expect(transaction.outputs[i].assets.length <= tokensPerBox).toBeTruthy();
}

if (i > 0) {
expect(transaction.outputs[i].value).toBe(_estimateBoxValue(transaction.outputs[i]));
}
}
});

it("Should produce multiple change boxes based on maxTokensPerChangeBox param with equal tokens", () => {
const tokensPerBox = 3;

const transaction = new TransactionBuilder(height)
.from(regularBoxesMock)
.sendChangeTo(a1.address)
.configureSelector((selector) => selector.ensureInclusion((i) => some(i.assets)))
.configure((settings) => settings.setMaxTokensPerChangeBox(tokensPerBox))
.build();

expect(transaction.inputs).toHaveLength(4);
expect(transaction.dataInputs).toHaveLength(0);
expect(transaction.outputs).toHaveLength(7);

for (let i = 0; i < transaction.outputs.length; i++) {
expect(transaction.outputs[i].assets).toHaveLength(tokensPerBox);

if (i > 0) {
expect(transaction.outputs[i].value).toBe(_estimateBoxValue(transaction.outputs[i]));
}
}
});

Expand Down
Loading

0 comments on commit 03fcb9e

Please sign in to comment.