DpExchange.Coinbase.Prime (DpExchangeCoinbase v0.2.24)

Copy Markdown View Source

Coinbase Prime custodial staking — a different product, a different host, and a different signing scheme from Advanced Trade.

Why this is its own module

Everything else in this package talks to api.coinbase.com/api/v3/brokerage and signs with a CDP JWT. Prime talks to api.prime.coinbase.com/v1 and signs with an HMAC over the request, using an access key, a passphrase and a signing key that Advanced Trade neither issues nor accepts. Putting the two behind one credential map would produce a request that is signed, plausible and rejected — and reported as an authentication problem at the venue rather than a wrong product here.

These are the endpoints that make Coinbase a custodial staking venue comparable to Gemini. They are not the CDP Staking API, whose seven endpoints take a wallet address and return unsigned transactions for the caller to sign and broadcast. That is a different capability, and reaching it through stake/3 would be this family's recurring failure at its most expensive: a caller believing it had staked while holding an unsigned transaction nobody sent.

Two scopes, and this package will not pick one for you

Prime publishes every staking operation twice — once across a portfolio and once on one wallet:

POST /v1/portfolios/{pid}/staking/initiate                      portfolio
POST /v1/portfolios/{pid}/staking/unstake                       portfolio
POST /v1/portfolios/{pid}/staking/transaction-validators/query  portfolio
POST /v1/portfolios/{pid}/wallets/{wid}/staking/initiate        wallet
POST /v1/portfolios/{pid}/wallets/{wid}/staking/unstake         wallet
POST /v1/portfolios/{pid}/wallets/{wid}/staking/unstake/preview wallet
GET  /v1/portfolios/{pid}/wallets/{wid}/staking/unstake/status  wallet
POST /v1/portfolios/{pid}/wallets/{wid}/staking/claim_rewards   wallet
GET  /v1/portfolios/{pid}/wallets/{wid}/staking/status          wallet

The two are not interchangeable: a portfolio-scoped unstake redeems across every wallet in the portfolio, and a wallet-scoped one redeems from the one named. Each is a separate function here, and DpExchange.Coinbase.stake/3 chooses between them only on what the caller actually said — a :wallet_id in opts means the wallet, its absence means the portfolio. Nothing is defaulted.

What was measured, and what was not

Nothing here has been run against Prime. The paths are read from the vendor's own pages on 2026-08-31, deduplicated from thirteen pages to nine endpoints — four pairs document one path under two names. The signing scheme is read from Prime's authentication documentation and has never been probed: this repository holds no Prime credential and D7 tier 4 says money-moving endpoints are answered in production by a consumer, not by a test here.

Responses come back as the venue's own maps for the same reason. A Types.StakingBalance built from an unverified field name would be a plausible number in the wrong field, which is worse than a map a caller has to read.

Summary

Types

Prime's own credential triple. Not the CDP key pair the rest of this package uses — they are issued separately and neither product accepts the other's.

Functions

Claims accrued rewards for one wallet — POST /portfolios/{pid}/wallets/{wid}/staking/claim_rewards.

What a wallet-scoped redemption would do, without doing it — POST /portfolios/{pid}/wallets/{wid}/staking/unstake/preview.

The validators a staking transaction would touch — POST /portfolios/{pid}/staking/transaction-validators/query.

Stakes amount of asset across a portfolioPOST /portfolios/{pid}/staking/initiate.

Stakes amount of asset on one walletPOST /portfolios/{pid}/wallets/{wid}/staking/initiate.

One wallet's staking state — GET /portfolios/{pid}/wallets/{wid}/staking/status.

Redeems amount of a staked asset across a portfolioPOST /portfolios/{pid}/staking/unstake.

How far a wallet's redemption has got — GET /portfolios/{pid}/wallets/{wid}/staking/unstake/status.

Redeems amount of a staked asset from one walletPOST /portfolios/{pid}/wallets/{wid}/staking/unstake.

Types

credentials()

@type credentials() :: %{
  access_key: String.t(),
  passphrase: String.t(),
  signing_key: String.t()
}

