The agent owner is sovereign. Withdraws bypass the executive delegation entirely — the owner signs one mpl-core::Execute(...) that CPI-signs the transfer on behalf of the agent’s Asset Signer PDA. v0.2 ships the SPL stable path in @leash/registry-utils; v0.2.1 adds the native-SOL path so creator fees from a Genesis token launch can be drained the same way.

What withdrawing actually does

The agent treasury is owned on-chain by the Asset Signer PDA derived from the agent’s Core asset. Only the asset’s owner field can authorise an mpl-core::Execute instruction, and Execute is the only path that lets a CPI signer act on behalf of the PDA. Concretely, every withdraw is one transaction with a single instruction:
mpl-core::Execute(
  asset = <agent Core asset>,
  authority = owner wallet (signs),
  instructions = [
    SPL Token: TransferChecked(
      source      = <treasury USDC ATA>,
      mint        = <USDC mint>,
      destination = <destination wallet's USDC ATA>,
      authority   = <treasury PDA>   // CPI-signed by mpl-core
      amount, decimals
    )
  ]
)
TransferChecked (discriminator 12) is preferred over plain Transfer because Token-2022 mints with a transfer-fee extension reject the legacy opcode. Same wire-shape works for both classic SPL Token and Token-2022 mints, so a USDC withdraw today and a future Token-2022 stable withdraw share one code path. If the destination wallet doesn’t have an ATA for the mint yet, the helper bundles a CreateIdempotent in front (the executive pays the ~2k lamport rent). That means “send to a fresh wallet” Just Works — no “create your ATA first” round-trip.

SDK usage

import { withdrawTreasury, withdrawTreasuryAll } from '@leash/registry-utils';

// Specific amount (atomic units — USDC has 6 decimals)
const res = await withdrawTreasury(umi, {
  agentAsset: 'CoreAss…Asset',
  mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', // USDC devnet
  destination: ownerWallet.address,
  amount: 5_000_000n, // $5 USDC
});

// Drain the whole balance (returns null if treasury is empty — no tx broadcast)
const drained = await withdrawTreasuryAll(umi, {
  agentAsset: asset,
  mint: USDC_DEVNET,
  destination: rescueWallet,
});
The signer comes from umi.identity by default. In the playground that’s the connected Privy embedded wallet — pass authority / payer overrides explicitly if you need them split.

Returned shape

{
  signature: string; // base58 tx sig — link out via Solscan
  treasury: string; // Asset Signer PDA
  sourceTokenAccount: string; // treasury USDC ATA (debited)
  destinationTokenAccount: string; // destination USDC ATA (credited)
  amount: bigint; // atomic units that moved
  destination: string; // destination wallet (not its ATA)
}

Reading the balance first (UI gating)

import { getTreasuryBalance } from '@leash/registry-utils';

const balance = await getTreasuryBalance(umi, {
  agentAsset: asset,
  mint: USDC_DEVNET,
});
// e.g. 12_345_000n -> "12.345 USDC available"

Withdrawing native SOL (Genesis creator fees)

Agent token launches via Metaplex Genesis route creator fees as raw lamports to the same Asset Signer PDA that holds the treasury’s SPL stables. To withdraw those lamports we need a path that doesn’t rely on SPL — withdrawTreasurySol{,All} wraps a hand-rolled SystemProgram.Transfer in mpl-core::Execute so the PDA can sign via CPI exactly the way the SPL flow does.
import {
  withdrawTreasurySol,
  withdrawTreasurySolAll,
  getTreasurySolBalance,
} from '@leash/registry-utils';

// Specific lamport amount
await withdrawTreasurySol(umi, {
  agentAsset: 'CoreAss…Asset',
  destination: ownerWallet.address,
  lamports: 1_000_000n, // 0.001 SOL
});

// Withdraw all lamports above the safety reserve. Returns null when there's
// nothing to withdraw — safe to wire to a one-click button.
await withdrawTreasurySolAll(umi, {
  agentAsset: asset,
  destination: rescueWallet,
  // Optional — defaults to 5_000 lamports (~0.000005 SOL). Bump it if
  // you want the PDA to keep enough SOL for its own future tx fees.
  reserveLamports: 100_000n,
});

// Read the balance + spendable amount (post-reserve) for UI gating
const sol = await getTreasurySolBalance(umi, { agentAsset: asset });
// {
//   treasury: 'AssetSigner…',
//   lamports: 12_500_000n,
//   sol: 0.0125,
//   spendableLamports: 12_495_000n,
//   spendableSol: 0.012495,
// }
The wire-shape mirrors the SPL flow: one transaction, one signature from the owner, mpl-core CPI-signs the inner instruction. The two helpers are different code paths only because the inner instruction (System.Transfer vs SPL.TransferChecked) differs — every other concern (asset/PDA derivation, fee payer override, authority override) is identical.

