Skip to content

[CONTRIBUTION] Peer (ZKP2P) peer-to-peer fiat offramp on Base: deposits, intents, fills, withdrawals, fees #9939

Description

@ADWilkinson

High-level description

Peer (formerly ZKP2P) is a non-custodial peer-to-peer fiat offramp on Base. A
maker escrows USDC and advertises the fiat rails they accept. A taker signals an
intent against that deposit, pays the maker off-chain (Venmo, Revolut, Wise,
Zelle, Cash App, PayPal), proves the payment with a TEE-TLS attestation, and the
escrow releases the USDC. There is no custodian and no offramp provider.

I would like to add five project-level spells covering the full lifecycle:

Model Grain
peer_base.deposits maker liquidity created and topped up
peer_base.intents takers claiming liquidity, with the binding fiat rate
peer_base.fills settled intents, gross and net, with the fee split
peer_base.withdrawals maker liquidity leaving escrow
peer_base.fees one row per non-zero fee earned per fill

Which sub-project

daily_spellbook, as project-level spells at
dbt_subprojects/daily_spellbook/models/_projects/peer/base/.

Not dex and not perpetual. This is not an AMM or aggregator swap and it does
not fit dex.trades: the counterparty is another person, one side of the trade
settles off-chain in fiat, and settlement is asynchronous over minutes to hours.
There is no existing sector for P2P or payments, which is part of what I am
asking about below.

Why it would benefit the community

Peer contracts have been live on Base since January 2025 across three escrow
generations. Sampling blocks 50047215 to 50087215, about 22 hours, gives 277
intents signalled, 117 filled, and 35578 USDC of gross filled volume. Today none
of that is queryable on Dune without hand-decoding raw logs,
so the on-chain fiat-offramp category is effectively invisible. These spells make
tractable a set of questions nobody can currently answer: real fiat corridor
volume by rail and currency, maker capital efficiency and redeployment, taker
fill latency, and the actual take rate of a non-custodial offramp versus a
centralized one.

Contracts and events

Base mainnet, chain id 8453. Three escrow generations and three orchestrator
generations are live, and the models cover all of them.

Role Address Live from
Escrow v2 0xCA38607D85E8F6294Dc10728669605E6664C2D70 2025-01-20
Escrow v2.1 0x2f121CDDCA6d652f35e8B3E560f9760898888888 2025-10-25
Escrow v2.2 0x777777779d229cdF3110e9de47943791c26300Ef 2026-03-11
Orchestrator v2.1 0x88888883Ed048FF0a415271B28b2F52d431810D0
Orchestrator v2.2 0x888888359E981B5225CA48fbCdCeff702FC3b888
Orchestrator v3 0x014025fDE093f8701d86e9f38e2C3a9b779cb5c7

Events consumed, with topic0 as computed and then confirmed against live Base
logs:

DepositReceived      v2    0x68a835da25522a6767ad280764ce2daed02507359a889e4a18219458d2f356b4
DepositReceived      v2.1+ 0x1236dbdc184b6c8721974cce53dabb6018679bca9a43784ab2ad71bcdb1d7dd1
DepositFundsAdded          0x6dd055b2e2bf3452b033736068d30d709ffb26f2016b48ba9a612c3701bf33a9
DepositWithdrawn           0xae1f357660ab777dcfd38c0ab6357834684ec26289ecfa07ec65dbf6c3c64312
DepositClosed              0x8ac07cc6e38c6222dd0309c80353c1962354bacf222b825d7401cc80e93ff3cc
IntentSignaled       v2    0xc2a37468e454b839a2cff965f5933f675c886d1304fa1d29ce661503166874f5
IntentSignaled       v2.1+ 0xf8c114f83581b2cf0b9f130782a93024aa8933e7d188901156bd68bdd558a20a
IntentFulfilled      v2    0xa14478886b7c7d8bcb5da958a490bef7c9ef76654d2f4b65a04d94533bf25dc9
IntentFulfilled      v2.1+ 0xd50b3b21bc45b85ddfaec58dbf56fe9b88754d08f47dcf5143b63258a57ad944
FundsUnlockedAndTransferred 0x45625e0810f65b3c601b5c91bdacdf9e8f9ec7098fce927c57a8a65a823fd617
IntentReferralFeeDistributed 0xa8441469d1604eb6b95bc9c00891d08bbf58a103b4d67ece28c885d98d0f8134
IntentManagerFeeSnapshotted 0x3f418918906a7be197ce7621b0e3d6848f86d2c97ddbc2bc7ac3568c5eea8b0b

