diff --git a/.changeset/loose-ears-cheat.md b/.changeset/loose-ears-cheat.md new file mode 100644 index 00000000..49afd76d --- /dev/null +++ b/.changeset/loose-ears-cheat.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/core": minor +--- + +Add ErgoTree named constants replacing support diff --git a/.changeset/sour-ideas-flow.md b/.changeset/sour-ideas-flow.md new file mode 100644 index 00000000..a9d98ea5 --- /dev/null +++ b/.changeset/sour-ideas-flow.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/core": minor +--- + +Add ErgoTree construction from ergoc compiler JSON output diff --git a/packages/core/src/models/ergoTree.spec.ts b/packages/core/src/models/ergoTree.spec.ts index f589290b..d71b4df9 100644 --- a/packages/core/src/models/ergoTree.spec.ts +++ b/packages/core/src/models/ergoTree.spec.ts @@ -1,11 +1,11 @@ import { Network } from "@fleet-sdk/common"; import { hex } from "@fleet-sdk/crypto"; -import { SBool, SigmaByteReader, estimateVLQSize } from "@fleet-sdk/serializer"; +import { SBool, SLong, SigmaByteReader, estimateVLQSize } from "@fleet-sdk/serializer"; import { ErgoTree$ } from "sigmastate-js/main"; import { describe, expect, it, test } from "vitest"; import { SInt } from "../constantSerializer"; import { ErgoAddress } from "./ergoAddress"; -import { ErgoTree } from "./ergoTree"; +import { ErgoTree, type JsonCompilerOutput } from "./ergoTree"; describe("ErgoTree model", () => { test.each([ @@ -290,3 +290,97 @@ describe("Encoding", () => { ); }); }); + +describe("JSON object construction", () => { + const testVectors: { + name: string; + tree: string; + compilerOutput: JsonCompilerOutput; + }[] = [ + { + name: "ErgoTree with segregated constants", + tree: "1a740a04c80104900304d8040e20fbbaac7337d051c10fc3da0ccb864f4d32d40027551e1c3ea3ce361f39b91e400e106b75d7c82b1c99619f2c6ea6d6585cc904ca01040201000490030404d801d601830304730073017302d1ededed9373037304917305b272017306007307917308b27201730900", + compilerOutput: { + header: "1a", + expressionTree: + "d801d601830304730073017302d1ededed9373037304917305b272017306007307917308b27201730900", + constants: [ + { value: "04c801", type: "Int" }, + { value: "049003", type: "Int", name: "_deadline_two" }, + { value: "04d804", type: "Int" }, + { + value: "0e20fbbaac7337d051c10fc3da0ccb864f4d32d40027551e1c3ea3ce361f39b91e40", + type: "Coll[Byte]" + }, + { + value: "0e106b75d7c82b1c99619f2c6ea6d6585cc9", + type: "Coll[Byte]", + name: "$tokenId", + description: "payment token id" + }, + { value: "04ca01", type: "Int", name: "_deadline", description: "Payment deadline" }, + { value: "0402", type: "Int" }, + { value: "0100", type: "Bool" }, + { value: "049003", type: "Int", name: "_deadline_two" }, + { value: "0404", type: "Int" } + ] + } + }, + { + name: "Without size flag", + tree: "10020580897a0402d19173007e730105", + compilerOutput: { + header: "10", + expressionTree: "d19173007e730105", + constants: [ + { value: "0580897a", type: "Long" }, + { value: "0402", type: "Int" } + ] + } + }, + { + name: "No constants segregation", + tree: "0a08d1910580897a0402", + compilerOutput: { + header: "0a", + expressionTree: "d1910580897a0402" + } + } + ]; + + test.each(testVectors)("Should construct from JSON object ($name)", (tv) => { + const tree = ErgoTree.from(tv.compilerOutput); + expect(tree.toHex()).to.be.equal(tv.tree); + }); + + it("Should support named constants", () => { + const tv = { + tree: "1a17030580897a0400059003d191b283010573007301007302", + compilerOutput: { + header: "1a", + expressionTree: "d191b283010573007301007302", + constants: [ + { value: "0580897a", type: "Long" }, + { value: "0400", type: "Int" }, + { value: "059003", type: "Long", name: "price2" } + ] + } + }; + + const tree = ErgoTree.from(tv.compilerOutput); + expect(tree.toHex()).to.be.equal(tv.tree); // no changes made + + // should replace named constant + tree.replaceConstant("price2", SLong(2n)); + expect(tree.constants[2].data).to.be.equal(2n); + expect(tree.toHex()).not.to.be.equal(tv.tree); + + // replacing by number should work too + expect(() => tree.replaceConstant(0, SLong(3n))).not.to.throw(); + + // should throw if named constant is not found + expect(() => tree.replaceConstant("non-existing", SLong(2n))).to.throw( + "Constant with name 'non-existing' not found." + ); + }); +}); diff --git a/packages/core/src/models/ergoTree.ts b/packages/core/src/models/ergoTree.ts index 4ca6a015..8dad4e0a 100644 --- a/packages/core/src/models/ergoTree.ts +++ b/packages/core/src/models/ergoTree.ts @@ -1,5 +1,11 @@ -import { type Base58String, type HexString, Network, ergoTreeHeaderFlags } from "@fleet-sdk/common"; -import { hex } from "@fleet-sdk/crypto"; +import { + type Base58String, + type HexString, + Network, + byteSizeOf, + ergoTreeHeaderFlags +} from "@fleet-sdk/common"; +import { type ByteInput, hex } from "@fleet-sdk/crypto"; import { SConstant, SigmaByteReader, @@ -18,14 +24,29 @@ export class ErgoTree { #byteReader?: SigmaByteReader; #root!: Uint8Array; #constants: SConstant[] = []; + #constantNames: Map = new Map(); - constructor(input: HexString | Uint8Array, network?: Network) { + constructor(input: ByteInput, network?: Network) { this.#byteReader = new SigmaByteReader(input); this.#header = this.#byteReader.readByte(); this.#network = network ?? Network.Mainnet; } + static from(input: JsonCompilerOutput, network?: Network): ErgoTree { + const tree = new ErgoTree(reconstructTreeFromObject(input).toBytes(), network); + if (tree.hasSegregatedConstants && input.constants?.length) { + for (let i = 0; i < input.constants.length; i++) { + const constant = input.constants[i]; + if (!constant.name) continue; + + tree.#nameConstant(i, constant.name); + } + } + + return tree; + } + get bytes(): Uint8Array { return this.serialize(); } @@ -35,15 +56,15 @@ export class ErgoTree { } get version(): number { - return this.#header & VERSION_MASK; + return getVersion(this.#header); } get hasSegregatedConstants(): boolean { - return (this.#header & ergoTreeHeaderFlags.constantSegregation) !== 0; + return hasFlag(this.#header, ergoTreeHeaderFlags.constantSegregation); } get hasSize(): boolean { - return (this.#header & ergoTreeHeaderFlags.sizeInclusion) !== 0; + return hasFlag(this.#header, ergoTreeHeaderFlags.sizeInclusion); } get constants(): ReadonlyArray { @@ -59,12 +80,19 @@ export class ErgoTree { return !!this.#root; } - replaceConstant(index: number, constant: SConstant): ErgoTree { + replaceConstant(index: number | string, constant: SConstant): ErgoTree { if (!this.hasSegregatedConstants) throw new Error("Constant segregation is not enabled."); this.#parse(); - const oldConst = this.#constants?.[index]; + if (typeof index === "string") { + const namedIndex = this.#constantNames.get(index); + if (namedIndex === undefined) throw new Error(`Constant with name '${index}' not found.`); + + index = namedIndex; + } + + const oldConst = this.#constants[index]; if (!oldConst) throw new Error(`Constant at index ${index} not found.`); if (oldConst.type.toString() !== constant.type.toString()) { throw new Error( @@ -77,6 +105,11 @@ export class ErgoTree { return this; } + #nameConstant(index: number, name: string): ErgoTree { + this.#constantNames.set(name, index); + return this; + } + toHex(): HexString { return hex.encode(this.serialize()); } @@ -129,3 +162,49 @@ export class ErgoTree { return this; } } + +function hasFlag(header: number, flag: number): boolean { + return (header & flag) !== 0; +} + +function getVersion(header: number): number { + return header & VERSION_MASK; +} + +function reconstructTreeFromObject(input: JsonCompilerOutput): SigmaByteWriter { + const numHead = Number.parseInt(input.header, 16); + const sizeDelimited = hasFlag(numHead, ergoTreeHeaderFlags.sizeInclusion); + const constSegregated = hasFlag(numHead, ergoTreeHeaderFlags.constantSegregation); + + let len = input.constants?.reduce((acc, c) => acc + byteSizeOf(c.value), 0) ?? 0; + len += estimateVLQSize(len); + const constBytes = + constSegregated && input.constants + ? new SigmaByteWriter(len) + .writeArray(input.constants, (w, c) => w.writeHex(c.value)) + .toBytes() + : new Uint8Array(0); + + len = constBytes.length + byteSizeOf(input.header) + byteSizeOf(input.expressionTree); + len += estimateVLQSize(len); + const writer = new SigmaByteWriter(len).write(numHead); + + if (sizeDelimited) { + writer.writeUInt(constBytes.length + byteSizeOf(input.expressionTree)); + } + + return writer.writeBytes(constBytes).writeHex(input.expressionTree); +} + +export interface ConstantInfo { + value: string; + type: string; + name?: string; + description?: string; +} + +export interface JsonCompilerOutput { + header: string; + expressionTree: string; + constants?: ConstantInfo[]; +}