Skip to main content

import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';

Integration patterns

Copy-paste-ready snippets for the situations every consumer hits. Each section is a self-contained pattern with the rationale + the code.

Dedupe by questionId

The single most important integration pattern. Without dedupe, an attacker can re-trigger OracleResult via ReemitResult and cause double-settlement.

dedupe in a consumer contract
struct Storage {
settled: map<uint64, bool> // questionId → handled?
// ... your other fields
}

fun onInternalMessage(in: InMessage) {
val msg = lazy ConsumerMessage.fromSlice(in.body);
match (msg) {
OracleResult => {
var st = Storage.load();

// 1. Verify sender is the bound assertion (anti-spoof)
assert (in.senderAddress == st.boundOracle) throw Err.NotOurOracle;

// 2. Dedupe — first delivery wins
if (st.settled.contains(msg.questionId)) {
return; // already processed, drop quietly
}
st.settled.set(msg.questionId, true);
st.save();

// 3. Now safe to settle business logic
handleResult(msg.questionId, msg.answer);
}
}
}
dedupe in an off-chain settler
async function onOracleResult(questionId: bigint, answer: boolean) {
// Idempotent insert — duplicate primary-key throws, we catch and bail
try {
await db.insert('settled_questions', {questionId, answer, settledAt: new Date()});
} catch (e) {
if (isDuplicateKeyError(e)) {
console.log(`questionId ${questionId} already settled — ignoring`);
return;
}
throw e;
}

await settleBusinessLogic(questionId, answer);
}

Sender verification (anti-spoof)

The chain doesn't verify message origin for you. Every message your contract receives could be from anywhere. Always check the sender on state-changing ops.

three places to check sender
match (msg) {
AssertionCreated => {
// Verify the factory we registered, not just any sender claiming to be one
assert (in.senderAddress == cfg.oracleFactory) throw Err.NotOurFactory;
}
OracleResult => {
// Verify the assertion we previously bound — not any assertion
assert (in.senderAddress == state.oracleAssertion!) throw Err.NotOurOracle;
}
RequestResolution => {
// Verify the creator we set at deploy
assert (in.senderAddress == cfg.creator) throw Err.OnlyCreator;
}
}

:::tip Pin sender at deploy The cleanest pattern: pin every "trusted address" in your contract's Config at deploy time. Don't accept "trust this sender now" messages from any authority — that's an upgrade path you don't want. :::

Refund-and-return on bad inputs

If your contract receives jettons via TEP-74 TransferNotificationForRecipient and the inputs don't validate, do not throw. Refund the jettons instead.

TransferNotificationForRecipient => {
// Auth guards — these can throw; they fire BEFORE legitimate funds land
assert (in.senderAddress == state.jettonWallet) throw Err.WrongWallet;
assert (msg.jettonAmount > 0) throw Err.ZeroAmount;

val owner = msg.transferInitiator!;

// Post-receipt validation — these MUST refund-and-return, not throw,
// otherwise the jettons strand at our wallet forever (pattern).
if (state.status != M_OPEN) {
refundJetton(state.jettonWallet!, owner, msg.jettonAmount);
return;
}
if (msg.jettonAmount < requiredAmount) {
refundJetton(state.jettonWallet!, owner, msg.jettonAmount);
return;
}
// ... happy path ...
}

The principle: throwing AFTER funds have landed strands them. Throwing BEFORE auth has cleared is safe — bounced messages return the gas, no funds were ever ours.

Bond-vs-TVL clamping

For consumer contracts that auto-request resolution (markets, vaults, etc.), size the oracle bond proportional to value at risk. Lying must cost more than the profit from steering the outcome:

const BOND_BPS = 200; // 2% of TVL
const MAX_BOND_CAP = ton("10000"); // usability cap

val scaledBond = state.totalCollateral * BOND_BPS / 10000;
val bond = min(max(scaledBond, oracleBondFloor), MAX_BOND_CAP) as coins;