Payment rail and currency are bytes32 on-chain: keccak256 of the lowercase
platform slug and of the ISO 4217 code respectively. The models resolve both back
to labels. Over a 30k block sample every observed rail hash (venmo, cashapp,
wise, revolut, paypal, zelle, chime, mercadopago) and every observed currency
hash (USD, EUR, GBP, CAD, AUD, ARS) resolved, with no unmapped values.

Two correctness traps worth flagging up front

These are the reasons I would rather agree the approach with you than guess.

1. IntentFulfilled.amount is net, not gross. From v2.1 that field is what
reached the taker after fees. Summing it as volume understates the protocol by
the entire fee take. Gross is FundsUnlockedAndTransferred.transferredAmount.

2. IntentManagerFeeSnapshotted.fee is a rate, not an amount. It is 1e18
fixed point, snapshotted at signal time, in a different block from the fill.
IntentReferralFeeDistributed.feeAmount next to it is an absolute amount in
6-decimal USDC base units. Adding the two into one fee column is wrong by twelve
orders of magnitude. The realised manager fee is
floor(amount_gross * rate / 1e18).

I validated the resulting identity against live Base logs over blocks
50047215 to 50087215:

signaled=277  fulfilled=117  referral_events=117  manager_snapshots=277
amount_gross == amount_net + referral_fee + floor(amount_gross * mgr_rate / 1e18)
  ok=117  mismatch=0
gross=35578.409753 USDC  referral=1332.929767  manager=0.679625  take=3.7484%

Worked single case, intent
0x00b793f4fc1da44d108d9af2c865813d6172d5b25490d588bffa761b37528cd8:
gross 65240205, referral 4566814, manager rate 1000000000000000 giving 65240,
and 65240205 - 4566814 - 65240 = 60608151, exactly IntentFulfilled.amount.

Decoded tables or raw logs

The Peer contracts are not submitted to Dune's decoder, so peer_base.*_evt_*
does not exist and the draft decodes source('base', 'logs') by topic0 with
fixed ABI offsets, following the pattern in
leverup_monad_perpetual_trades.sql.

This is the main thing I would like your call on. Options as I see them:

  1. Submit the escrow and orchestrator ABIs at dune.com/contracts/new, wait for
    the backfill, and rewrite the models onto decoded tables. Cleaner, and I am
    happy to do it. It does mean six contracts across three generations.
  2. Keep the raw-log decode. The ABI layouts are fixed and already deployed, and
    the topic0 filters are selective, but I appreciate it is not your default.

I will do whichever you prefer.

State of the draft

The five models and their _schema.yml are written. Against main at 21ac0dc:

uv run dbt --warn-error compile          EXIT=0
  Found 2379 models, 2737 data tests, 47 seeds, 9762 sources, 823 macros
  (2374 models before, so the five new ones parse and compile)

Each model is incremental on delta with merge,
incremental_predicate('DBT_INTERNAL_DEST.block_time'), an explicit
schema/alias, and filtering_columns. Every model carries a
dbt_utils.unique_combination_of_columns matching its unique_key plus
not_null on the key columns, which is 5 uniqueness tests and 30 not_null
tests. check-script-ref-and-source, check-model-has-description and
check-model-has-properties-file pass.

I have deliberately not opened a PR, since the README asks for this issue first.
Say the word and I will open it, in whichever of the two shapes above you prefer.

Open questions

  1. Raw logs now, or decoded contracts first (see above)?
  2. Are project-level spells under _projects/peer/base/ the right home, or would
    you rather this became the start of a payments or P2P sector?
  3. Should fills also feed any existing aggregate, or stay standalone? It is not
    a dex.trades shape, but I want to check rather than assume.
  4. Seed file: docs/seeds/seed_overview.md calls seeds encouraged rather than
    required for standalone project spells. Happy to add one if you want it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions