Skip to content

Commit

Permalink
feat(core): allow manual token minting
Browse files Browse the repository at this point in the history
  • Loading branch information
capt-nemo429 committed Dec 9, 2022
1 parent f52d9a4 commit 8e07d82
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 23 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"license": "MIT",
"scripts": {
"ver:stable": "standard-version",
"ver:alpha": "standard-version --dry-run --prerelease alpha",
"ver:alpha": "standard-version --prerelease alpha",
"clear": "pnpm -r exec rm -rf dist"
},
"devDependencies": {
Expand Down
25 changes: 19 additions & 6 deletions packages/core/src/builder/selector/boxSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
Box,
BoxCandidate,
FilterPredicate,
first,
isUndefined,
SortingDirection,
SortingSelector,
TokenTargetAmount
Expand All @@ -19,7 +21,7 @@ import {
utxoSum
} from "@fleet-sdk/common";
import { DuplicateInputSelectionError } from "../../errors/duplicateInputSelectionError";
import { InsufficientAssets, InsufficientInputs } from "../../errors/insufficientInputs";
import { InsufficientInputs } from "../../errors/insufficientInputs";
import { ISelectionStrategy } from "./strategies/ISelectionStrategy";
import { AccumulativeSelectionStrategy } from "./strategies/accumulativeSelectionStrategy";
import { CustomSelectionStrategy, SelectorFunction } from "./strategies/customSelectionStrategy";
Expand Down Expand Up @@ -84,7 +86,7 @@ export class BoxSelector<T extends Box<bigint>> {
}

const unreached = this._getUnreachedTargets(selected, target);
if (some(unreached)) {
if (unreached.nanoErgs || some(unreached.tokens)) {
throw new InsufficientInputs(unreached);
}

Expand All @@ -100,12 +102,12 @@ export class BoxSelector<T extends Box<bigint>> {
};
}

private _getUnreachedTargets(inputs: Box<bigint>[], target: SelectionTarget): InsufficientAssets {
const unreached: InsufficientAssets = {};
private _getUnreachedTargets(inputs: Box<bigint>[], target: SelectionTarget): SelectionTarget {
const unreached: SelectionTarget = { nanoErgs: undefined, tokens: undefined };
const selectedNanoergs = sumBy(inputs, (input) => input.value);

if (target.nanoErgs && target.nanoErgs > selectedNanoergs) {
unreached["nanoErgs"] = target.nanoErgs - selectedNanoergs;
unreached.nanoErgs = target.nanoErgs - selectedNanoergs;
}

if (isEmpty(target.tokens)) {
Expand All @@ -115,7 +117,18 @@ export class BoxSelector<T extends Box<bigint>> {
for (const tokenTarget of target.tokens) {
const totalSelected = utxoSum(inputs, tokenTarget.tokenId);
if (isDefined(tokenTarget.amount) && tokenTarget.amount > totalSelected) {
unreached[tokenTarget.tokenId] = tokenTarget.amount - totalSelected;
if (tokenTarget.tokenId === first(inputs).boxId) {
continue;
}

if (isUndefined(unreached.tokens)) {
unreached.tokens = [];
}

unreached.tokens.push({
tokenId: tokenTarget.tokenId,
amount: tokenTarget.amount - totalSelected
});
}
}

Expand Down
112 changes: 112 additions & 0 deletions packages/core/src/builder/transactionBuilder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Network } from "@fleet-sdk/common";
import { ensureBigInt, first, some, sumBy, utxoSum } from "@fleet-sdk/common";
import { stringToBytes } from "@scure/base";
import { InvalidInput } from "../errors";
import { MalformedTransaction } from "../errors/malformedTransaction";
import { NonStandardizedMinting } from "../errors/nonStandardizedMinting";
import { NotAllowedTokenBurning } from "../errors/notAllowedTokenBurning";
import { ErgoAddress, ErgoUnsignedInput, MAX_TOKENS_PER_BOX } from "../models";
import { SByte, SColl, SConstant } from "../serialization";
import { invalidBoxesMock, manyTokensBoxesMock, regularBoxesMock } from "../tests/mocks/mockBoxes";
import { OutputBuilder, SAFE_MIN_BOX_VALUE } from "./outputBuilder";
import { FEE_CONTRACT, RECOMMENDED_MIN_FEE_VALUE, TransactionBuilder } from "./transactionBuilder";
Expand Down Expand Up @@ -780,6 +783,27 @@ describe("Token minting", () => {
});
});

it("Should mint setting a the tokenId", () => {
const input = first(regularBoxesMock);

const transaction = new TransactionBuilder(height)
.from(input)
.to(
new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address).mintToken({
tokenId: input.boxId,
amount: 100n,
name: "TestToken"
})
)

.sendChangeTo(a1.address)
.payMinFee()
.build();

const mintingBox = transaction.outputs[0];
expect(mintingBox.assets).toEqual([{ tokenId: input.boxId, amount: "100" }]);
});

it("Should mint and transfer other tokens in the same box", () => {
const transaction = new TransactionBuilder(height)
.from(regularBoxesMock)
Expand Down Expand Up @@ -823,6 +847,94 @@ describe("Token minting", () => {
});
});

/**
* From bk#4411 in Discord:
*
* Here's a little feedback: I'm doing something not supported by EIP-004 when I create a
* new token by creating a normal "mint token" style box with 1 new token, but I am also
* putting 1 of the same token in a separate box in the same tx. You can see an example here:
* https://testnet.ergoplatform.com/en/transactions/9a8489d6d894e420a0b6a0e9f4e2c26ff65b44539789a2c275ef08df39d2e5be
*
* It's not supported in the EIP-004 sense because the "emission amount" ends up wrong (see
* https://testnet.ergoplatform.com/en/token/9c41d475fec39024194982e64f9c34c27c8bc11900ba85d985ccef1f5ec8d95f
* that it is 1 instead of 2). My fleet issue is that my tx seems to fail validation in
* BoxSelector._getUnreachedTargets. I have hacked around this by adding a
* .burnTokens({amount: '-2', tokenId: newUserTokenId,}) to my tx builder which works but
* feels like an abuse of the API.
*/
describe("Non-standardized token minting", () => {
const input = first(regularBoxesMock);
const mintingTokenId = input.boxId;

it("Should 'manually' mint tokens, this must bypass EIP-4 validations", () => {
const transaction = new TransactionBuilder(height)
.from(input)
.to(
new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address)
.addTokens({
tokenId: mintingTokenId,
amount: 1n
})
.setAdditionalRegisters({
R4: SConstant(SColl(SByte, stringToBytes("utf8", "TestToken"))), // name
R5: SConstant(SColl(SByte, stringToBytes("utf8", "Description test"))), // description
R6: SConstant(SColl(SByte, stringToBytes("utf8", "4"))) // decimals
})
)
.and.to(
new OutputBuilder(SAFE_MIN_BOX_VALUE, a2.address).addTokens({
tokenId: mintingTokenId,
amount: 1n
})
)
.sendChangeTo(a1.address)
.payMinFee()
.build();

expect(transaction.outputs).toHaveLength(4); // output 1, 2, change and fee
expect(
transaction.outputs.filter((x) => x.assets.some((x) => x.tokenId === input.boxId))
).toHaveLength(2);

const mintingBox = transaction.outputs[0];
expect(mintingBox.assets).toEqual([{ tokenId: mintingTokenId, amount: "1" }]);
expect(mintingBox.additionalRegisters).toEqual({
R4: "0e0954657374546f6b656e",
R5: "0e104465736372697074696f6e2074657374",
R6: "0e0134"
});

const sendingBox = transaction.outputs[1];
expect(sendingBox.assets).toEqual([{ tokenId: mintingTokenId, amount: "1" }]);
expect(sendingBox.additionalRegisters).toEqual({});
});

it("Should fail if trying to mint in a non-standardized way", () => {
expect(() => {
new TransactionBuilder(height)
.from(input)
.to(
new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address).mintToken({
tokenId: mintingTokenId,
amount: 1n,
name: "TestToken",
decimals: 4,
description: "Description test"
})
)
.and.to(
new OutputBuilder(SAFE_MIN_BOX_VALUE, a2.address).addTokens({
tokenId: mintingTokenId,
amount: 1n
})
)
.sendChangeTo(a1.address)
.payMinFee()
.build();
}).toThrow(NonStandardizedMinting);
});
});

