Skip to content

Trade Workflows

A contract in Foundation Protocol is a service agreement between two entities — party_a (the requester / payer) and party_b (the provider / payee) — driven through its lifecycle by an Arbiter entity. This page shows how to run the workflow end-to-end against the built-in ArbiterCheckPoint.

For the conceptual model, see Trade & Trust. For the formal state machine and message kinds, see Contract Lifecycle.

Setup — an Arbiter and two parties

import asyncio

from fp import EntityKind, Host, Message, MessageKind
from fp.trade import (
    ArbiterCheckPoint,
    ContractActionPayload,
    ContractCreatePayload,
    ContractRatePayload,
    FundingMode,
)


async def main():
    host = Host(name="Hub")

    arbiter = host.register_entity(name="Arbiter", kind=EntityKind.ARBITER)
    arbiter_cp = arbiter.get_checkpoint(ArbiterCheckPoint)

    alice = host.register_entity(name="Alice", kind=EntityKind.HUMAN)
    bob = host.register_entity(name="Bob", kind=EntityKind.HUMAN)

    # Pre-fund both ledger accounts (Arbiter manages a virtual balance)
    arbiter_cp.ledger.deposit(alice.uid, 1000)
    arbiter_cp.ledger.deposit(bob.uid, 200)

In ESCROW mode the Arbiter holds a virtual ledger and moves balances internally at settlement. DIRECT mode delegates payment to an external rail — the Arbiter only provides trust backing.

The happy path

A complete ESCROW contract walks through six messages. The Arbiter's state machine validates each transition and notifies both parties.

1. Create

Party A (or Party B) authors the contract and sends it to the Arbiter:

await alice.send_message(
    to=arbiter.entity_card,
    message=Message(
        kind=MessageKind.CONTRACT_CREATE,
        payload=ContractCreatePayload(
            party_a=alice.address,
            party_b=bob.address,
            title="Market research report",
            description="Q2 SEA market research, including competitor analysis.",
            amount=200,
            funding_mode=FundingMode.ESCROW,
        ).model_dump(),
    ),
)

The Arbiter creates the contract in DRAFT and notifies Bob for approval.

2. Approve

contract_id = next(iter(arbiter_cp.contracts))

await bob.send_message(
    to=arbiter.entity_card,
    message=Message(
        kind=MessageKind.CONTRACT_APPROVE,
        payload=ContractActionPayload(contract_id=contract_id).model_dump(),
    ),
)

The Arbiter checks Alice's balance, freezes the amount (ESCROW), and transitions the contract to ACTIVE.

3. Execute

Once active, the Arbiter steps aside and Alice and Bob communicate directly via normal INVOKE messages. The Arbiter sees nothing of the execution — it only re-enters when a state-changing message arrives.

4. Complete

Bob signals that delivery is done:

await bob.send_message(
    to=arbiter.entity_card,
    message=Message(
        kind=MessageKind.CONTRACT_COMPLETE,
        payload=ContractActionPayload(contract_id=contract_id).model_dump(),
    ),
)

State becomes COMPLETING and the Arbiter asks Alice to accept.

5. Accept and rate

await alice.send_message(
    to=arbiter.entity_card,
    message=Message(
        kind=MessageKind.CONTRACT_ACCEPT,
        payload=ContractActionPayload(contract_id=contract_id).model_dump(),
    ),
)

await alice.send_message(
    to=arbiter.entity_card,
    message=Message(
        kind=MessageKind.CONTRACT_RATE,
        payload=ContractRatePayload(
            contract_id=contract_id,
            rating=5,
            review="Solid work.",
        ).model_dump(),
    ),
)

ACCEPT moves to SETTLING. RATE records Alice's review (Party A rates Party B only — see Trust Protocol for the rationale).

6. Settle

In ESCROW mode the Arbiter performs the internal transfer automatically and the contract advances to SETTLED. In DIRECT mode the Arbiter sends Alice a PAY_REQUEST; payment is then handled by the external rail and the contract waits for the receipt before settling.

The full SETTLED snapshot is signed by the Arbiter (SHA256) and becomes the immutable audit record. Reputation is recomputed from these signed snapshots — see Reputation.

Other workflows

The same CONTRACT_* message family covers the non-happy paths:

Message Trigger Effect
CONTRACT_AMEND A or B amends in DRAFT draft_version++
CONTRACT_REJECT counterparty refuses in DRAFT CANCELLED
CONTRACT_REWORK A asks for changes after COMPLETE back to ACTIVE; capped by max_rework_count
CONTRACT_CANCEL either side cancels CANCELLED with appropriate refund
CONTRACT_DISPUTE either side disputes DISPUTED; Arbiter resolves

Working runnables for each of these flows live in the example/ directory:

Plugging in your own approval policy

The Arbiter's state machine is fixed, but each party's response to a contract event is up to them. ContractApprovalCheckPoint (importable from fp.trade) is the seam — it sits in the recipient's checkpoint pipeline and decides whether the contract message goes straight to the handler or pauses for owner review.

from fp.trade import ContractApprovalCheckPoint

# Auto-approve up to $50, manual review above
class BudgetGatedApproval(ContractApprovalCheckPoint):
    async def auto_approve(self, contract) -> bool:
        return contract.amount <= 50

See Checkpoints & Authorization for how the checkpoint pipeline composes user-defined policies with the built-ins.