The Trust Problem in Digital Asset Marketplaces
Buying a Telegram channel is sketchy. Buying any social media asset is sketchy. The seller could take your money and vanish. The buyer could receive the asset and dispute the payment. Every centralized escrow service is a single point of failure — and a single point of trust.
We built ITOhub to solve this with on-chain escrow, oracle-confirmed transfers, and a deal lifecycle managed entirely by a Finite State Machine in a FunC smart contract.
Here's how — and why FSMs are the perfect pattern for on-chain business logic.
Why Finite State Machines?
Every marketplace deal follows a predictable flow: created → funded → asset transferred → confirmed → completed (or disputed). This is textbook FSM territory.
The beauty of encoding this in a smart contract:
- Every state transition is on-chain — fully auditable
- Invalid transitions are impossible — the contract rejects them
- No human arbitrator needed for the happy path
- Dispute resolution has clear rules — not vibes
┌─────────┐ fund() ┌──────────┐ confirm_transfer() ┌────────────────┐
│ CREATED │────────────▶│ FUNDED │──────────────────────▶│ TRANSFER_SENT │
└─────────┘ └──────────┘ └───────┬────────┘
│ │ │
│ cancel() │ cancel() oracle_confirm()
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌───────────────┐
│CANCELLED│ │ REFUNDED │ │ COMPLETED │
└─────────┘ └──────────┘ └───────────────┘
│
auto-release
vault → seller
The Smart Contract (FunC)
TON's smart contracts run on TVM (TON Virtual Machine). We chose FunC — lower-level than Tact, but gives us precise control over gas and message handling.
State Encoding
;; Deal states
const int STATE_CREATED = 0;
const int STATE_FUNDED = 1;
const int STATE_TRANSFER_SENT = 2;
const int STATE_COMPLETED = 3;
const int STATE_CANCELLED = 4;
const int STATE_REFUNDED = 5;
const int STATE_DISPUTED = 6;
;; Deal storage layout
;; [state:3][buyer:267][seller:267][amount:120][asset_id:256][oracle:267][created_at:32][timeout:32]
State Transition Guard
Every operation checks the current state before executing. This is the core FSM enforcement:
() assert_state(int current, int expected) impure inline {
throw_unless(400, current == expected); ;; 400 = invalid state transition
}
() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
int op = in_msg_body~load_uint(32);
;; Load deal state
var ds = get_data().begin_parse();
int state = ds~load_uint(3);
slice buyer = ds~load_msg_addr();
slice seller = ds~load_msg_addr();
int amount = ds~load_coins();
;; ... rest of fields
if (op == op::fund) {
assert_state(state, STATE_CREATED);
;; Verify sender is buyer
throw_unless(401, equal_slices(sender, buyer));
;; Verify sent amount matches deal
throw_unless(402, msg_value >= amount);
;; Transition: CREATED → FUNDED
save_state(STATE_FUNDED, buyer, seller, amount, ...);
return ();
}
if (op == op::confirm_transfer) {
assert_state(state, STATE_FUNDED);
;; Only seller can confirm they've initiated the transfer
throw_unless(401, equal_slices(sender, seller));
;; Transition: FUNDED → TRANSFER_SENT
save_state(STATE_TRANSFER_SENT, buyer, seller, amount, ...);
return ();
}
if (op == op::oracle_confirm) {
assert_state(state, STATE_TRANSFER_SENT);
;; Only designated oracle can confirm
throw_unless(401, equal_slices(sender, oracle));
;; Transition: TRANSFER_SENT → COMPLETED
;; Release vault to seller
send_raw_message(build_transfer_msg(seller, amount), 64);
save_state(STATE_COMPLETED, buyer, seller, amount, ...);
return ();
}
;; ... cancel, dispute, timeout handlers
}
The Vault Pattern
Funds don't sit in a shared pool — each deal gets its own contract instance (a child contract deployed from a factory). This means:
;; Factory contract deploys a new deal contract per transaction
() create_deal(slice buyer, slice seller, int amount, int asset_id, slice oracle) impure {
cell state_init = build_deal_state_init(buyer, seller, amount, asset_id, oracle);
slice deal_address = calculate_address(state_init);
;; Deploy deal contract with initial state
send_raw_message(
begin_cell()
.store_uint(0x18, 6)
.store_slice(deal_address)
.store_coins(50000000) ;; 0.05 TON for gas
.store_uint(6, 107) ;; state_init flag
.store_ref(state_init)
.store_uint(op::init, 32)
.end_cell(),
1
);
}
Why per-deal contracts? Isolation. A bug in one deal can't drain the funds of another. Each vault is a single-purpose, deterministic contract.
The Oracle Layer
The trickiest part: how do you verify that a Telegram channel was actually transferred?
We built a confirmation oracle that:
- Monitors the asset (e.g., checks Telegram channel admin list via Bot API)
- Sends on-chain confirmation when transfer is verified
- Has a timeout — if oracle doesn't confirm within N hours, buyer can dispute
// Oracle service (NestJS)
@Injectable()
export class TransferOracle {
async verifyChannelTransfer(deal: Deal): Promise<boolean> {
const channel = await this.telegramBot.getChat(deal.assetId);
const admins = await this.telegramBot.getChatAdministrators(deal.assetId);
// Check if buyer is now an admin with full rights
const buyerAdmin = admins.find(
a => a.user.id === deal.buyerTelegramId
&& a.status === 'creator'
);
if (buyerAdmin) {
// Send on-chain confirmation
await this.tonService.sendOracleConfirmation(deal.contractAddress);
return true;
}
return false;
}
@Cron('*/60 * * * * *') // Check every minute
async pollPendingTransfers() {
const pending = await this.dealRepo.findByState('TRANSFER_SENT');
for (const deal of pending) {
await this.verifyChannelTransfer(deal);
}
}
}
Oracle Trust Minimization
The oracle can only do two things: confirm or not confirm. It cannot:
- Redirect funds
- Change deal parameters
- Cancel a funded deal unilaterally
If the oracle is compromised, the worst case is a stalled deal — which the timeout mechanism resolves automatically.
The Telegram Mini App
The frontend runs as a Telegram Mini App (TMA), giving users a native feel:
// Deal creation flow
async function createDeal(params: DealParams) {
// 1. Create deal record in backend
const deal = await api.post('/deals', {
assetType: 'telegram-channel',
assetId: params.channelId,
price: params.price,
sellerTelegramId: params.sellerId,
});
// 2. Deploy on-chain contract via TonConnect
const tx = {
validUntil: Math.floor(Date.now() / 1000) + 600,
messages: [{
address: FACTORY_CONTRACT,
amount: toNano('0.1').toString(), // Deploy gas
payload: buildCreateDealPayload(deal),
}],
};
await tonConnect.sendTransaction(tx);
// 3. Redirect to deal tracking page
navigate(`/deals/${deal.id}`);
}
Lessons from Production
1. FSMs make auditing trivial
Every deal has a complete state history on-chain. When disputes arise, you can trace exactly what happened and when. No he-said-she-said.
2. Per-deal contracts are worth the deployment cost
On TON, deploying a contract costs ~0.05 TON (~$0.10). That's nothing compared to the security benefit of full isolation. We considered a shared-state approach and rejected it — the attack surface is too large.
3. Oracle design is the real security challenge
The smart contract logic is deterministic and auditable. The oracle is the trust assumption. We're working toward a multi-oracle model where 2-of-3 confirmations are required, reducing single-point-of-failure risk.
4. Timeouts prevent deadlocks
Every state has a timeout. If the seller doesn't confirm transfer within 24h, buyer can cancel and get refunded. If the oracle doesn't confirm within 48h, either party can trigger dispute resolution. No deal gets stuck forever.
The Numbers
Since launch on ITOhub:
- Deal lifecycle: CREATED → COMPLETED averages 2.3 hours
- Zero fund losses from contract bugs
- Dispute rate: <5% (most resolved by oracle timeout)
- Full deal audit available via TON Explorer
When to Use This Pattern
The FSM + on-chain vault pattern works for any marketplace where:
- Assets can be verified programmatically (channel ownership, domain transfer, NFT holding)
- Both parties need protection (not just the buyer)
- The happy path is common (disputes are the exception)
It doesn't work well when:
- Asset transfer verification requires human judgment
- The asset is physical (you need a shipping oracle, which is a whole other problem)
- Transaction volume requires sub-second finality (TON is fast, but contract deployment adds latency)
Open Source Plans
We're planning to open-source the core FSM escrow contract as a reusable template. If you're building a marketplace on TON and want early access, check out docs.itohub.org or ping us in the Telegram bot.
Gerus Lab builds decentralized protocols, AI-powered products, and Telegram-native applications. From NFT staking to DCA bots to escrow systems — we turn complex architectures into production software.
Top comments (0)