Zum Inhalt springen

Become an OpenFiat arbitrator

Arbitrators decide disputes with their own OPEN at risk, not their reputation. This walks through bonding, finding a case, and casting the commit-then-reveal vote that actually pays out.

What you need

  • A Solana wallet holding at least 10,000 OPEN — the deployed staking config's min_stake_arbitrator, which governance can change — bond it via openfiat-app's Stake page, or the instructions below if you're building your own client
  • A wallet that can sign messages, which every major Solana wallet does — openfiat-app's Arbitrate page runs the whole case in the browser, and the SDKs are there if you would rather script it
  • Network access to at least one OpenFiat node's JSON-RPC endpoint, your own or a public one

Working a case

Work through these in order, for one case at a time. Each step carries the command or code it needs.

  1. 01

    Bond OPEN to unlock the arbitration pool

    Arbitrators must stake before they can see a single case's evidence — that's what makes bribing one pointless (you don't know which case to target) and gives the network something to slash if you vote against the revealed consensus. The deployed devnet config sets the arbitrator minimum at 10,000 OPEN, ten times the 1,000 every other role posts.

    stake.ts
    import { PublicKey, Transaction } from "@solana/web3.js";
    import { getAssociatedTokenAddressSync, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
    import { onchain } from "@openfiat/sdk";
    
    // Role.Arbitrator = 1 — same instruction pair openfiat-app's Stake page
    // submits when you connect a wallet and bond there directly.
    const from = getAssociatedTokenAddressSync(mint, owner, false, TOKEN_2022_PROGRAM_ID);
    const amount = 10_000n * 1_000_000_000n; // OPEN has 9 decimals (OFS-4100 §1)
    
    const instructions = [
      onchain.staking.initializeStakeAccountIx(owner, onchain.Role.Arbitrator),
      onchain.staking.stakeIx(owner, mint, onchain.Role.Arbitrator, from, amount),
    ];
    const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
    const tx = new Transaction({ feePayer: owner, blockhash, lastValidBlockHeight }).add(...instructions);
    const { signature } = await wallet.signAndSendTransaction(tx);
  2. 02

    Find an open case

    Arbitrators choose which disputes to work — nobody assigns you one. Poll any node for cases still short of their required arbitrator count.

    shell
    # Any node will do — arbitrators pick a case, nobody assigns one.
    curl -s -X POST http://localhost:7080/rpc -H 'content-type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"getDisputes","params":{}}'
    # Filter the result for status: "Open" and arbitrators.length < required_arbitrators
  3. 03

    Join before you can see the evidence

    Joining is what unlocks a case for you: the buyer's and seller's own submissions, payment confirmations, and their trade's message log. Once a case has its full complement of arbitrators, it locks and the commit phase begins.

    join.rs
    use openfiat_disputes::events::ArbitratorJoin;
    use openfiat_sdk::wallet::Keypair;
    use openfiat_sdk::{Client, ClientConfig};
    
    // Joining is what unlocks the case's evidence for you — buyer/seller
    // submissions and the trade's own message log become visible once you're
    // in `dispute.arbitrators`.
    let client = Client::new(ClientConfig { endpoint: node_url, ..Default::default() });
    client
        .send_arbitrator_join(
            ArbitratorJoin {
                dispute_id: dispute.id.clone(),
                arbitrator: peer_id(&keypair),
                arbitrator_public_key: keypair.public_key(),
                timestamp: Timestamp::now(),
            },
            &keypair,
        )
        .await?;
  4. 04

    Commit your vote — twice

    Two commit-reveal votes run side by side: an off-chain one that records into the case's own audit trail and reputation, and an on-chain one against openfiat-escrow's DisputeCase account that actually decides the stake-weighted outcome. Use the same decision and the same salt for both — but each side's own enum, not the same number: off-chain Invalid is 2, on-chain InvalidDispute is 3, because MutualSettlement sits at 2 on chain. Hash the wrong byte and you commit to something you can never reveal, which is precisely what gets an arbitrator slashed.

    commit.rs + commit.ts
    // Off-chain (marketplace record + reputation) — Rust SDK
    let secret: [u8; 32] = rand::random();
    let commitment = openfiat_disputes::commitment::compute(vote, &secret); // sha256(vote || secret)
    client.send_vote_commit(VoteCommit { dispute_id, arbitrator: peer_id(&keypair), commitment, timestamp: Timestamp::now() }, &keypair).await?;
    // keep `secret` — you need it again at reveal, and it must never be guessable before then
    
    // On-chain (the stake-weighted vote that actually pays out) — TypeScript SDK
    import { onchain } from "@openfiat/sdk";
    import { createHash } from "node:crypto";
    
    // onchain.DisputeOutcome, NOT the off-chain Vote byte: the two enums
    // diverge from Invalid onwards (see this file's header). Hash the wrong
    // one and the commitment can never be opened.
    const commitment = createHash("sha256").update(Buffer.from([onchain.DisputeOutcome.BuyerWins])).update(salt).digest();
    const ix = onchain.escrow.commitDisputeVoteIx(arbitrator, reservationId, commitment);
  5. 05

    Reveal once the window opens

    Reveal your outcome and salt in both places once the commit window closes. On-chain, this is also where your vote gets its real weight: the reveal instruction reads your Arbitrator-role stake account directly, so a wallet with no arbitrator stake simply cannot supply a valid one.

    reveal.rs + reveal.ts
    // Off-chain — Rust SDK
    client.send_vote_reveal(VoteReveal { dispute_id, arbitrator: peer_id(&keypair), vote, secret, timestamp: Timestamp::now() }, &keypair).await?;
    
    // On-chain — reads your Arbitrator StakeAccount directly (no CPI) to weight this vote
    import { onchain } from "@openfiat/sdk";
    
    const [arbitratorStake] = onchain.staking.stakeAccountPda(arbitrator, onchain.Role.Arbitrator);
    const ix = onchain.escrow.revealDisputeVoteIx(
      arbitrator,
      reservationId,
      onchain.DisputeOutcome.BuyerWins,
      salt,
      arbitratorStake,
    );
  6. 06

    The outcome executes itself

    Once every arbitrator has revealed, or the reveal window closes, anyone — you, the buyer, the seller, or an unrelated bot — can call execute_dispute_outcome. It only tallies votes already recorded on-chain. The design then pays the majority a share of the case's fees and takes part of the stake of whoever revealed against it — but neither the reward nor the penalty is implemented, so today the tally moves the traders' funds and nothing else.

    resolve.ts
    import { onchain } from "@openfiat/sdk";
    
    // Permissionless — it only tallies votes the reveal step above already
    // recorded on-chain, so anyone (the buyer, the seller, either arbitrator,
    // or an unrelated crank bot) can call it once the reveal window closes.
    const ix = onchain.escrow.executeDisputeOutcomeIx(mint, seller, reservationId, destinations);

Arbitrators never hold trade funds. A ruling only ever moves money the escrow program itself already locked before the dispute opened.