diff --git a/packages/core/src/builder/pluginContext.spec.ts b/packages/core/src/builder/pluginContext.spec.ts new file mode 100644 index 00000000..77cc3792 --- /dev/null +++ b/packages/core/src/builder/pluginContext.spec.ts @@ -0,0 +1,123 @@ +import { NotAllowedTokenBurning } from "../errors"; +import { regularBoxesMock } from "../tests/mocks/mockBoxes"; +import { OutputBuilder } from "./outputBuilder"; +import { createPluginContext, FleetPluginContext } from "./pluginContext"; +import { TransactionBuilder } from "./transactionBuilder"; + +describe("Plugin context", () => { + const creationHeight = 894169; + let builder!: TransactionBuilder; + let context!: FleetPluginContext; + + beforeEach(() => { + builder = new TransactionBuilder(creationHeight); + context = createPluginContext(builder); + }); + + it("Should add inputs", () => { + const fromMethod = jest.spyOn(builder, "from"); + const configureSelectorMethod = jest.spyOn(builder, "configureSelector"); + + const newLen = context.addInputs(regularBoxesMock); + + expect(fromMethod).toBeCalledTimes(1); + expect(configureSelectorMethod).toBeCalledTimes(1); + expect(builder.inputs).toHaveLength(newLen); + expect(builder.inputs).toHaveLength(regularBoxesMock.length); + }); + + it("Should add a single input", () => { + const fromMethod = jest.spyOn(builder, "from"); + const configureSelectorMethod = jest.spyOn(builder, "configureSelector"); + + let newLen = context.addInputs(regularBoxesMock[0]); + expect(fromMethod).toBeCalledTimes(1); + expect(configureSelectorMethod).toBeCalledTimes(1); + expect(builder.inputs).toHaveLength(newLen); + expect(builder.inputs).toHaveLength(1); + + newLen = context.addInputs(regularBoxesMock[1]); + expect(fromMethod).toBeCalledTimes(2); + expect(configureSelectorMethod).toBeCalledTimes(2); + expect(builder.inputs).toHaveLength(newLen); + expect(builder.inputs).toHaveLength(2); + }); + + it("Should add data inputs", () => { + const withDataFromMethod = jest.spyOn(builder, "withDataFrom"); + + const newLen = context.addDataInputs(regularBoxesMock); + + expect(withDataFromMethod).toBeCalledTimes(1); + expect(builder.dataInputs).toHaveLength(newLen); + expect(builder.dataInputs).toHaveLength(regularBoxesMock.length); + }); + + it("Should add outputs", () => { + const toMethod = jest.spyOn(builder, "to"); + + let newLen = context.addOutputs( + new OutputBuilder(10000n, "9gn5Jo6T7m4pAzCdD9JFdRMPxnfKLPgcX68rD8RQvPLyJsTpKcq") + ); + + expect(toMethod).toBeCalledTimes(1); + expect(newLen).toBe(builder.outputs.length); + + newLen = context.addOutputs( + new OutputBuilder(20000n, "9gn5Jo6T7m4pAzCdD9JFdRMPxnfKLPgcX68rD8RQvPLyJsTpKcq") + ); + + expect(toMethod).toBeCalledTimes(2); + expect(builder.outputs).toHaveLength(newLen); + expect(builder.outputs).toHaveLength(2); + }); + + it("Should burn tokens, plugin context allowed", () => { + const burnTokensMethod = jest.spyOn(builder, "burnTokens"); + + builder + .from(regularBoxesMock) + .configure((settings) => settings.allowTokenBurningFromPlugins(true)); + + context.burnTokens([ + { tokenId: "bf2afb01fde7e373e22f24032434a7b883913bd87a23b62ee8b43eba53c9f6c2", amount: 1n }, + { tokenId: "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283", amount: 126n } + ]); + + expect(burnTokensMethod).toBeCalledTimes(1); + expect(builder.burning).toHaveLength(2); + }); + + it("Should burn tokens, globally allowed", () => { + const burnTokensMethod = jest.spyOn(builder, "burnTokens"); + + builder.from(regularBoxesMock).configure((settings) => settings.allowTokenBurning(true)); + + context.burnTokens([ + { tokenId: "bf2afb01fde7e373e22f24032434a7b883913bd87a23b62ee8b43eba53c9f6c2", amount: 1n }, + { tokenId: "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283", amount: 126n } + ]); + + expect(burnTokensMethod).toBeCalledTimes(1); + expect(builder.burning).toHaveLength(2); + }); + + it("Should fail if token burning is not allowed", () => { + const burnTokensMethod = jest.spyOn(builder, "burnTokens"); + + builder.from(regularBoxesMock); + + expect(() => { + context.burnTokens([ + { tokenId: "bf2afb01fde7e373e22f24032434a7b883913bd87a23b62ee8b43eba53c9f6c2", amount: 1n }, + { + tokenId: "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283", + amount: 126n + } + ]); + }).toThrow(NotAllowedTokenBurning); + + expect(burnTokensMethod).not.toBeCalled(); + expect(builder.burning).toBeUndefined(); + }); +}); diff --git a/packages/core/src/builder/pluginContext.ts b/packages/core/src/builder/pluginContext.ts new file mode 100644 index 00000000..6a76b54d --- /dev/null +++ b/packages/core/src/builder/pluginContext.ts @@ -0,0 +1,58 @@ +import { Amount, Box, OneOrMore, TokenAmount } from "@fleet-sdk/common"; +import { NotAllowedTokenBurning, OutputBuilder, TransactionBuilder } from ".."; +import { CollectionAddOptions } from "../models/collections/collection"; + +export type FleetPluginContext = { + /** + * Add and ensures selection of one or more inputs to the inputs list + * @param inputs + * @returns new list length + */ + addInputs: (inputs: OneOrMore>, options?: CollectionAddOptions) => number; + + /** + * Add one or more data inputs to the data inputs list + * @param dataInputs + * @returns new list length + */ + addDataInputs: (dataInputs: OneOrMore>, options?: CollectionAddOptions) => number; + + /** + * Add one or more outputs to the outputs list + * @param outputs + * @param options + * @returns new list length + */ + addOutputs: (outputs: OneOrMore, options?: CollectionAddOptions) => number; + + /** + * Burn tokens + * @param tokens + * @throws Burning tokens thought a plugin, requires explicitly permission + * from {@link TransactionBuilder.configure}, if token burning is not allowed + * it will thrown a {@link NotAllowedTokenBurning} exception. + */ + burnTokens: (tokens: OneOrMore>) => void; +}; + +export function createPluginContext(transactionBuilder: TransactionBuilder): FleetPluginContext { + return { + addInputs: (inputs, options) => + transactionBuilder + .from(inputs, options) + .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, + addDataInputs: (dataInputs, options) => + transactionBuilder.withDataFrom(dataInputs, options).dataInputs.length, + burnTokens: (tokens) => { + if (!transactionBuilder.settings.canBurnTokensFromPlugins) { + throw new NotAllowedTokenBurning(); + } + transactionBuilder.burnTokens(tokens); + } + }; +} diff --git a/packages/core/src/builder/transactionBuilder.spec.ts b/packages/core/src/builder/transactionBuilder.spec.ts index 28af9736..9496cb02 100644 --- a/packages/core/src/builder/transactionBuilder.spec.ts +++ b/packages/core/src/builder/transactionBuilder.spec.ts @@ -1061,3 +1061,130 @@ describe("Token burning", () => { }).toThrow(MalformedTransaction); }); }); + +describe("Plugins", () => { + it("Should include inputs, data inputs and outputs from plugin", () => { + const tx = new TransactionBuilder(height) + .extend(({ addInputs, addDataInputs, addOutputs }) => { + addInputs(regularBoxesMock); + addDataInputs(first(regularBoxesMock)); + addOutputs(new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address, height)); + }) + .sendChangeTo("9hY16vzHmmfyVBwKeFGHvb2bMFsG94A1u7To1QWtUokACyFVENQ") + .build(); + + expect(tx.outputs).toHaveLength(2); // output from plugin context and change + expect(tx.outputs[0].ergoTree).toBe(a1.ergoTree); + expect(tx.inputs).toHaveLength(regularBoxesMock.length); // should include all inputs added from plugin context + expect(tx.dataInputs).toHaveLength(1); + expect(tx.dataInputs[0].boxId).toBe(regularBoxesMock[0].boxId); + }); + + it("Should include inputs, data inputs and outputs from multiple plugins", () => { + const tx = new TransactionBuilder(height) + .extend(({ addInputs, addDataInputs, addOutputs }) => { + addInputs(regularBoxesMock[1]); + addDataInputs(regularBoxesMock[1]); + addOutputs(new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address, height)); + }) + .extend(({ addInputs, addDataInputs, addOutputs }) => { + addInputs(regularBoxesMock[2]); + addDataInputs(regularBoxesMock[2]); + addOutputs(new OutputBuilder(SAFE_MIN_BOX_VALUE, a2.address, height)); + }) + .sendChangeTo("9hY16vzHmmfyVBwKeFGHvb2bMFsG94A1u7To1QWtUokACyFVENQ") + .build(); + + expect(tx.outputs).toHaveLength(3); // two output from plugin context and one from change + expect(tx.outputs[0].ergoTree).toBe(a1.ergoTree); + expect(tx.outputs[1].ergoTree).toBe(a2.ergoTree); + + expect(tx.inputs).toHaveLength(2); // should include all inputs added from plugin context + + expect(tx.dataInputs).toHaveLength(2); + expect(tx.dataInputs[0].boxId).toBe(regularBoxesMock[1].boxId); + expect(tx.dataInputs[1].boxId).toBe(regularBoxesMock[2].boxId); + }); + + it("Should burn tokens, plugin context allowed", () => { + const tokenIda = "bf2afb01fde7e373e22f24032434a7b883913bd87a23b62ee8b43eba53c9f6c2"; + const tokenIdb = "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283"; + + const tx = new TransactionBuilder(height) + .from(regularBoxesMock) + .configure((settings) => settings.allowTokenBurningFromPlugins(true)) + .extend(({ burnTokens }) => { + burnTokens([ + { tokenId: tokenIda, amount: 1n }, + { tokenId: tokenIdb, amount: 126n } + ]); + }) + .sendChangeTo("9hY16vzHmmfyVBwKeFGHvb2bMFsG94A1u7To1QWtUokACyFVENQ") + .build("EIP-12"); + + expect(utxoSum(tx.outputs, tokenIda) - utxoSum(tx.inputs, tokenIda)).toBe(-1n); + expect(utxoSum(tx.outputs, tokenIdb) - utxoSum(tx.inputs, tokenIdb)).toBe(-126n); + }); + + it("Should burn tokens, global context allowed", () => { + const tokenIda = "bf2afb01fde7e373e22f24032434a7b883913bd87a23b62ee8b43eba53c9f6c2"; + const tokenIdb = "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283"; + + const tx = new TransactionBuilder(height) + .from(regularBoxesMock) + .configure((settings) => settings.allowTokenBurning(true)) + .extend(({ burnTokens }) => { + burnTokens([ + { tokenId: tokenIda, amount: 1n }, + { tokenId: tokenIdb, amount: 126n } + ]); + }) + .sendChangeTo("9hY16vzHmmfyVBwKeFGHvb2bMFsG94A1u7To1QWtUokACyFVENQ") + .build("EIP-12"); + + expect(utxoSum(tx.outputs, tokenIda) - utxoSum(tx.inputs, tokenIda)).toBe(-1n); + expect(utxoSum(tx.outputs, tokenIdb) - utxoSum(tx.inputs, tokenIdb)).toBe(-126n); + }); + + it("Should fail if burning is not allowed", () => { + const tokenIda = "bf2afb01fde7e373e22f24032434a7b883913bd87a23b62ee8b43eba53c9f6c2"; + const tokenIdb = "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283"; + + expect(() => { + new TransactionBuilder(height) + .from(regularBoxesMock) + .extend(({ burnTokens }) => { + burnTokens([ + { tokenId: tokenIda, amount: 1n }, + { tokenId: tokenIdb, amount: 126n } + ]); + }) + .build(); + }).toThrow(NotAllowedTokenBurning); + }); + + it("Should not execute plugins more tha one time", () => { + const plugin = jest.fn(({ addOutputs }) => { + addOutputs(new OutputBuilder(SAFE_MIN_BOX_VALUE, a1.address)); + }); + + const tx = new TransactionBuilder(height) + .from(regularBoxesMock) + .extend(plugin) + .sendChangeTo(a2.address); + + let builtTx = tx.build(); + + expect(tx.outputs).toHaveLength(1); + expect(builtTx.outputs).toHaveLength(2); // output from plugin and change + + expect(tx.outputs.at(0).ergoTree).toBe(a1.ergoTree); + expect(plugin).toBeCalledTimes(1); + + for (let i = 0; i < 5; i++) { + builtTx = tx.build(); + } + + expect(plugin).toBeCalledTimes(1); // should not do subsequent calls + }); +}); diff --git a/packages/core/src/builder/transactionBuilder.ts b/packages/core/src/builder/transactionBuilder.ts index ce216775..a0c2f972 100644 --- a/packages/core/src/builder/transactionBuilder.ts +++ b/packages/core/src/builder/transactionBuilder.ts @@ -26,25 +26,27 @@ import { NonStandardizedMinting } from "../errors/nonStandardizedMinting"; import { ErgoAddress, InputsCollection, OutputsCollection, TokensCollection } from "../models"; import { CollectionAddOptions } from "../models/collections/collection"; import { OutputBuilder, SAFE_MIN_BOX_VALUE } from "./outputBuilder"; +import { createPluginContext, FleetPluginContext } from "./pluginContext"; import { BoxSelector } from "./selector"; import { TransactionBuilderSettings } from "./transactionBuilderSettings"; +type PluginListItem = { execute: FleetPlugin; pending: boolean }; type TransactionType = T extends "default" ? UnsignedTransaction : EIP12UnsignedTransaction; +type SelectorSettings = Omit>, "select">; +export type SelectorCallback = (selector: SelectorSettings) => void; +export type FleetPlugin = (context: FleetPluginContext) => void; export const RECOMMENDED_MIN_FEE_VALUE = BigInt(1100000); export const FEE_CONTRACT = "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a701730073011001020402d19683030193a38cc7b2a57300000193c2b2a57301007473027303830108cdeeac93b1a57304"; -type SelectorSettings = Omit>, "select">; -export type SelectorCallback = (selector: SelectorSettings) => void; - type EjectorContext = { inputs: InputsCollection; dataInputs: InputsCollection; outputs: OutputsCollection; burning: TokensCollection | undefined; settings: TransactionBuilderSettings; - selection: (selectorCallBack?: SelectorCallback) => void; + selection: (selectorCallBack: SelectorCallback) => void; }; export class TransactionBuilder { @@ -54,10 +56,11 @@ export class TransactionBuilder { private readonly _settings!: TransactionBuilderSettings; private readonly _creationHeight!: number; - private _selectorCallback?: SelectorCallback; + private _selectorCallbacks?: SelectorCallback[]; private _changeAddress?: ErgoAddress; private _feeAmount?: bigint; private _burning?: TokensCollection; + private _plugins?: PluginListItem[]; constructor(creationHeight: number) { this._inputs = new InputsCollection(); @@ -173,24 +176,35 @@ export class TransactionBuilder { return this; } - public configureSelector(selectorCallback?: SelectorCallback): TransactionBuilder { - this._selectorCallback = selectorCallback; + public configureSelector(selectorCallback: SelectorCallback): TransactionBuilder { + if (isUndefined(this._selectorCallbacks)) { + this._selectorCallbacks = []; + } + + this._selectorCallbacks.push(selectorCallback); return this; } - public eject(ejector: (context: EjectorContext) => void): TransactionBuilder { - const selection = (selectorCallback?: SelectorCallback) => { - this._selectorCallback = selectorCallback; - }; + public 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 { ejector({ inputs: this.inputs, dataInputs: this.dataInputs, outputs: this.outputs, burning: this.burning, settings: this.settings, - selection: selection + selection: (selectorCallback: SelectorCallback) => { + this.configureSelector(selectorCallback); + } }); return this; @@ -199,6 +213,16 @@ export class TransactionBuilder { public build(): UnsignedTransaction; public build(buildOutputType: T): TransactionType; public build(buildOutputType?: T): TransactionType { + if (some(this._plugins)) { + const context = createPluginContext(this); + for (const plugin of this._plugins) { + if (plugin.pending) { + plugin.execute(context); + plugin.pending = false; + } + } + } + if (this._isMinting()) { if (this._isMoreThanOneTokenBeingMinted()) { throw new MalformedTransaction("only one token can be minted per transaction."); @@ -218,8 +242,10 @@ export class TransactionBuilder { } const selector = new BoxSelector(this.inputs.toArray()); - if (isDefined(this._selectorCallback)) { - this._selectorCallback(selector); + if (some(this._selectorCallbacks)) { + for (const selectorCallBack of this._selectorCallbacks) { + selectorCallBack(selector); + } } const target = some(this._burning) diff --git a/packages/core/src/models/collections/collection.spec.ts b/packages/core/src/models/collections/collection.spec.ts index a5d67c55..0312c836 100644 --- a/packages/core/src/models/collections/collection.spec.ts +++ b/packages/core/src/models/collections/collection.spec.ts @@ -47,13 +47,15 @@ describe("collection base", () => { expect(collection.at(3)).toBe(3); }); - it("Should not fail when trying to place at index 0 and collection is empty", () => { + it("Should not fail when trying to place at index == Collection.length", () => { const collection = new MockCollection(); collection.add(5, { index: 0 }); + collection.add(6, { index: 1 }); - expect(collection).toHaveLength(1); + expect(collection).toHaveLength(2); expect(collection.at(0)).toBe(5); + expect(collection.at(1)).toBe(6); }); it("Should should fail when trying to add out of range", () => { diff --git a/packages/core/src/models/collections/collection.ts b/packages/core/src/models/collections/collection.ts index c2621ea9..ba94d5d6 100644 --- a/packages/core/src/models/collections/collection.ts +++ b/packages/core/src/models/collections/collection.ts @@ -52,7 +52,7 @@ export abstract class Collection implements Iterable protected _addOne(item: InternalType | ExternalType, options?: CollectionAddOptions): number { if (isDefined(options) && isDefined(options.index)) { - if (options.index === 0 && this.length === 0) { + if (options.index === this.length) { this._items.push(this._map(item)); return this.length;