Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
];
Expand Down
88 changes: 88 additions & 0 deletions docs/reduced-transactions.md
Original file line number Diff line number Diff line change
@@ -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:<Base64url reduced bytes>`. 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.
27 changes: 27 additions & 0 deletions examples/reduced-transactions/fixture.mjs
Original file line number Diff line number Diff line change
@@ -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());
}
37 changes: 37 additions & 0 deletions examples/reduced-transactions/index.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
95 changes: 95 additions & 0 deletions examples/reduced-transactions/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions examples/reduced-transactions/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
42 changes: 42 additions & 0 deletions examples/reduced-transactions/test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});