Skip to content

Add decoder for Peer (ZKP2P) peer-to-peer fiat offramp on Base #12929

Description

@ADWilkinson

Abstract

Transactions with Peer (formerly ZKP2P) on Base currently decode as bare
contract interactions. A user who offramps USDC to their bank through Peer sees
a transfer into an unknown contract and, later, nothing at all; a user who
onramps sees USDC arrive from an unknown contract. Neither side gets a
counterparty, a readable note, or a usable disposal event.

Peer is a non-custodial peer-to-peer fiat offramp. 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 contract
releases the USDC to the taker. It has been live on Base since January 2025;
sampling blocks 50047215 to 50087215, about 22 hours, gives 277 intents
signalled and 117 filled.

I would like to add a decoder for it, and I am happy to write it. Before I do,
there is one accounting design decision below that I would rather agree with you
than guess at.

Motivation

The interesting property for rotki is that one leg of every Peer trade is fiat
and lands in the user's bank, not on chain. The chain records the crypto leg
plus, usefully, the exact fiat amount, currency and rail that the counterparty
committed to. So the decoder can produce an event that says what the fiat leg
was, which is precisely the information a user needs to reconcile against a bank
or Revolut import. Without a decoder that information sits in bytes32 keccak
hashes in the log data and is not recoverable by hand.

Concretely, a user offramping 1000 USDC to EUR today gets an unlabelled transfer
out to 0x7777..., then their deposit is consumed by a stranger's intent in a
transaction their address does not appear in. There is currently no way to see
in rotki that the position was disposed of.

Specification

Scope

Base only, so a single module directory:

rotkehlchen/chain/base/modules/peer/__init__.py
rotkehlchen/chain/base/modules/peer/constants.py
rotkehlchen/chain/base/modules/peer/decoder.py
rotkehlchen/tests/unit/decoders/test_peer.py
frontend/app/public/assets/images/protocols/peer.svg
docs/changelog.rst

CPT_PEER: Final = 'peer', registered through
counterparties() -> (CounterpartyDetails(identifier=CPT_PEER, label='Peer', image='peer.svg'),).
No balances class: escrowed liquidity is claimable by the depositor, so I would
propose a follow-up for ProtocolWithBalance rather than bundling it here.

There is no Ethereum-side deployment, so test_main.py needs no change.

Contracts

Three escrow generations and three orchestrator generations are live and all are
in addresses_to_decoders():

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

Addresses are from the protocol's own indexer configuration and each was
confirmed to be the deployed bytecode at the stated block. Deployment dates came
from a binary search on eth_getCode.

Topics

Computed with keccak and then confirmed against live Base logs:

DepositReceived      v2    0x68a835da25522a6767ad280764ce2daed02507359a889e4a18219458d2f356b4
DepositReceived      v2.1+ 0x1236dbdc184b6c8721974cce53dabb6018679bca9a43784ab2ad71bcdb1d7dd1
DepositFundsAdded          0x6dd055b2e2bf3452b033736068d30d709ffb26f2016b48ba9a612c3701bf33a9
DepositWithdrawn           0xae1f357660ab777dcfd38c0ab6357834684ec26289ecfa07ec65dbf6c3c64312
IntentSignaled       v2.1+ 0xf8c114f83581b2cf0b9f130782a93024aa8933e7d188901156bd68bdd558a20a
IntentFulfilled      v2.1+ 0xd50b3b21bc45b85ddfaec58dbf56fe9b88754d08f47dcf5143b63258a57ad944
IntentReferralFeeDistributed 0xa8441469d1604eb6b95bc9c00891d08bbf58a103b4d67ece28c885d98d0f8134

paymentMethod and fiatCurrency are bytes32: keccak256 of the lowercase
rail slug and of the ISO 4217 code. Over a 30k block sample every observed value
resolved against the protocol's published rail and currency lists, so the notes
can name the rail and currency rather than print a hash.

Proposed events

For a tracked maker (the offramping user):

Log type / subtype notes
DepositReceived, DepositFundsAdded DEPOSIT / DEPOSIT_ASSET Deposit 1000 USDC into Peer to sell for EUR via Revolut
DepositWithdrawn WITHDRAWAL / REMOVE_ASSET Withdraw 1000 USDC from Peer

For a tracked taker:

Log type / subtype notes
IntentSignaled INFORMATIONAL / NONE Signal intent to buy 161.30098 USDC for 166.60 USD via Venmo
IntentFulfilled where fundsTransferredTo is tracked RECEIVE / NONE Receive 146.38064 USDC from Peer after paying 166.60 USD via Venmo

For a tracked integrator:

Log type / subtype notes
IntentReferralFeeDistributed RECEIVE / NONE Receive 14.92034 USDC as a Peer referral fee

The decision I would like your view on

How should the maker's disposal be represented?

The maker's USDC leaves their wallet at deposit time, which is a transfer into a
protocol, not a sale. The actual disposal happens later, when a taker fills the
deposit, and that fill occurs in a transaction the maker's address does not
appear in at all. The proceeds are fiat and arrive in a bank account.

So the deposit cannot be SPEND, and there is no maker-side on-chain event at
fill to hang the disposal on. Three options, and I do not want to pick
unilaterally:

  1. Decode the deposit as DEPOSIT / DEPOSIT_ASSET and accept that the
    disposal is only completed when the user imports the matching bank or
    Revolut credit. Simplest, and consistent with how a transfer into a protocol
    is treated elsewhere, but the escrowed balance then looks open until the
    user reconciles manually.
  2. Additionally decode the escrow's FundsUnlockedAndTransferred for deposits
    belonging to a tracked address, producing a maker-side event in a third
    party's transaction. This is accurate but means decoding a transaction the
    user did not send, which I do not think fits the current model.
  3. Leave the fill entirely to the fiat-side import and treat the deposit as a
    plain protocol deposit, with the rail and currency in the notes so the user
    can match it up.

I lean towards 1, with the fiat amount, currency and rail in the deposit note so
the pairing is obvious. Option 2 seems like it would need a mechanism that does
not exist yet.

One correctness note for whoever reviews the eventual PR. From v2.1,
IntentFulfilled.amount is what actually reached the taker, already net of
fees. The gross released from escrow is
FundsUnlockedAndTransferred.transferredAmount. Separately,
IntentManagerFeeSnapshotted.fee is a rate in 1e18 fixed point snapshotted at
signal time, not a token amount, while IntentReferralFeeDistributed.feeAmount
next to it is an absolute amount in 6-decimal USDC. I verified the identity
gross = net + referral + floor(gross * rate / 1e18) against live Base logs over
blocks 50047215 to 50087215 and it held for 117 of 117 fills with no mismatches.
The taker-side event should use the net amount, which is what they received.

Tests

rotkehlchen/tests/unit/decoders/test_peer.py, one test per real Base mainnet
transaction, whole-list assert events == [...] with walrus-bound amounts, gas
event first, matching the house style in test_runmoney.py. Coverage: maker
deposit, maker withdrawal, taker fill on v2.2 and v3, and a referral-fee
recipient. I will open the companion rotki/test-caching branch with the same
branch name so the rebase check passes.

Happy to take this on once you confirm the direction on the maker disposal
question, and to drop it if you would rather not carry another Base protocol.

Activity

  1. ADWilkinson commented on Aug 25, 2026

    @ADWilkinson
    Author

    Quick follow-up: the decoder is ready to build; the only blocker is how to represent the maker-side disposal. My proposed safe default is a protocol deposit with fiat amount, currency, and rail in the note, and no synthetic event in a third party's transaction (options 1/3 above). If that accounting shape is acceptable, I'll open the decoder PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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