From 2d75a8929302c5160e4c1df850c638a1670981a4 Mon Sep 17 00:00:00 2001 From: Jusung Park Date: Mon, 7 Sep 2026 18:35:13 -0400 Subject: [PATCH] docs: explain reduced transactions and ErgoPay with offline example --- docs/.vitepress/config.ts | 3 +- docs/reduced-transactions.md | 88 +++++++++++++++++ examples/reduced-transactions/fixture.mjs | 27 ++++++ examples/reduced-transactions/index.mjs | 37 ++++++++ .../reduced-transactions/package-lock.json | 95 +++++++++++++++++++ examples/reduced-transactions/package.json | 16 ++++ examples/reduced-transactions/test.mjs | 42 ++++++++ 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 docs/reduced-transactions.md create mode 100644 examples/reduced-transactions/fixture.mjs create mode 100644 examples/reduced-transactions/index.mjs create mode 100644 examples/reduced-transactions/package-lock.json create mode 100644 examples/reduced-transactions/package.json create mode 100644 examples/reduced-transactions/test.mjs diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8476c59..bdaaf65 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: "Reduced transactions and ErgoPay", link: "/reduced-transactions" } ] } ]; diff --git a/docs/reduced-transactions.md b/docs/reduced-transactions.md new file mode 100644 index 0000000..9116b9b --- /dev/null +++ b/docs/reduced-transactions.md @@ -0,0 +1,88 @@ +# Reduced Transactions and ErgoPay + +An unsigned transaction describes which boxes to spend and which boxes to create. A wallet on another device also needs the result of evaluating the input contracts before it can produce their proofs. A **reduced transaction** packages the unsigned transaction with those script-reduction results. Reduction uses the spending boxes, data inputs, and blockchain context; it does not require private keys. + +Fleet builds the unsigned transaction. This guide uses `ergo-lib-wasm-nodejs` to reduce it and prepare an ErgoPay payload. Read [Transaction building](./transaction-building.md) first. + +## Run the Offline Example + +The [complete example](https://github.com/fleet-sdk/docs/tree/master/examples/reduced-transactions) requires Node.js 18 or later. From its directory, run: + +```sh +npm ci +npm test +npm start +``` + +It pins `@fleet-sdk/core` to `0.12.0` and `ergo-lib-wasm-nodejs` to `0.28.0`. It builds a transaction, actually reduces its input contract, and prints a JSON response containing 275 bytes of serialized reduced data, encoded as Base64url. + +::: warning Offline fixture +The example invents an input box and ten block headers. Its public key is a test constant, not a wallet identity. These values do not describe spendable funds on either network. No network requests, wallet connections, signing, or broadcasting occur. Do not send the example payload to a wallet. +::: + +## Build, Then Reduce + +The example first uses `TransactionBuilder` to create an unsigned transaction. The bridge into ergo-lib is its EIP-12 JSON representation: + +```js +const unsigned = new TransactionBuilder(height) + .from([input]) + .to(new OutputBuilder(1000000n, address)) + .sendChangeTo(address) + .payFee(1100000n) + .build().toEIP12Object(); +``` + +Amounts are nanoERG: this input contains 10,000,000 nanoERG, allocated to a 1,000,000 output, a 1,100,000 fee output, and 7,900,000 change. + +Convert the complete box objects and the unsigned transaction into ergo-lib objects, then call the reducer: + +```js +const reduced = ergo.ReducedTransaction.from_unsigned_tx( + ergo.UnsignedTransaction.from_json(JSON.stringify(unsigned)), + ergo.ErgoBoxes.from_boxes_json([input]), + ergo.ErgoBoxes.empty(), + fixtureContext() +); +``` + +The empty collection means this transaction has no data inputs. For a transaction that references data inputs, supply their complete boxes in the corresponding order. Likewise, supply the complete spending boxes matching the unsigned transaction's inputs. An input ID alone cannot provide its contract, value, tokens, or registers. + +`fixtureContext()` constructs an `ErgoStateContext` from a pre-header, ten recent headers, and default parameters. Its fabricated history and genesis parameters serve this offline exercise only. A production integration must supply coherent current chain context and protocol parameters from its node, together with real unspent boxes; it must not reuse this fixture or assume genesis parameters are current. Contracts involving height, headers, or data inputs make that distinction particularly significant. + +A successful reduction is not proof that these inputs exist or remain unspent on the network. It also does not sign the transaction. + +## Encode the Reduced Bytes + +Serialize the **reduced transaction**, not the unsigned JSON: + +```js +const bytes = reduced.sigma_serialize_bytes(); +const encoded = Buffer.from(bytes).toString("base64url"); +``` + +The example checks the transport by parsing the bytes back with `ReducedTransaction.sigma_parse_bytes()` and comparing the embedded unsigned transaction ID. This checks serialization, not wallet compatibility or network acceptance. + +## Choose an ErgoPay Transport + +[EIP-0020](https://github.com/ergoplatform/eips/blob/master/eip-0020.md) defines two forms: + +- **Static:** `ergopay:`. This embeds the transaction directly, without a message or callback. +- **Dynamic:** `ergopay://example.com/request/123`. The wallet fetches JSON from `https://example.com/request/123`. The URI omits the HTTPS prefix. Public endpoints use HTTPS; the protocol allows HTTP for IP addresses during local testing. + +A dynamic endpoint returns a signing request such as: + +```js +const response = { + reducedTx: encoded, + address, + message: "Review the transaction details before signing.", + messageSeverity: "INFORMATION" +}; +``` + +`address` optionally helps the wallet select the signing key. A request needs a transaction or a message. The runnable example prints a warning instead of this production-facing message and demonstrates a JSON serialization round trip without starting an HTTP server. + +For longer payloads, prefer dynamic delivery; EIP-0020 recommends it above 400 characters. After delivery, the user reviews the request in their wallet. Signing and submission are subsequent wallet operations. An optional `replyTo` callback is not guaranteed; monitor the transaction independently before treating a payment as confirmed. + +For the underlying reduced-transaction format, see [EIP-0019](https://github.com/ergoplatform/eips/blob/master/eip-0019.md). Publishing an endpoint, connecting a wallet, and confirming a real payment are separate integration steps beyond this offline example. diff --git a/examples/reduced-transactions/fixture.mjs b/examples/reduced-transactions/fixture.mjs new file mode 100644 index 0000000..cc935c0 --- /dev/null +++ b/examples/reduced-transactions/fixture.mjs @@ -0,0 +1,27 @@ +import ergo from "ergo-lib-wasm-nodejs"; + +// Fabricated identifiers and headers: this is not a snapshot of either Ergo network. +export const height = 1000; +export const publicKey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +export const ergoTree = `0008cd${publicKey}`; + +export function fixtureInput() { + const box = ergo.ErgoBox.from_json(JSON.stringify({ + value: "10000000", ergoTree, assets: [], additionalRegisters: {}, + creationHeight: height - 1, transactionId: "00".repeat(32), index: 0 + })); + return JSON.parse(box.to_json()); +} + +export function fixtureContext() { + const header = (h) => ({ + version: 2, id: "00".repeat(32), parentId: "00".repeat(32), + adProofsRoot: "00".repeat(32), stateRoot: "00".repeat(33), + transactionsRoot: "00".repeat(32), extensionHash: "00".repeat(32), + timestamp: 1700000000000, nBits: 117586360, height: h, votes: "000000", + powSolutions: { pk: publicKey, w: publicKey, n: "00".repeat(8), d: 0 } + }); + const headers = ergo.BlockHeaders.from_json(Array.from({ length: 10 }, (_, i) => header(height - 1 - i))); + const preHeader = ergo.PreHeader.from_block_header(ergo.BlockHeader.from_json(JSON.stringify(header(height)))); + return new ergo.ErgoStateContext(preHeader, headers, ergo.Parameters.default_parameters()); +} diff --git a/examples/reduced-transactions/index.mjs b/examples/reduced-transactions/index.mjs new file mode 100644 index 0000000..c9e6475 --- /dev/null +++ b/examples/reduced-transactions/index.mjs @@ -0,0 +1,37 @@ +import { pathToFileURL } from "node:url"; +import { OutputBuilder, TransactionBuilder } from "@fleet-sdk/core"; +import ergo from "ergo-lib-wasm-nodejs"; +import { fixtureContext, fixtureInput, height, publicKey } from "./fixture.mjs"; + +export function createExample() { + const input = fixtureInput(); + const address = ergo.Address.from_public_key(Uint8Array.from(Buffer.from(publicKey, "hex"))).to_base58(ergo.NetworkPrefix.Testnet); + const unsigned = new TransactionBuilder(height) + .from([input]) + .to(new OutputBuilder(1000000n, address)) + .sendChangeTo(address) + .payFee(1100000n) + .build().toEIP12Object(); + const reduced = ergo.ReducedTransaction.from_unsigned_tx( + ergo.UnsignedTransaction.from_json(JSON.stringify(unsigned)), + ergo.ErgoBoxes.from_boxes_json([input]), + ergo.ErgoBoxes.empty(), + fixtureContext() + ); + const bytes = reduced.sigma_serialize_bytes(); + const encoded = Buffer.from(bytes).toString("base64url"); + const response = { + reducedTx: encoded, + address, + message: "Offline tutorial fixture. Do not sign or submit this transaction.", + messageSeverity: "WARNING" + }; + return { unsigned, bytes, response, staticUri: `ergopay:${encoded}` }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { bytes, response } = createExample(); + console.log("Offline fixture only; no signing, network requests, or broadcasting."); + console.log("Reduced transaction bytes:", bytes.length); + console.log(JSON.stringify(response, null, 2)); +} diff --git a/examples/reduced-transactions/package-lock.json b/examples/reduced-transactions/package-lock.json new file mode 100644 index 0000000..7662d79 --- /dev/null +++ b/examples/reduced-transactions/package-lock.json @@ -0,0 +1,95 @@ +{ + "name": "fleet-reduced-transactions-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fleet-reduced-transactions-example", + "dependencies": { + "@fleet-sdk/core": "0.12.0", + "ergo-lib-wasm-nodejs": "0.28.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/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/ergo-lib-wasm-nodejs": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/ergo-lib-wasm-nodejs/-/ergo-lib-wasm-nodejs-0.28.0.tgz", + "integrity": "sha512-APQEac2IISTtQ/3Wi9EnyyV4rkXvC+jrovLEpt1vqJLNCK9E5bMOttZws70VTqu9CY0g5wzSTNJdOjM0GuDZbA==", + "license": "CC0-1.0" + } + } +} diff --git a/examples/reduced-transactions/package.json b/examples/reduced-transactions/package.json new file mode 100644 index 0000000..a36e67a --- /dev/null +++ b/examples/reduced-transactions/package.json @@ -0,0 +1,16 @@ +{ + "name": "fleet-reduced-transactions-example", + "private": true, + "type": "module", + "scripts": { + "start": "node index.mjs", + "test": "node --test test.mjs" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@fleet-sdk/core": "0.12.0", + "ergo-lib-wasm-nodejs": "0.28.0" + } +} diff --git a/examples/reduced-transactions/test.mjs b/examples/reduced-transactions/test.mjs new file mode 100644 index 0000000..98c17e1 --- /dev/null +++ b/examples/reduced-transactions/test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import ergo from "ergo-lib-wasm-nodejs"; +import { createExample } from "./index.mjs"; +import { fixtureContext, fixtureInput } from "./fixture.mjs"; + +test("actual reduction preserves the Fleet transaction ID and value accounting", () => { + const { unsigned, bytes } = createExample(); + const parsed = ergo.ReducedTransaction.sigma_parse_bytes(bytes).unsigned_tx(); + const original = ergo.UnsignedTransaction.from_json(JSON.stringify(unsigned)); + assert.equal(parsed.id().to_str(), original.id().to_str()); + assert.equal(unsigned.inputs.length, 1); + assert.equal(unsigned.outputs.reduce((sum, output) => sum + BigInt(output.value), 0n), 10000000n); +}); + +test("dynamic JSON and static URI carry identical reduced bytes", () => { + const { bytes, response, staticUri } = createExample(); + const transported = JSON.parse(JSON.stringify(response)); + assert.match(transported.reducedTx, /^[A-Za-z0-9_-]+$/); + assert.deepEqual(Buffer.from(transported.reducedTx, "base64url"), Buffer.from(bytes)); + assert.deepEqual(Buffer.from(staticUri.slice("ergopay:".length), "base64url"), Buffer.from(bytes)); + assert.equal(transported.messageSeverity, "WARNING"); +}); + +test("an unsigned JSON document is not serialized ReducedTransaction data", () => { + const { unsigned } = createExample(); + assert.throws(() => ergo.ReducedTransaction.sigma_parse_bytes(Buffer.from(JSON.stringify(unsigned)))); +}); + +test("reduction needs the complete spending box", () => { + const { unsigned } = createExample(); + assert.throws(() => ergo.ReducedTransaction.from_unsigned_tx( + ergo.UnsignedTransaction.from_json(JSON.stringify(unsigned)), + ergo.ErgoBoxes.empty(), ergo.ErgoBoxes.empty(), fixtureContext() + )); +}); + +test("the fixture's output ID is derived from its fabricated box, not a claimed chain UTXO", () => { + const input = fixtureInput(); + assert.equal(input.transactionId, "00".repeat(32)); + assert.equal(ergo.ErgoBox.from_json(JSON.stringify(input)).box_id().to_str(), input.boxId); +});