@titon-network/fortuna-sdk
v0.6.0
Published
TypeScript SDK for Fortuna — TON-native threshold-BLS VRF. Request randomness, fulfill as an operator, decode events, scaffold a consumer. **TSA AUDITED — zero findings** (https://github.com/titon-network/fortuna/blob/main/AUDIT_REPORT.md): 5 symbolic-exe
Maintainers
Readme
@titon-network/fortuna-sdk
TypeScript SDK for Fortuna — TON's threshold-BLS VRF. Consumers request randomness, operators sign + aggregate off-chain, and Fortuna delivers a uniform beta: uint256 via VrfCallback — verifiable against the group public key cached from Atlas.
Audit status
🛡️ TSA-audited — zero findings. Full report:
AUDIT_REPORT.md.
Fortuna consumes:
- Atlas — group public key + rotation (via
GroupKeySyncat0x51). - ForgeTON — operator stake + mirror (via
AutomatonSyncat0x1A).
Fortuna launches without slashing (D-011). Positive-incentive economics only: operators compete for submitterReward via first-message-wins. Miss-slashing is deferred to a post-shadow-data revision.
Live deployments
| Network | Fortuna | Atlas | ForgeTON pool |
|---------|---------|-------|---------------|
| Mainnet (live 2026-05-18, multi-op n=2 / threshold=2 / epoch=1) | UQAao6PxnFWUAPfPRPbWwe6ZZxMPrBhEhp4ISmwP1rIp8OLc | UQClahzd17lho1xb8v-ev-2ZHITcAZyKQgtRqseSaQOciimT | UQDqNyNHJ4ulbZ1jCi_zUT-QrvVBmPcrIeyDdtpkwQMJO8TV |
| Testnet | 0QAHAhzkfTlT0xW4jL2tCkaOJRBPaWvASmdv8oVNsOHnkeYT | 0QCsCVyRSgq_wDHb0txeZ7rxJ2y0si9_udTsaSbAQdPgCewx | 0QD-9fxQ8k1f23xNFyWI-edoh-WnAIDMUrwvodnkSWYuz1rF |
Both networks run the same bytecode (code hash ad8ce25d…). The SDK exports FORTUNA_TESTNET and FORTUNA_MAINNET so you can target either with the same import path.
Install
npm install @titon-network/fortuna-sdk @ton/core@ton/core is a peer dependency — bring your own version (>= 0.63.0). @titon-network/atlas-sdk + @titon-network/forgeton-sdk are peer deps for test fixtures.
Connect — testnet vs mainnet (the only difference)
The SDK is network-agnostic. You pick the network in two lines: the
TonClient endpoint and which deployment constant you import.
// ── testnet ──────────────────────────────────────────────────────────
import { TonClient } from '@ton/ton';
import { Fortuna, FORTUNA_TESTNET } from '@titon-network/fortuna-sdk';
const tonClient = new TonClient({
endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC',
});
const fortuna = tonClient.open(Fortuna.createFromAddress(FORTUNA_TESTNET.fortuna));
const health = await fortuna.getContractHealth();
console.log(`connected to testnet — paused=${health.paused} feeAccumulated=${health.config.feeAccumulated}`);For mainnet, change exactly two lines:
// ── mainnet ──────────────────────────────────────────────────────────
import { TonClient } from '@ton/ton';
import { Fortuna, FORTUNA_MAINNET } from '@titon-network/fortuna-sdk';
const tonClient = new TonClient({
endpoint: 'https://toncenter.com/api/v2/jsonRPC', // ← no `testnet.`
});
const fortuna = tonClient.open(
Fortuna.createFromAddress(FORTUNA_MAINNET.fortuna), // ← was FORTUNA_TESTNET
);That's it. The same npm package, same bytecode, same code paths work on
either network — only the TonClient endpoint and the deployment constant
change. Pointing at a custom deployment (your own fork, a private chain)
is just Fortuna.createFromAddress(Address.parse('EQ...')) — no constant
required.
Prefer the safe-by-default helper for scripts: assertDeployment('testnet' |
'mainnet') returns the populated FortunaDeployment or throws an
actionable error if the requested network isn't shipped.
30-second integration — request randomness
Fortuna is live on both networks. This block calls the deployed VRF directly — copy, paste, run.
import { TonClient } from '@ton/ton';
import { toNano } from '@ton/core';
import {
Fortuna, FORTUNA_TESTNET, QueryIdStream,
} from '@titon-network/fortuna-sdk';
const tonClient = new TonClient({
endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC',
});
const fortuna = tonClient.open(Fortuna.createFromAddress(FORTUNA_TESTNET.fortuna));
const queryIds = new QueryIdStream();
await fortuna.sendRequestRandomness(consumerVia, {
value: toNano('0.25'), // baseFee + submitterReward + callbackGas + reserve + slack
queryId: queryIds.next(), // 64-bit collision-free id
seed: 0xdeadbeefn, // 256-bit consumer-supplied entropy
callbackGas: toNano('0.05'),
});Operators sign alpha off-chain, aggregate, and race to submit. The first valid
FulfillRandomness triggers VrfCallback { queryId, beta } on your consumer
at opcode 0x50 with bounce: false and callbackGas attached.
Mainnet diff — two lines:
- import { Fortuna, FORTUNA_TESTNET, QueryIdStream } from '@titon-network/fortuna-sdk';
+ import { Fortuna, FORTUNA_MAINNET, QueryIdStream } from '@titon-network/fortuna-sdk';
- const tonClient = new TonClient({ endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC' });
+ const tonClient = new TonClient({ endpoint: 'https://toncenter.com/api/v2/jsonRPC' });
- const fortuna = tonClient.open(Fortuna.createFromAddress(FORTUNA_TESTNET.fortuna));
+ const fortuna = tonClient.open(Fortuna.createFromAddress(FORTUNA_MAINNET.fortuna));Audit detail
| Surface | Result |
|---------|--------|
| TSA static analysis (5 checkers) | 3 single-contract (drain-check / bounce-check / replay-attack) + 2 inter-contract (ic-fortuna-forgeton-drain / ic-fortuna-atlas-drain) — 0 findings, 0 outstanding |
| Manual review | Per-receiver walk + closed prior findings F1–F4 + cross-border FF-1 (ForgeTON gas-floor) / FA-1 (Atlas SyncRequest re-push) + 3-way triangle T-1..T-3 — every probe reproduced as a regression test under tests/audit/ |
| Contract tests | 188/188 passing (16 suites); 1000-iter property fuzz green; gas regressions guarded at 20% threshold |
| SDK smoke tests | 67/67 passing (DocsSurface + Surface) |
| ABI byte-shape pin | Pinned via tests/IntegrationFullStack.spec.ts — real ForgeTON + real Atlas + Fortuna in one sandbox; any drift in AutomatonSync (0x1A), Slash (0x14), or GroupKeySync (0x51) fails CI |
| TSA coverage caveat | TSA v0.5.3 has unimplemented-instruction gaps (SHA256U, BLS_G1_INGROUP, certain STIR variants). Dropped symbolic states cover the deep state of RequestRandomness / FulfillRandomness / Reclaim / GroupKeySync past the BLS subgroup check — covered by manual review + tests/VrfLifecycle.spec.ts + tests/Fuzz.spec.ts + sender-pin regression tests. See report §"TSA limitations observed" |
| Foundation | Built on TSA-audited @titon-network/forgeton-sdk + @titon-network/atlas-sdk |
Surfaces
┌─────────────────────────────────────────────────────────────┐
│ explainError FortunaError summarizeTx formatTxSummary │ diagnostics
├─────────────────────────────────────────────────────────────┤
│ decodeEvent decodeEvents tryDecodeEvent │ events
├─────────────────────────────────────────────────────────────┤
│ Fortuna newFortuna FORTUNA_DEFAULTS │ contract + factory
├─────────────────────────────────────────────────────────────┤
│ computeAlpha computeBeta signAlpha aggregateSignatures │ VRF primitives
├─────────────────────────────────────────────────────────────┤
│ QueryIdStream submissionJitter │ off-chain ops
├─────────────────────────────────────────────────────────────┤
│ 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, VrfCallback receiver checklist, and common-error table. Reference it from your existing CLAUDE.md / .cursorrules / AGENTS.md and your assistant will generate correct Fortuna-consumer code on the first try.
The same content powers llms.txt (the llmstxt.org entry point) and AGENTS.md (full surface map). Need deeper integration context? Load llms-full.txt — the canonical end-to-end AI brief covering Tolk patterns + TS request/decode + sandbox tests + every revert code in one file.
Want a clone-and-run dapp?
examples/coin-flip/ is a complete fair-coin VRF dapp in 4 files (Tolk + Blueprint compile descriptor + TS wrapper + sandbox test). Drop them into your Blueprint project, run npx blueprint test, and you have a fully-tested Fortuna integration. More examples (raffle, dice, mystery-box) coming in examples/.
The consumer playbook
You're building a product that needs verifiable randomness. Here's the shape:
- Design your consumer contract with a receiver at opcode
0x50(VrfCallback). Scaffold vianpx fortuna scaffold consumer→ dropstemplates/consumer.tolkinto your cwd as a starter. Your receiver uses the 256-bitbetafor whatever randomness-consuming logic you need. - Deploy your contract + send
RequestRandomnessto Fortuna:await fortuna.sendRequestRandomness(consumerVia, { value: baseRequestFee + submitterReward + callbackGas + minForwardReserve + slack, queryId: nextQueryId, // use QueryIdStream to avoid collisions seed: appEntropy, // 256-bit consumer-supplied entropy callbackGas: toNano('0.05'), }); - Wait for the callback. Operators sign alpha off-chain, aggregate, and race to submit. The first valid submission triggers
VrfCallback { queryId, beta }to your consumer withbounce: falseandcallbackGasattached. - Consume beta safely.
betais uniform uint256 — modulo into your app's range (beta % 100,beta % gridSize, etc). The full 256 bits is cryptographically secure. - Handle stuck requests. If operators don't fulfill within
requestTtl(default 1h), callsendReclaimRequest— consumer gets the fee back. Stale-epoch requests (rotation happened mid-flight) auto-abort on fulfill attempts; reclaim is the recovery path.
That's it. Key management, rotation, operator set drift, BLS aggregation — all handled off-chain or by Atlas + ForgeTON.
What does a request cost?
Defaults — tunables drift from defaults via UpdateConfig; read live values with fortuna.getConfig().
| Item | Default | Paid for |
|------|---------|----------|
| baseRequestFee | 0.1 TON | Per-request protocol fee; accrues to feeAccumulated, owner withdraws |
| submitterReward | 0.05 TON | Winner-takes-all payout to the first valid FulfillRandomness submitter |
| callbackGas | consumer chooses | Forwarded with VrfCallback — your callback's gas budget |
| minForwardReserve | 0.02 TON | Reserve floor forwarded with callback so consumer can process it |
| minStorageReserve | 0.1 TON | Fortuna's own rent floor (enforced before every outbound) |
| requestTtl | 3600s (1h) | Operators have this window to fulfill; then reclaim kicks in |
On a RequestRandomness, attached value must be ≥ baseRequestFee + submitterReward + callbackGas + minForwardReserve + ~0.05 TON forward-fee slack.
CLI shortcut: npx fortuna estimate request --callback-gas 0.05.
Quickstart
Request randomness from a consumer contract
From your consumer's triggering flow:
struct (0x00000030) RequestRandomness {
queryId: uint64
seed: uint256
callbackGas: coins
}
fun triggerVrf(queryId: uint64, seed: uint256) {
val reqMsg = createMessage({
bounce: BounceMode.NoBounce,
value: ton("0.25"), // baseFee + submitterReward + callbackGas + forwardReserve + slack
dest: storage.fortuna,
body: RequestRandomness {
queryId,
seed,
callbackGas: ton("0.05"),
},
});
reqMsg.send(SEND_MODE_REGULAR);
}Receive + verify the callback
struct (0x00000050) VrfCallback {
queryId: uint64
beta: uint256 // uniform 256-bit randomness
}
fun handleVrfCallback(msg: VrfCallback, sender: address) {
var storage = lazy ConsumerStorage.load();
assert (sender == storage.fortuna) throw E_NOT_FORTUNA;
// Your business logic here.
doSomethingWithBeta(msg.queryId, msg.beta);
}Off-chain: verify beta byte-identically
import { computeBeta } from '@titon-network/fortuna-sdk';
// After reading the VrfCallback event on-chain:
const expectedBeta = computeBeta(aggSig, consumerAddr, queryId, seed);
// expectedBeta is the same 32 bytes the consumer stored.Solo-operator mode (dev / demo)
For local testing with a single operator (t=n=1):
import { signAlpha, computeAlpha, randomBlsSecret, blsPublicKey } from '@titon-network/fortuna-sdk';
const sk = randomBlsSecret();
const pk = blsPublicKey(sk); // 48 bytes — register at Atlas as the sole pkShare
const alpha = computeAlpha(consumer, queryId, seed);
const sig = signAlpha(sk, alpha); // 96 bytes — submit as aggSigNot for mainnet value — a single signer means a single point of failure. OK for dev, demos, low-stakes deploys.
Sandbox test
import { Blockchain } from '@ton/sandbox';
import { deployFortunaFixture } from '@titon-network/fortuna-sdk/testing';
const blockchain = await Blockchain.create();
const { fortuna, operator, requestRandomness, fulfillRandomness } =
await deployFortunaFixture(blockchain);
// Your consumer sends a request (through whatever path it normally does)…
// …then the fixture's one-liner completes the round-trip:
await fulfillRandomness({ consumer: myConsumer.address, queryId: 1n, seed: 0xdeadbeefn });See skills/fortuna-integrate-consumer.md for the full walkthrough.
CLI
# Diagnostics (no network)
npx fortuna explain 161 # describe an exit code
npx fortuna decode <hex|base64> # decode an event body
npx fortuna hash # bundled artifact code hash
npx fortuna schema # SDK-expected schema versions
npx fortuna estimate request --callback-gas 0.05
# Live-state (needs @ton/ton)
npx fortuna info <addr> --testnet
npx fortuna request <consumer> --queryId 42 --addr <fortuna> --testnet
npx fortuna health --addr <fortuna> --testnet
npx fortuna verify --testnet
# Scaffolding
npx fortuna init # write FORTUNA.md (agent context) to cwd
npx fortuna scaffold consumer # drop a Tolk VRF consumer templateAll commands accept --json for machine-readable output.
Links
- Fortuna repo
- Atlas — the group-key backbone Fortuna binds to
- ForgeTON — the staking pool Fortuna mirrors operators from
- DESIGN.md — architectural decisions + rejected alternatives (D-001..D-019)
- OPCODES.md + ERRORS.md — generated flat references
License
MIT. See LICENSE.
🛡️ TSA-audited — zero findings (report). Live on TON mainnet (2026-05-18) and testnet.
