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';
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 registeredRequestResolution— verify sender is the creator role- Any owner-only ops — verify sender is the owner role
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 case | Bond formula |
|---|---|
| Prediction market | max(min_bond, 2% × TVL) |
| Bridge | max(min_bond, 10% × max_single_withdrawal) |
| Insurance | max(min_bond, 20% × max_payout) |
| RWA milestone | max(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.
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 ...
}
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 yourRequestResolutionhandler, not as a parameter. Don't accept user-supplied callback addresses.
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
awaitingDeadlineandOracleResulthasn't arrived byawaitingDeadline + 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
questionIdonOracleResult(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 +
CancelAwaitingescape 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.
Related
- Patterns — the production-ready code snippets
- Testing — how to test the items above
- Question templates — phrasing precision