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:
- 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.
- 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.
- 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.
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
bytes32keccakhashes 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 atransaction 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:
CPT_PEER: Final = 'peer', registered throughcounterparties() -> (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
ProtocolWithBalancerather than bundling it here.There is no Ethereum-side deployment, so
test_main.pyneeds no change.Contracts
Three escrow generations and three orchestrator generations are live and all are
in
addresses_to_decoders():0xCA38607D85E8F6294Dc10728669605E6664C2D700x2f121CDDCA6d652f35e8B3E560f97608988888880x777777779d229cdF3110e9de47943791c26300Ef0x88888883Ed048FF0a415271B28b2F52d431810D00x888888359E981B5225CA48fbCdCeff702FC3b8880x014025fDE093f8701d86e9f38e2C3a9b779cb5c7Addresses 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:
paymentMethodandfiatCurrencyarebytes32:keccak256of the lowercaserail 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):
DepositReceived,DepositFundsAddedDEPOSIT/DEPOSIT_ASSETDeposit 1000 USDC into Peer to sell for EUR via RevolutDepositWithdrawnWITHDRAWAL/REMOVE_ASSETWithdraw 1000 USDC from PeerFor a tracked taker:
IntentSignaledINFORMATIONAL/NONESignal intent to buy 161.30098 USDC for 166.60 USD via VenmoIntentFulfilledwherefundsTransferredTois trackedRECEIVE/NONEReceive 146.38064 USDC from Peer after paying 166.60 USD via VenmoFor a tracked integrator:
IntentReferralFeeDistributedRECEIVE/NONEReceive 14.92034 USDC as a Peer referral feeThe 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 atfill to hang the disposal on. Three options, and I do not want to pick
unilaterally:
DEPOSIT/DEPOSIT_ASSETand accept that thedisposal 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.
FundsUnlockedAndTransferredfor depositsbelonging 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.
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.amountis what actually reached the taker, already net offees. The gross released from escrow is
FundsUnlockedAndTransferred.transferredAmount. Separately,IntentManagerFeeSnapshotted.feeis a rate in 1e18 fixed point snapshotted atsignal time, not a token amount, while
IntentReferralFeeDistributed.feeAmountnext 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 overblocks 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 mainnettransaction, whole-list
assert events == [...]with walrus-bound amounts, gasevent first, matching the house style in
test_runmoney.py. Coverage: makerdeposit, maker withdrawal, taker fill on v2.2 and v3, and a referral-fee
recipient. I will open the companion
rotki/test-cachingbranch with the samebranch 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.