describe("Token burning", () => {
it("Should explicitly burn tokens", () => {
const nftTokenId = "bf2afb01fde7e373e22f24032434a7b883913bd87a23b62ee8b43eba53c9f6c2";
Expand Down
58 changes: 53 additions & 5 deletions packages/core/src/builder/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BuildOutputType,
EIP12UnsignedTransaction,
HexString,
isUndefined,
Network,
TokenAmount,
UnsignedTransaction
Expand All @@ -20,6 +21,7 @@ import {
utxoSum
} from "@fleet-sdk/common";
import { InvalidInput, MalformedTransaction, NotAllowedTokenBurning } from "../errors";
import { NonStandardizedMinting } from "../errors/nonStandardizedMinting";
import { ErgoAddress, InputsCollection, OutputsCollection, TokensCollection } from "../models";
import { OutputBuilder, SAFE_MIN_BOX_VALUE } from "./outputBuilder";
import { BoxSelector } from "./selector";
Expand Down Expand Up @@ -192,8 +194,16 @@ export class TransactionBuilder {
public build(): UnsignedTransaction;
public build<T extends BuildOutputType>(buildOutputType: T): TransactionType<T>;
public build<T extends BuildOutputType>(buildOutputType?: T): TransactionType<T> {
if (!this._validateTokenMinting()) {
throw new MalformedTransaction("only one token can be minted per transaction.");
if (this._isMinting()) {
if (this._isMoreThanOneTokenBeingMinted()) {
throw new MalformedTransaction("only one token can be minted per transaction.");
}

if (this._isTheSameTokenBeingMintedOutsideTheMintingBox()) {
throw new NonStandardizedMinting(
"EIP-4 tokens cannot be minted from outside the minting box."
);
}
}

const outputs = this.outputs.clone();
Expand Down Expand Up @@ -277,20 +287,58 @@ export class TransactionBuilder {
return unsignedTransaction;
}

private _validateTokenMinting(): boolean {
private _isMinting(): boolean {
for (const output of this._outputs) {
if (output.minting) {
return true;
}
}

return false;
}

private _isMoreThanOneTokenBeingMinted(): boolean {
let mintingCount = 0;

for (const output of this._outputs) {
if (isDefined(output.minting)) {
mintingCount++;

if (mintingCount > 1) {
return false;
return true;
}
}
}

return true;
return false;
}

private _isTheSameTokenBeingMintedOutsideTheMintingBox(): boolean {
const mintingTokenId = this._getMintingTokenId();

if (isUndefined(mintingTokenId)) {
return false;
}

for (const output of this._outputs) {
if (output.tokens.contains(mintingTokenId)) {
return true;
}
}

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;
}

private _calcBurningBalance(
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/errors/insufficientInputs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ describe("Insufficient inputs error", () => {
it("Should hold details and return formatted error message", () => {
const unreached = {
nanoErgs: 10n,
"007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283": 100n
tokens: [
{
tokenId: "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283",
amount: 100n
}
]
};

const error = new InsufficientInputs(unreached);

expect(error.unreached).toBe(unreached);
Expand Down
30 changes: 20 additions & 10 deletions packages/core/src/errors/insufficientInputs.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
export type InsufficientAssets = { [key: string]: bigint };
import { some } from "@fleet-sdk/common";
import { SelectionTarget } from "../builder/selector/boxSelector";

export class InsufficientInputs extends Error {
readonly unreached: InsufficientAssets;
readonly unreached: SelectionTarget;

constructor(unreached: InsufficientAssets) {
super(
`Insufficient inputs:${Object.keys(unreached)
.map((key) => {
return `\n > ${key}: ${unreached[key].toString()}`;
})
.join()}`
);
constructor(unreached: SelectionTarget) {
const strings = [];
if (unreached.nanoErgs) {
strings.push(buildString("nanoErgs", unreached.nanoErgs));
}

if (some(unreached.tokens)) {
for (const token of unreached.tokens) {
strings.push(buildString(token.tokenId, token.amount));
}
}

super(`Insufficient inputs:${strings.join()}`);

this.unreached = unreached;
}
}

function buildString(tokenId: string, amount?: bigint): string {
return `\n > ${tokenId}: ${amount?.toString()}`;
}
5 changes: 5 additions & 0 deletions packages/core/src/errors/nonStandardizedMinting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class NonStandardizedMinting extends Error {
constructor(message: string) {
super(message);
}
}
16 changes: 16 additions & 0 deletions packages/core/src/models/collections/tokensCollection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ describe("Tokens collection", () => {
expect(collection).toHaveLength(2);
});

it("Should lookup at collection and return true is a tokenId is included otherwise, return false", () => {
const collection = new TokensCollection([
{ tokenId: tokenA, amount: 50n },
{ tokenId: tokenB, amount: 20n }
]);

expect(collection).toHaveLength(2);

expect(collection.contains(tokenA)).toBeTruthy();
expect(collection.contains(tokenB)).toBeTruthy();

expect(
collection.contains("d601123e8838b95cdaebe24e594276b2a89cd38e98add98405bb5327520ecf6c")
).toBeFalsy();
});

it("Should create a filled and accumulative collection", () => {
const collection = new TokensCollection(
[
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/models/collections/tokensCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,8 @@ export class TokensCollection extends Collection<TokenAmount<bigint>> {

return this;
}

contains(tokenId: string): boolean {
return this._items.some((x) => x.tokenId === tokenId);
}
}

0 comments on commit 8e07d82

Please sign in to comment.