Open a basket position — one call on a managed wallet, or client-signed transactions (BYOW).
How you open depends on who controls the Solana wallet. The managed flow is the
recommended default: Cesto signs server-side and you make one call. The BYOW flow is
for self-custody or partner-controlled wallets: Cesto builds the exact unsigned
transactions, the wallet signs them, and Cesto verifies and lands them — Cesto never holds
the user’s key.
Managed (recommended)
BYOW (client-signed)
For Cesto-managed wallets (provisioned via users.create),
there is no signing step at all — one call returns an { executionId }, and the
*AndWait variant polls it to a terminal status:
import { Cesto } from '@cesto/sdk';const cesto = new Cesto({ apiKey: process.env.CESTO_API_KEY }); // write-scoped key, server-side onlyconst result = await cesto.open.startAndWait({ user: user.solanaAddress, // or the externalUserId / EVM wallet you provisioned with product: 'stable-genius', amount: 100_000_000n, // bigint, input-token base units (100 USDC @ 6 decimals)});result.status; // 'COMPLETED' | 'PARTIALLY_COMPLETED' | 'FAILED'result.transactions; // per-leg { nodeId, ok, signature?, error? }
Cesto sponsors gas and ATA rent, so managed wallets need only USDC — no SOL.
Execution results, partial-completion semantics, and error handling are identical to
the BYOW flow described on this page. Full reference, funding, and withdrawal:
Managed Wallets.
The BYOW flow is prepare → sign → submit → poll:
1. prepare your server ─▶ Cesto Cesto builds all unsigned txs → { executionId, transactions, expiresAt }2. sign user's wallet the wallet signs each transaction locally (Phantom / Solflare / keypair)3. submit your server ─▶ Cesto Cesto byte-verifies ownership + integrity, then lands the transactions4. poll your server ─▶ Cesto poll the execution until COMPLETED / PARTIALLY_COMPLETED / FAILED
Opening requires a write-scoped API key (read-only keys get a 403 on write
routes). Cesto sponsors the network gas and account (ATA) rent, so the user’s
wallet needs no SOL — only the basket’s input token (USDC) to invest (plus
the platform fee).
Gas sponsorship is on by default. If a deployment runs without a configured sponsor,
the wallet falls back to paying its own gas and must then hold a little SOL.
There is no separate ownership challenge: the signatures are the ownership proof.
Prepare only needs the wallet address.
Which flow you use depends on your wallet setup — see
Integration Models. Backends that can sign (your own Privy
or a keypair you custody) use the one-call flow; browser EOAs like Phantom /
Solflare use the split flow.
When your backend controls the signing keypair — bots, agents, services you custody —
open.execute runs the whole flow in one call. You supply a signTransactions
callback, so the SDK never touches the private key. The SDK ships a signWithKeypair
helper that builds that callback from your keypair:
import { Cesto, signWithKeypair } from '@cesto/sdk';import { Keypair } from '@solana/web3.js';import bs58 from 'bs58';const cesto = new Cesto({ apiKey: process.env.CESTO_API_KEY }); // write-scoped key// Load the key from a server-side secret — never hardcode it or ship it to a client.const keypair = Keypair.fromSecretKey(bs58.decode(process.env.SOLANA_SECRET_KEY!));const wallet = keypair.publicKey.toBase58();const result = await cesto.open.execute({ wallet, product: 'stable-genius', amount: 100_000_000n, // input base units (e.g. 100 USDC @ 6 decimals) signTransactions: signWithKeypair(keypair), // signs each prepared leg locally});result.status; // 'COMPLETED' | 'PARTIALLY_COMPLETED' | 'FAILED'result.transactions; // per-leg { nodeId, ok, signature?, error? }
signWithKeypair needs @solana/web3.js (an optional peer dependency — install it
alongside @cesto/sdk). Prefer to sign yourself? Pass any
async (transactions) => … callback that returns one { nodeId, signedTransaction }
per prepared leg — only signatures may be added, the message bytes must stay
byte-identical.
In a web app the SDK runs on your backend, but the signatures come from the user’s
wallet in the browser. Use the lower-level methods and hop between the two:
1
Backend: prepare
Cesto builds the unsigned transactions and retains the canonical bytes for ~60
seconds.
Send prepared.transactions to the browser immediately — the set expires ~60s
after prepare. Slippage uses the platform default; there is no slippage parameter.
2
Browser: the user signs
Sign the transactions as-is — any modified byte is rejected at submit.
import { VersionedTransaction } from '@solana/web3.js';const signed = await Promise.all( prepared.transactions.map(async ({ nodeId, transaction }) => { const tx = VersionedTransaction.deserialize(Buffer.from(transaction, 'base64')); const signedTx = await wallet.signTransaction(tx); // wallet-adapter return { nodeId, signedTransaction: Buffer.from(signedTx.serialize()).toString('base64') }; }),);// POST { executionId, transactions: signed } back to your backend
Every prepared transaction, signed by the wallet (base64).
submit is single-use and never retried automatically. If it times out
client-side it may still have been accepted — poll getExecution instead of
re-sending. An expired (~60s), replaced, or already-submitted preparation fails with
a 409; run prepare again.
getExecution fetches the current status with per-transaction outcomes.
waitForExecution polls it until a terminal status and throws a CestoError on
timeout (the execution keeps running server-side). These are shared with the managed
flow — managed executions poll through the same methods.
They are scoped to the issuing API key, which covers everything on this page and
the managed flow. The one exception is
start with consent: those executions belong to the user
rather than your key, so a lookup returns 404 however long you poll — read
positions.getHoldings instead.
Ownership proof = the signatures. Only the wallet holder can produce a valid
signature over the prepared message bytes; prepare takes the address only and creates
nothing durable.
Tamper-proof submit. Cesto keeps the canonical unsigned transactions it built. On
submit, each transaction’s message bytes must be byte-identical — only signatures
may be added. Any changed instruction, account, or fee payer is rejected.
Single-use, short-lived preparations. One submit per prepare, ~60s expiry, and a
new prepare for the same wallet + product replaces the previous one.
Quotes locked at prepare. The swap quote is baked into the prepared transaction;
slippage tolerance protects against drift between prepare and submit.
Keys stay server-side. In the one-call flow the keypair never leaves your
backend — load it from the environment (process.env), never hardcode it, and never
send signedTransactions or key material anywhere but back to Cesto’s submit
endpoint.
Examples on this page move real mainnet funds. Open positions run on Solana mainnet
with real USDC — test with small amounts first.
There is a third case, between the two tabs above: the wallet already belongs to a Cesto
account holder. Cesto can sign for them — but they own the key, so a write-scoped API key
alone must never move their funds. Instead the user approves the action once, by
signing a message, and Cesto opens the position with their Cesto wallet. No per-transaction
signing, no round trip to the browser for each leg.It is the same open.start as the
managed flow, with a consent field added — that field is the whole difference between the
two. A managed user cannot produce a consent signature (Cesto holds the key); an account
holder must.Check first — this path only works for wallets Cesto holds a key for:
const { exists, delegatedSigning } = await cesto.users.lookup({ wallet });// delegatedSigning: true → Cesto can sign for this user
An external wallet (Phantom, Solflare) that has never used Cesto returns false; those
users take the BYOW flow above. See Users.
The SDK derives the canonical params itself and resolves any slug to the exact product
id start will carry, so the approval always matches the action.
2
The user signs the message
An Ed25519 signature over challenge.message, base58-encoded, from
challenge.expectedPubkey. It’s a message — not a transaction — so there is nothing to
land on chain.
const { executionId, status } = await cesto.open.start({ user: userWalletAddress, product: challenge.productId, // the resolved id — skips a second slug lookup amount: 100_000_000n, consent: { challengeToken: challenge.challengeToken, signature },});// status: 'QUEUED' | 'SCHEDULED_FOR_MARKET_OPEN'
4
Track the result
start resolves as soon as the job is queued, not when it has settled. Read the
position back to see the outcome:
const holdings = await cesto.positions.getHoldings({ wallet: userWalletAddress, product: challenge.productId,});// holdings.hasPosition flips true once the open completes
What the user approves is bound to the action and its parameters — this wallet, this
basket, this amount. The signature cannot be replayed against a different amount or basket.
The challenge is short-lived and single-use, so create it when the user is ready to approve
rather than ahead of time.
positions.getExecution / waitForExecution cannot see these executions. They are
recorded against the user rather than the API key that started them, and those methods are
scoped to the issuing key — so polling one returns 404 indefinitely rather than ever
reaching a terminal status. Use getHoldings as above. For the same reason
open.startAndWait rejects when consent is present, instead of polling forever.This applies only to the approval flow. The managed and client-signed flows poll normally.
start is never retried automatically, whatever maxRetries says. The approval is
single-use, so a request that landed server-side but whose response was lost would be
rejected on retry as a replayed nonce — reporting failure for an open that is actually
running. On an ambiguous failure, read positions.getHoldings to see whether the open
landed, rather than re-sending.
Terminal statuses are COMPLETED, PARTIALLY_COMPLETED, and FAILED — identical in both
models.
Partial completion is real. A multi-token open is several independent transactions
with no atomicity across them. If a leg fails, the execution ends PARTIALLY_COMPLETED
and result.transactions shows exactly which legs landed (with signatures) and which
failed. What to do next is your call: retry the open for the remainder, or
close what landed.
To read the resulting position, use
positions.getHoldings — SDK positions
are self-custody and do not appear in positions.list.
Swap-only baskets (one or more tokens). No prediction markets, lending, or perps.
Open during close is gated — opening while a close is in flight for the same
wallet + product is rejected with 403, code FORBIDDEN_OPERATION. Concurrent opens
are otherwise not limited.
Gas sponsored — Cesto pays network gas + ATA rent; the wallet needs only the input
token (USDC), no SOL (unless the deployment runs without a sponsor).