Skip to content

LP integration — Withdraw flow

Withdrawals require an Ed25519 signature over a network-domain-tagged payload. Every withdrawal is signature-verified server-side before it executes; the balance/nonce check runs and then USDC is moved from the vault. Withdrawals confirm automatically — no claim step.

Fogo-only destination

Withdrawals go to a Fogo wallet (USDC.s) today — set chain: "fogo" and a base58 Fogo recipient. The signed-message layout below still carries a chain byte (solana/ethereum/polygon are part of the wire contract for forward-compat), but cross-chain withdraw destinations are not currently offered — only fogo is supported.

Sign the tagged bytes

You MUST sign the tagged bytes below — a bare 81-byte (untagged) signature fails verification with 403 invalid_signature.

Why the tag

A leading ASCII network tag is mixed into every cross-chain signed message (withdraw + LP delegation). It binds the signature to one network/env, so a signature minted on testnet/staging can never be replayed on mainnet/prod (and vice-versa).

Env Tag (ASCII, 18 bytes)
testnet / staging parti-fogo-testnet
mainnet / production parti-fogo-mainnet

Production uses Fogo mainnet; non-production uses testnet. Production (oracle-api.parti.com) is mainnet, so sign against the mainnet tag.

Canonical tagged 99-byte message

The tag is prepended to the 81-byte body (18 + 81 = 99 bytes):

Offset Length Field Encoding
[0..18) 18 network tag ASCII parti-fogo-{testnet\|mainnet}
[18..50) 32 user pubkey base58-decoded → 32 raw bytes
[50..82) 32 recipient pubkey base58 (sol/fogo) → 32 bytes; EVM 0x… is hex-decoded and left-padded to 32
[82..90) 8 amount u64 little-endian, micro-USDC
[90] 1 chain id u8: solana=0, fogo=1, ethereum=2, polygon=3 (only fogo=1 is currently supported)
[91..99) 8 nonce u64 little-endian

This is the exact canonical withdraw message. Both user and recipient MUST decode to exactly 32 bytes.

const TAG = isMainnet ? 'parti-fogo-mainnet' : 'parti-fogo-testnet';
const tag = new TextEncoder().encode(TAG);          // 18 bytes
const buf = new Uint8Array(tag.length + 81);        // 99
let o = 0;
buf.set(tag, o); o += tag.length;
buf.set(base58ToBytes(user), o); o += 32;
buf.set(base58ToBytes(recipient), o); o += 32;       // or leftPad32(hexToBytes(recip.slice(2))) for EVM
buf.set(u64LE(amountMicro), o); o += 8;              // setBigUint64(..., true)
buf[o] = CHAIN_IDS[chain]; o += 1;                   // 0/1/2/3
buf.set(u64LE(nonce), o);

Sign buf with your Solana wallet's Ed25519 key. Signature must be exactly 64 bytes, sent as hex.

Submit — POST /v1/withdraw-signed

Checks run in order: trading-pause guard → session → per-IP rate limit.

{
  "user": "<base58 pubkey, must equal session user>",
  "amount": "25000000",
  "chain": "fogo",
  "recipient": "<base58 Fogo address>",
  "nonce": 1750700001,
  "signature": "<64-byte hex Ed25519>"
}

amount goes over the wire as an integer string of micro-USDC to avoid float drift. The FE computes amountMicro = round(amountUsdc * 1_000_000).

Nonce rules

  • nonce is an integer chosen by the client. Use a monotonically increasing value. The FE strategy (nextWithdrawNonce) is next = max(prevStored, Date.now_ms) + 1, persisted per-user in localStorage.
  • The API enforces freshness/replay protection. Reusing or regressing a nonce is rejected (surfaced as 422 engine_rejected).

Validation order & status

  1. Schema floor → 400 invalid_shape / 400 invalid_json.
  2. Session user must equal body user403 session_user_mismatch.
  3. amount must parse to a positive bigint → 400 invalid_amount.
  4. Signature: must be 64 bytes (400 invalid_signature_format), must verify (403 invalid_signature).
  5. Forward to the matching service: unavailable → 503 engine_binding_disabled; unreachable → 502 engine_unreachable; rejected (balance/nonce) → 422 { "error": "engine_rejected", "detail": "<≤200 chars>" }.

Success: the response body is passed through. On success it carries a tx_signature (and/or ok: true) — your confirmation the vault moved funds.