Via the Leash HTTP API

Every SDK helper above has a 1:1 HTTP equivalent on api.leash.market — useful when the signer lives somewhere the TypeScript SDK can’t reach (Python service, Privy embedded wallet, TEE, hardware key behind a bastion). The API never sees the secret material; it only builds the unsigned transaction, returns it, and broadcasts the signed bytes you hand back.
# 1. Prepare — the API derives the treasury PDA + destination ATA, encodes
#    `mpl-core::Execute(SPL.TransferChecked)`, and persists a `prepared`
#    event row your code can later join to the on-chain receipt.
PREP=$(curl -sX POST https://api.leash.market/v1/agents/$MINT/treasury/withdraw/prepare \
  -H "Authorization: Bearer $LSH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "payer":           "FFvP…owner",
    "authority":       "FFvP…owner",
    "spl_mint":        "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7",
    "destination":     "FFvP…owner",
    "amount":          "99000000",
    "token_program":   "token-2022",
    "decimals":        6
  }')

EVENT_ID=$(echo "$PREP" | jq -r '.event_id')
TX=$(echo "$PREP"       | jq -r '.transaction.base64')

# 2. Sign locally with the asset owner's key.
SIGNED=$(node ./sign.mjs "$TX")

# 3. Broadcast.
curl -sX POST https://api.leash.market/v1/submit \
  -H "Authorization: Bearer $LSH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"event_id\": \"$EVENT_ID\", \"transaction_base64\": \"$SIGNED\" }"

# 4. Track — phase flips to `confirmed` once the chain agrees.
curl -s https://api.leash.market/v1/events/$EVENT_ID \
  -H "Authorization: Bearer $LSH_TEST_KEY" | jq .
The same lifecycle covers withdraw-all/prepare, withdraw-sol/prepare, and withdraw-sol-all/prepare. The full treasury surface (request fields, response shape, no-op semantics, and the matching agent.treasury.* event kinds) is documented in the API reference at Treasury endpoints. Two reference scripts live at apps/api/scripts/:
  • pnpm --filter @leash/api e2e:devnet — drives the full payment-link flow plus a real agent.treasury.provision round-trip.
  • pnpm --filter @leash/api withdraw — withdraws a configurable amount of USDG from a target agent through the API; defaults exactly match this guide’s 99 USDG example.

The web playground flow

The agent detail page (/agents/<mint>) ships a Withdraw treasury card right under the spend allowance section.
  • Currency dropdown lets the owner pick any stablecoin in KNOWN_STABLE_SYMBOLS (USDC, USDT, USDG) or SOL (native) for Genesis creator fees. The selected token’s mint, decimals, and program (classic SPL Token vs Token-2022 vs native System) are wired through to the SDK automatically — no separate UI for Token-2022 mints, and no separate “withdraw SOL” card. Picking SOL hides the ATA copy in the destination hint (System transfers don’t need one).
  • Destination input defaults to the connected owner wallet. Edit it to any valid Solana address; the UI flips to a yellow “external” badge and requires you to tick a confirmation box before the button unlocks. This is the rescue path for compromised executives — withdraw to a fresh wallet you control before rotating.
  • Amount in the selected token (decimal). The badge below shows the current treasury balance for that token, so “what’s the max I can pull” is a glance away. Switching the currency dropdown updates both the input symbol and the available-balance pill in place.
  • Withdraw all reads the on-chain balance and drains it in one tx. Disabled when the treasury is at zero for the selected token.
Successful txs surface a Tx confirmed card with a Solscan link. Treasury balance and remaining-allowance tiles refresh automatically.

Withdraw vs revoke

These solve different problems and you usually want both:
ActionWhat it doesWhen to use
revokeSpendDelegationDrops the executive’s SPL delegation. Funds stay on the treasury PDA.Lock down spending without moving money.
withdrawTreasuryMoves stablecoins out of the treasury to a destination wallet.Agent retired; you want the SPL stables back.
withdrawTreasurySolMoves native SOL out of the treasury to a destination wallet.Drain Genesis creator fees / reclaim rent.
Belt-and-braces during incident response: revoke first (instant, zero funds at risk if the executive key was leaked), then withdraw to a clean wallet.

What’s not covered yet

  • Closing empty ATAs. The treasury holds onto its empty ATAs after a full withdraw (~2k lamports of rent each). Closing requires a separate mpl-core::Execute(SPL.CloseAccount) — coming in v0.3.
  • Owner transfer. This guide assumes the asset owner stays the same wallet. Transferring ownership of the Core asset is a separate mpl-core::Transfer flow — see the Metaplex Core docs.