Skip to main content

Security checklist for integrators

You're an attacker now. You've shipped your consumer contract; it holds user funds; it interacts with an oracle. What does an adversary look for?

This page is the checklist. Every item is something that has actually broken oracle integrations in production on other chains. Walk through before mainnet.

The eight killers

import {DocFeatures, DocFeature} from '@site/src/components/docs/DocFeatures';

Attacker deploys a contract with the OracleResult opcode shape, points it at your callback, fakes any answer. Your contract pays out. Attacker pays gas to call ReemitResult; OracleResult fires again; your contract pays out twice. Attacker proposes a lie. Bond is $100 against $1M of payouts they steer. They take the lie + the loss; the math works for them. Attacker sends 1 nano-USDT with a malformed payload. Your contract throws. The jetton lands at your wallet but isn't credited to anyone — stranded. The factory's hop drops once in 10,000 spawns. Your consumer goes M_AWAITING forever. User funds locked. Your consumer was registered as the callback recipient — but did you check that it's YOUR consumer's address, not a typo someone exploited? Reasonable people disagree about the answer. Disputes pile up. Liquidity flees the market. Reputation gone. Assertion goes Cancelled via TimeoutRefund. Your consumer treats absence of OracleResult as "still pending forever" instead of "refund the users".

Walkthrough

1. Sender verification

Every state-changing inbound must check in.senderAddress against an address pinned at deploy time. Defense applies to:

  • OracleResult — verify sender is the assertion you bound, not "any address claiming to be one"
  • AssertionCreated — verify sender is the OracleFactory you registered
  • RequestResolution — verify sender is the creator role
  • Any owner-only ops — verify sender is the owner role
the pattern
match (msg) {
OracleResult => {
assert (state.boundAssertion != null) throw Err.NotBound;
assert (in.senderAddress == state.boundAssertion!) throw Err.NotOurOracle;
// ... safe to act ...
}
}

No exceptions. Even on internal helper opcodes — assume every inbound message is from an adversary.

2. Dedupe on OracleResult

The ReemitResult opcode is permissionless. Anyone can pay 0.02 TON to retrigger your callback. Your dedupe pattern:

OracleResult => {
// Verify sender (anti-spoof) FIRST
assert (in.senderAddress == state.boundAssertion!) throw Err.NotOurOracle;

// Then dedupe by questionId
if (st.settled.contains(msg.questionId)) return;
st.settled.set(msg.questionId, true);
st.save();

// NOW act on the answer
}

The dedupe map can also be a status flag (if status != AWAITING return) if your consumer only handles one question per instance. Either works; the common form is a map<uint64, bool> for consumers handling many questions.

3. Bond sizing

Lying must cost the proposer more than the profit from steering the outcome. Three rules of thumb by use case:

Use caseBond formula
Prediction marketmax(min_bond, 2% × TVL)
Bridgemax(min_bond, 10% × max_single_withdrawal)
Insurancemax(min_bond, 20% × max_payout)
RWA milestonemax(min_bond, 5% × milestone_value)

The lower the bond, the more attractive lying becomes. Cap at MAX_BOND_CAP = 10 000 USDT for usability — beyond that, security has to escalate to the resolver, not the bond.

4. Refund-and-return, not throw

Audit M-1 pattern: every post-funds-received guard MUST refund the input, not throw. Throwing AFTER funds have landed strands them at your wallet forever.

wrong — throws after funds landed
TransferNotificationForRecipient => {
assert (state.jettonWallet != null) throw Err.WalletNotSet;
assert (in.senderAddress == state.jettonWallet!) throw Err.WrongWallet;
assert (msg.jettonAmount >= MIN_AMOUNT) throw Err.TooLow; // ❌
// ... handle ...
}
right — refund post-funds-received
TransferNotificationForRecipient => {
// BEFORE funds are considered ours — auth, can throw
assert (state.jettonWallet != null) throw Err.WalletNotSet;
assert (in.senderAddress == state.jettonWallet!) throw Err.WrongWallet;
assert (msg.jettonAmount > 0) throw Err.ZeroAmount;

val owner = msg.transferInitiator!;

// AFTER funds have landed — refund-and-return, never throw
if (msg.jettonAmount < MIN_AMOUNT) {
refundJetton(state.jettonWallet!, owner, msg.jettonAmount);
return;
}
// ... handle ...
}

5. Timeout escape

If your consumer can enter a "waiting for the oracle" state, give it an escape. Pattern: stamp a deadline when entering, expose a creator-callable cancel after the deadline.

RequestResolution => {
// ... auth ...
state.status = AWAITING;
state.awaitingDeadline = (blockchain.now() + AWAITING_TIMEOUT) as uint32;
st.save();
}

CancelAwaiting => {
assert (in.senderAddress == cfg.creator) throw Err.OnlyCreator;
assert (state.status == AWAITING) throw Err.NotAwaiting;
assert (state.boundAssertion == null) throw Err.RaceLost; // race protection
assert (blockchain.now() >= state.awaitingDeadline) throw Err.TooEarly;
state.status = OPEN;
state.awaitingDeadline = 0;
st.save();
}

The race-protection check is critical — if the assertion DOES bind between the timeout firing and the cancel arriving, the assertion takes priority.

6. Callback recipient verification

When your consumer calls CreateAssertion, the Meta.callbackRecipient field is YOUR contract's address. If you pass a typo'd address, OracleResult flies to the wrong place. Defense:

  • Pin the callback recipient = contract.getAddress() in your RequestResolution handler, not as a parameter. Don't accept user-supplied callback addresses.
pin to self
val meta = Meta {
...
callbackRecipient: contract.getAddress(), // not from user input
...
};

7. Question templates

UMA's #1 failure mode in production has been bad question phrasing. Every question you spawn should:

  • Reference a specific verifiable source URL
  • Specify timezone + exact deadline
  • Include a fallback rule if source is unreachable
  • Define edge-case boundaries (equality, rounding)

See Question templates for the full guide. Bake the template into your consumer code so every assertion inherits the same unambiguous phrasing — no human error.

8. Cancelled handling

The assertion can end in STATUS_CANCELLED (no answer ever delivered) via TimeoutRefund. Your consumer needs to handle this. Options:

  • Detect via timeout: if you set awaitingDeadline and OracleResult hasn't arrived by awaitingDeadline + ARBITRATION_WINDOW, treat as cancelled and refund users
  • Subscribe to OracleDisputed: when an assertion enters Disputed, receive the notification and set a longer internal timer

Don't assume "still pending" forever. Stuck states are user-fund-locking bugs.

Before mainnet — final checklist

Run through this list one more time before flipping the switch:

  • Anti-spoof on every state-changing inbound (sender pinned to address registered at deploy)
  • Dedupe by questionId on OracleResult (status check OR settled map)
  • Bond sized to value-at-risk, clamped to MAX_BOND_CAP
  • Refund-and-return (not throw) on all post-funds-received guards
  • Awaiting timeout + CancelAwaiting escape with race protection
  • Callback recipient pinned to contract.getAddress(), not user-supplied
  • Question template baked into consumer (no human error in phrasing)
  • Cancelled / no-answer path documented + tested
  • All unit + integration tests green (see Testing)
  • End-to-end smoke against the live factory passed
  • At least one external review of the contract by someone other than the author
  • Production deploy script with a multisig signer (not a single key)
  • Monitoring on the consumer's TON + USDT balances (any unexpected drain = page on-call)

OMY gives you the truth. The consumer is your responsibility.