Staking Sui
A worked, self-contained guide to delegating native SUI to a validator through the service. It covers both execution modes — steps-only, where your Sui intermediary already holds the SUI, and bridge-in, where the SUI arrives from another chain and is staked in the same signed request.
The venue
Validator staking
Coin
SUI only
What you get
a StakedSui object owned by your intermediary, earning staking rewards from the next epoch
Yield source
protocol staking rewards
Minimum
1 SUI (1000000000 MIST)
Move call
sui_system::request_add_stake
Coin is taken
by value — the call consumes it
Returns something you must consume
no, it returns nothing
The call takes the coin by value, which is what makes it usable on a bridge-in: the coin it consumes can be the action coin itself, and its only other arguments are a shared object and a plain address, so nothing owned needs naming. That matters because a bridge-in signs its bytes before your deposit lands and therefore may not reference an owned object at all.
"Staking" is literal here. A validator stake is a delegation that is withdrawable but only credits rewards per epoch.
The two execution modes
The steps are nearly identical in both modes. What differs is the endpoint, whether a quote and an API key are involved, and how the staked amount is chosen.
steps-only
bridge-in
Endpoint
POST /api/v1/executions/{wallet}/steps
POST /api/v1/executions/{wallet}
Carries a quote
no
yes
x-api-key header
not required
required
Precondition
the intermediary already holds the SUI
the SUI is bridged in from the origin chain
Staked amount
a concrete u64 you compute
the whole action coin, passed by value
May name an owned object
yes
no — shared objects and {ACTION_COIN} only
Swap type
not applicable
EXACT_INPUT or EXACT_OUTPUT
Lifecycle
create → sign → broadcast
create → sign → deposit → sweep → broadcast
Addresses (Sui mainnet)
0x3 system package, in full
0x0000000000000000000000000000000000000000000000000000000000000003
SuiSystemState — shared, taken by &mut
0x0000000000000000000000000000000000000000000000000000000000000005
Move call
sui_system::request_add_stake
Minimum stake
1000000000 MIST (1 SUI)
The validator argument is the validator's own SUI address, not its staking-pool object id. Read it from the active validator set on the system state object (metadata.sui_address), and re-source it if a validator leaves the set — staking to an address that is no longer active aborts.
The Move signature is:
It is an entry function that returns nothing: the StakedSui it creates is transferred to the intermediary by the call itself, so there is no result for your steps to place anywhere.
Coin type and asset id
SUI (native)
9
0x2::sui::SUI
The shorthand 0x2::sui::SUI is legal in a type argument, but a target package and every objectId must be written in full 64-hex form.
destinationAsset and quote.destinationAsset want the asset id, never a Move coin type — the service resolves the id against its own token list and derives the coin type itself. List the ids from GET /api/v1/supported_tokens, and note which side Sui appears on: with ?flow=inOperation (or no flow at all) Sui tokens are in result.out, because Sui is a bridge destination and never a bridge source. With ?flow=outOperation they are in result.in instead. Native SUI is listed with an empty contractAddress, which means the native coin rather than an unknown token.
The listing is not a capability check — it is the upstream token catalogue filtered by chain, and it says nothing about whether this deployment has Sui destinations enabled. The probe for that is GET /api/v1/executions/{wallet}/intermediary returning a non-null sui; a null there means either not enabled or a derivation failure, and either way you cannot build a Sui execution right now.
Amounts everywhere are atomic: 1 SUI is 1000000000 MIST.
Mode 1 — steps-only (the intermediary already holds the SUI)
Staking the whole action coin
One command, and no sink, because the call consumes the coin by value and returns nothing:
Two things about that mapping are worth stating explicitly, because each one is a 400 if you get it wrong. mutable belongs on the system state, which is taken by &mut; setting mutable without shared is a 400. And nothing is sent for &mut TxContext — the runtime supplies it.
Staking an amount and keeping the rest
Split the amount off the action coin. The split output is consumed by the stake call, but splitCoins only borrows the coin it splits from, so the action coin still needs its trailing sink:
The precise rule behind that last command is worth knowing, because it decides whether the sink is mandatory or merely tidy. What aborts is an unconsumed result; an unconsumed input coin is simply written back to its owner. So when the action coin resolves to coin objects the trailing transfer is a harmless self-transfer, and when it resolves to a credited balance — the case after every bridge-in — the action coin is a result and the transfer is load-bearing. You do not get to choose which: it is decided at build time from what the intermediary happens to hold. Write steps that are correct either way.
A stake below 1 SUI aborts. The staking pool enforces a minimum of exactly one SUI so check the amount before you send it rather than letting the user discover it as a simulation failure. On the whole-coin shape that means the action coin itself must be worth at least 1 SUI after the service's commission has been split off it.
Leave something behind on a steps-only stake. If you stake the whole balance, the intermediary ends up holding zero SUI, and the next steps-only execution over it — including the unstake that gets your stake back — is refused with the intermediary holds no <coinType>: neither a coin object nor an accumulator balance. Staking an amount and leaving the remainder keeps the position reachable.
The create request
type may be omitted — the service infers the family from the destination asset — but an omitted type is recorded as the literal "evm", and every response for that execution then reports "type": "evm" back to you. Always send it explicitly. metadata is free-form and never interpreted. No x-api-key is needed on this endpoint. A TON origin wallet also sends publicKey.
Send "dry": true first if you want the fee before committing. A dry run signs nothing and persists nothing, returns details.networkFee and omits the signing payload entirely. Its echoed steps are your steps back verbatim — note that a real create echoes something different, the re-rendered user block of the signed transaction, in which every pure argument comes back as type: "raw" carrying its encoded bytes.
One Sui-specific note: a dry run wants real steps. The action-coin check runs before the preview returns, so an explicit "steps": [] is a 400. Omitting steps altogether is carved out and does return an empty shell, but a preview built that way prices nothing useful — send the steps you intend to execute.
How much can be staked
The service takes its commission on chain, as a split out of the action coin. It is emitted at the first point the action coin is provably funded: one command past the last of your steps that mergeCoins another coin into {ACTION_COIN}, and ahead of every step when none does. Every shape in this guide is the second case — none of them merges into the action coin — so on a steps-only execution the action coin your steps see is already net of the fee, and an amount you split off it must satisfy:
networkFee comes back on both a dry preview and a real create as result.details.networkFee, a decimal string in the action coin's atomic units. There is no separate serviceFee field, and the commission does not appear in the echoed steps.
The whole-coin shape avoids the arithmetic entirely: passing {ACTION_COIN} by value stakes exactly what is left after the carve, whatever that is. With the 1 SUI minimum in play that is also the shape most likely to abort on a thin balance, so check the net figure, not the gross one.
Mode 2 — bridge-in (bridge the SUI in, then stake it)
A bridge-in brings the SUI from another chain and stakes it in one signed request. It is pre-signed at create: the 201 already carries a populated details.messageToSign, so you sign immediately and then send the deposit — there is no deferred-signing round trip to poll for.
The steps
The single request_add_stake command shown above, with {ACTION_COIN} as the coin argument and no sink at all — the call consumes the coin by value, which is the whole reason this venue works on a bridge-in.
Do not carve {MIN_AMOUNT_OUT} off the action coin. By the time your first step runs the commission has already been split off, so the action coin is worth exactly {MIN_AMOUNT_OUT} — splitting that amount out of it yields the whole coin plus a live zero-value remainder, and the transaction aborts on the unconsumed result. Pass the coin by value when you mean "all of it". Reserve {MIN_AMOUNT_OUT} for a Move call's own amount operand, such as a slippage floor — and leave it as the sentinel. Do not resolve it yourself from a preview: a bridge-in is priced by a probe that cannot draw on a delivery which has not landed, so the service reserves only what its own balance of that coin covers and shrinks the substituted figure to fit. The sentinel shrinks with it; a literal cannot, and the create is refused.
Every argument must be shared or {ACTION_COIN}. A bridge-in's bytes are signed before the deposit lands and carry no object version that could be refreshed later, so an owned object's version would go stale before they are broadcast; naming one is a 400 (a pre-signed sui execution may not reference an owned object). The shape above satisfies this: the system state is shared, the validator is a pure address, and the call needs no owned position object.
The test is "is this an object argument without shared: true", not "does it look like an address" — so an {INTERMEDIARY} sentinel sitting in an objectId counts as an owned reference too. {ACTION_COIN} is the only exemption.
There is also nothing to set up on a first stake. The venue needs no position object created up front, so a first-time user's steps are byte-identical to a repeat user's. The one first-time cost is fee-side and handled by the service: a wallet's very first Sui deposit carries an extra allowance in the bridge-in fee.
The create request
type: "sui" must agree with the destination chain here. A type that disagrees with the action chain is a 400 naming the mismatch.
A missing x-api-key is a flat 401 from the middleware, before any validation runs. An invalid one is different: it passes the middleware and is only rejected when the quote is fetched, which happens after your steps have been validated — so a bad key on a bad payload surfaces as the payload's 400 first.
Four fields default if you leave them out: swapType to EXACT_INPUT, slippageTolerance to 100 basis points when zero or absent, deadline to ten minutes out, and version to "1.0".
EXACT_INPUT vs EXACT_OUTPUT
EXACT_INPUT—quote.amountis in the origin coin's atomic units. The user fixes what they spend; the staked amount is whatever the bridge delivers, net of slippage and commission.EXACT_OUTPUT—quote.amountis in the destination (Sui) coin's atomic units. The user fixes the amount that lands on Sui, and the origin side is grossed up to cover it.
Either way the whole-coin shape stakes exactly what arrives, so neither needs the amount wired into a step.
What you should check against the 1 SUI staking threshold is not the amount the user typed. The coin request_add_stake receives is the guaranteed delivery minus the commission — and on a bridge-in response that is exactly what quote.minAmountOut already is. The service nets the commission out of both quote.minAmountOut and quote.amountOut before echoing them, on the dry path and the real one alike, so the check is simply:
Do not subtract details.networkFee from it as well — that charges the fee twice and rejects quotes that would have succeeded. (The gross figure, which the signed transaction reserves, is not exposed; minAmountOut in the response is the spendable half.) The one state to guard is a preview that came back without networkFee: it could not be priced, so its figures are the raw quote with no commission carved out at all. Treat that as no usable quote rather than comparing it against the floor.
On EXACT_INPUT the Sui-side figure is not knowable before the quote comes back, so a preview is the only way to check the floor. On EXACT_OUTPUT you already know it: the figure is the amount you asked for, so the floor check is amount >= 1000000000 on the value the user typed. The service quotes at amount + networkFee and then nets the commission back out before echoing, and an EXACT_OUTPUT quote carries no output-side slippage haircut — the service rejects a quote whose minAmountOut is anything other than its amountOut, because on this swap type the slippage allowance sits on the input side, inside quote.amountIn. So do not discount the figure by slippageBps or by networkFee; neither applies.
Two probes, though, not one. On a real create the commission the quote is grossed up by is measured before the quote, and the commission netted back out is measured again while the transaction is assembled. Both price the same transaction shape, so they normally agree, but the echoed figure is amount plus however much they differ by — it can land just under the amount you asked for. A dry run uses a single probe for both halves, so a preview always shows exactly amount and never reveals that drift. Do not let a request sit exactly on 1000000000: leave a margin, and re-read quote.minAmountOut off the 201 before treating the threshold as cleared.
A preview is a quote, not a commitment. The create runs its own fee probe, and on EXACT_OUTPUT it re-quotes at an amount grossed up by that freshly measured fee — so details.networkFee and quote.minAmountOut can both move between the dry round and the signed transaction. Re-read them off the 201 rather than carrying the preview's figures forward into a floor check or a displayed amount.
One thing to carry into step 3 of the lifecycle below: on EXACT_INPUT the origin deposit is the amount the user entered, but on EXACT_OUTPUT it is quote.amountIn from the create response, verbatim and already atomic.
Lifecycle
Create. The
201carries the quote,details.networkFee, and the signing payload underresult.details.Sign
details.payloadwith your origin wallet, using thesigningStandardthe response names, andPOST /api/v1/executions/{wallet}/submit. The reply is{"status": "SIGNED_PENDING_DEPOSIT"}while the deposit is still outstanding, or{"status": "SIGNING"}if it already settled.Send the deposit to
quote.depositAddresson the origin chain and report it withPOST /api/v1/executions/deposit/submit.Poll
GET /api/v1/executions/{wallet}?id=<executionId>throughCREATED→DEPOSIT_PENDING→DEPOSIT_PROCESSING→OPERATION_PENDING→OPERATION_PROCESSING→SUCCESS.
Two things about a Sui bridge-in specifically. quote.recipient is a derived inbox address belonging to your intermediary, not the intermediary itself — do not label it as the user's address. And there is one extra hop between "deposit settled" and "transaction broadcast", normally seconds, while the service sweeps the delivery into the intermediary's credited balance. The execution can sit at OPERATION_PENDING or OPERATION_PROCESSING for that hop; nothing about it needs handling on your side.
A bridge-in costs materially more than the same steps run steps-only. Its fee includes an allowance for that sweep — a second, service-funded transaction — plus a further allowance on a wallet's very first Sui deposit. Never present the two fees as comparable, and always quote a bridge-in with a bridge-in preview.
A surplus stays behind. The signed transaction reserves exactly the guaranteed minAmountOut, so anything the bridge delivers above that stays credited to the intermediary rather than being staked. It is not lost — a later steps-only execution picks it up as the action coin — but a UI that reports "staked" without accounting for it understates the user's balance.
Rules that apply here
Name the action coin. At least one argument must be
{ "kind": "object", "objectId": "{ACTION_COIN}" }. A pure value carrying the sentinel does not count.Sink the action coin, and sink it last. Something must consume it by value. A
moveCallparameter declaredCoin<T>does, which is why the whole-coin shape needs no extra command;splitCoinsdoes not, which is why the partial-amount shape does. The command that consumes it has to be the last one that mentions it: a PTB value moved by value cannot be named again, so a sink followed by any further reference to{ACTION_COIN}aborts withInvalidValueUsageinstead. That bites hardest where the move is amoveCallCoin<T>parameter, because appending the trailingtransferObjectsthe partial-amount shape ends with is exactly the wrong fix on the whole-coin one. Note that this is the one rule with no create-time field check behind it — it is caught, if at all, by simulation. The fee probe does run your steps on every flow, but on a bridge-in it runs relaxed at a stand-in amount and the assembled transaction itself is never simulated, so treat what it catches as a bonus: a missing sink can still reach the chain and abort there.Consume everything your steps return.
request_add_stakereturns nothing, so the whole-coin shape has nothing to place — but everysplitCoinsoutput is a result, and Sui aborts on an unconsumed result withUnusedValueWithoutDrop, sinceCoin<T>has nodropability. AmakeMoveVecis not a way out of this: it does move its elements, but thevector<T>it produces has nodropeither, so a vector nobody reads aborts the same way and takes the coins inside it down with the transaction.The intermediary must actually hold the SUI on a steps-only create, as coin objects, as a credited balance, or both. If it holds more SUI coin objects than the service will fold into one action coin and no credited balance, the create is refused rather than spending only some of them. That bound is a deployment setting (default 64, allowed range 1–511), not a protocol constant, so treat the exact number as deployment-specific — the rejection names the figure in force:
intermediary holds too many sui action coin objects: 0x… holds more than <N> <coinType> objects and no accumulator balance. It only bites when there is no credited balance to fall back on.One live Sui execution per wallet at a time. Any non-terminal Sui execution blocks the next one, whichever side Sui is on. A second create is
409.Never name the sponsor or the service's own entry points.
0x2::coin::redeem_fundsand everything in0x2::funds_accumulatorare reserved.Do not add gas, a fee transfer, or a budget. The service sponsors the transaction and emits its own commission commands ahead of yours. You never pay SUI for gas even when SUI is the coin you are staking.
Steps are validated and simulated before anything is signed, on every flow, so a step that would revert is normally a descriptive 400 at create rather than a failure on chain. The simulation you will actually hit is the fee probe: it runs first, at a stand-in commission, and fails closed — an abort in your steps is 400 sui transaction simulation reverted: fee probe aborted at command N: …, and a probe that cannot run is a 502 sui fee estimation probe failed: …. Two qualifications: on a steps-only create the assembled transaction is then simulated a second time with the real commission, and only that second call fails open on a transport error; on a bridge-in the assembled transaction is never simulated at all, and the probe below is the only verdict. On a dry preview a failed probe does not 400 either — it answers 200 with networkFee omitted. One exception: a preview still resolves every object your steps name, at its live version, and a step naming an object that does not exist is a 400 on the preview too, since no version of that payload could ever execute. Any other resolution failure fails open and simply leaves networkFee off.
One caveat matters more for a staking flow than for anything else. A bridge-in is priced by simulating the same commands at a smaller amount under a different payer, because the real bytes draw on a credited balance that does not exist until the deposit lands. The stand-in amount is capped by what the service itself holds of that coin for pricing, which can be far below a real delivery. So a step that aborts on a small input — the 1 SUI staking minimum being exactly that — can fail the probe even though the real delivery would have satisfied it. The error says so:
That is a service-side sizing matter rather than a defect in your steps: report it, and prove the array with a steps-only execution against the same coin in the meantime.
That command index is absolute. On a bridge-in the service emits exactly three commands of its own ahead of your step 0 — one redeem_funds materialising the delivered balance, then the commission split and its transfer — so your step 0 is command 3. On a steps-only run it is two to four: nothing for a single coin object or one redeem_funds for a credited balance, optionally one mergeCoins folding the intermediary's other coin objects into it, then the commission pair. Subtract before concluding that a particular step is at fault. The commission pair is only prepended when none of your steps merges a coin into {ACTION_COIN}, which is every shape in this guide; an array that does fund the coin that way sees the pair land one command after that merge instead, displacing every step from there on. Those commands are not in the echoed steps, so this error is the one place they are visible to you.
One abort you never have to decode by hand: when the failing command is the commission split, the guard says so by name rather than by index — sui transaction simulation reverted: the <amount> commission split aborted at command N: … (the action coin does not hold the commission at that point in the transaction).
Reading the position afterwards
A stake is not derivable from an address the way a Solana token account is — it is an object created by whichever transaction happened to mint it, so you look it up on chain against the resolved intermediary from GET /api/v1/executions/{wallet}/intermediary → result.sui.
A validator stake is an owned 0x3::staking_pool::StakedSui, carrying principal, pool_id and stake_activation_epoch. principal excludes accrued rewards — those live in the staking pool and only materialise on withdrawal — so summing principal understates what an unstake returns. Computing the accrued figure means reading the pool's exchange-rate table at the activation epoch and at the current one.
Each stake mints its own object, so a position built up over several executions is spread across several StakedSui objects rather than growing one.
Sign and submit
Identical to every other destination. The create response returns result.details.payload and result.details.signingStandard — the standard is your origin wallet's (erc191 for EVM, raw_ed25519 for Solana, nep413 for NEAR, tip191 for Tron, sep53 for Stellar, ton_connect for TON). Sign that payload and post it:
An ed25519 origin (Solana, NEAR, Stellar) adds publicKey; a TON origin adds both publicKey and a tonConnect envelope. You never handle the Sui transaction bytes and you never pay SUI for gas — the service sponsors the transaction and recovers the cost through the commission.
Note that a signed Sui transaction is valid for the current epoch and the next, roughly one to two days, and is permanently unexecutable after that. Steps-only broadcasts as soon as your signature lands, so it finishes far inside that window; a bridge-in waits for the deposit, and in practice the quote's own deadline expires a late bridge-in first. Treat the quote deadline as the deposit deadline you show the user.
Last updated
