Skip to main content

cc_buyback — buybacks for program-owned wallets

The ordinary Solana buyback hands you a transaction to sign: you call POST /api/buyback, Collector Crypt builds and co-signs it, and your wallet adds the second signature. That requires a private key.

A smart-contract wallet does not have one. Its address is a PDA — it authorises actions through its own program, never with an ed25519 signature — so it can never complete a transaction CC built. Same for a custody vault that holds a card on a user's behalf.

cc_buyback is the path for those sellers. CC signs a quote — a message, not a transaction — and you build and broadcast the transaction yourself. The program verifies the quote, confirms the card already reached its destination, and pays you.

It is the Solana counterpart of GachaVault.sellBack on the EVM side, and it works the same way in the way that matters: a signed price, a single-use quote, and an atomic swap.

It is not a CPI-only buyback, and cc_buyback never moves the card

The card is moved by you, before you call in. cc_buyback reads the transaction's instruction history to confirm the transfer happened, then releases the payment. A total compromise of this program cannot move an NFT. It also does not have to be called by CPI — a plain wallet can use the same path with three top-level instructions (see Plain wallets).

The rule

Move the card first. Then ask to be paid, in the same transaction.

cc_buyback reads the list of instructions that have already completed alongside it and requires one that transferred this asset to this destination. If it is not there, nothing is paid. Because a Solana transaction is all-or-nothing, you can never end up paid without delivering, or having delivered without being paid.

Reading the current owner would not be equivalent, and that is why the check is what it is: a card sitting in the prize wallet because somebody else just sold it back would satisfy an "is-it-there-now" test while you delivered nothing.

What an integrator must satisfy

No registration, no allow-list, no account CC creates for you, and no counter you have to keep.

  1. Sign as the seller — the exact address named in the quote. A PDA signing via its program's invoke_signed counts.
  2. Own the card, and transfer it to destinationOwner as an instruction that completes before authorize_and_pay in the same transaction.
  3. Include CC's quote as a top-level instruction. It is an Ed25519 precompile instruction, and precompiles cannot be reached by CPI — it must sit at the top level of the transaction, not inside your program's call.
  4. Own a token account for the payment mint. That is where the money lands; create it before you call, or the program fails with AccountNotInitialized (3012).
  5. Send to an allow-listed destination. destinationOwner comes from the quote; you cannot choose it.

Everything else — expiry, replay, float — the program handles.

Transaction shape

[0]  ComputeBudget            (optional)
[1] Ed25519SigVerify… the quote. TOP-LEVEL, always
[2] <your program> e.g. a smart wallet's "execute" instruction
├─ transfer asset → destinationOwner ← must complete first
├─ cc_buyback::authorize_and_pay
└─ spl-memo "<memo>:buyback" (optional, aids reconciliation)

The transfer and the authorize_and_pay call must share the same parent instruction, so that the transfer counts as a completed sibling.

Plain wallets

A wallet with a private key needs no program at all — top-level instructions are siblings of each other:

[0]  Ed25519SigVerify…        the quote
[1] transfer asset → destinationOwner
[2] cc_buyback::authorize_and_pay

Supported card standards

The transfer you emit must be one the program recognises.

nft_standardTransfer instruction
corempl-core TransferV1
pnft, nftmpl-token-metadata TransferV1
cnft-coreBubblegum transferV2

Compressed cards work like any other. A transferV2 that appears in the completed list means Bubblegum has already verified the Merkle proof and the leaf owner, so cc_buyback needs none of the proof accounts.

Getting a quote — POST /api/buyback

Add sellerProgram to the ordinary buyback request and the route answers with a quote instead of a transaction. The field is recorded for reporting; it is not an allow-list.

Request Body:

{
"playerAddress": "DjXQhLaUMCvjQGeiFGR1mz28drf5wWJZwRUMfCyMhT9b",
"nftAddress": "DYBgWyBTCyYp21Vs3nVn53YgCEYLVBE9KFQUJALQ4Nxr",
"sellerProgram": "XmSwiXQsxSZYKVYbSAkkvQVvdrKo1nwwfvZBPQrLzbU"
}

playerAddress is the seller — the PDA that owns the card and will be paid.

Response:

{
"success": true,
"memo": "cc-6f3a1b2c-...",
"refundAmount": 28050000,
"quote": {
"hash": "9ZecA6tJ5xbWCsbS13rgjucvtbR4QjHxU5aD5vJa3XNz",
"signature": "base64 ed25519 signature",
"ed25519Instruction": { "programId": "Ed25519SigVerify111111111111111111111111111", "keys": [], "data": "base64" },
"quoteMarker": "9ZecA6tJ5xbWCsbS13rgjucvtbR4QjHxU5aD5vJa3XNz",
"quoteId": "84213",
"price": "28050000",
"expiresAt": 1789154944,
"paymentMint": "Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr",
"destinationOwner": "Lowovruwau5pKnA7yhGj3JiE5moXTsn7mGG6dU53S2z",
"treasury": "9ZSgA3PMjeAU8K6CWzgJ8oDnhZwwSgHZu6KP3X95t7Jq",
"rentVault": "HypwMQDGznHy2h3mTwkTAViCnT11TxAFti1dpWeV4Vyk",
"ccProgram": "CcBuyM7sDhedBGLZxivBvgZVdqzrQAG66KYHgnTTEpLF",
"standard": "core",
"computeUnitLimit": 250000,
"addressLookupTable": null,
"memo": "cc-6f3a1b2c-...:buyback"
}
}

Use it as follows:

  • ed25519Instruction — build instruction [1] verbatim from programId, no accounts, and data base64-decoded.
  • standard — tells you which transfer to emit.
  • treasury, rentVault, paymentMint, destinationOwner, memoProgram — accounts for authorize_and_pay.
  • price — base units of paymentMint. refundAmount is the same number.
  • expiresAt — unix seconds. Quotes live 600 seconds.
  • addressLookupTable — when set, build a v0 transaction against it. Core fits a legacy transaction; pNFT does not.

Error codes: NO_LANE (503, that token has no payment lane), INSUFFICIENT_FLOAT (503, top-up pending), ALREADY_SETTLED (409, this card has already been bought back).

A failed attempt does not lock the card. Ask for a new quote and try again — two live quotes cannot both pay, because each needs the card delivered and the card only moves once.

authorize_and_pay

authorize_and_pay(price: u64, quote_id: u64, expires_at: i64, memo: String)

Anchor discriminator: sha256("global:authorize_and_pay")[..8] = [196, 1, 233, 204, 98, 232, 22, 54].

#AccountSignerWritableNotes
0policyPDA [b"policy"]
1seller_authorityyesyou. Its owner is not inspected
2assetthe card. Matched against your transfer; never read or written
3destination_ownerfrom the quote
4treasury_tokenyesfrom the quote
5seller_tokenyesyour token account for payment_mint
6payment_mintfrom the quote
7sysvar_instructionsSysvar1nstructions1111111111111111111111111
8quote_markeryesquoteMarker from the quote
9rent_vaultyesrentVault from the quote
10system_program
11token_program
12memo_programMemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr (memoProgram from the quote)

Checks run in this order:

  1. not paused; price > 0; memo non-empty and ≤ 256 bytes; not expired
  2. seller_authority signed
  3. delivery — a completed sibling transferred asset to destination_owner
  4. rent return — for nft/pnft only, see below
  5. destination_owner is allow-listed
  6. the payment lane exists and treasury_token is that lane's treasury
  7. the lane's float and delegate allowance cover price
  8. the quote signature verifies against CC's signer
  9. the quote has not been spent — then it is marked spent
  10. payment is transferred to seller_token
  11. the memo is emitted as an SPL Memo instruction

Returning the token-account rent

Collector Crypt pays for the token account that holds your card when it is awarded — the rent-exempt minimum of a 165-byte SPL token account, currently 1,488,440 lamports. On a buyback that account is emptied, and that rent has to come back or the wallet funding buybacks drains one sale at a time.

So when the delivery that matched was an mpl-token-metadata TransferV1 — that is, nft or pnft — the transaction must also contain one of these as a completed sibling:

  • an spl-token or Token-2022 CloseAccount (data == [9]) whose destination (account index 1) is CC's rent destination. Closing the emptied card account is the natural way to do this, and it costs you nothing you had.
  • a System Transfer to CC's rent destination of at least that minimum, if your program cannot close the account itself. The program reads the figure from the rent sysvar rather than hardcoding it, so it always matches the cluster. Do not hardcode 2,039,280 — that is the retired 3480 lamports/byte-year rate and is 37% too high.

Otherwise the program returns RentNotReturned and nothing settles.

