DEV Community

Gerus Lab
Gerus Lab

Posted on

Finite State Machines for Trustless Escrow: How We Built a Decentralized Marketplace on TON

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Monitors the asset (e.g., checks Telegram channel admin list via Bot API)
  2. Sends on-chain confirmation when transfer is verified
  3. 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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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)