@titon-network/fortuna-sdk
v0.2.0
Published
TypeScript SDK for Fortuna — TON-native threshold-BLS VRF. Request verifiable randomness, decode events, integrate as a consumer.
Maintainers
Readme
@titon-network/fortuna-sdk
TypeScript SDK for Fortuna — TON-native threshold-BLS VRF. One on-chain pairing per randomness, verified in TVM.
Use this SDK if you're building a contract that needs unbiasable randomness — gaming outcomes, NFT trait rolls, lottery payouts, shuffled queue order, any fair-pick problem. Fortuna runs an off-chain DKG'd operator network (staked through ForgeTON), assigns one aggregator per request, verifies the threshold-aggregated BLS signature on chain, and delivers a unique 256-bit output via a standard VrfCallback opcode.
Think Chainlink VRF, sovereign and TON-native.
npm install @titon-network/fortuna-sdk @ton/core@ton/core is a peer dependency — bring your own version (>= 0.63.0).
Surfaces
┌─────────────────────────────────────────────────────────────┐
│ explainError FortunaError summarizeTx formatTxSummary │ diagnostics
├─────────────────────────────────────────────────────────────┤
│ decodeEvent decodeEvents tryDecodeEvent │ events
├─────────────────────────────────────────────────────────────┤
│ Fortuna newOracle FORTUNA_DEFAULTS │ contract + factory
├─────────────────────────────────────────────────────────────┤
│ OP ERR loadFortunaCode FORTUNA_TESTNET │ constants + artifacts
└─────────────────────────────────────────────────────────────┘No façade, no fluent builder — the surface is small by design. Fortuna has one role (threshold-BLS VRF); integrate at the ABI level.
Make your LLM Fortuna-aware (one-time, 5 seconds)
If you're building with Claude Code / Cursor / Aider / Cline / any AI coding assistant, run:
npx fortuna initIt writes FORTUNA.md to your repo root — a dense, AI-optimised brief covering the integration pattern, critical constants, callback-safety checklist, and common-error table. Reference it from your existing CLAUDE.md / .cursorrules / AGENTS.md and your assistant will generate correct Fortuna code on the first try.
The same content powers llms.txt (follows the llmstxt.org convention) and AGENTS.md (full surface map).
The consumer-contract playbook
You're building a product that needs randomness. Here's the shape:
- Deploy your consumer contract with a handler for
VrfCallback(opcode0x50). Runnpx fortuna generate consumer --type <kind>to scaffold a starter:raffle— simple winner pick (examples/consumer-template.tolk)nft-trait-roll— per-mint trait assignment with pending-requests dict (examples/consumer-nft-trait-roll.tolk)lottery— weighted winner pick (examples/consumer-lottery.tolk)shuffle-queue— Fisher-Yates shuffle (examples/consumer-shuffle-queue.tolk)
- Call
RequestRandomnessfrom your consumer with aqueryId(your internal identifier) and aseed(any uint256 — mixed intoalphafor request personalisation). Attachfortuna.getRequiredRequestValue(callbackGas) + callbackGasTON. Usenew QueryIdStream()from@titon-network/fortuna-sdkfor collision-free ids. - Wait for the callback. Fortuna assigns an aggregator (round-robin over pkShare-registered automatons); on fulfillment your contract receives
VrfCallback { queryId, beta }from the Fortuna address.betais your 256-bit VRF output — use it as entropy. - (Optional) Reclaim if the aggregator stalls. After
req.deadlineelapses, anyone can callReclaimRequest— Fortuna slashes the aggregator via ForgeTON and reassigns (first reclaim) or refunds the consumer (subsequent reclaims).
That's it. Operator set, DKG, BLS aggregation, threshold verification, slashing — all handled by the pool + oracle.
What does a request cost?
Defaults — override via UpdateFortunaConfig, or read live with fortuna.getConfig():
| Item | Default | Paid to |
|------|---------|---------|
| minRequestFee | 0.5 TON | Fortuna (split: 70% operator reward on fulfill, 30% protocol) |
| callbackGas (you size) | 0.02–1 TON | Your consumer (returned via VrfCallback) |
| minGasForRequest | 0.02 TON | Burned to process the request receiver |
| Minimum total value: | ~0.54 TON | (+ small buffer recommended) |
On reclaim after deadline:
- First reclaim: aggregator slashed at ForgeTON (1 TON from their stake); request reassigned; fee stays locked pending the retry.
- Subsequent reclaims / stale-epoch / pool-below-threshold: consumer refunded
minRequestFee + callbackGas(no slash — capacity-aware, not aggregator fault).
Use await fortuna.getRequiredRequestValue(callbackGas) to size value: at runtime — it reads live config so an owner-side UpdateFortunaConfig can't break hardcoded callers.
Quickstart
Deploy a fresh Fortuna
import { TonClient } from '@ton/ton';
import { toNano } from '@ton/core';
import { newOracle } from '@titon-network/fortuna-sdk';
const tonClient = new TonClient({ endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC' });
const fortuna = tonClient.open(newOracle({
owner: ownerAddress,
forgeton: forgetonPoolAddress, // the ForgeTON pool Fortuna will stake/slash through
}));
await fortuna.sendDeploy(ownerSender, toNano('1'));
console.log('Fortuna deployed at', fortuna.address.toString());Post-deploy:
- ForgeTON owner must admit Fortuna as a consumer:
forgeton.sendSetConsumer(ownerSender, { value, contract: fortuna.address, isActive: true }) - Run the off-chain DKG ceremony across your automaton set. The ceremony outputs the aggregate
groupPk(G1, 48 bytes) + per-automatonsk_i+pkShare_i. - Publish
groupPkto Fortuna:fortuna.sendPublishGroupKey(ownerSender, { value, threshold, memberCount, groupPk: cellFromBytes(pk) }) - Each automaton calls
fortuna.sendRegisterBlsSharewith its own pkShare.
Request randomness from your consumer
On-chain (Tolk) — inside your consumer's receiver:
import "./messages" // your copy of RequestRandomness from examples/consumer-template.tolk
val req = createMessage({
bounce: BounceMode.NoBounce,
dest: storage.fortuna,
value: ton("0.6"), // ≥ minRequestFee (0.5 TON default) + callbackGas + minGasForRequest + buffer
body: RequestRandomness {
queryId: myQueryId,
seed: mySeed,
callbackGas: ton("0.05"),
},
});
req.send(SEND_MODE_REGULAR);Off-chain (TypeScript) — pre-compute the value for any external trigger:
import { Fortuna } from '@titon-network/fortuna-sdk';
const fortuna = tonClient.open(Fortuna.createFromAddress(FORTUNA_TESTNET.fortuna));
const value = await fortuna.getRequiredRequestValue(toNano('0.05'));
// → minRequestFee + callbackGas + minGasForRequestImplement the callback
Inside your consumer's onInternalMessage:
struct (0x00000050) VrfCallback {
queryId: uint64
beta: uint256
}
fun handleVrfCallback(msg: VrfCallback, sender: address) {
assert (sender == storage.fortuna) throw E_NOT_FORTUNA;
// `msg.beta` is your 256-bit VRF output — use it.
// Example: pick a winner from an enumerable set.
val winnerIdx = msg.beta % storage.participantCount;
// ...
}See examples/consumer-template.tolk for a complete, compilable consumer skeleton.
Decode Fortuna events
import { decodeEvents, FortunaEvent } from '@titon-network/fortuna-sdk';
for (const tx of result.transactions) {
const externalOuts = [...tx.outMessages.values()]
.filter(m => m.info.type === 'external-out')
.map(m => m.body);
for (const ev of decodeEvents(externalOuts)) {
switch (ev.kind) {
case 'RandomnessRequested':
console.log(`queryId=${ev.queryId} aggregator=${ev.aggregator}`);
break;
case 'RandomnessFulfilled':
console.log(`queryId=${ev.queryId} beta=0x${ev.beta.toString(16)}`);
break;
}
}
}Or use the one-shot summarizer:
import { summarizeTxs, formatTxSummary } from '@titon-network/fortuna-sdk';
for (const s of summarizeTxs(result.transactions)) {
console.log(formatTxSummary(s));
// [ok] events: RandomnessRequested
// [fail] exit 205 InsufficientFee — RequestRandomness msgValue below ...
}Interpret exit codes
import { explainError } from '@titon-network/fortuna-sdk';
const e = explainError(209);
// {
// code: 209,
// origin: 'fortuna',
// name: 'StaleEpoch',
// message: 'Fulfill for a request whose groupEpoch is older than the current storage.groupEpoch.',
// hint: 'A rotation happened after this request was created...'
// }Fortuna also surfaces ForgeTON bounce-back codes (160 NotAuthorizedConsumer, 179 SlashBudgetExceeded) for consumers debugging slash failures, and TVM's common codes (9 CellUnderflow, 13 OutOfGas, 0xFFFF UnknownOpcode).
CLI
The SDK ships a small fortuna CLI for local introspection + project scaffolding. All commands accept --json for machine-readable output.
Scaffolding
$ npx fortuna init # write FORTUNA.md (agent context)
$ npx fortuna generate consumer --type raffle # scaffold a Tolk consumer
$ npx fortuna generate consumer --type lottery --out myLottery.tolk --forceIntrospection (no network)
$ fortuna explain 244
exit 244 (fortuna): InvalidG1Point
groupPk/pkShare failed BLS_G1_INGROUP subgroup check.
hint: Point is either not on the curve or not in the prime-order subgroup...
$ fortuna schema
SDK expects:
FORTUNA_STORAGE_VERSION: 1
...
BLS:
pubkey bytes: 48 (G1)
sig bytes: 96 (G2)
DST: BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_
$ fortuna hash
hex: <bundled artifact code hash>
$ fortuna decode <hex-boc> # decode an emitted FortunaEvent cellLive-oracle (needs npm install @ton/ton)
$ fortuna info <fortuna-addr> --testnet
$ fortuna estimate request --oracle <addr> --callback-gas 0.05 --testnet
$ fortuna verify --testnet # drift-check SDK vs canonical deploySandbox testing (subpath import)
import { Blockchain } from '@ton/sandbox';
import { deployFortunaFixture } from '@titon-network/fortuna-sdk/testing';
const blockchain = await Blockchain.create();
const { fortuna, automaton, fulfill } = await deployFortunaFixture(blockchain);
// fortuna is live, operator registered — ready for RequestRandomnessOne line to a ready-to-request Fortuna. Under the hood: deploy + generate BLS keys + publish group key + register share. See src/testing/index.ts.
The BLS ciphersuite (for aggregators)
Fortuna's on-chain BLS_VERIFY uses the min-pk variant:
- Pubkeys (G1): 48 bytes compressed
- Signatures (G2): 96 bytes compressed
- Domain-separation tag:
BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_
@noble/curves/bls12-381's default DST is _NUL_ — that does NOT match TVM. Off-chain signers must pass BLS_DST_G2_POP (exported from @titon-network/fortuna-sdk) explicitly to bls.longSignatures.hash().
import { bls12_381 as bls } from '@noble/curves/bls12-381';
import { BLS_DST_G2_POP } from '@titon-network/fortuna-sdk';
const msgPoint = bls.longSignatures.hash(alphaBytes, BLS_DST_G2_POP);
const sig = bls.longSignatures.sign(msgPoint, sk).toBytes(true); // 96-byte G2See examples/aggregator-skeleton.ts for a reference off-chain signer.
What's where
| File | Purpose |
|------|---------|
| src/contracts/Fortuna.ts | Fortuna class — ABI wrapper with send/get methods, SchemaDriftError, validateConfig, validateAgainstLive |
| src/opcodes.ts | OP + ERR constants + schema versions + BLS ciphersuite constants |
| src/errors.ts | explainError(code) + FortunaError — covers Fortuna (100-269), ForgeTON bounce-backs (160, 179), TVM (0-99, 0xFFFF) |
| src/events/ | Typed FortunaEvent union + decodeEvent / decodeEvents / tryDecodeEvent |
| src/factory.ts | newOracle({ owner, forgeton }) — one-line deploy handle |
| src/query-id.ts | QueryIdStream — collision-free u64 queryId generator |
| src/solo.ts | generateGroupKey + signAlpha + computeAlpha — BLS helpers for solo / aggregator signing with the correct DST |
| src/testing/ | deployFortunaFixture — one-line sandbox setup. Imported via @titon-network/fortuna-sdk/testing subpath |
| src/diagnostics.ts | summarizeTx / formatTxSummary — collapse a Transaction to its useful fields |
| src/artifacts/loader.ts | loadFortunaCode() + FORTUNA_CODE_HASH |
| src/deployments.ts | FORTUNA_TESTNET / FORTUNA_MAINNET canonical addresses + assertDeployment() loud-error helper |
| src/cli.ts | fortuna CLI — explain / decode / hash / schema / info / estimate / verify / init / generate |
| ERRORS.md | Flat Markdown table of every exit code (Fortuna + ForgeTON + TVM). Generated. |
| OPCODES.md | Flat Markdown table of every wire opcode. Generated. |
| llms.txt | Single-page AI-assistant context (llmstxt.org convention) |
| templates/ | Shipped assets for fortuna init (agent-context.md) |
| examples/ | Runnable TS examples + four Tolk consumer templates (raffle / NFT / lottery / shuffle) |
| skills/ | AI-assistant playbooks — 11 persona-grouped skills (deploy, request, receiver, lifecycle, scaffold, gas-sizing, integration-test, monitor-events, aggregator-setup, debug-exit-code, owner-ops) |
Schema drift
Persistent structs (FortunaStorage, OperatorInfo, RequestInfo, etc.) carry a schemaVersion: uint8 first field. The SDK's getters verify this version and throw SchemaDriftError on mismatch — that's the signal to upgrade either the SDK or the contract. See src/contracts/Fortuna.ts and the fortuna schema CLI command.
Links
- Fortuna contract source
- On-chain ABI (messages.tolk)
- Error codes (errors.tolk)
- Integration tests — end-to-end with real ForgeTON
- ForgeTON — the staking pool Fortuna consumes
License
MIT.
