Skip to content

Commit

Permalink
add ErgoUnsignedTransaction#chain method
Browse files Browse the repository at this point in the history
  • Loading branch information
arobsn committed Oct 23, 2024
1 parent 10a41c0 commit 45b8c65
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 9 deletions.
Empty file.
5 changes: 3 additions & 2 deletions packages/core/src/builder/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export class TransactionBuilder {
#burning?: TokensCollection;
#plugins?: PluginListItem[];

constructor(creationHeight: number) {
constructor(creationHeight: number, parent?: ErgoUnsignedTransaction) {
this.#inputs = new InputsCollection();
this.#dataInputs = new InputsCollection();
this.#outputs = new OutputsCollection();
Expand Down Expand Up @@ -370,7 +370,8 @@ export class TransactionBuilder {
const unsignedTransaction = new ErgoUnsignedTransaction(
inputs,
this.dataInputs.toArray(),
buildedOutputs
buildedOutputs,
this
);

let burning = unsignedTransaction.burning;
Expand Down
Empty file.
176 changes: 173 additions & 3 deletions packages/core/src/models/ergoUnsignedTransaction.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { serializeTransaction } from "@fleet-sdk/serializer";
import { regularBoxes } from "_test-vectors";
import { regularBoxes, validBoxes } from "_test-vectors";
import { 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, EIP12UnsignedInput, UnsignedTransaction } from "@fleet-sdk/common";

describe("ErgoUnsignedTransaction model", () => {
describe("Model", () => {
it("Should generate the right transactionId and boxIds for outputs", () => {
for (const tx of mockedUnsignedTransactions) {
const unsigned = new ErgoUnsignedTransaction(
Expand Down Expand Up @@ -101,3 +110,164 @@ describe("ErgoUnsignedTransaction model", () => {
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: Box): 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();

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

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");
});
});
48 changes: 44 additions & 4 deletions packages/core/src/models/ergoUnsignedTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,47 @@ import {
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";
import type { ErgoBox } from "./ergoBox";
import type { ErgoBoxCandidate } from "./ergoBoxCandidate";
import { ErgoUnsignedTransactionChain } from "./ergoUnsignedTransactionChain";

type TransactionType<T> = T extends "default"
? UnsignedTransaction
: EIP12UnsignedTransaction;

export type ChainCallback = (
child: TransactionBuilder,
parent: ErgoUnsignedTransaction
) => TransactionBuilder | ErgoUnsignedTransaction | ErgoUnsignedTransactionChain;

export class ErgoUnsignedTransaction {
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[]
outputs: ErgoBoxCandidate[],
builder?: TransactionBuilder
) {
this.#inputs = inputs;
this.#dataInputs = dataInputs;
this.#outputCandidates = outputs;
this.#builder = builder;
}

get id(): string {
Expand Down Expand Up @@ -76,13 +88,41 @@ 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<T extends PlainObjectType>(type: T): TransactionType<T>;
toPlainObject<T extends PlainObjectType>(type?: T): TransactionType<T> {
return {
inputs: this.inputs.map((input) => input.toPlainObject(type || "minimal")),
inputs: this.inputs.map((input) => input.toPlainObject(type ?? "minimal")),
dataInputs: this.dataInputs.map((input) =>
input.toDataInputPlainObject(type || "minimal")
input.toDataInputPlainObject(type ?? "minimal")
),
outputs: this.#outputCandidates.map((output) => output.toPlainObject())
} as TransactionType<T>;
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/models/ergoUnsignedTransactionChain.ts
Original file line number Diff line number Diff line change
@@ -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());
}
}

0 comments on commit 45b8c65

Please sign in to comment.