A tool to discover and select Ergo Babel fee boxes (EIP-0031) for paying transaction fees in tokens instead of ERG. It finds on-chain liquidity boxes for a specific token and filters them by the required ERG amount.
A tool to discover and select Ergo Babel fee boxes (EIP-0031) for paying transaction fees in tokens instead of ERG. It finds on-chain liquidity boxes for a specific token and filters them by the required ERG amount.
Comprehensive guide to paying game fees in tokens (e.g. CYPX) instead of ERG, using Babel boxes (EIP-0031). Covers both Nautilus (browser extension) and ErgoPay (mobile wallet) flows.
Status: Fully implemented and live. Both flows confirmed working on mainnet.
A Babel box is an on-chain UTXO that enables users to pay miner fees in tokens instead of ERG. It works like a DEX limit order:
The contract body (without header byte) is a fixed template parameterized by token ID:
0604000e20{tokenId}0400040005000500d803d601e30004d602e4c6a70408d603e4c6a7050595e67201d804d604b2a5e4720100d605b2db63087204730000d606db6308a7d60799c1a7c17204d1968302019683050193c27204c2a7938c720501730193e4c672040408720293e4c672040505720393e4c67204060ec5a796830201929c998c7205029591b1720673028cb272067303000273047203720792720773057202
The full ErgoTree = header + body. Two valid header formats exist:
| Header | Hex | Description |
|---|---|---|
| Compact | 10 | No size prefix. Fleet SDK default. ErgoTree length = 194 bytes (388 hex chars). |
| With-size | 18c101 | Has VLQ-encoded size prefix. Created by ergo-lib-wasm / Ergo node. |
Both encode the exact same contract. However, Explorer API does exact string matching on ErgoTree, so you must search for both formats when discovering Babel boxes.
| Register | Type | Content |
|---|---|---|
| R4 | SGroupElement (0x08cd...) | Public key of the Babel box creator (for identification) |
| R5 | SLong (0x05...) | Token price in nanoERG per raw token unit |
| R6 | Coll[Byte] (0x0e20...) | Original box ID (self-reference for recreation) |
When spending a Babel box, you must provide a context extension telling the contract which output index recreates it:
{ "0": "0402" }
"0" = variable index 0 in the contract"0402" = Sigma-serialized SInt(1) (output index 1)04 = SInt type tag, 02 = zigzag-encoded value 1tokenPrice = R5 value (nanoERG per raw token unit)
ergNeeded = TX_FEE (1,100,000) + MIN_BOX_VALUE (1,000,000) per treasury output
babelSwapTokens = ergNeeded / tokenPrice + 1 // +1 for rounding safety
// Recreated Babel output:
babelOutput.value = babelBox.value - ergNeeded
babelOutput.tokens = babelBox.tokens + babelSwapTokens
The +1n rounding prevents underpaying when ergNeeded doesn't divide evenly by tokenPrice.
Query the Explorer API for unspent boxes by ErgoTree. Must try both header formats:
// api/_lib/babel-discovery.ts OR src/lib/ergo/babel.ts
function getBabelErgoTrees(tokenId: string): string[] {
const body = BABEL_BODY_TEMPLATE.replace('{tokenId}', tokenId);
return [
`10${body}`, // compact header
`18c101${body}`, // with-size header
];
}
async function findBabelBoxes(tokenId: string): Promise<BabelBox[]> {
for (const ergoTree of getBabelErgoTrees(tokenId)) {
const resp = await fetch(
`https://api.ergoplatform.com/api/v1/boxes/unspent/byErgoTree/${ergoTree}`
);
// ... parse and return boxes
if (boxes.length > 0) break; // found boxes, skip other format
}
return allBoxes.sort((a, b) => b.value - a.value); // most liquidity first
}
Explorer API v1 returns registers as objects, not plain hex strings:
{
"R4": { "serializedValue": "08cd...", "sigmaType": "SGroupElement", "renderedValue": "..." },
"R5": { "serializedValue": "05fcfe20", "sigmaType": "SLong", "renderedValue": "..." }
}
You need a flattenRegisters() helper to extract serializedValue:
function flattenRegisters(regs: Record<string, any>): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(regs)) {
out[key] = typeof val === 'object' && val !== null ? val.serializedValue : val;
}
return out;
}
Pick the first box with enough ERG to cover the miner fee plus one MIN_BOX_VALUE per treasury output:
function selectBabelBox(boxes: BabelBox[], requiredNanoErg: number): BabelBox | null {
return boxes.find(b => b.value >= requiredNanoErg + MIN_BOX_VALUE) ?? null;
}
// Single output: selectBabelBox(boxes, TX_FEE + MIN_BOX_VALUE)
// Batch (N outputs): selectBabelBox(boxes, TX_FEE + MIN_BOX_VALUE * N)
R5 is Sigma-serialized SLong — type byte 0x05 followed by zigzag-encoded VLQ:
function getTokenPriceFromBox(box: BabelBox): bigint {
const bytes = hexToBytes(box.additionalRegisters.R5);
// bytes[0] = 0x05 (SLong type tag)
// Decode zigzag VLQ starting at byte 1
let value = 0n, shift = 0n;
for (let i = 1; i < bytes.length; i++) {
value |= (BigInt(bytes[i]) & 0x7fn) << shift;
if ((bytes[i] & 0x80) === 0) break;
shift += 7n;
}
return (value >> 1n) ^ -(value & 1n); // zigzag decode
}
In Fleet SDK (client-side), you can use:
const tokenPrice = SConstant.from<bigint>(babelBox.additionalRegisters.R5).data;
Fee amounts are stored in collections.game_config_overrides (Supabase), resolved via getGameConfig(collectionId) in api/_lib/config.ts. Example config:
{
"fee_token": {
"token_id": "01dce8a5632d19799950ff90bca3b5d0ca3ebfa8aaafd06f0cc6dd1e97150e7f",
"name": "CYPX",
"decimals": 4,
"training_fee": 37,
"default_race_entry_fee": 187,
"treatment_fees": {
"stim_pack": 19,
"cryo_pod": 37,
"full_reset": 94
}
}
}
Config stores human-readable amounts (e.g. 37 CYPX). Raw token units on-chain use amount * 10^decimals:
37 CYPX (4 decimals) → 370,000 raw token units
187 CYPX → 1,870,000 raw token units
You must scale in ALL locations where token amounts are used:
| Location | Code |
|---|---|
| Nautilus TX builder (client) | BigInt(amount) * BigInt(10 ** decimals) |
| ErgoPay TX builder (server) | Same formula |
| ErgoPay batch TX builder | Same formula |
| TX verification (server) | verifyTokenTxOnChain(..., expectedTokenAmount) |
| Credit ledger recording | Store both human-readable + raw |
| Frontend display | Show human-readable amount |
The system supports both ERG and token payments. The paymentCurrency field ('erg' or 'token') controls which path:
// Frontend sends: { paymentCurrency: 'token', ... }
// Backend checks:
if (paymentCurrency === 'token') {
const feeTokenConfig = mergedConfig?.fee_token;
// ... build token TX
} else {
// ... build ERG TX (original path)
}
File: src/lib/ergo/transactions.ts
Nautilus (browser extension wallet) provides the EIP-12 dApp connector API. The client builds the full unsigned TX locally, then Nautilus signs and submits it.
Nautilus wallet API supports token-aware UTXO selection natively:
// ERG-only selection:
const utxos = await window.ergo.get_utxos({ nanoErgs: requiredAmount.toString() });
// Token-aware selection (for Babel TXs):
const utxos = await window.ergo.get_utxos({
tokens: [{ tokenId: feeToken.tokenId, amount: totalTokensNeeded.toString() }],
});
We use Fleet SDK's TransactionBuilder but bypass BabelSwapPlugin because it validates ErgoTree format (requires compact 0x10 header + exact length 388). On-chain Babel boxes created with ergo-lib-wasm have the 0x18c101 header and fail that validation.
Solution: Manual Babel swap using ErgoUnsignedInput + OutputBuilder.from():
import { TransactionBuilder, OutputBuilder, SAFE_MIN_BOX_VALUE, ErgoUnsignedInput } from '@fleet-sdk/core';
import { SConstant, SColl, SByte, SInt } from '@fleet-sdk/serializer';
// 1. Read price from R5 (works regardless of ErgoTree header format)
const tokenPrice = SConstant.from<bigint>(babelBox.additionalRegisters.R5).data;
const ergNeeded = BigInt(TX_FEE) + SAFE_MIN_BOX_VALUE;
const babelSwapTokens = ergNeeded / tokenPrice + 1n;
// 2. Build treasury output (receives tokens + MIN_ERG)
const treasuryOutput = new OutputBuilder(SAFE_MIN_BOX_VALUE, treasuryErgoTree)
.addTokens({ tokenId: feeToken.tokenId, amount: feeToken.amount })
.setAdditionalRegisters(registers);
// 3. Build Babel swap as a TransactionBuilder extension
const babelSwap = ({ addInputs, addOutputs }) => {
const input = new ErgoUnsignedInput(babelBox);
const changeAmount = BigInt(babelBox.value) - babelSwapTokens * tokenPrice;
// OutputBuilder.from() preserves original ErgoTree bytes — critical!
const outputsLength = addOutputs(
OutputBuilder.from(input)
.setValue(changeAmount)
.addTokens({ tokenId: feeToken.tokenId, amount: babelSwapTokens })
.setAdditionalRegisters({ R6: SColl(SByte, input.boxId) }),
);
// Context extension: variable 0 = output index of recreated Babel box
addInputs(input.setContextExtension({ 0: SInt(outputsLength - 1) }));
};
// 4. Build and sign
const unsignedTx = new TransactionBuilder(blockHeight)
.from(utxos)
.to(treasuryOutput)
.extend(babelSwap) // Babel box added via extension
.payMinFee()
.sendChangeTo(changeAddress)
.build()
.toEIP12Object();
const txId = await signAndSubmitTx(unsignedTx);
OutputBuilder.from(input) preserves the original ErgoTree bytes from the Babel box. This is essential — see Pitfall #1.SInt(outputsLength - 1) computes the correct output index dynamically (after TransactionBuilder adds change/fee outputs).SColl(SByte, input.boxId) writes the original box ID into R6 of the recreated output.Files: api/_lib/ergo-tx-builder.ts, api/v2/ergopay/tx/request.ts
ErgoPay (mobile wallet) cannot build TXs locally. Instead:
ergopay.duckdns.org/api/v1/reducedTx)ergopay:// URLThe server must fetch UTXOs from Explorer and select boxes that contain the required token. This is a two-step process (unlike Nautilus which has native token-aware selection):
// api/_lib/ergo-tx-builder.ts
// Step 1: Fetch ALL unspent boxes for the sender
async function fetchAllUtxos(address: string): Promise<ExplorerBox[]> {
const resp = await fetch(
`${EXPLORER_API}/boxes/unspent/byAddress/${address}?limit=50&sortBy=value&sortDirection=desc`
);
return (data.items || data || []).map(b => ({
boxId: b.boxId, value: b.value, ergoTree: b.ergoTree,
assets: (b.assets || []).map(a => ({ tokenId: a.tokenId, amount: a.amount })),
}));
}
// Step 2: Select boxes prioritizing those with the required token
function selectBoxes(
allBoxes: ExplorerBox[],
requiredNanoErgs: number,
requiredToken?: { tokenId: string; amount: bigint },
): { boxes: ExplorerBox[]; totalValue: number } {
if (requiredToken) {
const withToken = allBoxes.filter(b =>
b.assets.some(a => a.tokenId === requiredToken.tokenId));
const withoutToken = allBoxes.filter(b =>
!b.assets.some(a => a.tokenId === requiredToken.tokenId));
const selected: ExplorerBox[] = [];
let totalErg = 0, totalTokens = 0n;
// First: pick boxes containing the token
for (const box of withToken) {
selected.push(box);
totalErg += box.value;
totalTokens += BigInt(
box.assets.find(a => a.tokenId === requiredToken.tokenId)?.amount ?? 0);
if (totalTokens >= requiredToken.amount && totalErg >= requiredNanoErgs) break;
}
// Then: add ERG-only boxes if still need more ERG
if (totalErg < requiredNanoErgs) {
for (const box of withoutToken) {
selected.push(box);
totalErg += box.value;
if (totalErg >= requiredNanoErgs) break;
}
}
if (totalTokens < requiredToken.amount)
throw new Error(`Insufficient tokens: need ${requiredToken.amount} raw units, wallet has ${totalTokens}`);
if (totalErg < requiredNanoErgs)
throw new Error(`Insufficient funds: need ${(requiredNanoErgs / 1e9).toFixed(4)} ERG`);
return { boxes: selected, totalValue: totalErg };
}
// ... ERG-only fallback (greedy selection)
}
// api/_lib/ergo-tx-builder.ts — buildUnsignedTokenFeeTx()
// 1. Fetch sender UTXOs, Babel boxes, and block height in parallel
const [allSenderBoxes, babelBoxes, creationHeight] = await Promise.all([
fetchAllUtxos(senderAddress),
findBabelBoxes(feeTokenId),
getCurrentHeight(),
]);
// 2. Select Babel box + calculate swap
const babelBox = selectBabelBox(babelBoxes, TX_FEE + MIN_BOX_VALUE);
const tokenPrice = getTokenPriceFromBox(babelBox);
const ergFromBabel = BigInt(TX_FEE) + BigInt(MIN_BOX_VALUE);
const babelSwapTokens = ergFromBabel / tokenPrice + 1n;
// 3. Select sender boxes with enough tokens
const totalTokensNeeded = feeTokenAmount + babelSwapTokens;
const { boxes: senderBoxes } = selectBoxes(allSenderBoxes, MIN_BOX_VALUE, {
tokenId: feeTokenId,
amount: totalTokensNeeded,
});
// 4. Build treasury output (tokens + MIN_ERG + R4-R6 metadata)
const treasuryOutput = {
value: MIN_BOX_VALUE.toString(),
address: treasuryAddress,
assets: [{ tokenId: feeTokenId, amount: feeTokenAmount.toString() }],
additionalRegisters: buildRegisters(metadata),
};
// 5. Build recreated Babel output
const babelOutput = {
value: (babelBox.value - TX_FEE - MIN_BOX_VALUE).toString(),
ergoTree: babelBox.ergoTree, // MUST use original ErgoTree bytes!
assets: [{ tokenId: feeTokenId,
amount: (BigInt(existingBabelTokens) + babelSwapTokens).toString() }],
additionalRegisters: {
...babelBox.additionalRegisters,
R6: sigmaCollByteFromHex(babelBox.boxId), // Self-reference
},
};
// 6. Assemble — Babel input is LAST, with context extension
return {
creationHeight,
fee: TX_FEE,
changeAddress: senderAddress,
inputs: [
...senderBoxes.map(b => ({ boxId: b.boxId })),
{ boxId: babelBox.boxId, extension: { '0': sigmaSerializeSInt(1) } },
],
dataInputs: [],
outputs: [treasuryOutput, babelOutput],
};
The unsigned TX is wrapped in the ErgoPay relay's expected format:
{
"address": "9senderAddress...",
"message": "CyberPets Racing: Training Fee (37 CYPX)",
"messageSeverity": "INFORMATION",
"replyTo": "https://nft-races.vercel.app/api/v2/ergopay/tx/callback/REQUEST_ID",
"unsignedTx": {
"creationHeight": 1724997,
"fee": 1100000,
"changeAddress": "9senderAddress...",
"inputs": [
{ "boxId": "aabb..." },
{ "boxId": "ccdd...", "extension": { "0": "0402" } }
],
"dataInputs": [],
"outputs": [
{
"value": "1000000",
"address": "9treasury...",
"assets": [{ "tokenId": "01dce8a5...", "amount": "370000" }],
"additionalRegisters": { "R4": "0e05...", "R5": "0e20...", "R6": "0e08..." }
},
{
"value": "234927040",
"ergoTree": "18c1010604000e20...",
"assets": [{ "tokenId": "01dce8a5...", "amount": "56" }],
"additionalRegisters": { "R4": "08cd...", "R5": "05fcfe20", "R6": "0e20..." }
}
]
}
}
Key differences from simple ERG TX:
| Field | ERG TX | Babel Token TX |
|---|---|---|
inputs[].extension | Not present | { "0": "0402" } on Babel input |
outputs[].ergoTree | Not present (uses address) | Present on Babel output |
outputs[].assets | Empty [] | Tokens on treasury + Babel outputs |
| Output count | 1 (treasury only) | 2 (treasury + recreated Babel box) |
The ErgoPay relay (ergopay.duckdns.org) must support:
input.extension, attach as ContextExtension on each UnsignedInputoutput.ergoTree is present, use it directly instead of deriving from output.addressFile: api/_lib/verify-tx.ts
For ERG payments, verify that TX outputs to the treasury address sum to at least the expected nanoERG amount:
async function verifyTxOnChain(txId, treasuryAddress, expectedAmountNanoerg)
→ { valid: boolean; reason?: string }
For token payments, verify that TX outputs to the treasury contain at least the expected token amount:
async function verifyTokenTxOnChain(txId, treasuryAddress, expectedTokenId, expectedTokenAmount)
→ { valid: boolean; reason?: string }
Both functions soft-fail (return valid: true) when:
The hard protection against replay is isTxIdUsed() — dedup check against the credit_ledger table.
Every TX ID is recorded in credit_ledger.tx_id. Before accepting a payment:
const used = await isTxIdUsed(txId);
if (used) return res.status(409).json({ error: 'Transaction already used' });
Symptom: "Script reduced to false" error when wallet tries to sign.
Cause: The Babel contract checks selfOutput.propositionBytes == SELF.propositionBytes. If you change the ErgoTree header (e.g. convert 18c101... to 10... compact format), the byte sequences no longer match and the script fails.
Fix: Always use babelBox.ergoTree directly from the on-chain box. Never normalize, compact, or re-encode the ErgoTree.
// WRONG — changes propositionBytes:
const compactTree = toCompactErgoTree(babelBox.ergoTree);
// CORRECT — preserves original bytes:
babelOutput.ergoTree = babelBox.ergoTree;
// Fleet SDK equivalent (correct):
OutputBuilder.from(input) // preserves ergoTree from input
Symptom: Fleet SDK throws "invalid babel contract" for valid on-chain boxes.
Cause: Fleet SDK's isValidBabelContract() requires the compact 0x10 header + exact ErgoTree length of 388 hex chars. Boxes created with ergo-lib-wasm or the Ergo node use the 0x18c101 header, which fails validation.
Fix: Bypass BabelSwapPlugin entirely. Use manual Babel swap via ErgoUnsignedInput + OutputBuilder.from() + setContextExtension(). See Section 4.
Symptom: ErgoPay relay returns NotEnoughTokensError — inputs have 0 (or few) tokens.
Cause: Original fetchUtxos() selected boxes by ERG value only. For Babel TXs, the sender must provide boxes containing the required token amount.
Fix: Two-step selection — fetch all UTXOs, then prioritize boxes containing the token. See selectBoxes() in Section 5.
Symptom: SConstant parsing fails, or registers contain [object Object].
Cause: Explorer API v1 returns registers as { serializedValue, sigmaType, renderedValue } objects, not plain hex strings.
Fix: Use flattenRegisters() to extract serializedValue. See Section 2.
Symptom: Paying 37 raw units instead of 370,000 (or vice versa).
Cause: Config stores human-readable amounts (37 CYPX) but the blockchain works in raw units (370,000 for 4-decimal token).
Fix: Apply BigInt(amount) * BigInt(10 ** decimals) consistently in all 10 locations:
src/lib/ergo/transactions.ts)api/_lib/ergo-tx-builder.ts)api/v2/ergopay/tx/request.ts) — 3 action types (train, race, treatment)api/_lib/verify-tx.ts)await Ledger Inserts in ServerlessSymptom: credit_ledger entries silently missing — action succeeds but no record.
Cause: Vercel kills the serverless function process after sending the HTTP response. Any un-awaited async operations (like supabase.from('credit_ledger').insert(...)) are terminated mid-flight.
Fix: Always await the recordLedgerEntry() call before returning the response.
Symptom: findBabelBoxes() returns empty array even though boxes exist on-chain.
Cause: Explorer does exact string matching on ErgoTree. If your boxes were created with the 0x18c101 header but you only search for 0x10, you'll find nothing.
Fix: Search for both header variants. Stop after the first format finds boxes. See findBabelBoxes() in Section 2.
| File | Purpose |
|---|---|
src/lib/ergo/babel.ts | Babel box discovery, ErgoTree construction, box selection |
src/lib/ergo/transactions.ts | TX builders: buildAndSubmitTokenFeeTx(), buildAndSubmitBatchTokenFeeTx() |
src/lib/ergo/types.ts | ErgoBox, OutputBox, ErgoBoxAsset interfaces |
| File | Purpose |
|---|---|
api/_lib/babel-discovery.ts | Server-side Babel box discovery (mirrors client babel.ts) |
api/_lib/ergo-tx-builder.ts | Unsigned TX builders: buildUnsignedTokenFeeTx(), buildUnsignedBatchTokenFeeTx(), fetchAllUtxos(), selectBoxes() |
api/v2/ergopay/tx/request.ts | ErgoPay payment request endpoint (orchestrates validation + TX build + relay POST) |
api/v2/ergopay/tx/callback/[requestId].ts | Callback from wallet with signed TX ID |
api/v2/ergopay/tx/status/[requestId].ts | Frontend polls for payment confirmation |
| File | Purpose |
|---|---|
api/_lib/config.ts | getGameConfig(collectionId) — resolves per-collection fee_token config |
api/_lib/verify-tx.ts | verifyTokenTxOnChain(), isTxIdUsed() — token TX verification + dedup |
api/_lib/execute-action.ts | Shared action executor (training, race entry, treatment) — token-aware ledger recording |
api/_lib/constants.ts | TREASURY_ADDRESS, TRAINING_FEE_NANOERG, REQUIRE_FEES |
Both client and server have ported Sigma serialization (no WASM dependency):
| Function | Format | Used For |
|---|---|---|
sigmaSerializeCollByte(data) | 0x0e + VLQ(len) + bytes | Register values (R4-R6) |
sigmaSerializeUtf8(text) | Coll[Byte] from UTF-8 | R4 (action type), R6 (context) |
sigmaSerializeHex(hex) | Coll[Byte] from hex | R5 (token ID) |
sigmaSerializeSInt(value) | 0x04 + zigzag VLQ | Context extension (output index) |
-- collections.game_config_overrides for CyberPets:
{
"fee_token": {
"token_id": "01dce8a5632d19799950ff90bca3b5d0ca3ebfa8aaafd06f0cc6dd1e97150e7f",
"name": "CYPX",
"decimals": 4,
"training_fee": 37,
"default_race_entry_fee": 187,
"treatment_fees": {
"stim_pack": 19,
"cryo_pod": 37,
"full_reset": 94
}
}
}
fee_token block to the collection's game_config_overrides in SupabasePaymentSelector component auto-detects fee_token config and shows the token payment option{
"type": "object",
"required": [
"token_id"
],
"properties": {}
}
{
"properties": {},
"required": [
"boxes"
],
"type": "object"
}
Input:
{}
Output:
{}
Code Example:
print(payments_api.find_babel_boxes(token_id=\'01dce8a5632d19799950ff90bca3b5d0ca3ebfa8aaafd06f0cc6dd1e97150e7f\'))
Input:
{}
Output:
{}
Code Example:
print(payments_api.find_babel_boxes(token_id=\'112233445566778899aabbccddeeff112233445566778899aabbccddeeff\'))
Input:
{}
Output:
{}
Code Example:
print(payments_api.find_babel_boxes(token_id=\'01dce8a5632d19799950ff90bca3b5d0ca3ebfa8aaafd06f0cc6dd1e97150e7f\', required_nano_erg=1000000000))
ergo:public-api:boxes:readergo:public-api:network-state:read10... and 18c101...).