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​