diff --git a/.changeset/hip-maps-sin.md b/.changeset/hip-maps-sin.md new file mode 100644 index 00000000..27779fce --- /dev/null +++ b/.changeset/hip-maps-sin.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/core": minor +--- + +Add `ensureInclusion` flag to the `TransactionBuilder#from` method to ensure both inclusion and order of inputs diff --git a/packages/core/src/builder/pluginContext.spec.ts b/packages/core/src/builder/pluginContext.spec.ts index 18ed862e..c298a036 100644 --- a/packages/core/src/builder/pluginContext.spec.ts +++ b/packages/core/src/builder/pluginContext.spec.ts @@ -17,29 +17,26 @@ describe("Plugin context", () => { it("Should add inputs", () => { const fromSpy = vi.spyOn(builder, "from"); - const configureSelectorMethod = vi.spyOn(builder, "configureSelector"); - const newLen = context.addInputs(regularBoxes); expect(fromSpy).toBeCalledTimes(1); - expect(configureSelectorMethod).toBeCalledTimes(1); + expect(fromSpy).toBeCalledWith(regularBoxes, { ensureInclusion: true }); expect(builder.inputs).toHaveLength(newLen); expect(builder.inputs).toHaveLength(regularBoxes.length); }); it("Should add a single input", () => { - const fromMethod = vi.spyOn(builder, "from"); - const configureSelectorMethod = vi.spyOn(builder, "configureSelector"); + const fromSpy = vi.spyOn(builder, "from"); + const inputs = regularBoxes[0]; let newLen = context.addInputs(regularBoxes[0]); - expect(fromMethod).toBeCalledTimes(1); - expect(configureSelectorMethod).toBeCalledTimes(1); + expect(fromSpy).toBeCalledTimes(1); + expect(fromSpy).toBeCalledWith(inputs, { ensureInclusion: true }); expect(builder.inputs).toHaveLength(newLen); expect(builder.inputs).toHaveLength(1); newLen = context.addInputs(regularBoxes[1]); - expect(fromMethod).toBeCalledTimes(2); - expect(configureSelectorMethod).toBeCalledTimes(2); + expect(fromSpy).toBeCalledTimes(2); expect(builder.inputs).toHaveLength(newLen); expect(builder.inputs).toHaveLength(2); }); diff --git a/packages/core/src/builder/pluginContext.ts b/packages/core/src/builder/pluginContext.ts index 698cec08..994242e2 100644 --- a/packages/core/src/builder/pluginContext.ts +++ b/packages/core/src/builder/pluginContext.ts @@ -5,7 +5,7 @@ import type { OneOrMore, TokenAmount } from "@fleet-sdk/common"; -import { NotAllowedTokenBurning, type OutputBuilder, type TransactionBuilder } from ".."; +import { type OutputBuilder, type TransactionBuilder, NotAllowedTokenBurning } from ".."; export type FleetPluginContext = { /** @@ -52,28 +52,16 @@ export type FleetPluginContext = { setFee: (amount: Amount) => void; }; -export function createPluginContext( - transactionBuilder: TransactionBuilder -): FleetPluginContext { +export function createPluginContext(builder: TransactionBuilder): FleetPluginContext { return { - addInputs: (inputs) => - transactionBuilder - .from(inputs) - .configureSelector((selector) => - selector.ensureInclusion( - Array.isArray(inputs) ? inputs.map((input) => input.boxId) : inputs.boxId - ) - ).inputs.length, - addOutputs: (outputs, options) => - transactionBuilder.to(outputs, options).outputs.length, + addInputs: (inputs) => builder.from(inputs, { ensureInclusion: true }).inputs.length, + addOutputs: (outputs, options) => builder.to(outputs, options).outputs.length, addDataInputs: (dataInputs, options) => - transactionBuilder.withDataFrom(dataInputs, options).dataInputs.length, + builder.withDataFrom(dataInputs, options).dataInputs.length, burnTokens: (tokens) => { - if (!transactionBuilder.settings.canBurnTokensFromPlugins) { - throw new NotAllowedTokenBurning(); - } - transactionBuilder.burnTokens(tokens); + if (!builder.settings.canBurnTokensFromPlugins) throw new NotAllowedTokenBurning(); + builder.burnTokens(tokens); }, - setFee: (amount) => transactionBuilder.payFee(amount) + setFee: (amount) => builder.payFee(amount) }; } diff --git a/packages/core/src/builder/selector/boxSelector.ts b/packages/core/src/builder/selector/boxSelector.ts index 83acd3e7..ef2142fe 100644 --- a/packages/core/src/builder/selector/boxSelector.ts +++ b/packages/core/src/builder/selector/boxSelector.ts @@ -73,11 +73,9 @@ export class BoxSelector> { } if (predicate) { - if (inclusion) { - selected = unselected.filter((box) => predicate(box) || inclusion.has(box.boxId)); - } else { - selected = unselected.filter(predicate); - } + selected = inclusion + ? unselected.filter((box) => predicate(box) || inclusion.has(box.boxId)) + : unselected.filter(predicate); } else if (inclusion) { selected = unselected.filter((box) => inclusion.has(box.boxId)); } @@ -93,9 +91,7 @@ export class BoxSelector> { if (some(remaining.tokens) && selected.some((input) => !isEmpty(input.assets))) { for (const t of remaining.tokens) { - if (t.amount && t.amount > _0n) { - t.amount -= utxoSum(selected, t.tokenId); - } + if (t.amount && t.amount > _0n) t.amount -= utxoSum(selected, t.tokenId); } } } @@ -183,9 +179,7 @@ export class BoxSelector> { } if (Array.isArray(predicateOrBoxIds)) { - for (const boxId of predicateOrBoxIds) { - this._ensureInclusionBoxIds.add(boxId); - } + for (const boxId of predicateOrBoxIds) this._ensureInclusionBoxIds.add(boxId); } else { this._ensureInclusionBoxIds.add(predicateOrBoxIds); } diff --git a/packages/core/src/builder/transactionBuilder.spec.ts b/packages/core/src/builder/transactionBuilder.spec.ts index a3766bca..c439fa5a 100644 --- a/packages/core/src/builder/transactionBuilder.spec.ts +++ b/packages/core/src/builder/transactionBuilder.spec.ts @@ -25,6 +25,7 @@ import { RECOMMENDED_MIN_FEE_VALUE, TransactionBuilder } from "./transactionBuilder"; +import { mockUTxO } from "packages/mock-chain/src"; const height = 844540; const a1 = { @@ -818,6 +819,22 @@ describe("Building", () => { } }); + it("Should ensure inclusion and preserver the order or inputs", () => { + const anotherInput = mockUTxO({ ergoTree: a1.ergoTree }); + const transaction = new TransactionBuilder(height) + .from(manyTokensBoxes, { ensureInclusion: true }) + .and.from(regularBoxes) + .and.from(anotherInput, { ensureInclusion: true }) + .sendChangeTo(a1.address) + .build(); + + const expectedInputs = [...manyTokensBoxes, anotherInput]; + expect(transaction.inputs).to.have.length(expectedInputs.length); + for (let i = 0; i < transaction.inputs.length; i++) { + expect(transaction.inputs[i].boxId).to.be.equal(expectedInputs[i].boxId); + } + }); + it("Should produce multiple change boxes based on maxTokensPerChangeBox and isolate erg in a exclusive box ", () => { const tokensPerBox = 3; @@ -908,7 +925,7 @@ describe("Building", () => { it("Should preserve inputs extension", () => { const input = new ErgoUnsignedInput(regularBoxes[0]); - input.setContextVars({ 0: "0580c0fc82aa02" }); + input.setContextExtension({ 0: "0580c0fc82aa02" }); const unsignedTransaction = new TransactionBuilder(height) .from(regularBoxes[1]) diff --git a/packages/core/src/builder/transactionBuilder.ts b/packages/core/src/builder/transactionBuilder.ts index 6faf4bec..bbc141d1 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -1,21 +1,21 @@ import { - _0n, type Amount, type Base58String, type Box, + type CollectionAddOptions, + type HexString, + type OneOrMore, + type TokenAmount, + _0n, byteSizeOf, chunk, - type CollectionAddOptions, ensureBigInt, first, - type HexString, isDefined, isHex, isUndefined, Network, - type OneOrMore, some, - type TokenAmount, utxoDiff, utxoSum } from "@fleet-sdk/common"; @@ -59,6 +59,17 @@ type EjectorContext = { selection: (selectorCallBack: SelectorCallback) => void; }; +/** + * Options for including inputs in the transaction builder + */ +type InputsInclusionOptions = { + /** + * If true, all the inputs will be included in the + * transaction while preserving the original order. + */ + ensureInclusion?: boolean; +}; + export class TransactionBuilder { private readonly _inputs!: InputsCollection; private readonly _dataInputs!: InputsCollection; @@ -66,6 +77,7 @@ export class TransactionBuilder { private readonly _settings!: TransactionBuilderSettings; private readonly _creationHeight!: number; + private _ensureInclusion?: Set; private _selectorCallbacks?: SelectorCallback[]; private _changeAddress?: ErgoAddress; private _feeAmount?: bigint; @@ -127,12 +139,26 @@ export class TransactionBuilder { } public from( - inputs: OneOrMore> | CollectionLike> + inputs: OneOrMore> | CollectionLike>, + options: InputsInclusionOptions = { ensureInclusion: false } ): TransactionBuilder { - this._inputs.add(isCollectionLike(inputs) ? inputs.toArray() : inputs); + const items = isCollectionLike(inputs) ? inputs.toArray() : inputs; + if (options.ensureInclusion) this.#ensureInclusionOf(items); + + this._inputs.add(items); return this; } + #ensureInclusionOf(inputs: OneOrMore>): void { + if (!this._ensureInclusion) this._ensureInclusion = new Set(); + + if (Array.isArray(inputs)) { + for (const input of inputs) this._ensureInclusion.add(input.boxId); + } else { + this._ensureInclusion.add(inputs.boxId); + } + } + public to( outputs: OneOrMore, options?: CollectionAddOptions @@ -193,19 +219,13 @@ export class TransactionBuilder { } public configureSelector(selectorCallback: SelectorCallback): TransactionBuilder { - if (isUndefined(this._selectorCallbacks)) { - this._selectorCallbacks = []; - } - + if (isUndefined(this._selectorCallbacks)) this._selectorCallbacks = []; this._selectorCallbacks.push(selectorCallback); - return this; } public extend(plugins: FleetPlugin): TransactionBuilder { - if (!this._plugins) { - this._plugins = []; - } + if (!this._plugins) this._plugins = []; this._plugins.push({ execute: plugins, pending: true }); return this; @@ -261,6 +281,10 @@ export class TransactionBuilder { } const selector = new BoxSelector(this.inputs.toArray()); + if (this._ensureInclusion?.size) { + selector.ensureInclusion(Array.from(this._ensureInclusion)); + } + if (some(this._selectorCallbacks)) { for (const selectorCallBack of this._selectorCallbacks) { selectorCallBack(selector); @@ -428,7 +452,6 @@ type ChangeEstimationParams = { function estimateMinChangeValue(params: ChangeEstimationParams): bigint { const size = BigInt(estimateChangeSize(params)); - return size * BOX_VALUE_PER_BYTE; } @@ -448,9 +471,7 @@ function estimateChangeSize({ size += 32; // BLAKE 256 hash length size = size * neededBoxes; - for (let i = 0; i < neededBoxes; i++) { - size += estimateVLQSize(baseIndex + i); - } + for (let i = 0; i < neededBoxes; i++) size += estimateVLQSize(baseIndex + i); for (const token of tokens) { size += byteSizeOf(token.tokenId) + estimateVLQSize(token.amount); diff --git a/packages/core/src/models/collections/inputsCollection.ts b/packages/core/src/models/collections/inputsCollection.ts index a7593e96..ab46d69e 100644 --- a/packages/core/src/models/collections/inputsCollection.ts +++ b/packages/core/src/models/collections/inputsCollection.ts @@ -2,8 +2,8 @@ import { type Amount, type Box, type BoxId, - Collection, - type OneOrMore + type OneOrMore, + Collection } from "@fleet-sdk/common"; import { isDefined } from "@fleet-sdk/common"; import { DuplicateInputError, NotFoundError } from "../../errors"; @@ -15,10 +15,7 @@ export class InputsCollection extends Collection> constructor(boxes: Box[]); constructor(boxes?: OneOrMore>) { super(); - - if (isDefined(boxes)) { - this.add(boxes); - } + if (isDefined(boxes)) this.add(boxes); } protected override _map(input: Box | ErgoUnsignedInput): ErgoUnsignedInput { @@ -54,7 +51,6 @@ export class InputsCollection extends Collection> } this._items.splice(index, 1); - return this.length; } } diff --git a/packages/core/src/models/collections/outputsCollection.ts b/packages/core/src/models/collections/outputsCollection.ts index 7a2ff531..27ffad4a 100644 --- a/packages/core/src/models/collections/outputsCollection.ts +++ b/packages/core/src/models/collections/outputsCollection.ts @@ -18,10 +18,7 @@ function setSum(map: Map, key: K, value: bigint) { export class OutputsCollection extends Collection { constructor(outputs?: OneOrMore) { super(); - - if (isDefined(outputs)) { - this.add(outputs); - } + if (isDefined(outputs)) this.add(outputs); } protected _map(output: OutputBuilder) { diff --git a/packages/core/src/models/collections/tokensCollection.ts b/packages/core/src/models/collections/tokensCollection.ts index aed9f1a8..f729a99c 100644 --- a/packages/core/src/models/collections/tokensCollection.ts +++ b/packages/core/src/models/collections/tokensCollection.ts @@ -34,10 +34,7 @@ export class TokensCollection extends Collection, OutputToke constructor(tokens: TokenAmount[], options: TokenAddOptions); constructor(tokens?: OneOrMore>, options?: TokenAddOptions) { super(); - - if (isDefined(tokens)) { - this.add(tokens, options); - } + if (isDefined(tokens)) this.add(tokens, options); } public get minting(): NewToken | undefined { diff --git a/plugins/babel-fees/src/plugins.ts b/plugins/babel-fees/src/plugins.ts index 2f97b8e5..765c4b4a 100644 --- a/plugins/babel-fees/src/plugins.ts +++ b/plugins/babel-fees/src/plugins.ts @@ -15,9 +15,7 @@ export function BabelSwapPlugin( babelBox: Box, token: TokenAmount ): FleetPlugin { - if (!isValidBabelBox(babelBox)) { - throw new Error("Invalid Babel Box."); - } + if (!isValidBabelBox(babelBox)) throw new Error("Invalid Babel Box."); if (!isBabelContractForTokenId(babelBox.ergoTree, token.tokenId)) { throw new Error( @@ -42,14 +40,10 @@ export function BabelSwapPlugin( .setAdditionalRegisters({ R4: input.additionalRegisters.R4, R5: input.additionalRegisters.R5, - R6: SColl(SByte, input.boxId).toHex() + R6: SColl(SByte, input.boxId) }) ); - addInputs( - input.setContextVars({ - 0: SInt(outputsLength - 1).toHex() - }) - ); + addInputs(input.setContextExtension({ 0: SInt(outputsLength - 1) })); }; }