diff --git a/.changeset/forty-trains-float.md b/.changeset/forty-trains-float.md new file mode 100644 index 00000000..b546ce54 --- /dev/null +++ b/.changeset/forty-trains-float.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/core": minor +--- + +Add transaction chain building diff --git a/packages/common/src/types/common.ts b/packages/common/src/types/common.ts index ec35d384..9de3412e 100644 --- a/packages/common/src/types/common.ts +++ b/packages/common/src/types/common.ts @@ -10,4 +10,4 @@ export type SortingDirection = "asc" | "desc"; export type FilterPredicate = (item: T) => boolean; -export type BuildOutputType = "default" | "EIP-12"; +export type PlainObjectType = "minimal" | "EIP-12"; diff --git a/packages/core/src/builder/outputBuilder.spec.ts b/packages/core/src/builder/outputBuilder.spec.ts index 3a121e70..815a8a62 100644 --- a/packages/core/src/builder/outputBuilder.spec.ts +++ b/packages/core/src/builder/outputBuilder.spec.ts @@ -82,6 +82,23 @@ describe("Constructor", () => { }); }); +describe("flags", () => { + it("Should set default flags", () => { + const builder = new OutputBuilder(SAFE_MIN_BOX_VALUE, address); + expect(builder.flags).toEqual({ change: false }); + }); + + it("Should set flags", () => { + const builder = new OutputBuilder(SAFE_MIN_BOX_VALUE, address); + + builder.setFlags({ change: true }); + expect(builder.flags).toEqual({ change: true }); + + builder.setFlags({ change: false }); + expect(builder.flags).toEqual({ change: false }); + }); +}); + describe("Creation height", () => { it("Should construct with no creation height and set it using setCreationHeight()", () => { const builder = new OutputBuilder(SAFE_MIN_BOX_VALUE, address); @@ -347,7 +364,6 @@ describe("Building", () => { it("Should build box without tokens", () => { const boxCandidate = new OutputBuilder(SAFE_MIN_BOX_VALUE, address, height).build(); - expect(boxCandidate.boxId).toBeUndefined(); expect(boxCandidate.value).toEqual(SAFE_MIN_BOX_VALUE); expect(boxCandidate.ergoTree).toEqual(ErgoAddress.fromBase58(address).ergoTree); expect(boxCandidate.creationHeight).toEqual(height); @@ -361,7 +377,6 @@ describe("Building", () => { .addTokens({ tokenId: tokenB, amount: 1n }) .build(); - expect(boxCandidate.boxId).toBeUndefined(); expect(boxCandidate.value).toEqual(SAFE_MIN_BOX_VALUE); expect(boxCandidate.ergoTree).toEqual(ErgoAddress.fromBase58(address).ergoTree); expect(boxCandidate.creationHeight).toEqual(height); @@ -382,7 +397,6 @@ describe("Building", () => { }) .build(regularBoxes); - expect(boxCandidate.boxId).toBeUndefined(); expect(boxCandidate.value).toEqual(SAFE_MIN_BOX_VALUE); expect(boxCandidate.ergoTree).toEqual(ErgoAddress.fromBase58(address).ergoTree); expect(boxCandidate.creationHeight).toEqual(height); @@ -411,7 +425,6 @@ describe("Building", () => { }) .build(regularBoxes); - expect(boxCandidate.boxId).toBeUndefined(); expect(boxCandidate.value).toEqual(SAFE_MIN_BOX_VALUE); expect(boxCandidate.ergoTree).toEqual(ErgoAddress.fromBase58(address).ergoTree); expect(boxCandidate.creationHeight).toEqual(height); @@ -452,7 +465,6 @@ describe("Building", () => { }) .build(regularBoxes); - expect(boxCandidate.boxId).toBeUndefined(); expect(boxCandidate.value).toEqual(SAFE_MIN_BOX_VALUE); expect(boxCandidate.ergoTree).toEqual(ErgoAddress.fromBase58(address).ergoTree); expect(boxCandidate.creationHeight).toEqual(height); @@ -478,7 +490,6 @@ describe("Building", () => { .setAdditionalRegisters({ R4: "0e00" }) .build(regularBoxes); - expect(boxCandidate.boxId).toBeUndefined(); expect(boxCandidate.value).toEqual(SAFE_MIN_BOX_VALUE); expect(boxCandidate.ergoTree).toEqual(ErgoAddress.fromBase58(address).ergoTree); expect(boxCandidate.creationHeight).toEqual(height); diff --git a/packages/core/src/builder/outputBuilder.ts b/packages/core/src/builder/outputBuilder.ts index 274e0a93..51f165d2 100644 --- a/packages/core/src/builder/outputBuilder.ts +++ b/packages/core/src/builder/outputBuilder.ts @@ -28,11 +28,13 @@ import { type TokenAddOptions, TokensCollection } from "../models/collections/tokensCollection"; +import { ErgoBoxCandidate } from "../models/ergoBoxCandidate"; export const BOX_VALUE_PER_BYTE = BigInt(360); export const SAFE_MIN_BOX_VALUE = BigInt(1000000); export type BoxValueEstimationCallback = (outputBuilder: OutputBuilder) => bigint; +export type TransactionOutputFlags = { change: boolean }; export function estimateMinBoxValue( valuePerByte = BOX_VALUE_PER_BYTE @@ -45,12 +47,13 @@ export function estimateMinBoxValue( const DUMB_TOKEN_ID = "0000000000000000000000000000000000000000000000000000000000000000"; export class OutputBuilder { - private readonly _address: ErgoAddress; - private readonly _tokens: TokensCollection; - private _value!: bigint; - private _valueEstimator?: BoxValueEstimationCallback; - private _creationHeight?: number; - private _registers: NonMandatoryRegisters; + readonly #address: ErgoAddress; + readonly #tokens: TokensCollection; + #value!: bigint; + #valueEstimator?: BoxValueEstimationCallback; + #creationHeight?: number; + #registers: NonMandatoryRegisters; + #flags: TransactionOutputFlags = { change: false }; constructor( value: Amount | BoxValueEstimationCallback, @@ -59,57 +62,62 @@ export class OutputBuilder { ) { this.setValue(value); - this._creationHeight = creationHeight; - this._tokens = new TokensCollection(); - this._registers = {}; + this.#creationHeight = creationHeight; + this.#tokens = new TokensCollection(); + this.#registers = {}; + this.#flags = { change: false }; if (typeof recipient === "string") { - this._address = isHex(recipient) + this.#address = isHex(recipient) ? ErgoAddress.fromErgoTree(recipient) : ErgoAddress.fromBase58(recipient); } else if (recipient instanceof ErgoTree) { - this._address = recipient.toAddress(); + this.#address = recipient.toAddress(); } else { - this._address = recipient; + this.#address = recipient; } } - public get value(): bigint { - return isDefined(this._valueEstimator) ? this._valueEstimator(this) : this._value; + get value(): bigint { + return isDefined(this.#valueEstimator) ? this.#valueEstimator(this) : this.#value; } - public get address(): ErgoAddress { - return this._address; + get address(): ErgoAddress { + return this.#address; } - public get ergoTree(): ErgoTreeHex { - return this._address.ergoTree; + get ergoTree(): ErgoTreeHex { + return this.#address.ergoTree; } - public get creationHeight(): number | undefined { - return this._creationHeight; + get creationHeight(): number | undefined { + return this.#creationHeight; } - public get assets(): TokensCollection { - return this._tokens; + get assets(): TokensCollection { + return this.#tokens; } - public get additionalRegisters(): NonMandatoryRegisters { - return this._registers; + get additionalRegisters(): NonMandatoryRegisters { + return this.#registers; } - public get minting(): NewToken | undefined { + get minting(): NewToken | undefined { return this.assets.minting; } - public setValue(value: Amount | BoxValueEstimationCallback): OutputBuilder { + get flags(): TransactionOutputFlags { + return this.#flags; + } + + setValue(value: Amount | BoxValueEstimationCallback): OutputBuilder { if (typeof value === "function") { - this._valueEstimator = value; + this.#valueEstimator = value; } else { - this._value = ensureBigInt(value); - this._valueEstimator = undefined; + this.#value = ensureBigInt(value); + this.#valueEstimator = undefined; - if (this._value <= _0n) { + if (this.#value <= _0n) { throw new Error("An UTxO cannot be created without a minimum required amount."); } } @@ -117,46 +125,47 @@ export class OutputBuilder { return this; } - public addTokens( + setFlags(flags: Partial): OutputBuilder { + this.#flags = { ...this.#flags, ...flags }; + return this; + } + + addTokens( tokens: OneOrMore> | TokensCollection, options?: TokenAddOptions ): OutputBuilder { if (tokens instanceof TokensCollection) { - this._tokens.add(tokens.toArray(), options); + this.#tokens.add(tokens.toArray(), options); } else { - this._tokens.add(tokens, options); + this.#tokens.add(tokens, options); } return this; } - public addNfts(...tokenIds: TokenId[]): OutputBuilder { + addNfts(...tokenIds: TokenId[]): OutputBuilder { const tokens = tokenIds.map((tokenId) => ({ tokenId, amount: _1n })); - return this.addTokens(tokens); } - public mintToken(token: NewToken): OutputBuilder { + mintToken(token: NewToken): OutputBuilder { this.assets.mint(token); return this; } - public setCreationHeight( - height: number, - options?: { replace: boolean } - ): OutputBuilder { + setCreationHeight(height: number, options?: { replace: boolean }): OutputBuilder { if ( isUndefined(options) || options.replace === true || - (options.replace === false && isUndefined(this._creationHeight)) + (options.replace === false && isUndefined(this.#creationHeight)) ) { - this._creationHeight = height; + this.#creationHeight = height; } return this; } - public setAdditionalRegisters( + setAdditionalRegisters( registers: SequentialNonMandatoryRegisters ): OutputBuilder { const hexRegisters: NonMandatoryRegisters = {}; @@ -169,19 +178,17 @@ export class OutputBuilder { } if (!areRegistersDenselyPacked(hexRegisters)) throw new InvalidRegistersPacking(); - this._registers = hexRegisters; + this.#registers = hexRegisters; return this; } - public eject(ejector: (context: { tokens: TokensCollection }) => void): OutputBuilder { - ejector({ tokens: this._tokens }); + eject(ejector: (context: { tokens: TokensCollection }) => void): OutputBuilder { + ejector({ tokens: this.#tokens }); return this; } - public build( - transactionInputs?: UnsignedInput[] | Box[] - ): BoxCandidate { + build(transactionInputs?: UnsignedInput[] | Box[]): ErgoBoxCandidate { let tokens: TokenAmount[]; if (this.minting) { @@ -201,13 +208,16 @@ export class OutputBuilder { if (isUndefined(this.creationHeight)) throw new UndefinedCreationHeight(); - return { - value: this.value, - ergoTree: this.ergoTree, - creationHeight: this.creationHeight, - assets: tokens, - additionalRegisters: this.additionalRegisters - }; + return new ErgoBoxCandidate( + { + value: this.value, + ergoTree: this.ergoTree, + creationHeight: this.creationHeight, + assets: tokens, + additionalRegisters: this.additionalRegisters + }, + this.#flags + ); } estimateSize(value = SAFE_MIN_BOX_VALUE): number { @@ -217,7 +227,7 @@ export class OutputBuilder { value, ergoTree: this.ergoTree, creationHeight: this.creationHeight, - assets: this._tokens.toArray(DUMB_TOKEN_ID), + assets: this.#tokens.toArray(DUMB_TOKEN_ID), additionalRegisters: this.additionalRegisters }; diff --git a/packages/core/src/builder/transactionBuilder.spec.ts b/packages/core/src/builder/transactionBuilder.spec.ts index c439fa5a..e5cece35 100644 --- a/packages/core/src/builder/transactionBuilder.spec.ts +++ b/packages/core/src/builder/transactionBuilder.spec.ts @@ -171,6 +171,14 @@ describe("basic construction", () => { expect(builder.fee).toBe(fee); }); + it("Should update creation height", () => { + const builder = new TransactionBuilder(1); + expect(builder.creationHeight).to.be.equal(1); + + builder.atHeight(2); + expect(builder.creationHeight).to.be.equal(2); + }); + it("Should set min recommended fee amount", () => { const builder = new TransactionBuilder(height).from(regularBoxes).payMinFee(); @@ -431,7 +439,7 @@ describe("Building", () => { .build() .toEIP12Object(); - expect(tx.outputs).toEqual([ + expect(tx.outputs).to.deep.equal([ expectedSendingBox, expectedBabelRecreatedBox, expectedFeeBox, @@ -750,13 +758,13 @@ describe("Building", () => { expect(change2.ergoTree).toBe(a1.ergoTree); expect(change2.creationHeight).toBe(height); - expect(change2.value).toBe(_estimateBoxValue(change2)); + expect(change2.value).toBe(_estimateBoxValue(change2.toCandidate())); 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(_estimateBoxValue(change3)); + expect(change3.value).toBe(_estimateBoxValue(change3.toCandidate())); expect(change3.assets).toHaveLength(72); expect(change3.additionalRegisters).toEqual({}); }); @@ -788,7 +796,7 @@ describe("Building", () => { if (i > 0) { expect(transaction.outputs[i].value).toBe( - _estimateBoxValue(transaction.outputs[i]) + _estimateBoxValue(transaction.outputs[i].toCandidate()) ); } } @@ -813,7 +821,7 @@ describe("Building", () => { if (i > 0) { expect(transaction.outputs[i].value).toBe( - _estimateBoxValue(transaction.outputs[i]) + _estimateBoxValue(transaction.outputs[i].toCandidate()) ); } } @@ -858,7 +866,7 @@ describe("Building", () => { for (let i = 1; i < transaction.outputs.length; i++) { expect(transaction.outputs[i].assets).toHaveLength(tokensPerBox); expect(transaction.outputs[i].value).toBe( - _estimateBoxValue(transaction.outputs[i]) + _estimateBoxValue(transaction.outputs[i].toCandidate()) ); } }); diff --git a/packages/core/src/builder/transactionBuilder.ts b/packages/core/src/builder/transactionBuilder.ts index bbc141d1..87b5f4f7 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -71,57 +71,57 @@ type InputsInclusionOptions = { }; export class TransactionBuilder { - private readonly _inputs!: InputsCollection; - private readonly _dataInputs!: InputsCollection; - private readonly _outputs!: OutputsCollection; - private readonly _settings!: TransactionBuilderSettings; - private readonly _creationHeight!: number; - - private _ensureInclusion?: Set; - private _selectorCallbacks?: SelectorCallback[]; - private _changeAddress?: ErgoAddress; - private _feeAmount?: bigint; - private _burning?: TokensCollection; - private _plugins?: PluginListItem[]; + readonly #inputs: InputsCollection; + readonly #dataInputs: InputsCollection; + readonly #outputs: OutputsCollection; + readonly #settings: TransactionBuilderSettings; + + #creationHeight: number; + #ensureInclusion?: Set; + #selectorCallbacks?: SelectorCallback[]; + #changeAddress?: ErgoAddress; + #feeAmount?: bigint; + #burning?: TokensCollection; + #plugins?: PluginListItem[]; constructor(creationHeight: number) { - this._inputs = new InputsCollection(); - this._dataInputs = new InputsCollection(); - this._outputs = new OutputsCollection(); - this._settings = new TransactionBuilderSettings(); - this._creationHeight = creationHeight; + this.#inputs = new InputsCollection(); + this.#dataInputs = new InputsCollection(); + this.#outputs = new OutputsCollection(); + this.#settings = new TransactionBuilderSettings(); + this.#creationHeight = creationHeight; } - public get inputs(): InputsCollection { - return this._inputs; + get inputs(): InputsCollection { + return this.#inputs; } - public get dataInputs(): InputsCollection { - return this._dataInputs; + get dataInputs(): InputsCollection { + return this.#dataInputs; } - public get outputs(): OutputsCollection { - return this._outputs; + get outputs(): OutputsCollection { + return this.#outputs; } - public get changeAddress(): ErgoAddress | undefined { - return this._changeAddress; + get changeAddress(): ErgoAddress | undefined { + return this.#changeAddress; } - public get fee(): bigint | undefined { - return this._feeAmount; + get fee(): bigint | undefined { + return this.#feeAmount; } - public get burning(): TokensCollection | undefined { - return this._burning; + get burning(): TokensCollection | undefined { + return this.#burning; } - public get settings(): TransactionBuilderSettings { - return this._settings; + get settings(): TransactionBuilderSettings { + return this.#settings; } - public get creationHeight(): number { - return this._creationHeight; + get creationHeight(): number { + return this.#creationHeight; } /** @@ -134,104 +134,99 @@ export class TransactionBuilder { * .and.from(otherInputs); * ``` */ - public get and(): TransactionBuilder { + get and(): TransactionBuilder { return this; } - public from( + atHeight(height: number): TransactionBuilder { + this.#creationHeight = height; + return this; + } + + from( inputs: OneOrMore> | CollectionLike>, options: InputsInclusionOptions = { ensureInclusion: false } ): TransactionBuilder { const items = isCollectionLike(inputs) ? inputs.toArray() : inputs; if (options.ensureInclusion) this.#ensureInclusionOf(items); - this._inputs.add(items); + this.#inputs.add(items); return this; } #ensureInclusionOf(inputs: OneOrMore>): void { - if (!this._ensureInclusion) this._ensureInclusion = new Set(); + if (!this.#ensureInclusion) this.#ensureInclusion = new Set(); if (Array.isArray(inputs)) { - for (const input of inputs) this._ensureInclusion.add(input.boxId); + for (const input of inputs) this.#ensureInclusion.add(input.boxId); } else { - this._ensureInclusion.add(inputs.boxId); + this.#ensureInclusion.add(inputs.boxId); } } - public to( + to( outputs: OneOrMore, options?: CollectionAddOptions ): TransactionBuilder { - this._outputs.add(outputs, options); - + this.#outputs.add(outputs, options); return this; } - public withDataFrom( + withDataFrom( dataInputs: OneOrMore>, options?: CollectionAddOptions ): TransactionBuilder { - this._dataInputs.add(dataInputs, options); + this.#dataInputs.add(dataInputs, options); return this; } - public sendChangeTo( - address: ErgoAddress | Base58String | HexString - ): TransactionBuilder { + sendChangeTo(address: ErgoAddress | Base58String | HexString): TransactionBuilder { if (typeof address === "string") { - this._changeAddress = isHex(address) + this.#changeAddress = isHex(address) ? ErgoAddress.fromErgoTree(address, Network.Mainnet) : ErgoAddress.fromBase58(address); } else { - this._changeAddress = address; + this.#changeAddress = address; } return this; } - public payFee(amount: Amount): TransactionBuilder { - this._feeAmount = ensureBigInt(amount); - + payFee(amount: Amount): TransactionBuilder { + this.#feeAmount = ensureBigInt(amount); return this; } - public payMinFee(): TransactionBuilder { + payMinFee(): TransactionBuilder { this.payFee(RECOMMENDED_MIN_FEE_VALUE); - return this; } - public burnTokens(tokens: OneOrMore>): TransactionBuilder { - if (!this._burning) { - this._burning = new TokensCollection(); - } - this._burning.add(tokens); - + burnTokens(tokens: OneOrMore>): TransactionBuilder { + if (!this.#burning) this.#burning = new TokensCollection(); + this.#burning.add(tokens); return this; } - public configure(callback: ConfigureCallback): TransactionBuilder { - callback(this._settings); - + configure(callback: ConfigureCallback): TransactionBuilder { + callback(this.#settings); return this; } - public configureSelector(selectorCallback: SelectorCallback): TransactionBuilder { - if (isUndefined(this._selectorCallbacks)) this._selectorCallbacks = []; - this._selectorCallbacks.push(selectorCallback); + configureSelector(selectorCallback: SelectorCallback): TransactionBuilder { + if (isUndefined(this.#selectorCallbacks)) this.#selectorCallbacks = []; + this.#selectorCallbacks.push(selectorCallback); return this; } - public extend(plugins: FleetPlugin): TransactionBuilder { - if (!this._plugins) this._plugins = []; - this._plugins.push({ execute: plugins, pending: true }); - + extend(plugins: FleetPlugin): TransactionBuilder { + if (!this.#plugins) this.#plugins = []; + this.#plugins.push({ execute: plugins, pending: true }); return this; } - public eject(ejector: (context: EjectorContext) => void): TransactionBuilder { + eject(ejector: (context: EjectorContext) => void): TransactionBuilder { ejector({ inputs: this.inputs, dataInputs: this.dataInputs, @@ -246,10 +241,10 @@ export class TransactionBuilder { return this; } - public build(): ErgoUnsignedTransaction { - if (some(this._plugins)) { + build(): ErgoUnsignedTransaction { + if (some(this.#plugins)) { const context = createPluginContext(this); - for (const plugin of this._plugins) { + for (const plugin of this.#plugins) { if (plugin.pending) { plugin.execute(context); plugin.pending = false; @@ -257,12 +252,12 @@ export class TransactionBuilder { } } - if (this._isMinting()) { - if (this._isMoreThanOneTokenBeingMinted()) { + if (this.#isMinting()) { + if (this.#isMoreThanOneTokenBeingMinted()) { throw new MalformedTransaction("only one token can be minted per transaction."); } - if (this._isTheSameTokenBeingMintedFromOutsideTheMintingBox()) { + if (this.#isTheSameTokenBeingMintedFromOutsideTheMintingBox()) { throw new NonStandardizedMinting( "EIP-4 tokens cannot be minted from outside of the minting box." ); @@ -272,31 +267,32 @@ export class TransactionBuilder { this.outputs .toArray() .map((output) => - output.setCreationHeight(this._creationHeight, { replace: false }) + output.setCreationHeight(this.#creationHeight, { replace: false }) ); const outputs = this.outputs.clone(); - if (isDefined(this._feeAmount)) { - outputs.add(new OutputBuilder(this._feeAmount, FEE_CONTRACT)); + if (isDefined(this.#feeAmount)) { + outputs.add(new OutputBuilder(this.#feeAmount, FEE_CONTRACT)); } const selector = new BoxSelector(this.inputs.toArray()); - if (this._ensureInclusion?.size) { - selector.ensureInclusion(Array.from(this._ensureInclusion)); + if (this.#ensureInclusion?.size) { + selector.ensureInclusion(Array.from(this.#ensureInclusion)); } - if (some(this._selectorCallbacks)) { - for (const selectorCallBack of this._selectorCallbacks) { + if (some(this.#selectorCallbacks)) { + for (const selectorCallBack of this.#selectorCallbacks) { selectorCallBack(selector); } } - const target = some(this._burning) - ? outputs.sum({ tokens: this._burning.toArray() }) + const target = some(this.#burning) + ? outputs.sum({ tokens: this.#burning.toArray() }) : outputs.sum(); let inputs = selector.select(target); - if (isDefined(this._changeAddress)) { + if (isDefined(this.#changeAddress)) { + const changeBoxes: OutputBuilder[] = []; const firstInputId = inputs[0].boxId; const manualMintingTokenId = target.tokens.some((x) => x.tokenId === firstInputId) ? firstInputId @@ -307,12 +303,11 @@ export class TransactionBuilder { } let change = utxoDiff(utxoSum(inputs), target); - const changeBoxes: OutputBuilder[] = []; if (some(change.tokens)) { let minRequiredNanoErgs = estimateMinChangeValue({ - changeAddress: this._changeAddress, - creationHeight: this._creationHeight, + changeAddress: this.#changeAddress, + creationHeight: this.#creationHeight, tokens: change.tokens, maxTokensPerBox: this.settings.maxTokensPerChangeBox, baseIndex: this.outputs.length + 1 @@ -326,21 +321,20 @@ export class TransactionBuilder { change = utxoDiff(utxoSum(inputs), target); minRequiredNanoErgs = estimateMinChangeValue({ - changeAddress: this._changeAddress, - creationHeight: this._creationHeight, + changeAddress: this.#changeAddress, + creationHeight: this.#creationHeight, tokens: change.tokens, maxTokensPerBox: this.settings.maxTokensPerChangeBox, baseIndex: this.outputs.length + 1 }); } - const chunkedTokens = chunk(change.tokens, this._settings.maxTokensPerChangeBox); + const chunkedTokens = chunk(change.tokens, this.#settings.maxTokensPerChangeBox); for (const tokens of chunkedTokens) { - const output = new OutputBuilder( - estimateMinBoxValue(), - this._changeAddress, - this._creationHeight - ).addTokens(tokens); + const output = new OutputBuilder(estimateMinBoxValue(), this.#changeAddress) + .setCreationHeight(this.#creationHeight) + .addTokens(tokens) + .setFlags({ change: true }); change.nanoErgs -= output.value; changeBoxes.push(output); @@ -348,33 +342,36 @@ export class TransactionBuilder { } if (change.nanoErgs > _0n) { - if (some(changeBoxes)) { - if (this.settings.shouldIsolateErgOnChange) { - outputs.add(new OutputBuilder(change.nanoErgs, this._changeAddress)); - } else { - const firstChangeBox = first(changeBoxes); - firstChangeBox.setValue(firstChangeBox.value + change.nanoErgs); - } - - outputs.add(changeBoxes); + if (!changeBoxes.length || this.settings.shouldIsolateErgOnChange) { + changeBoxes.unshift( + new OutputBuilder(change.nanoErgs, this.#changeAddress) + .setCreationHeight(this.#creationHeight) + .setFlags({ change: true }) + ); } else { - outputs.add(new OutputBuilder(change.nanoErgs, this._changeAddress)); + const firstChangeBox = first(changeBoxes); + firstChangeBox.setValue(firstChangeBox.value + change.nanoErgs); } } + + outputs.add(changeBoxes); } for (const input of inputs) { if (!input.isValid()) throw new InvalidInput(input.boxId); } + const buildedOutputs = outputs + .toArray() + .map((output) => + output.setCreationHeight(this.#creationHeight, { replace: false }).build(inputs) + ); + const unsignedTransaction = new ErgoUnsignedTransaction( inputs, this.dataInputs.toArray(), - outputs - .toArray() - .map((output) => - output.setCreationHeight(this._creationHeight, { replace: false }).build(inputs) - ) + buildedOutputs, + this ); let burning = unsignedTransaction.burning; @@ -382,37 +379,40 @@ export class TransactionBuilder { throw new MalformedTransaction("it's not possible to burn ERG."); } - if (some(burning.tokens) && some(this._burning)) { - burning = utxoDiff(burning, { nanoErgs: _0n, tokens: this._burning.toArray() }); + if (some(burning.tokens) && some(this.#burning)) { + burning = utxoDiff(burning, { + nanoErgs: _0n, + tokens: this.#burning.toArray() + }); } - if (!this._settings.canBurnTokens && some(burning.tokens)) { + if (!this.#settings.canBurnTokens && some(burning.tokens)) { throw new NotAllowedTokenBurning(); } return unsignedTransaction; } - private _getMintingOutput(): OutputBuilder | undefined { - for (const output of this._outputs) { + #getMintingOutput(): OutputBuilder | undefined { + for (const output of this.#outputs) { if (output.minting) return output; } return; } - private _isMinting(): boolean { - return this._getMintingOutput() !== undefined; + #isMinting(): boolean { + return this.#getMintingOutput() !== undefined; } - private _getMintingTokenId(): string | undefined { - return this._getMintingOutput()?.minting?.tokenId; + #getMintingTokenId(): string | undefined { + return this.#getMintingOutput()?.minting?.tokenId; } - private _isMoreThanOneTokenBeingMinted(): boolean { + #isMoreThanOneTokenBeingMinted(): boolean { let mintingCount = 0; - for (const output of this._outputs) { + for (const output of this.#outputs) { if (output.minting) { mintingCount++; if (mintingCount > 1) return true; @@ -422,12 +422,12 @@ export class TransactionBuilder { return false; } - private _isTheSameTokenBeingMintedFromOutsideTheMintingBox(): boolean { - const mintingTokenId = this._getMintingTokenId(); + #isTheSameTokenBeingMintedFromOutsideTheMintingBox(): boolean { + const mintingTokenId = this.#getMintingTokenId(); if (isUndefined(mintingTokenId)) return false; let count = 0; - for (const output of this._outputs) { + for (const output of this.#outputs) { if (output.assets.contains(mintingTokenId)) { count++; if (count > 1) return true; diff --git a/packages/core/src/models/collections/inputsCollection.spec.ts b/packages/core/src/models/collections/inputsCollection.spec.ts index 92fad936..8d348783 100644 --- a/packages/core/src/models/collections/inputsCollection.spec.ts +++ b/packages/core/src/models/collections/inputsCollection.spec.ts @@ -29,7 +29,9 @@ describe("inputs collection", () => { collection.add(regularBoxes); expect(collection).toHaveLength(regularBoxes.length); - expect(collection.toArray()).toEqual(regularBoxes); + expect(collection.toArray().map((x) => x.boxId)).toEqual( + regularBoxes.map((x) => x.boxId) + ); }); it("Should add a multiple items and map properly", () => { @@ -49,7 +51,9 @@ describe("inputs collection", () => { ); expect(collection).toHaveLength(regularBoxes.length); - expect(collection.toArray()).toEqual(regularBoxes); + expect(collection.toArray().map((x) => x.boxId)).toEqual( + regularBoxes.map((x) => x.boxId) + ); }); it("Should throw if box is already included", () => { diff --git a/packages/core/src/models/ergoBox.spec.ts b/packages/core/src/models/ergoBox.spec.ts index 0bf38c45..bbe1f158 100644 --- a/packages/core/src/models/ergoBox.spec.ts +++ b/packages/core/src/models/ergoBox.spec.ts @@ -1,4 +1,4 @@ -import type { NonMandatoryRegisters } from "@fleet-sdk/common"; +import type { Amount, BoxCandidate, NonMandatoryRegisters, Box } from "@fleet-sdk/common"; import { invalidBoxes, manyTokensBoxes, @@ -6,23 +6,42 @@ import { regularBoxes, validBoxes } from "_test-vectors"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, test } from "vitest"; import { ErgoBox } from "./ergoBox"; describe("Construction", () => { - it("Should construct from a vanilla object", () => { - for (const box of regularBoxes) { - const ergoBox = new ErgoBox(box); - - expect(ergoBox.boxId).toBe(box.boxId); - expect(ergoBox.value).toBe(box.value); - expect(ergoBox.ergoTree).toBe(box.ergoTree); - expect(ergoBox.assets).toEqual(box.assets); - expect(ergoBox.creationHeight).toBe(box.creationHeight); - expect(ergoBox.additionalRegisters).toBe(box.additionalRegisters); - expect(ergoBox.transactionId).toBe(box.transactionId); - expect(ergoBox.index).toBe(box.index); - } + test.each(regularBoxes)("Should construct from a vanilla object", (tv) => { + const ergoBox = new ErgoBox(tv); + + expect(ergoBox.boxId).toBe(tv.boxId); + expect(ergoBox.value).toBe(tv.value); + expect(ergoBox.ergoTree).toBe(tv.ergoTree); + expect(ergoBox.assets).toEqual(tv.assets); + expect(ergoBox.creationHeight).toBe(tv.creationHeight); + expect(ergoBox.additionalRegisters).toBe(tv.additionalRegisters); + expect(ergoBox.transactionId).toBe(tv.transactionId); + expect(ergoBox.index).toBe(tv.index); + }); + + test.each(regularBoxes)("Should construct from a candidate and compute boxId", (tv) => { + const ergoBox = new ErgoBox(boxToCandidate(tv), tv.transactionId, tv.index); + + expect(ergoBox.boxId).to.be.equal(tv.boxId); + expect(ergoBox.transactionId).to.be.equal(tv.transactionId); + expect(ergoBox.index).to.be.equal(tv.index); + }); + + it("Should throw if transactionId or index is not provided for box candidate", () => { + const box = regularBoxes[0]; + const candidate = boxToCandidate(box); + + expect( + () => new ErgoBox(candidate, undefined as unknown as string, box.index) + ).to.throw("TransactionId and Index must be provided for Box generation."); + + expect( + () => new ErgoBox(candidate, box.transactionId, undefined as unknown as number) + ).to.throw("TransactionId and Index must be provided for Box generation."); }); }); @@ -51,3 +70,13 @@ describe("Validation", () => { } }); }); + +function boxToCandidate(tv: Box): BoxCandidate { + return { + value: tv.value, + ergoTree: tv.ergoTree, + creationHeight: tv.creationHeight, + assets: tv.assets, + additionalRegisters: tv.additionalRegisters + }; +} diff --git a/packages/core/src/models/ergoBox.ts b/packages/core/src/models/ergoBox.ts index b5528c14..9d78c595 100644 --- a/packages/core/src/models/ergoBox.ts +++ b/packages/core/src/models/ergoBox.ts @@ -1,30 +1,106 @@ -import type { Amount, Box, NonMandatoryRegisters, TokenAmount } from "@fleet-sdk/common"; -import { ensureBigInt } from "@fleet-sdk/common"; +import type { + Amount, + Box, + NonMandatoryRegisters, + BoxCandidate, + TokenAmount, + PlainObjectType, + DataInput +} from "@fleet-sdk/common"; +import { FleetError, isDefined, isUndefined } from "@fleet-sdk/common"; import { blake2b256, hex } from "@fleet-sdk/crypto"; import { serializeBox } from "@fleet-sdk/serializer"; +import { ErgoBoxCandidate } from "./ergoBoxCandidate"; export class ErgoBox { - boxId!: string; - value!: bigint; - ergoTree!: string; - creationHeight!: number; - assets!: TokenAmount[]; - additionalRegisters!: R; - transactionId!: string; - index!: number; - - constructor(box: Box) { - this.boxId = box.boxId; - this.ergoTree = box.ergoTree; - this.creationHeight = box.creationHeight; - this.value = ensureBigInt(box.value); - this.assets = box.assets.map((asset) => ({ - tokenId: asset.tokenId, - amount: ensureBigInt(asset.amount) - })); - this.additionalRegisters = box.additionalRegisters; - this.transactionId = box.transactionId; - this.index = box.index; + #candidate: ErgoBoxCandidate; + + #boxId?: string; + #transactionId: string; + #index: number; + + get value(): bigint { + return this.#candidate.value; + } + + get ergoTree(): string { + return this.#candidate.ergoTree; + } + + get creationHeight(): number { + return this.#candidate.creationHeight; + } + + get assets(): TokenAmount[] { + return this.#candidate.assets; + } + + get additionalRegisters(): R { + return this.#candidate.additionalRegisters; + } + + get boxId(): string { + if (!this.#boxId) { + this.#boxId = hex.encode(blake2b256(serializeBox(this).toBytes())); + } + + return this.#boxId; + } + + get transactionId(): string { + return this.#transactionId; + } + + get index(): number { + return this.#index; + } + + get change(): boolean { + return !!this.#candidate.flags?.change; + } + + constructor(candidate: ErgoBoxCandidate, transactionId: string, index: number); + constructor(candidate: BoxCandidate, transactionId: string, index: number); + constructor(box: Box); + constructor( + box: Box | ErgoBoxCandidate | BoxCandidate, + transactionId?: string, + index?: number + ) { + if (isBox(box)) { + this.#candidate = new ErgoBoxCandidate(box); + this.#transactionId = box.transactionId; + this.#index = box.index; + this.#boxId = box.boxId; + } else { + if (!transactionId || isUndefined(index)) { + throw new FleetError( + "TransactionId and Index must be provided for Box generation." + ); + } + + this.#candidate = box instanceof ErgoBoxCandidate ? box : new ErgoBoxCandidate(box); + this.#transactionId = transactionId; + this.#index = index; + } + } + + toPlainObject(type: "minimal"): DataInput; + toPlainObject(type: "EIP-12"): Box; + toPlainObject(type: PlainObjectType): Box | DataInput; + toPlainObject(type: PlainObjectType): Box | DataInput { + if (type === "minimal") return { boxId: this.boxId }; + + return { + boxId: this.boxId, + ...this.#candidate.toPlainObject(), + transactionId: this.transactionId, + index: this.index + }; + } + + toCandidate(): ErgoBoxCandidate { + return this.#candidate; } public isValid(): boolean { @@ -38,3 +114,8 @@ export class ErgoBox { return box.boxId === hash; } } + +function isBox(box: Box | BoxCandidate): box is Box { + const castedBox = box as Box; + return !!castedBox.boxId && !!castedBox.transactionId && isDefined(castedBox.index); +} diff --git a/packages/core/src/models/ergoBoxCandidate.ts b/packages/core/src/models/ergoBoxCandidate.ts new file mode 100644 index 00000000..efc31cb6 --- /dev/null +++ b/packages/core/src/models/ergoBoxCandidate.ts @@ -0,0 +1,47 @@ +import type { + NonMandatoryRegisters, + TokenAmount, + BoxCandidate, + Amount +} from "@fleet-sdk/common"; +import { ensureBigInt } from "@fleet-sdk/common"; +import { ErgoBox } from "./ergoBox"; +import type { TransactionOutputFlags } from "../builder"; + +export class ErgoBoxCandidate { + value: bigint; + ergoTree: string; + creationHeight: number; + assets: TokenAmount[]; + additionalRegisters: R; + flags?: TransactionOutputFlags; + + constructor(candidate: BoxCandidate, flags?: TransactionOutputFlags) { + this.ergoTree = candidate.ergoTree; + this.creationHeight = candidate.creationHeight; + this.value = ensureBigInt(candidate.value); + this.assets = candidate.assets.map((asset) => ({ + tokenId: asset.tokenId, + amount: ensureBigInt(asset.amount) + })); + this.additionalRegisters = candidate.additionalRegisters; + this.flags = flags; + } + + toBox(transactionId: string, index: number): ErgoBox { + return new ErgoBox(this, transactionId, index); + } + + toPlainObject(): BoxCandidate { + return { + value: this.value.toString(), + ergoTree: this.ergoTree, + creationHeight: this.creationHeight, + assets: this.assets.map((asset) => ({ + tokenId: asset.tokenId, + amount: asset.amount.toString() + })), + additionalRegisters: this.additionalRegisters + }; + } +} diff --git a/packages/core/src/models/ergoUnsignedInput.spec.ts b/packages/core/src/models/ergoUnsignedInput.spec.ts index dc2d0ec1..cd370667 100644 --- a/packages/core/src/models/ergoUnsignedInput.spec.ts +++ b/packages/core/src/models/ergoUnsignedInput.spec.ts @@ -16,7 +16,7 @@ describe("Construction", () => { expect(input.additionalRegisters).toBe(box.additionalRegisters); expect(input.transactionId).toBe(box.transactionId); expect(input.index).toBe(box.index); - expect(input.extension).toBeUndefined(); + expect(input.extension).to.be.deep.equal({}); } }); }); @@ -45,7 +45,7 @@ describe("Tweaking", () => { describe("Unsigned input object conversion", () => { it("Should convert to default unsigned input object and set empty extension", () => { for (const box of regularBoxes) { - expect(new ErgoUnsignedInput(box).toUnsignedInputObject("default")).toEqual({ + expect(new ErgoUnsignedInput(box).toPlainObject("minimal")).toEqual({ boxId: box.boxId, extension: {} }); @@ -57,7 +57,7 @@ describe("Unsigned input object conversion", () => { expect( new ErgoUnsignedInput(box) .setContextExtension({ 0: "0580c0fc82aa02" }) - .toUnsignedInputObject("default") + .toPlainObject("minimal") ).toEqual({ boxId: box.boxId, extension: { 0: "0580c0fc82aa02" } @@ -70,7 +70,7 @@ describe("Unsigned input object conversion", () => { expect( new ErgoUnsignedInput(box) .setContextExtension({ 0: "0580c0fc82aa02" }) - .toUnsignedInputObject("EIP-12") + .toPlainObject("EIP-12") ).toEqual({ boxId: box.boxId, value: box.value.toString(), @@ -92,7 +92,7 @@ describe("Unsigned input object conversion", () => { describe("Unsigned data input object conversion", () => { it("Should convert to default data input object and set empty extension", () => { for (const box of regularBoxes) { - expect(new ErgoUnsignedInput(box).toPlainObject("default")).toEqual({ + expect(new ErgoUnsignedInput(box).toDataInputPlainObject("minimal")).toEqual({ boxId: box.boxId }); } @@ -103,7 +103,7 @@ describe("Unsigned data input object conversion", () => { expect( new ErgoUnsignedInput(box) .setContextExtension({ 0: "0580c0fc82aa02" }) - .toPlainObject("default") + .toDataInputPlainObject("minimal") ).toEqual({ boxId: box.boxId }); @@ -115,7 +115,7 @@ describe("Unsigned data input object conversion", () => { expect( new ErgoUnsignedInput(box) .setContextExtension({ 0: "0580c0fc82aa02" }) - .toPlainObject("EIP-12") + .toDataInputPlainObject("EIP-12") ).toEqual({ boxId: box.boxId, value: box.value.toString(), diff --git a/packages/core/src/models/ergoUnsignedInput.ts b/packages/core/src/models/ergoUnsignedInput.ts index 002969a0..4201fad6 100644 --- a/packages/core/src/models/ergoUnsignedInput.ts +++ b/packages/core/src/models/ergoUnsignedInput.ts @@ -1,19 +1,17 @@ import type { Amount, Box, - BuildOutputType, + PlainObjectType, ContextExtension, - DataInput, - EIP12UnsignedDataInput, EIP12UnsignedInput, NonMandatoryRegisters, - UnsignedInput + UnsignedInput, + DataInput, + EIP12UnsignedDataInput } from "@fleet-sdk/common"; import type { ConstantInput } from "../builder"; import { ErgoBox } from "./ergoBox"; -type InputType = T extends "default" ? UnsignedInput : EIP12UnsignedInput; -type DataInputType = T extends "default" ? DataInput : EIP12UnsignedDataInput; type InputBox = Box & { extension?: ContextExtension; }; @@ -24,19 +22,16 @@ export class ErgoUnsignedInput< > extends ErgoBox { #extension?: ContextExtension; - public get extension(): ContextExtension | undefined { - return this.#extension; + get extension(): ContextExtension { + return this.#extension || {}; } constructor(box: InputBox) { super(box); - - if (box.extension) { - this.setContextVars(box.extension); - } + if (box.extension) this.setContextExtension(box.extension); } - public setContextExtension(extension: ContextExtensionInput): ErgoUnsignedInput { + setContextExtension(extension: ContextExtensionInput): ErgoUnsignedInput { const vars: ContextExtension = {}; for (const key in extension) { const c = extension[key] as ConstantInput; @@ -54,34 +49,21 @@ export class ErgoUnsignedInput< /** * @deprecated use `setContextExtension` instead. */ - public setContextVars(extension: ContextExtensionInput): ErgoUnsignedInput { + setContextVars(extension: ContextExtensionInput): ErgoUnsignedInput { return this.setContextExtension(extension); } - public toUnsignedInputObject(type: T): InputType { - return { - ...this.toPlainObject(type), - extension: this.#extension || {} - } as InputType; + override toPlainObject(type: "EIP-12"): EIP12UnsignedInput; + override toPlainObject(type: "minimal"): UnsignedInput; + override toPlainObject(type: PlainObjectType): EIP12UnsignedInput | UnsignedInput; + override toPlainObject(type: PlainObjectType): EIP12UnsignedInput | UnsignedInput { + return { ...super.toPlainObject(type), extension: this.extension }; } - public toPlainObject(type: T): DataInputType { - if (type === "EIP-12") { - return { - boxId: this.boxId, - value: this.value.toString(), - ergoTree: this.ergoTree, - creationHeight: this.creationHeight, - assets: this.assets.map((asset) => ({ - tokenId: asset.tokenId, - amount: asset.amount.toString() - })), - additionalRegisters: this.additionalRegisters, - transactionId: this.transactionId, - index: this.index - } as DataInputType; - } - - return { boxId: this.boxId } as DataInputType; + toDataInputPlainObject(type: "EIP-12"): EIP12UnsignedDataInput; + toDataInputPlainObject(type: "minimal"): DataInput; + toDataInputPlainObject(type: PlainObjectType): EIP12UnsignedDataInput | DataInput; + toDataInputPlainObject(type: PlainObjectType): EIP12UnsignedDataInput | DataInput { + return super.toPlainObject(type); } } diff --git a/packages/core/src/models/ergoUnsignedTransaction.spec.ts b/packages/core/src/models/ergoUnsignedTransaction.spec.ts index 5d9e7b65..87497658 100644 --- a/packages/core/src/models/ergoUnsignedTransaction.spec.ts +++ b/packages/core/src/models/ergoUnsignedTransaction.spec.ts @@ -1,28 +1,40 @@ import { serializeTransaction } from "@fleet-sdk/serializer"; -import { regularBoxes } from "_test-vectors"; -import { mockedUnsignedTransactions } from "_test-vectors"; +import { regularBoxes, validBoxes, mockedUnsignedTransactions } from "_test-vectors"; import { describe, expect, it } from "vitest"; -import { OutputBuilder, SAFE_MIN_BOX_VALUE, TransactionBuilder } from "../builder"; +import { + FEE_CONTRACT, + OutputBuilder, + RECOMMENDED_MIN_FEE_VALUE, + SAFE_MIN_BOX_VALUE, + TransactionBuilder +} from "../builder"; import { ErgoUnsignedInput } from "./ergoUnsignedInput"; import { ErgoUnsignedTransaction } from "./ergoUnsignedTransaction"; +import { ErgoBoxCandidate } from "./ergoBoxCandidate"; +import { ErgoAddress } from "./ergoAddress"; +import { blake2b256, hex } from "@fleet-sdk/crypto"; +import type { + Box, + DataInput, + EIP12UnsignedDataInput, + EIP12UnsignedInput, + UnsignedInput, + UnsignedTransaction +} from "@fleet-sdk/common"; -describe("ErgoUnsignedTransaction model", () => { - it("Should generate the right transactionId", () => { +describe("Model", () => { + it("Should generate the right transactionId and boxIds for outputs", () => { for (const tx of mockedUnsignedTransactions) { const unsigned = new ErgoUnsignedTransaction( tx.inputs.map((input) => new ErgoUnsignedInput(input)), tx.dataInputs.map((dataInput) => new ErgoUnsignedInput(dataInput)), - tx.outputs.map((output) => ({ - ...output, - value: BigInt(output.value), - assets: output.assets.map((token) => ({ - tokenId: token.tokenId, - amount: BigInt(token.amount) - })) - })) + tx.outputs.map((output) => new ErgoBoxCandidate(output)) ); - expect(unsigned.id).toBe(tx.id); + expect(unsigned.id).to.be.equal(tx.id); + expect(unsigned.outputs.map((x) => x.boxId)).to.be.deep.equal( + tx.outputs.map((x) => x.boxId) + ); } }); @@ -31,14 +43,7 @@ describe("ErgoUnsignedTransaction model", () => { const unsigned = new ErgoUnsignedTransaction( tx.inputs.map((input) => new ErgoUnsignedInput(input)), tx.dataInputs.map((dataInput) => new ErgoUnsignedInput(dataInput)), - tx.outputs.map((output) => ({ - ...output, - value: BigInt(output.value), - assets: output.assets.map((token) => ({ - tokenId: token.tokenId, - amount: BigInt(token.amount) - })) - })) + tx.outputs.map((output) => new ErgoBoxCandidate(output)) ); expect(unsigned.toBytes()).toEqual(serializeTransaction(tx).toBytes()); @@ -93,4 +98,190 @@ describe("ErgoUnsignedTransaction model", () => { expect(transaction.burning).toEqual({ nanoErgs: 0n, tokens: tokensToBurn }); }); + + it("Should filter change boxes", () => { + const transaction = new TransactionBuilder(1) + .from(regularBoxes) + .withDataFrom(regularBoxes[0]) + .to( + new OutputBuilder( + SAFE_MIN_BOX_VALUE, + "9i3g6d958MpZAqWn9hrTHcqbBiY5VPYBBY6vRDszZn4koqnahin" + ) + ) + .payMinFee() + .sendChangeTo("9i3g6d958MpZAqWn9hrTHcqbBiY5VPYBBY6vRDszZn4koqnahin") + .build(); + + expect(transaction.change).to.have.length(1); + }); +}); + +describe("Chaining", () => { + const height = 12434; + const value = SAFE_MIN_BOX_VALUE; + const [address1, address2, address3] = [ + "9i3g6d958MpZAqWn9hrTHcqbBiY5VPYBBY6vRDszZn4koqnahin", + "9h9wCAdtnFbBoFQL6ePYNiz6SyAFpYWZX9VaBnyJj8BHzqx6yFn", + "9fJCKJahBVQUKq8eR714YJu4Euds8nPTijBok3EDNzj49L8cACn" + ].map(ErgoAddress.decode); + + it("Should chain transactions and include father change as child's input", () => { + const chain = new TransactionBuilder(height) + .from(regularBoxes) + .to(new OutputBuilder(value, address1)) + .payMinFee() + .sendChangeTo(address2) + .build() + .chain((builder) => + builder + .to(new OutputBuilder(value, address3)) + .build() + .chain((builder) => builder.to(new OutputBuilder(value, address3)).build()) + ); + + const chainArray = chain.toArray(); + expect(chainArray).to.have.length(3); + + const [first, second, third] = chainArray; + expect(first.outputs.map((x) => x.boxId)).to.containSubset( + second.inputs.map((x) => x.boxId) + ); + + expect(second.outputs.map((x) => x.boxId)).to.containSubset( + third.inputs.map((x) => x.boxId) + ); + }); + + it("Should chain transactions and override change address and fee from the father to all children", () => { + const feeValue = RECOMMENDED_MIN_FEE_VALUE * 2n; + + const chain = new TransactionBuilder(height) + .from(regularBoxes) + .to(new OutputBuilder(value, address1)) + .payFee(feeValue) + .sendChangeTo(address2) + .build() + .chain((builder) => + builder + .to(new OutputBuilder(value, address3)) + .build() + .chain((b) => + b + .to(new OutputBuilder(value, address1)) + .build() + .chain((b) => b.to(new OutputBuilder(value, address2)).build()) + ) + ); + + const chainArray = chain.toArray(); + expect(chainArray).to.have.length(4); + + const feeOutputs = chainArray + .flatMap((x) => x.outputs) + .filter((x) => x.ergoTree === FEE_CONTRACT); + expect(feeOutputs).to.have.length(4); + expect(feeOutputs.every((x) => x.value === feeValue)).to.be.true; + + const changeOutputs = chainArray.flatMap((x) => x.change); + expect(changeOutputs.every((x) => x.ergoTree === address2.ergoTree)).to.be.true; + }); + + it("Should chain transactions and customize fee and change address", () => { + const feeValue = RECOMMENDED_MIN_FEE_VALUE * 2n; + const chain = new TransactionBuilder(height) + .from(regularBoxes) + .to(new OutputBuilder(value, address1)) + .payFee(feeValue) + .sendChangeTo(address2) + .build() + .chain((builder) => + builder + .to(new OutputBuilder(value, address3)) + .payMinFee() + .build() + .chain((b) => + b + .to(new OutputBuilder(value, address1)) + .sendChangeTo(address3) + .build() + .chain((b) => b.to(new OutputBuilder(value, address2))) + ) + ); + + const chainArray = chain.toArray(); + expect(chainArray).to.have.length(4); + + const feeOutputs = chainArray + .flatMap((x) => x.outputs) + .filter((x) => x.ergoTree === FEE_CONTRACT); + expect(feeOutputs).to.have.length(4); + expect(feeOutputs[0].value).to.be.equal(feeValue); + expect(feeOutputs[1].value).to.be.equal(RECOMMENDED_MIN_FEE_VALUE); + expect(feeOutputs[2].value).to.be.equal(RECOMMENDED_MIN_FEE_VALUE); + expect(feeOutputs[3].value).to.be.equal(RECOMMENDED_MIN_FEE_VALUE); + + const changeOutputs = chainArray.flatMap((x) => x.change); + expect(changeOutputs).to.have.length(4); + expect(changeOutputs[0].ergoTree).to.be.equal(address2.ergoTree); + expect(changeOutputs[1].ergoTree).to.be.equal(address2.ergoTree); + expect(changeOutputs[2].ergoTree).to.be.equal(address3.ergoTree); + expect(changeOutputs[2].ergoTree).to.be.equal(address3.ergoTree); + }); + + it("Should convert to plain object", () => { + const computeId = (tx: UnsignedTransaction) => + hex.encode(blake2b256(serializeTransaction(tx).toBytes())); + const isEip12Input = ( + x: UnsignedInput | EIP12UnsignedDataInput | EIP12UnsignedInput | DataInput + ): x is EIP12UnsignedInput => + "value" in x && "additionalRegisters" && "ergoTree" in x && "assets" in x; + + const chain = new TransactionBuilder(height) + .from(regularBoxes) + .to(new OutputBuilder(value, address1)) + .withDataFrom(validBoxes[0]) + .payMinFee() + .sendChangeTo(address2) + .build() + .chain((b) => b.to(new OutputBuilder(value, address3)).payMinFee()); + + const chainArray = chain.toArray(); + const nodeTxns = chain.toPlainObject(); + const eip12Txns = chain.toEIP12Object(); + + // check correctness of ids + for (let i = 0; i < chainArray.length; i++) { + expect(computeId(nodeTxns[i])).to.be.equal(chainArray[i].id); + expect(computeId(eip12Txns[i])).to.be.equal(chainArray[i].id); + } + + // check object structure + expect(nodeTxns.flatMap((x) => x.inputs).every((x) => !isEip12Input(x))).to.be.true; + expect(nodeTxns.flatMap((x) => x.dataInputs).every((x) => !isEip12Input(x))).to.be + .true; + + expect(eip12Txns.flatMap((x) => x.inputs).every(isEip12Input)).to.be.true; + expect(eip12Txns.flatMap((x) => x.dataInputs).every(isEip12Input)).to.be.true; + }); + + it("Should fail when trying to chain without the parent builder", () => { + const tx = new TransactionBuilder(height) + .from(regularBoxes) + .to(new OutputBuilder(value, address1)) + .withDataFrom(validBoxes[0]) + .payMinFee() + .sendChangeTo(address2) + .build(); + + const noParentBuilderTx = new ErgoUnsignedTransaction( + tx.inputs, + tx.dataInputs, + tx.outputs.map((x) => x.toCandidate()) + ); + + expect(() => + noParentBuilderTx.chain((b) => b.to(new OutputBuilder(value, address3)).payMinFee()) + ).to.throw("Cannot chain transactions without a parent TransactionBuilder"); + }); }); diff --git a/packages/core/src/models/ergoUnsignedTransaction.ts b/packages/core/src/models/ergoUnsignedTransaction.ts index bff6613f..81dcddda 100644 --- a/packages/core/src/models/ergoUnsignedTransaction.ts +++ b/packages/core/src/models/ergoUnsignedTransaction.ts @@ -1,54 +1,86 @@ import { - type BoxCandidate, type BoxSummary, - type BuildOutputType, + type PlainObjectType, type EIP12UnsignedTransaction, type UnsignedTransaction, utxoDiff, - utxoSum + utxoSum, + FleetError } from "@fleet-sdk/common"; import { blake2b256, hex } from "@fleet-sdk/crypto"; import { serializeTransaction } from "@fleet-sdk/serializer"; +import { TransactionBuilder } from "../builder"; import type { ErgoUnsignedInput } from "./ergoUnsignedInput"; - -type Input = ErgoUnsignedInput; -type Output = BoxCandidate; -type ReadOnlyInputs = readonly Input[]; -type ReadOnlyOutputs = readonly Output[]; +import type { ErgoBox } from "./ergoBox"; +import type { ErgoBoxCandidate } from "./ergoBoxCandidate"; +import { ErgoUnsignedTransactionChain } from "./ergoUnsignedTransactionChain"; type TransactionType = T extends "default" ? UnsignedTransaction : EIP12UnsignedTransaction; +export type ChainCallback = ( + child: TransactionBuilder, + parent: ErgoUnsignedTransaction +) => TransactionBuilder | ErgoUnsignedTransaction | ErgoUnsignedTransactionChain; + export class ErgoUnsignedTransaction { - private readonly _inputs!: ReadOnlyInputs; - private readonly _dataInputs!: ReadOnlyInputs; - private readonly _outputs!: ReadOnlyOutputs; - - constructor(inputs: Input[], dataInputs: Input[], outputs: Output[]) { - this._inputs = Object.freeze(inputs); - this._dataInputs = Object.freeze(dataInputs); - this._outputs = Object.freeze(outputs); + readonly #inputs: ErgoUnsignedInput[]; + readonly #dataInputs: ErgoUnsignedInput[]; + readonly #outputCandidates: ErgoBoxCandidate[]; + + #child?: ErgoUnsignedTransaction; + #outputs?: ErgoBox[]; + #change?: ErgoBox[]; + #id?: string; + #builder?: TransactionBuilder; + + constructor( + inputs: ErgoUnsignedInput[], + dataInputs: ErgoUnsignedInput[], + outputs: ErgoBoxCandidate[], + builder?: TransactionBuilder + ) { + this.#inputs = inputs; + this.#dataInputs = dataInputs; + this.#outputCandidates = outputs; + this.#builder = builder; } get id(): string { - return hex.encode(blake2b256(this.toBytes())); + if (!this.#id) { + this.#id = hex.encode(blake2b256(this.toBytes())); + } + + return this.#id; } - get inputs(): ReadOnlyInputs { - return this._inputs; + get inputs(): ErgoUnsignedInput[] { + return this.#inputs; } - get dataInputs(): ReadOnlyInputs { - return this._dataInputs; + get dataInputs(): ErgoUnsignedInput[] { + return this.#dataInputs; } - get outputs(): ReadOnlyOutputs { - return this._outputs; + get outputs(): ErgoBox[] { + if (!this.#outputs) { + this.#outputs = this.#outputCandidates.map((x, i) => x.toBox(this.id, i)); + } + + return this.#outputs; + } + + get change(): ErgoBox[] { + if (!this.#change) { + this.#change = this.outputs.filter((x) => x.change); + } + + return this.#change; } get burning(): BoxSummary { - const diff = utxoDiff(utxoSum(this.inputs), utxoSum(this.outputs)); + const diff = utxoDiff(utxoSum(this.inputs), utxoSum(this.#outputCandidates)); if (diff.tokens.length > 0) { diff.tokens = diff.tokens.filter((x) => x.tokenId !== this.inputs[0].boxId); } @@ -56,17 +88,43 @@ export class ErgoUnsignedTransaction { return diff; } + get child(): ErgoUnsignedTransaction | undefined { + return this.#child; + } + + chain(callback: ChainCallback): ErgoUnsignedTransactionChain { + if (!this.#builder) { + throw new FleetError( + "Cannot chain transactions without a parent TransactionBuilder" + ); + } + + const height = this.#builder.creationHeight; + const builder = new TransactionBuilder(height).from(this.change); + if (this.#builder.fee) builder.payFee(this.#builder.fee); + if (this.#builder.changeAddress) builder.sendChangeTo(this.#builder.changeAddress); + + const response = callback(builder, this); + if (response instanceof TransactionBuilder) { + this.#child = response.build(); + } else if (response instanceof ErgoUnsignedTransactionChain) { + this.#child = response.first(); + } else { + this.#child = response; + } + + return new ErgoUnsignedTransactionChain(this); + } + toPlainObject(): UnsignedTransaction; - toPlainObject(outputType: T): TransactionType; - toPlainObject(outputType?: T): TransactionType { + toPlainObject(type: T): TransactionType; + toPlainObject(type?: T): TransactionType { return { - inputs: this.inputs.map((input) => - input.toUnsignedInputObject(outputType || "default") - ), + inputs: this.inputs.map((input) => input.toPlainObject(type ?? "minimal")), dataInputs: this.dataInputs.map((input) => - input.toPlainObject(outputType || "default") + input.toDataInputPlainObject(type ?? "minimal") ), - outputs: this.outputs.map((output) => _stringifyBoxAmounts(output)) + outputs: this.#outputCandidates.map((output) => output.toPlainObject()) } as TransactionType; } @@ -76,20 +134,9 @@ export class ErgoUnsignedTransaction { toBytes(): Uint8Array { return serializeTransaction({ - inputs: this.inputs.map((input) => input.toUnsignedInputObject("default")), - dataInputs: this.dataInputs.map((input) => input.toPlainObject("default")), - outputs: this.outputs + inputs: this.inputs, + dataInputs: this.dataInputs, + outputs: this.#outputCandidates }).toBytes(); } } - -function _stringifyBoxAmounts(output: BoxCandidate): T { - return { - ...output, - value: output.value.toString(), - assets: output.assets.map((token) => ({ - tokenId: token.tokenId, - amount: token.amount.toString() - })) - } as T; -} diff --git a/packages/core/src/models/ergoUnsignedTransactionChain.ts b/packages/core/src/models/ergoUnsignedTransactionChain.ts new file mode 100644 index 00000000..85329858 --- /dev/null +++ b/packages/core/src/models/ergoUnsignedTransactionChain.ts @@ -0,0 +1,34 @@ +import type { EIP12UnsignedTransaction, UnsignedTransaction } from "@fleet-sdk/common"; +import type { ErgoUnsignedTransaction } from "./ergoUnsignedTransaction"; + +export class ErgoUnsignedTransactionChain { + #entryPoint: ErgoUnsignedTransaction; + + constructor(entryPoint: ErgoUnsignedTransaction) { + this.#entryPoint = entryPoint; + } + + first(): ErgoUnsignedTransaction { + return this.#entryPoint; + } + + toArray() { + let parent = this.#entryPoint; + const chain = [parent]; + + while (parent.child) { + chain.push(parent.child); + parent = parent.child; + } + + return chain; + } + + toEIP12Object(): EIP12UnsignedTransaction[] { + return this.toArray().map((child) => child.toEIP12Object()); + } + + toPlainObject(): UnsignedTransaction[] { + return this.toArray().map((child) => child.toPlainObject()); + } +} diff --git a/packages/serializer/src/serializers/boxSerializer.ts b/packages/serializer/src/serializers/boxSerializer.ts index dc838b65..1827a36c 100644 --- a/packages/serializer/src/serializers/boxSerializer.ts +++ b/packages/serializer/src/serializers/boxSerializer.ts @@ -24,7 +24,7 @@ export function serializeBox( ): SigmaByteWriter; export function serializeBox( box: Box | BoxCandidate, - writer = new SigmaByteWriter(5_0000), + writer = new SigmaByteWriter(4_096), distinctTokenIds?: string[] ): SigmaByteWriter { writer.writeBigVLQ(ensureBigInt(box.value)); @@ -40,7 +40,6 @@ export function serializeBox( function isBox(box: Box | BoxCandidate): box is Box { const castedBox = box as Box; - return isDefined(castedBox.transactionId) && isDefined(castedBox.index); } @@ -51,7 +50,6 @@ function writeTokens( ): void { if (isEmpty(tokens)) { writer.write(0); - return; } diff --git a/packages/wallet/src/prover/prover.ts b/packages/wallet/src/prover/prover.ts index 4d239f40..f359e057 100644 --- a/packages/wallet/src/prover/prover.ts +++ b/packages/wallet/src/prover/prover.ts @@ -109,7 +109,7 @@ function buildKeyMapper(keys: ErgoHDKey[] | KeyMap) { if (secret) return secret; } - if (pskMap.size === 1) return pskMap.values().next().value; + if (pskMap.size === 1) return pskMap.values().next().value as ErgoHDKey; for (const pk of pskMap.keys()) { // try to determine the secret key from the input by checking the ErgoTree and Registers