diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8476c59..367f646 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -60,7 +60,8 @@ function sidebarGuide() { items: [ { text: "Wallet interaction", link: "/wallet-interaction" }, { text: "Transaction building", link: "/transaction-building" }, - { text: "Token burning", link: "/token-burning" } + { text: "Token burning", link: "/token-burning" }, + { text: "Compile-time constants", link: "/compile-time-constants" } ] } ]; diff --git a/docs/compile-time-constants.md b/docs/compile-time-constants.md new file mode 100644 index 0000000..175884d --- /dev/null +++ b/docs/compile-time-constants.md @@ -0,0 +1,110 @@ +# Compile-Time Constants + +Suppose you want to create several time-locked boxes, each with a different unlock height. You can reuse one ErgoScript contract and supply the height when compiling it, instead of editing the contract text for each box. + +This guide assumes you know how to [build an output box](./transaction-building.md#step-4-add-outputs). The example runs locally in Node.js without a wallet or a blockchain connection. + +## Choose What Is Fixed in the Contract + +A compile-time constant is a value supplied before ErgoScript becomes an ErgoTree. It becomes part of the compiled spending condition. In this contract, `unlockHeight` is fixed during compilation, while `HEIGHT` is read from the blockchain context when a transaction attempts to spend the box: + +```scala +sigmaProp(HEIGHT >= unlockHeight) +``` + +The condition allows **anyone** to spend the box at or after the chosen height. It does not require an owner's signature. We use it to demonstrate compilation; adding an owner condition is a separate contract-design step. + +Changing a JavaScript variable later cannot change the ErgoTree of an existing box. Compile with another height to create a different contract for a new box. + +## Step. 1: Install the Compiler + +Create a folder and install the versions used in this example: + +```bash +npm install --save-exact @fleet-sdk/compiler@0.12.0 @fleet-sdk/core@0.12.0 @fleet-sdk/serializer@0.11.0 +``` + +`@fleet-sdk/compiler` compiles ErgoScript locally. `@fleet-sdk/serializer` provides typed constants, and `@fleet-sdk/core` builds the output. + +## Step. 2: Supply a Typed Constant + +Pass named constants through the compiler's `map` option. A key must match the identifier in the script. Here, `SInt(1500000)` gives `unlockHeight` the ErgoScript `Int` type, matching `HEIGHT`: + +```js +map: { unlockHeight: SInt(1500000) } +``` + +Do not pass a bare JavaScript number as the map value. The compiler expects a typed constant (or its serialized representation). The `SInt` wrapper states the on-chain type; use a different serializer type only when the contract expects it. + +## Step. 3: Compile and Build an Output + +Save this complete example as `index.mjs`: + +```js +import { compile } from "@fleet-sdk/compiler"; +import { OutputBuilder } from "@fleet-sdk/core"; +import { SInt } from "@fleet-sdk/serializer"; + +const contract = "sigmaProp(HEIGHT >= unlockHeight)"; +const tree = compile(contract, { + version: 1, + network: "testnet", + map: { unlockHeight: SInt(1500000) } +}); + +const output = new OutputBuilder(1000000n, tree.encode(), 1400000).build(); +console.log("ErgoTree:", tree.toHex()); +console.log("Testnet address:", tree.encode()); +console.log("Output ErgoTree:", output.ergoTree); +``` + +Run it: + +```bash +node index.mjs +``` + +With the pinned versions above, it prints: + +```text +ErgoTree: 190b0104c08db701d192a37300 +Testnet address: nwhHeDwivvm8YuWn5VEjDmTk +Output ErgoTree: 190b0104c08db701d192a37300 +``` + +`compile()` returns an `ErgoTree`. Its `encode()` method produces the contract address, which `OutputBuilder` accepts as its destination. The compiler's `network` option selects a testnet address. The fixed creation height of `1400000` is demonstration data, not a query for the current height. + +This builds an output candidate only. It does not select inputs, sign, broadcast, or evaluate a spending transaction. To include the output in a transaction, follow [Transaction Building](./transaction-building.md) and obtain current inputs and height from your wallet. + +## Reuse the Contract with Another Height + +Keep the same script and supply a different value: + +```js +const laterTree = compile(contract, { + version: 1, + network: "testnet", + map: { unlockHeight: SInt(1600000) } +}); +``` + +The resulting ErgoTree and address differ. By default, the compiler segregates constants from the executable part of the tree, so these two contracts can share a template while containing different heights. A matching template alone does not mean the spending conditions are identical: compare the constants too. Constant positions in the compiled tree are not a replacement for the named `map` used when compiling the source. + +## Choose Between Constants and Box Data + +Use compile-time constants for parameters that should be fixed in a particular contract, such as this unlock height. Box registers store data on a box, while context variables are supplied for a spending input; a contract must explicitly read and validate either. Supplying a context variable called `unlockHeight` does not fill in a missing compile-time identifier. + +If compilation reports an unresolved identifier, check that `map` contains the exact script name. If it reports a type mismatch, check that the serializer type matches the operation in the contract. + +## Run the Reproducible Example + +The documentation repository includes this example and checks for its compiled result, parameter changes, invalid maps, and output construction: + +```bash +cd examples/compile-time-constants +npm ci +npm start +npm test +``` + +These checks exercise the real compiler and builder. They do not prove that a spending transaction is valid on-chain. diff --git a/examples/compile-time-constants/index.mjs b/examples/compile-time-constants/index.mjs new file mode 100644 index 0000000..f347698 --- /dev/null +++ b/examples/compile-time-constants/index.mjs @@ -0,0 +1,15 @@ +import { compile } from "@fleet-sdk/compiler"; +import { OutputBuilder } from "@fleet-sdk/core"; +import { SInt } from "@fleet-sdk/serializer"; + +const contract = "sigmaProp(HEIGHT >= unlockHeight)"; +const tree = compile(contract, { + version: 1, + network: "testnet", + map: { unlockHeight: SInt(1500000) } +}); + +const output = new OutputBuilder(1000000n, tree.encode(), 1400000).build(); +console.log("ErgoTree:", tree.toHex()); +console.log("Testnet address:", tree.encode()); +console.log("Output ErgoTree:", output.ergoTree); diff --git a/examples/compile-time-constants/package-lock.json b/examples/compile-time-constants/package-lock.json new file mode 100644 index 0000000..69d8e04 --- /dev/null +++ b/examples/compile-time-constants/package-lock.json @@ -0,0 +1,146 @@ +{ + "name": "fleet-compile-time-constants-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fleet-compile-time-constants-example", + "dependencies": { + "@fleet-sdk/compiler": "0.12.0", + "@fleet-sdk/core": "0.12.0", + "@fleet-sdk/serializer": "0.11.0" + } + }, + "node_modules/@fleet-sdk/common": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@fleet-sdk/common/-/common-0.10.0.tgz", + "integrity": "sha512-N92zENyHYhKtKxhJ6jJbWgV3PCkCGM0LYLmn6OOXNqDVbwT9UFgHOTt7eXFd9tqIhwMMPCnlffNe4c+P+CnsJA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@fleet-sdk/compiler": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@fleet-sdk/compiler/-/compiler-0.12.0.tgz", + "integrity": "sha512-WH05qMRmWe8qTI1oX2NZ3qJobp2ZYPh3DqAAtKRPxmeHmfWmvFWM6QHwWeGR7M86QCQczWNdqeOY7qJKs12G4g==", + "license": "MIT", + "dependencies": { + "@fleet-sdk/common": "^0.10.0", + "@fleet-sdk/core": "^0.12.0", + "@fleet-sdk/crypto": "^0.11.0", + "@fleet-sdk/serializer": "^0.11.0", + "sigmastate-js": "0.4.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@fleet-sdk/core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@fleet-sdk/core/-/core-0.12.0.tgz", + "integrity": "sha512-AYdfivEzfokem2eovnhp5rfv+cFrVR87l/ff71uxt1Xn1Arv0QivNtXn9H00i0t3LkiTxI7ttgU56FAbjWbUKw==", + "license": "MIT", + "dependencies": { + "@fleet-sdk/common": "^0.10.0", + "@fleet-sdk/crypto": "^0.11.0", + "@fleet-sdk/serializer": "^0.11.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@fleet-sdk/crypto": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@fleet-sdk/crypto/-/crypto-0.11.0.tgz", + "integrity": "sha512-oGyrnL0AyzPSsPdA32y4TEFQ6vJlNDMt9nwiArd2TYbtRCDMNTslHQmC/An4clf4R0e/c4yuZJSdfzHC3F0ssQ==", + "license": "MIT", + "dependencies": { + "@fleet-sdk/common": "^0.10.0", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@fleet-sdk/serializer": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@fleet-sdk/serializer/-/serializer-0.11.0.tgz", + "integrity": "sha512-EYun0nzxJn+23aOeaMM5COj62ibVrzgNOx2I6AM6P23mRs72I0Dv2prdBnU/lst4hqbNHi8a1E6UNvpjH2vhGQ==", + "license": "MIT", + "dependencies": { + "@fleet-sdk/common": "^0.10.0", + "@fleet-sdk/crypto": "^0.11.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sigmajs-crypto-facade": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/sigmajs-crypto-facade/-/sigmajs-crypto-facade-0.0.7.tgz", + "integrity": "sha512-4XK8ZS9NKAbo8aGnU6o5GkBW6Upl8+OK8A1KreVDMAamfvZ0iq4LoVH8rHaeEPf9moVtaC4QZY5RYI+0OwiydA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sigmastate-js": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/sigmastate-js/-/sigmastate-js-0.4.6.tgz", + "integrity": "sha512-Vo/TSFbkKrG28eiWn7EmoaBNgyabC6En6B7cKjb3z2ivBpFBMCGxUZgmKu83GgJboRvCikZ3/vvWFfbxpbloig==", + "license": "MIT", + "dependencies": { + "@fleet-sdk/common": "0.1.3", + "@noble/hashes": "1.1.4", + "sigmajs-crypto-facade": "0.0.7" + } + }, + "node_modules/sigmastate-js/node_modules/@fleet-sdk/common": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@fleet-sdk/common/-/common-0.1.3.tgz", + "integrity": "sha512-gYEkHhgGpgIcmCL3nCw8E9zHkT2WLmR+mPdxFlUE6fwcwISURbJrP6W9mF7D5Y0ShAP5Is2w3edh7AyIc7ctIQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/sigmastate-js/node_modules/@noble/hashes": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.4.tgz", + "integrity": "sha512-+PYsVPrTSqtVjatKt2A/Proukn2Yrz61OBThOCKErc5w2/r1Fh37vbDv0Eah7pyNltrmacjwTvdw3JoR+WE4TA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + } + } +} diff --git a/examples/compile-time-constants/package.json b/examples/compile-time-constants/package.json new file mode 100644 index 0000000..986863e --- /dev/null +++ b/examples/compile-time-constants/package.json @@ -0,0 +1,14 @@ +{ + "name": "fleet-compile-time-constants-example", + "private": true, + "type": "module", + "scripts": { + "start": "node index.mjs", + "test": "node --test test.mjs" + }, + "dependencies": { + "@fleet-sdk/compiler": "0.12.0", + "@fleet-sdk/core": "0.12.0", + "@fleet-sdk/serializer": "0.11.0" + } +} diff --git a/examples/compile-time-constants/test.mjs b/examples/compile-time-constants/test.mjs new file mode 100644 index 0000000..cb3c963 --- /dev/null +++ b/examples/compile-time-constants/test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import { compile } from "@fleet-sdk/compiler"; +import { ErgoAddress, Network, OutputBuilder } from "@fleet-sdk/core"; +import { SInt, SBool } from "@fleet-sdk/serializer"; + +const script = "sigmaProp(HEIGHT >= unlockHeight)"; +const options = { version: 1, network: "testnet" }; +const buildTree = (height) => compile(script, { + ...options, map: { unlockHeight: SInt(height) } +}); + +test("named parameter compiles to the same condition as an explicit Int literal", () => { + const tree = buildTree(1500000); + assert.equal(tree.toHex(), compile("sigmaProp(HEIGHT >= 1500000)", options).toHex()); + assert.equal(tree.toHex(), "190b0104c08db701d192a37300"); + assert.equal(ErgoAddress.decode(tree.encode()).network, Network.Testnet); +}); + +test("changing the parameter changes the contract, not its shared template", () => { + const first = buildTree(1500000); + const second = buildTree(1600000); + assert.notEqual(first.toHex(), second.toHex()); + assert.notEqual(first.encode(), second.encode()); + assert.deepEqual(first.template, second.template); + assert.equal(first.toHex(), "190b0104c08db701d192a37300"); +}); + +test("missing identifiers and incompatible typed constants fail compilation", () => { + assert.throws(() => compile(script, options)); + assert.throws(() => compile(script, { ...options, map: { unlockHeight: SBool(true) } })); + assert.throws(() => compile(script, { ...options, map: { unlockHeight: 1500000 } })); +}); + +test("the output contains the compiled contract and intended demonstration values", () => { + const tree = buildTree(1500000); + const output = new OutputBuilder(1000000n, tree.encode(), 1400000).build(); + assert.equal(output.ergoTree, tree.toHex()); + assert.equal(BigInt(output.value), 1000000n); + assert.equal(output.creationHeight, 1400000); +}); + +test("the complete documented example is the executable example and its output matches", () => { + const document = readFileSync(new URL("../../docs/compile-time-constants.md", import.meta.url), "utf8"); + const example = readFileSync(new URL("index.mjs", import.meta.url), "utf8"); + assert.ok(document.includes("```js\n" + example + "```")); + const output = execFileSync(process.execPath, [new URL("index.mjs", import.meta.url).pathname], { encoding: "utf8" }); + assert.ok(document.includes("```text\n" + output + "```")); +});