forgeton-sdk
v0.4.0
Published
TypeScript SDK for ForgeTON — the shared-security staking pool for TON. Stake automatons, slash, receive AutomatonSync pushes.
Maintainers
Readme
forgeton-sdk
TypeScript SDK for ForgeTON — the shared-security staking pool for TON. Stake once, get slashed by any admitted consumer.
Use this SDK if you're building an on-chain service that needs an off-chain operator network — executors, VRF signers, oracle reporters, keeper bots, anything where trust comes from stake-at-risk. ForgeTON already runs the operator set (automatons), handles staking + cooldowns, and broadcasts AutomatonSync pushes. Your product contract admits itself as a consumer, receives the sync stream, and slashes operators for product-specific misbehavior.
Think EigenLayer, purpose-built for TON.
npm install forgeton-sdk @ton/core@ton/core is a peer dependency — bring your own version (>= 0.63.0).
Surfaces
┌─────────────────────────────────────────────────────────────┐
│ explainError ForgetonError summarizeTx formatTxSummary│ diagnostics
├─────────────────────────────────────────────────────────────┤
│ decodeEvent decodeEvents tryDecodeEvent │ events
├─────────────────────────────────────────────────────────────┤
│ ForgeTON newPool FORGETON_DEFAULTS │ contract + factory
├─────────────────────────────────────────────────────────────┤
│ OP ERR loadForgetonCode FORGETON_TESTNET │ constants + artifacts
└─────────────────────────────────────────────────────────────┘No façade, no fluent builder — the surface is small by design. ForgeTON has one role (operator set + slashing); integrate at the ABI level.
The consumer-contract playbook
You're about to integrate ForgeTON into a new product. Here's the shape:
- Deploy your product contract. Implement a
handleAutomatonSync(msg)receiver that maintains your local automaton mirror — and callforgeton.sendSlashwhen you detect misbehavior. Seeexamples/consumer-template.tolkfor a copy-paste-ready Tolk starter. - Ask the ForgeTON owner to admit you via
SetConsumer { contract: <you>, isActive: true }(opcode0x18). Until you're admitted,Slashsends will bounce withE_NOT_AUTHORIZED_CONSUMER(160). - Pick a
reasontag. Anyuint32— it's a product-defined slash reason that shows up in theAutomatonSlashedevent for off-chain attribution. Kronos uses1 = MISSED_EXECUTION. Document yours. - Pick a
ctxpayload. Anyuint64— typically your product's identifier (jobId / requestId / roundId). Sent on the inboundSlashonly —ctxdoes not appear in the outboundAutomatonSlashedevent. Indexers correlate by transaction hash if they need per-incident attribution. The event itself carriesslasher + reason.
That's it. Stake, slashing, sync fan-out, upgrade timelock — all handled by the pool.
Quickstart
Deploy a fresh ForgeTON
import { TonClient } from '@ton/ton';
import { toNano } from '@ton/core';
import { newPool } from 'forgeton-sdk';
const tonClient = new TonClient({ endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC' });
const pool = tonClient.open(newPool({ owner: ownerAddress }));
await pool.sendDeploy(ownerSender, toNano('0.5'));
console.log('ForgeTON deployed at', pool.address.toString());
// Admit your consumer contract:
await pool.sendSetConsumer(ownerSender, {
value: toNano('0.1'),
contract: consumerAddress,
isActive: true,
});newPool({ owner }) is a one-line shortcut for ForgeTON.createFromConfig({ owner }, loadForgetonCode()). Use the long form if you want to swap the bundled code (e.g. running against a custom build).
Stake as an automaton
Automatons stake once and participate in every admitted consumer's product. The register call must also fund the sync fan-out: one push per admitted consumer.
import { ForgeTON, FORGETON_TESTNET } from 'forgeton-sdk';
const pool = tonClient.open(ForgeTON.createFromAddress(FORGETON_TESTNET.forgeton));
// getRegisterValue does the math for you (config + consumerCount + formula).
const value = await pool.getRegisterValue();
await pool.sendRegisterAutomaton(automatonSender, { value });Manual formula (still useful if you want to add a buffer):
const cfg = await pool.getConfig();
const consumerCount = await pool.getConsumerCount();
const value = cfg.minStake + cfg.minGasForRegister + BigInt(consumerCount) * cfg.syncGasCost;For unstake, getUnstakeValue({ crossingThreshold: true }) does the same — pass false for partial unstakes that don't trigger fan-out.
Slash a misbehaving automaton (from your consumer contract)
Consumer-side: build a Slash message. Only admitted consumers can send it; the pool enforces this on the Tolk side.
There is no shared Tolk package — every consumer copies the wire structs into its own messages.tolk. The bytes must be identical to ForgeTON's contracts/messages.tolk:167. Copy verbatim:
// In your consumer's messages.tolk — these MUST match forgeton/contracts/messages.tolk byte-for-byte:
struct (0x00000014) Slash {
automaton: address
reason: uint32
ctx: uint64
}
struct (0x0000001A) AutomatonSync {
automaton: address
isActive: bool
}Then send:
val slashMsg = createMessage({
bounce: BounceMode.NoBounce,
dest: storage.forgeton,
value: ton("0.05"), // covers the pool's compute + storage delta
body: Slash {
automaton: offender,
reason: REASON_MISSED_EXECUTION, // your product-defined tag
ctx: requestId, // your product's identifier
},
});
slashMsg.send(SEND_MODE_REGULAR);TypeScript-side (e.g., for tests, scripts, or external senders):
await pool.sendSlash(consumerSender, {
value: toNano('0.05'),
automaton: offenderAddress,
reason: 1,
ctx: 42n,
});See examples/consumer-template.tolk for a complete starter consumer that you can deploy and customise.
Handle AutomatonSync pushes (consumer-side mirror)
On every lifecycle event (register, terminal unstake, terminal slash), the pool sends AutomatonSync { automaton, isActive } to every admitted consumer. You maintain your own mirror:
// In your consumer contract (Tolk):
fun handleAutomatonSync(msg: AutomatonSync, sender: address) {
var storage = lazy ConsumerStorage.load();
assert (sender == storage.forgeton) throw E_NOT_FORGETON;
if (msg.isActive) {
// Add to your mirror...
} else {
// Remove from your mirror (swap-and-pop for dense storage)...
}
storage.syncesReceived += 1;
storage.save();
}Per-consumer ConsumerInfo.syncesLanded on the pool is the sync drift counter — compare against your local syncesReceived to detect skipped pushes (tight-budget fan-out tail-skips). Owner can repair with ForceSync { automaton }.
Diagnose any tx in one call
import { summarizeTx, formatTxSummary } from 'forgeton-sdk';
const result = await pool.sendSlash(via, opts);
for (const tx of result.transactions) {
const s = summarizeTx(tx);
console.log(formatTxSummary(s));
// [ok] events: AutomatonSlashed
// [fail] exit 160 NotAuthorizedConsumer — Slash sender is not in ForgeTON's consumer set.
if (!s.success) throw new Error(s.explanation?.hint ?? 'unknown failure');
}summarizeTx(tx) returns { success, exitCode, actionResultCode, explanation, events, rawExternalBodies } — one call replaces hand-walking tx.description.computePhase.exitCode, then plucking external-out bodies, then decoding each.
Decode events from your indexer
import { decodeEvents } from 'forgeton-sdk';
// Each `Cell` is the body of an external-out message emitted by ForgeTON.
for (const ev of decodeEvents(externalBodies)) {
switch (ev.kind) {
case 'AutomatonRegistered':
console.log(`+ automaton ${ev.automaton} staked ${ev.stake}`);
break;
case 'AutomatonSlashed':
console.warn(`${ev.automaton} slashed -${ev.amount} by ${ev.slasher} (reason=${ev.reason})`);
break;
case 'ConsumerUpdated':
console.log(`consumer ${ev.contract} ${ev.isActive ? 'admitted' : 'removed'} (count=${ev.consumerCount})`);
break;
}
}For a working end-to-end indexer pattern (poll TonClient → extract bodies → decode), see examples/04-indexer.ts.
Reference
ForgeTON
Hand-written wrapper matching the on-chain ABI 1:1. send* methods for every inbound opcode, get* getters for every get fun.
Construct:
newPool({ owner, workchain? })— one-liner, bundlesloadForgetonCode().ForgeTON.createFromConfig({ owner }, code, workchain?)— explicit, lets you swap the artifact.ForgeTON.createFromAddress(addr)— handle for an existing deploy (no init data).
Send methods:
| Method | Who | Purpose |
|--------|-----|---------|
| sendDeploy(via, value) | anyone | Initial deploy. Note: unlike other send*, takes value as a positional arg (Blueprint convention). |
| sendRegisterAutomaton | anyone | Stake as an automaton. Gas: minStake + minGasForRegister + consumerCount × syncGasCost — or use getRegisterValue(). |
| sendIncreaseStake | the staked automaton | Top up stake. |
| sendRequestUnstake | the staked automaton | Start cooldown. |
| sendCancelUnstake | the staked automaton | Abort cooldown. |
| sendUnstake | the staked automaton | Withdraw after cooldown elapsed. If cross-zero threshold: minGasForUnstake + consumerCount × syncGasCost — or use getUnstakeValue(). |
| sendSlash | any admitted consumer | Slash an automaton. reason (uint32) surfaces in AutomatonSlashed; ctx (uint64) is inbound-only — does not appear in the event. |
| sendSetConsumer | owner | Admit (isActive: true) or remove (isActive: false) a consumer. ≤ MAX_CONSUMERS = 16. |
| sendForceSync | owner | Broadcast a single automaton's current state to every consumer (drift recovery). |
| sendUpdateForgetonConfig | owner | Rewrite the 8-field config blob. Pre-validate locally with ForgeTON.validateConfig(opts). |
| sendWithdrawSlashed | owner | Withdraw accrued slash funds. |
| sendPruneAutomaton | owner | Emergency remove a stuck automaton. |
| sendPause / sendUnpause | owner | Emergency halt on non-exit paths. |
| sendProposeCodeUpgrade | owner | Propose new code. Either eta (absolute unix-seconds, ≥ now+24h) or delaySeconds (relative, ≥ 86400). Exactly one. |
| sendExecuteCodeUpgrade | owner | Activate after eta. |
| sendCancelCodeUpgrade | owner | Abort a pending proposal. |
Helpers (no I/O for validate*, one or two getter calls for estimate*):
| Method | Returns | Notes |
|--------|---------|-------|
| getRegisterValue() | Promise<bigint> | Live getConfig + getConsumerCount; applies the formula. Use for sendRegisterAutomaton. |
| getUnstakeValue({ crossingThreshold }) | Promise<bigint> | crossingThreshold: true (default) includes the fan-out gas; false for partials. |
| ForgeTON.validateConfig(opts) | void (throws) | Local pre-flight matching the contract's lower + upper bounds. Throws ForgetonError with the same exit code the contract would. |
Getters:
| Method | Returns |
|--------|---------|
| getStorageVersion() | FORGETON_STORAGE_VERSION — use to verify deployed schema matches SDK |
| getOwner() | owner address |
| getIsPaused() | pause flag |
| getConfig() | full ForgetonConfigReply (8 fields incl. maxSlashPerConsumerPerDay) |
| getAutomaton(addr) | AutomatonInfo \| null — throws SchemaDriftError on parse failure (contract / SDK version mismatch) |
| getIsAutomaton(addr) | boolean — convenience over getAutomaton |
| getActiveAutomatonCount() | active pool size |
| getAutomatonCount() | zombie-inclusive count (incl. fully-slashed leftovers) |
| getTotalStaked() | sum of all stakes |
| getConsumer(addr) | ConsumerInfo \| null (incl. per-consumer syncesLanded) — throws SchemaDriftError on parse failure |
| getIsConsumer(addr) | boolean — convenience over getConsumer |
| getConsumerAt(index) | dense consumer-set lookup in [0, consumerCount) |
| getConsumerCount() | admitted consumer count |
| getSyncCursor() | round-robin starting index for the next pushSync — use to predict fan-out order under tight budget |
| getSyncesAttempted() / getSyncesLanded() / getSlashesApplied() | drift counters (see table below) |
| getPendingUpgrade() | { codeHash, eta } — (0n, 0) when none pending |
Drift counters at a glance
Three counters with similar names — same word, different scopes:
| Counter | Where | Bumps on… |
|---------|-------|-----------|
| getSyncesAttempted() | global | every lifecycle event that would trigger a sync (register / terminal unstake / terminal slash / ForceSync), counted per-event regardless of fan-out |
| getSyncesLanded() | global | every consumer push that actually fires (passes the per-iteration reserve check) — equals the sum of all per-consumer ConsumerInfo.syncesLanded |
| ConsumerInfo.syncesLanded | per-consumer | a successful sync push to this consumer specifically — diff against the consumer's local syncesReceived to detect skips |
Indexer rule of thumb: syncesAttempted > syncesLanded ⇒ at least one consumer is being skipped (tight-budget tail). Owner repairs with sendForceSync({ automaton }).
Events
import { decodeEvent, decodeEvents, tryDecodeEvent } from 'forgeton-sdk';
decodeEvent(body) // throws on unknown opcode — use when source is trusted
tryDecodeEvent(body) // returns null on unknown opcode
decodeEvents(bodies) // batch — silently drops unknown opcodesAll event kinds (typed discriminated union via event.kind):
AutomatonRegistered StakeIncreased UnstakeRequested UnstakeCancelled
Unstaked AutomatonSlashed AutomatonPruned ForgetonConfigUpdated
ConsumerUpdated SlashedWithdrawn PausedChanged ForceSyncTriggered
UpgradeProposed UpgradeCancelled CodeUpdatedAutomatonSlashed carries slasher: Address and reason: number so indexers can attribute slashes to specific consumers / product-defined reasons. It does NOT carry ctx — if you need per-incident correlation (jobId / requestId / etc), correlate by transaction hash with the inbound Slash body.
Error introspection
import { explainError, ForgetonError, ERR } from 'forgeton-sdk';
const e = explainError(160);
// { code: 160, origin: 'forgeton', name: 'NotAuthorizedConsumer',
// message: 'Slash sender is not in ForgeTON\'s consumer set.',
// hint: 'Owner must SetConsumer { contract: <yourContract>, isActive: true } before that contract can send Slash.' }
throw new ForgetonError(ERR.NotAuthorizedConsumer, `tx ${txHash}`);Covers every E_* from contracts/errors.tolk plus common TVM exit codes.
Diagnostics
import { summarizeTx, summarizeTxs, formatTxSummary } from 'forgeton-sdk';
const s = summarizeTx(tx);
// { success, exitCode, actionResultCode, explanation, events, rawExternalBodies }
formatTxSummary(s);
// '[ok] events: AutomatonSlashed' | '[fail] exit 160 NotAuthorizedConsumer — ...'Pass any @ton/core Transaction (sandbox BlockchainTransaction works too). Events are decoded into the typed ForgetonEvent union; non-ForgeTON external-outs stay in rawExternalBodies if you need them.
Constants
| Constant | Value | Purpose |
|----------|-------|---------|
| FORGETON_DEFAULTS | { minStake, slashAmount, unstakeCooldown, syncGasCost, minStorageReserve, minGasForRegister, minGasForUnstake, maxSlashPerConsumerPerDay } | Default config values used at deploy. 8 fields — the per-consumer 24h slash circuit-breaker cap is the 8th. |
| FORGETON_STORAGE_VERSION | 1 | Root-storage schema version — compare against getStorageVersion() |
| FORGETON_CONFIG_BLOB_VERSION | 1 | Config-blob schema version |
| AUTOMATON_INFO_VERSION | 1 | Map-value schema for AutomatonInfo |
| CONSUMER_INFO_VERSION | 1 | Map-value schema for ConsumerInfo |
| MAX_CONSUMERS | 16 | Hard cap on simultaneous admitted consumers |
| MIN_UPGRADE_DELAY_SECONDS | 86400 | 24 h timelock floor between propose and execute |
| FORGETON_CODE_HASH | { hex, base64 } | Code-hash of the bundled BoC; compare against on-chain code hash to detect drift |
Live deployments
import { FORGETON_TESTNET, ForgeTON } from 'forgeton-sdk';
console.log(FORGETON_TESTNET.forgeton.toString()); // address
console.log(FORGETON_TESTNET.expectedCodeHash); // verify against on-chain
console.log(FORGETON_TESTNET.expectedSchema.storage); // 2
const pool = tonClient.open(ForgeTON.createFromAddress(FORGETON_TESTNET.forgeton));FORGETON_MAINNET will be added when the mainnet deploy lands.
Bundled compiled contract
import { loadForgetonCode, FORGETON_CODE_HASH, ForgeTON } from 'forgeton-sdk';
const code = loadForgetonCode();
const pool = ForgeTON.createFromConfig({ owner }, code);
// Verify the bundled code matches what's on-chain:
const liveHash = (await tonClient.getContractState(addr)).codeHash?.toString('hex');
if (liveHash !== FORGETON_CODE_HASH.hex) console.warn('SDK ↔ on-chain code drift');Ships with the compiled ForgeTON.compiled.json under artifacts/. Useful for deploying your own pool instance or for sandbox tests.
Examples
Runnable scripts under examples/ — sandbox-based, so they execute offline. Each can be adapted to TonClient + a real wallet by swapping the Blockchain.create() setup at the top.
| Script | What it does |
|--------|--------------|
| 01-deploy-pool.ts | Deploy a fresh pool, verify config, admit a consumer. |
| 02-register-as-automaton.ts | Use getRegisterValue to stake, walk full unstake cooldown. |
| 03-slash-from-consumer.ts | Real-counterparty slash via TestConsumer, summarised with summarizeTx. |
| 04-indexer.ts | Poll-loop pattern: extract external-out bodies → decodeEvents. |
| operator-skeleton.ts | Minimal off-chain automaton operator: register, watch for deactivation, exit. |
| consumer-template.tolk | Production-grade Tolk consumer starter — copy and customise. |
Run from sdk/: npx ts-node examples/01-deploy-pool.ts.
CLI
A small CLI ships with the package — useful for one-off introspection without writing a script:
npx forgeton explain 160 # describe an exit code
npx forgeton decode <hex-event-body> # decode an external-out body
npx forgeton info <pool-addr> --testnet # pretty-print pool state
npx forgeton estimate register --testnet # compute the right `value:` for registerNetwork commands (info, estimate) need @ton/ton installed (you probably already have it).
Versioning
This is a v0.x SDK. The on-chain ABI is still evolving; SDK majors track contract upgrades. Pin exact versions in production.
When a new schema version ships:
- Increment the matching
*_VERSIONconstant (FORGETON_STORAGE_VERSION,CONSUMER_INFO_VERSION, …). - Indexers compare
getStorageVersion()against the exported constant — a mismatch means the SDK and the deployed contract disagree on storage layout.getAutomaton/getConsumerwill throwSchemaDriftErrorrather than return malformed data. Redeploy or upgrade.
Working with AI tools
Drop this SDK into your project and your AI assistant picks it up:
AGENTS.md— terse map of the SDK auto-discovered by Claude Code, Cursor, and other editors that follow the agents.md convention.skills/— invocable skill files for common tasks (integrate-consumer,deploy-pool,run-operator,debug-exit-code). Compatible with Claude Code's skill system; useful as raw markdown for any LLM.
Known consumers
- Kronos — decentralized automation (recurring on-chain jobs). Uses
reason: 1 = MISSED_EXECUTION,ctx = jobId. - Future: Fortuna (VRF), oracles, functions.
License
MIT.
