İçeriğe geç

Make your first trade

A trade is four moves: you reserve part of a merchant's advertisement, the stablecoin goes into an on-chain escrow vault, you pay through your ordinary bank or mobile-money rail, and the merchant confirms — at which point the escrow releases to you. Nobody in the middle can take the funds, and nobody has to trust the other side's goodwill.

Milestone 1 of 2 · Make and take offers

Before you start

  • A Solana wallet you control, holding the token account the escrow will pay into
  • Local currency and a payment method the merchant accepts, or the stablecoin if you are selling
  • Access to an OpenFiat node's JSON-RPC endpoint — your own, or one run by someone you trust to relay your signed events
  • A client that can sign protocol events: the Rust SDK covers every step below; the TypeScript SDK covers browsing and reserving, and the rest go through raw JSON-RPC calls
  • Note: the reference web app's trading screens are still simulated data, so the steps below are the real path today, not a browser click-through

Steps

  1. 01

    Understand what actually protects you

    Two separate layers are involved, and confusing them is the one mistake that can cost you money. The peer-to-peer layer coordinates the trade: it records that you reserved part of an advertisement and marks the reservation EscrowLocked. That state is bookkeeping across nodes — it does not by itself prove anything exists on Solana. What actually holds the stablecoin is a trade escrow vault created on-chain by the seller's wallet, keyed by your reservation id. Before you send any fiat, check that the vault exists and is funded.

  2. 02

    Find an advertisement

    Ask any node for the advertisements it has synchronized. The method takes no filters and returns the whole book, so narrow it down yourself: match fiat_currency to your currency, direction to the side you want, payment_methods to a rail you can actually use, and check your amount sits between min_trade and max_trade with enough available_liquidity behind it. Only ads with status Active can be reserved.

    shell
    curl -s -X POST http://localhost:7080/rpc -H 'content-type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"getAdvertisements","params":{}}'
    
    # Returns every synchronized ad. Filter client-side on fiat_currency,
    # direction, payment_methods, min_trade/max_trade and available_liquidity.
  3. 03

    Reserve the amount you want

    A reservation is a signed request naming the advertisement and the amount. The node validates it against the ad's limits and remaining liquidity, and either stores it as EscrowLocked or rejects it outright — nothing is recorded for a request that fails validation. From this moment you have 30 minutes: if the trade has not progressed by then, the reservation expires automatically on every node and the liquidity returns to the advertisement. You can also cancel it yourself while it is still in EscrowLocked.

    Rust
    use openfiat_reservations::events::ReservationRequest;
    use openfiat_reservations::ReservationId;
    use openfiat_types::{Amount, Timestamp};
    
    let request = ReservationRequest {
        id: ReservationId::new("res-1"),
        advertisement_id: ad.id.clone(),
        requester: my_peer_id.clone(),
        requester_public_key: keypair.public_key(),
        // Base units and decimals must match the advertised asset.
        amount: Amount::new(2_000_000, 6),
        timestamp: Timestamp::now(),
    };
    let reservation_id = client.send_reservation_request(request, &keypair).await?;
    TypeScript
    import { reservations, toBytes, type ReservationRequest } from "@openfiat/sdk";
    
    const request: ReservationRequest = {
      id: "res-1",
      advertisement_id: ad.id,
      requester: toBytes(peerId),
      requester_public_key: toBytes(keypair.publicKey),
      amount: { base_units: 2_000_000, decimals: 6 },
      timestamp: Date.now(),
    };
    const reservationId = await reservations.sendReservationRequest(client, request, keypair);
  4. 04

    Check the escrow before you pay

    This is the step that protects you, and the one no software can take on your behalf. The seller creates the trade escrow vault and funds it from their liquidity vault — two instructions on the escrow program, both signed by their wallet, with the vault address derived from your reservation id. Read that account on Solana and confirm it holds the amount you expect. If it does not exist or is empty, do not send fiat: cancel the reservation, or simply let it expire.

    shell
    # The vault is a PDA of the escrow program, derived from your reservation id.
    # Devnet escrow program: HaPpM1QYM3dKp3sX7zhEdft9hB6ncu6xfALAbkyQChQP
    #
    # The node's own read-only join over reservation + settlement:
    curl -s -X POST http://localhost:7080/rpc -H 'content-type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"getTrade","params":{"id":"res-1"}}'
  5. 05

    Pay through the agreed rail, then declare it

    Send the local currency exactly as the advertisement specifies — same method, same account, and any reference the merchant asked for. Pay from an account in your own name where you can, because that is the evidence an arbitrator will look at if this goes wrong. Then tell the network you have paid, attaching the transfer's reference so the merchant can find it. You have 30 minutes from escrow lock to declare payment, and the merchant then has 30 minutes to approve or reject.

    Rust
    use openfiat_settlement::events::PaymentSubmitted;
    
    let payment = PaymentSubmitted {
        settlement_id: settlement_id.clone(),
        buyer: my_peer_id.clone(),
        // Whatever lets the merchant find the transfer on their side.
        payment_reference: Some("MPESA-QK12345678".to_string()),
        timestamp: Timestamp::now(),
    };
    client.send_payment_submitted(payment, &keypair).await?;
    TypeScript
    // The TypeScript SDK has no typed settlement helper yet, so sign and
    // send the event through the client's own signed-call path.
    const action = {
      settlement_id: settlementId,
      buyer: toBytes(peerId),
      payment_reference: "MPESA-QK12345678",
      timestamp: Date.now(),
    };
    const bytes = new TextEncoder().encode(JSON.stringify(action));
    const signature = await sign(keypair, bytes);
    await client.sendSigned("sendPaymentSubmitted", { action, signature: toBytes(signature) });
  6. 06

    The merchant approves and escrow releases

    Once the merchant sees the money arrive they approve the settlement, which flips the on-chain vault to approved. Release is permissionless from that point: the merchant never hands you the stablecoin themselves, and cannot withhold it after approving — you, a relaying node, or anyone else can trigger release_escrow, and the program pays your token account and takes the protocol fee. Watch getTrade until the settlement reaches Completed, which is recorded only once the release transaction is confirmed on-chain.

  7. 07

    If it goes wrong, open a dispute

    Only the buyer or the seller of that settlement can open a dispute — the node rejects anyone else. State plainly what happened and keep your payment evidence. Three arbitrators then stake their own OPEN to take the case, vote by commit-and-reveal so none can follow another's lead, and the outcome executes on-chain against the same escrow vault. One gap worth knowing: the crate supports both sides agreeing a mutual settlement instead, but that path is not exposed over JSON-RPC yet, so today a dispute means arbitration.

    Rust
    use openfiat_disputes::events::DisputeOpen;
    use openfiat_disputes::DisputeId;
    
    let open = DisputeOpen {
        id: DisputeId::new("dsp-1"),
        settlement_id: settlement_id.clone(),
        opener: my_peer_id.clone(),
        opener_public_key: keypair.public_key(),
        reason: "Paid in full on 12 March, reference MPESA-QK12345678; not released.".to_string(),
        timestamp: Timestamp::now(),
    };
    client.send_dispute_open(open, &keypair).await?;