core and cnft-core are exempt. An mpl-core asset needs no account at the destination and a compressed NFT has none at all, so CC fronted nothing for them and charging would invent a fee.

The rent destination is a field on CC's own policy, changeable only by CC's multisig. It is deliberately not a program-derived address: lamports sent to a PDA could not be spent on the awards that produced them.

The memo instruction matters to you only in that you must pass memo_program. CC's settlement webhook and every buyback report key on an on-chain memo, so a transaction without one is only picked up later by a reconciliation cron.

Errors

CodeNameMeaning
6003QuoteExpiredpast expiresAt
6027QuoteAlreadyUsedthis quote already paid
6025AssetNotDeliveredno qualifying transfer in this transaction
6029LaneNotFoundno payment lane for that mint
6030RentNotReturnedan nft/pnft buyback did not return the token-account rent
6007WrongTreasurytreasury is not the one the lane names
6010DelegateNotSetCC's allowance is missing
6011AllowanceExhaustedallowance below price — retry shortly
6012InsufficientFloatlane balance below price
3012(Anchor) AccountNotInitializedyour seller_token does not exist yet

Quotes are single-use

On success the program creates a small account at [b"quote", digest] — the quoteMarker in the response. Its existence is the record that the quote was spent, so presenting the same quote twice fails with QuoteAlreadyUsed.

You never have to track this. There is no nonce to read and no counter to keep in sync, which is what lets an arbitrary wallet integrate: cc_buyback owns replay protection, not you.

The marker only has to outlive the quote. After expiresAt anyone may call close_quote_marker to reclaim its rent — the quote is unusable by then regardless. You do not need to do this: CC fronts the marker's rent from a program-owned vault, so that a wallet with no fee payer can still sell, and CC's own reconciliation job sweeps expired markers and refunds the vault. The refund goes to the vault rather than to whoever closed the account, so there is nothing to farm here.

The signed quote

CC signs the SHA-256 of:

"cc-buyback-quote-v2" | cc_buyback program id | seller | asset
| destinationOwner | paymentMint | price (u64 LE) | expiresAt (i64 LE)
| quoteId (u64 LE) | memo (UTF-8)

Every field is bound, so a quote cannot be moved to another seller, card, destination, price or payment token. paymentMint is in there because the treasury has several payment lanes — without it a quote priced in one token would be redeemable against another's float.

Payment lanes

Each payment token is its own lane with its own treasury and its own float, and a buyback pays in the token the pack was bought with. A lane with no float answers INSUFFICIENT_FLOAT; a token with no lane answers NO_LANE. Same model as the EVM vault's lanes.

Settlement

You broadcast the transaction, so CC may never see it. Reconciliation is automatic: a cron derives the marker from the stored quote, asks the chain whether it exists, recovers the settling signature, and confirms the card arrived before returning it to the prize pool.

You can also report it yourself by posting the signed transaction to POST /api/submitTransaction, which records the signature and broadcasts on your behalf. Either way, GET /api/pack/status?memo=… shows the result.

Addresses

Devnet
ProgramCcBuyM7sDhedBGLZxivBvgZVdqzrQAG66KYHgnTTEpLF
Policy6UjEiB4XzrFmxxfzchy95Vygaeo11uvstBYJTqDTbHjq
Rent vaultHypwMQDGznHy2h3mTwkTAViCnT11TxAFti1dpWeV4Vyk
USDC lane mintGh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr

Mainnet is not deployed yet. The policy PDA is [b"policy"] and the rent vault [b"rent"] under the program, so both follow the program id.

Worked example

A devnet swap by a PDA with no private key — 3iRzAyeH7Rk1jwWivHiKPoghqRgxdrek4E26pEbsXm5eE2tM7qb2QQt7Nrj76LP7SheRLMRu136YSB8AFUZMRpp8:

Program log: Instruction: Sell               the seller's program
Program log: Instruction: Transfer mpl-core — the card moves
Program CcBuyM7s… invoke [2]
Program log: Instruction: AuthorizeAndPay then the payment
Program CcBuyM7s… success

The transaction carries exactly one signature — the fee payer's. The seller, DjXQhLaUMCvjQGeiFGR1mz28drf5wWJZwRUMfCyMhT9b, is a PDA: it authorised the sale through its own program, received 28.05 USDC, and gave up the card, without ever producing a signature.