Prime's own credential triple. Not the CDP key pair the rest of this package uses — they are issued separately and neither product accepts the other's.

Functions

claim_rewards(credentials, portfolio_id, wallet_id, opts)

@spec claim_rewards(credentials(), String.t(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Claims accrued rewards for one wallet — POST /portfolios/{pid}/wallets/{wid}/staking/claim_rewards.

A write, not a report. It does not say what has accrued; it moves what has. A caller wanting the figure reads staking_status/4.

preview_unstake_wallet(credentials, portfolio_id, wallet_id, asset, amount, opts)

@spec preview_unstake_wallet(
  credentials(),
  String.t(),
  String.t(),
  String.t(),
  Decimal.t(),
  keyword()
) :: {:ok, map()} | {:error, term()} | {:refused, term()}

What a wallet-scoped redemption would do, without doing it — POST /portfolios/{pid}/wallets/{wid}/staking/unstake/preview.

A preview is not a reservation. Nothing is held, and the unbonding schedule it reports is the schedule as of the moment it was asked. It is the only endpoint in this module that moves nothing.

query_transaction_validators(credentials, portfolio_id, opts)

@spec query_transaction_validators(credentials(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

The validators a staking transaction would touch — POST /portfolios/{pid}/staking/transaction-validators/query.

A read, despite the POST: Prime takes the query in a body. opts[:query] is the venue's own filter map and is sent as given, because a filter this package reshaped would be a second place to be wrong about a vocabulary only Prime defines.

stake_portfolio(credentials, portfolio_id, asset, amount, opts)

@spec stake_portfolio(credentials(), String.t(), String.t(), Decimal.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Stakes amount of asset across a portfolioPOST /portfolios/{pid}/staking/initiate.

This acts on every eligible wallet in the portfolio. stake_wallet/6 names one instead, and the two are not the same operation.

This moves funds. opts[:idempotency_key] is passed through where the caller supplies one; this package does not generate it, because an idempotency key a caller cannot reproduce protects nothing on a retry it did not make.

stake_wallet(credentials, portfolio_id, wallet_id, asset, amount, opts)

@spec stake_wallet(
  credentials(),
  String.t(),
  String.t(),
  String.t(),
  Decimal.t(),
  keyword()
) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Stakes amount of asset on one walletPOST /portfolios/{pid}/wallets/{wid}/staking/initiate.

staking_status(credentials, portfolio_id, wallet_id, opts)

@spec staking_status(credentials(), String.t(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

One wallet's staking state — GET /portfolios/{pid}/wallets/{wid}/staking/status.

Not get_staking_balances/1. That callback answers "every staked position, one per asset"; this names one wallet and reports that wallet's state. Returning it there would answer a narrower question while looking like the wider one, which is why the callback stays declared absent on this venue.

unstake_portfolio(credentials, portfolio_id, asset, amount, opts)

@spec unstake_portfolio(credentials(), String.t(), String.t(), Decimal.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Redeems amount of a staked asset across a portfolioPOST /portfolios/{pid}/staking/unstake.

Returns before the redemption completes. The asset unbonds on the chain's schedule; unstake_status/4 is what reports progress, and a caller treating this return value as settled will spend an asset it does not have yet.

unstake_status(credentials, portfolio_id, wallet_id, opts)

@spec unstake_status(credentials(), String.t(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

How far a wallet's redemption has got — GET /portfolios/{pid}/wallets/{wid}/staking/unstake/status.

This is the endpoint that says a redemption is not finished. Unstaking is a process, not an event: the asset unbonds over days and arrives in parts. A consumer that never reads this will report a redemption as complete the moment it was accepted.

unstake_wallet(credentials, portfolio_id, wallet_id, asset, amount, opts)

@spec unstake_wallet(
  credentials(),
  String.t(),
  String.t(),
  String.t(),
  Decimal.t(),
  keyword()
) :: {:ok, map()} | {:error, term()} | {:refused, term()}

Redeems amount of a staked asset from one walletPOST /portfolios/{pid}/wallets/{wid}/staking/unstake.

preview_unstake_wallet/6 answers what this would do without doing it.