The three terms:

  • scaledBond — proportional security
  • oracleBondFloor — minimum even for tiny markets (prevents dust attacks)
  • MAX_BOND_CAP — usability ceiling for huge markets; beyond this, security escalates to the resolver instead of the bond

Awaiting timeout escape

If your consumer enters Awaiting status (waiting for oracle resolution), make sure there's a way out. The factory hop can drop; the creator must be able to recover.

struct MarketState {
status: uint8 = M_OPEN
awaitingDeadline: uint32 = 0 // stamped when entering M_AWAITING
// ...
}

RequestResolution => {
// ... auth ...
state.status = M_AWAITING;
state.awaitingDeadline = (blockchain.now() + AWAITING_TIMEOUT) as uint32; // 1 day
st.save();
// ... send CreateAssertion to factory ...
}

CancelAwaiting => {
assert (in.senderAddress == cfg.creator) throw Err.OnlyCreator;
assert (state.status == M_AWAITING) throw Err.NotAwaiting;
assert (state.oracleAssertion == null) throw Err.OracleAlreadyBound; // race protection
assert (blockchain.now() >= state.awaitingDeadline) throw Err.AwaitingTimeoutOngoing;
state.status = M_OPEN;
state.awaitingDeadline = 0;
st.save();
}

The race-protection check (state.oracleAssertion == null) is essential — if AssertionCreated arrives between the timeout firing and the cancel, the assertion takes priority and the cancel must reject.

Gas reserves on outgoing sends

OMY contracts use RESERVE_MODE_AT_MOST with a fixed GAS_RESERVE floor (0.2 TON). Every outgoing sendJetton happens AFTER reserving gas. Adopt the same pattern:

fun sendJetton(jettonWallet: address, to: address, amount: coins) {
reserveToncoinsOnBalance(GAS_RESERVE, RESERVE_MODE_AT_MOST);
createMessage({
bounce: BounceMode.NoBounce,
dest: jettonWallet,
value: JETTON_FWD_GAS, // ~0.05 TON per outgoing transfer
body: AskToTransfer {...},
}).send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_IGNORE_ERRORS);
}

The reserve guarantees that even if a message fails and bounces, the contract keeps enough TON to receive the bounce and continue operating.

Bounce handlers for cleanup

If your consumer expects bounces (e.g., from an attempted transfer to an account that doesn't exist), handle them explicitly. The default empty onBouncedMessage swallows the bounce — which is fine for fire-and-forget payouts but not for accounting-sensitive flows.

fun onBouncedMessage(in: InMessageBounced) {
// For OMY interaction: empty handler is the audit-cleared default.
// For YOUR contract: if you sent USDT via sendJetton, the bounce here means
// the recipient wallet rejected. Restore the credit:
var st = Storage.load();
// ... parse in.body to identify which user, restore their balance, save ...
}

Time-bounded windows

TON has no cron. Your contract can't fire a transition "after N seconds" by itself. Every time-based action requires either:

  1. An external trigger (a keeper bot calling finalize()), OR
  2. A check at the next inbound message (assert blockchain.now() >= deadline)

OMY is built on pattern (1). Your consumer probably wants pattern (2) for the cheap path:

// In your consumer, when handling OracleResult:
if (blockchain.now() < cfg.scheduledStart) {
refundJetton(...); // too early
return;
}

Build for re-deploy, not upgrade

OMY contracts have no upgrade hooks. A bug fix ships as v2 contracts at new addresses; users migrate via SDK/UI. Your consumer should be designed the same way:

  • Don't bake "current oracle address" into many places. Pin it once in Config and never write oracleVersion: 1 or similar.
  • Make storage easy to dump + replay onto a new deployment.
  • If your contract holds significant collateral, document a migration script separately.

This is more disciplined than "upgrade everything at once" but the security trade-off is real: no upgrade path means no governance footgun.

Where next