Skip to main content

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.

Testnet today

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:

FieldSelling a card (side: "buy")Buying a card (side: "sell")
fundermerchantuser
cardSenderusermerchant
cardReceivermerchantuser

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.

stateWaiting onClockWhen it runs out
quotedThe funder to lock the payout——
awaiting_codeThe card sender60 minrefunded_to_funder
awaiting_attestationThe card receiverMerchant 30 min, user 60 minsettled_to_card_sender
disputedAn adminNone—
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​

MethodPathPurpose
POST/api/v1/giftcard-quotes/rankPrice a card against the live book
POST/api/v1/giftcard-tradesOpen 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}/escrowReport the lock and publish the receiver's encryption key
POST/api/v1/giftcard-trades/{id}/codeDeliver the sealed code
POST/api/v1/giftcard-trades/{id}/attestThe card receiver says whether the card worked
POST/api/v1/giftcard-trades/{id}/disputeSend 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​

  1. Lock the payout. Read trade.escrowKind and use that chain field for the address: giftcard_attested_escrow when it is attested, giftcard_escrow when it is legacy. 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) and counterparty = trade.merchantAddress. The escrow only ever pays one of those two addresses, less RecoilPay's fee on a completed trade.

  2. 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 to awaiting_code. recipientPubkey and keySignature are explained under Encryption.

  3. Wait for the code. The merchant seals it to recipientPubkey. When state is awaiting_attestation, sealedCode holds the envelope.

  4. Open it in the browser with the user's encryption key, and let them redeem it.

  5. 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​

  1. Wait for the merchant to fund the escrow. The trade moves to awaiting_code and recipientPubkey is the merchant's key.
  2. Deliver the code within 60 minutes: POST …/code { commitment, sealed, signature }, where sealed is the code sealed to recipientPubkey and commitment is keccak256(utf8(code)). The plaintext never goes to the server.
  3. 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:

  1. The wallet signs a fixed message, KEY_DERIVATION_MESSAGE, which begins RecoilPay Gift Cards. ECDSA signing is deterministic, so the same wallet always gets the same signature.
  2. 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).
  3. 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 agreementA fresh ephemeral keypair; ECDH with the recipient's public key; the X coordinate is the shared secret
Key derivationHKDF-SHA256, salt = the ephemeral public key (uncompressed), info = recoilpay/giftcard-code/v1, 32 bytes
CipherAES-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.

→ Going to production