Gift card trades
RecoilPay runs a peer-to-peer gift card book. Merchants publish rates for buying and selling cards, as a percentage of face value. Users take a rate to sell a card for USDC, or to buy one with USDC. The USDC sits in an escrow contract until the card has been checked, and the code itself travels encrypted end to end between the two browsers.
This page is for building the user side into your own app. Merchants work from the solver portal; see Operators and solvers.
Payouts are in testnet USDC on Base Sepolia, OP Sepolia and BNB Chain Testnet. Each chain lists two gift card escrows in GET /api/v1/chains — giftcard_attested_escrow and the older giftcard_escrow — and a trade says which of the two holds its money. Read both from there and pick by the trade, never by which one is set.
There's no gift card package in the intent kit yet. The RecoilPay app is a client of the API below, the same one you'd use.
The shape of a trade
Every trade has three roles, and the server works them out for you:
| Field | Selling a card (side: "buy") | Buying a card (side: "sell") |
|---|---|---|
funder | merchant | user |
cardSender | user | merchant |
cardReceiver | merchant | user |
side is named from the merchant's point of view: a merchant's buy rate is what a user selling a card gets. Build your UI on funder, cardSender and cardReceiver rather than on side, and you won't get the direction backwards.
The funder is always the card receiver, so the escrow always goes funder → card sender on success, and back to the funder on failure.
state | Waiting on | Clock | When it runs out |
|---|---|---|---|
quoted | The funder to lock the payout | — | — |
awaiting_code | The card sender | 60 min | refunded_to_funder |
awaiting_attestation | The card receiver | Merchant 30 min, user 60 min | settled_to_card_sender |
disputed | An admin | None | — |
settled_to_card_sender, refunded_to_funder, failed | — | — | Terminal |
Whoever the clock is waiting on loses. awaiting names that party and deadlineAt says when. As a backstop, the escrow contract lets the funder take back any lock that's had no attestation for 7 days.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/giftcard-quotes/rank | Price a card against the live book |
POST | /api/v1/giftcard-trades | Open a trade on a ranked offer |
GET | /api/v1/giftcard-trades?userAddress=0x… | A wallet's trades |
GET | /api/v1/giftcard-trades/{id} | One trade |
POST | /api/v1/giftcard-trades/{id}/escrow | Report the lock and publish the receiver's encryption key |
POST | /api/v1/giftcard-trades/{id}/code | Deliver the sealed code |
POST | /api/v1/giftcard-trades/{id}/attest | The card receiver says whether the card worked |
POST | /api/v1/giftcard-trades/{id}/dispute | Send the trade to review |
No API key. JSON is camelCase both ways, and responses wrap the result in data. Every action on a trade is authorised by the user's wallet signing the trade id itself (EIP-191 personal_sign over the plain id string). The server recovers the address and checks it against userAddress. The routes are in the OpenAPI spec under the giftcards tag; there the request fields are shown in snake_case, which the server also accepts.
Price a card
curl -s "$RECOIL_API/api/v1/giftcard-quotes/rank" -H 'content-type: application/json' -d '{
"userIsSelling": true,
"brand": "Steam",
"countryCode": "US",
"cardType": "ecode",
"faceMinorUnits": "10000"
}'
faceMinorUnits is the face value in the card currency's minor units ($100.00 is 10000). cardType is ecode or physical. The response ranks offers best first:
{
"data": [{
"quoteId": "…", "solverId": "solver-…",
"brand": "Steam", "countryCode": "US", "currency": "USD", "cardType": "ecode",
"rate": "0.85", "payoutMinorUnits": "85000000",
"protocolFee": "850000", "netPayoutMinorUnits": "84150000",
"payoutChain": "eip155:84532", "payoutAsset": "USDC", "payoutDecimals": 6,
"reputation": 0.9, "successRate": 1, "compositeScore": 0.87
}],
"totalConsidered": 4,
"totalFilteredOut": 1
}
payoutMinorUnits is what the funder locks, in the payout token's base units — so it is what the user pays when buying.
protocolFee is RecoilPay's fee at that rate and netPayoutMinorUnits is payoutMinorUnits minus it: what the card sender is actually paid, and therefore what to show a user who is selling. The escrow takes the fee out of the lock when it releases to the card sender, and only then — an unwind of any kind returns the whole lock. On testnet the fee is "0", and on a chain still using the legacy escrow it is "0" too.
Trades carry the same two fields, fixed when the trade opened, so a rate changed afterwards cannot alter what either party was told.
Then open the trade with POST /api/v1/giftcard-trades { quoteId, faceMinorUnits, userAddress }. The trade starts in quoted. A merchant can only carry open trades worth up to five times their bond, so this can be refused for capacity; a smaller card may still go through.
When the user buys a card
-
Lock the payout. Read
trade.escrowKindand use that chain field for the address:giftcard_attested_escrowwhen it isattested,giftcard_escrowwhen it islegacy. Do not choose by which address the registry happens to have — the server froze the answer when the trade opened and will look for the lock only in the contract the trade names. Approve the escrow for the token if needed, then call:lock(bytes32 tradeId, address token, uint256 amount, address counterparty)The call is identical in both contracts. Pass
tradeId = keccak256(utf8(trade.id)),amount = payoutMinorUnits(the gross — the fee comes out of it on release, not on the way in) andcounterparty = trade.merchantAddress. The escrow only ever pays one of those two addresses, less RecoilPay's fee on a completed trade. -
Report it and publish a key.
POST …/escrow { txHash, recipientPubkey, keySignature, signature }. The aggregator checks on chain that the lock landed with the right token, amount and parties before the trade moves toawaiting_code.recipientPubkeyandkeySignatureare explained under Encryption. -
Wait for the code. The merchant seals it to
recipientPubkey. Whenstateisawaiting_attestation,sealedCodeholds the envelope. -
Open it in the browser with the user's encryption key, and let them redeem it.
-
Attest.
POST …/attest { valid: true, signature }releases the escrow to the merchant.{ valid: false, reason, signature }opens a dispute; it doesn't refund straight away. If the user says nothing within 60 minutes, the escrow pays the merchant, so tell them.
When the user sells a card
- Wait for the merchant to fund the escrow. The trade moves to
awaiting_codeandrecipientPubkeyis the merchant's key. - Deliver the code within 60 minutes:
POST …/code { commitment, sealed, signature }, wheresealedis the code sealed torecipientPubkeyandcommitmentiskeccak256(utf8(code)). The plaintext never goes to the server. - Wait for the merchant to check it. If they don't answer within 30 minutes, the escrow pays the user. If they say it's bad, the trade goes to review.
Either party can POST …/dispute { reason, signature } while a trade is open. An admin rules, and a ruling against a merchant can be paid from their bond.
Encryption
Codes are sealed so that only the card receiver can read them, and RecoilPay holds no key that opens them.
The receiver's key isn't their wallet key, because no wallet hands a page its private key. Instead:
- The wallet signs a fixed message,
KEY_DERIVATION_MESSAGE, which beginsRecoilPay Gift Cards. ECDSA signing is deterministic, so the same wallet always gets the same signature. privateKey = keccak256(signature), re-hashed in the rare case it isn't a valid secp256k1 scalar. The public key is its uncompressed point (0x04…, 65 bytes).- That signature never leaves the browser. Instead, the wallet signs
RecoilPay: encryption key for trade {tradeId} is {publicKeyHex}(keySignature), which proves the published key belongs to this party on this trade.
The envelope is ECIES over secp256k1:
| Step | |
|---|---|
| Key agreement | A fresh ephemeral keypair; ECDH with the recipient's public key; the X coordinate is the shared secret |
| Key derivation | HKDF-SHA256, salt = the ephemeral public key (uncompressed), info = recoilpay/giftcard-code/v1, 32 bytes |
| Cipher | AES-256-GCM, 12-byte random IV, 16-byte tag |
| Envelope | { v: 1, alg: "ECIES-secp256k1-AES256GCM", epk, iv, ct, tag }, all hex with 0x |
The code is trimmed before sealing and before hashing for the commitment. To get the exact derivation message, test vectors and a reference implementation, talk to us. Your envelopes have to open in the merchant's browser, and theirs in yours.