Integrating Shield Swap
Shield Swap is the concentrated-liquidity AMM on Aleo, deployed as
shield_swap_v3.aleo. Trading on it differs from trading on an EVM DEX in
one structural way: swaps are private, so a swap is two transactions rather
than one. The first submits the request; when it finalizes, the chain
computes the actual output and stores it in a mapping under a swap id. The
second transaction claims that output, which lands in the trader's account
as private records — Aleo's encrypted spendable notes, analogous to UTXOs.
Until the claim runs, the output is not spendable.
@provablehq/shield-swap-sdk wraps this lifecycle in typed methods on a
Veil client and handles the parts that are easy to get wrong: deriving the
single-use blinded identity a private swap is claimed with, selecting the
records a swap spends, and sequencing the two-phase claim. This guide walks
the full integration with a local private key — the setup for a bot, script,
or server — and notes where the connected-wallet path differs. Each step
links to the corresponding /api/shield-swap
reference page for complete parameters, return shapes, and errors.
Set up a client
Chain reads need only a transport. Writes need three more things: an account that can sign, a proving configuration (delegated below, or fully local), and a record scanner so the client can find the unspent private records that swaps and mints spend.
createAleoClient wires all of that
in one call — it derives the account from the private key, builds the
transport, and constructs the proving config internally. Extending the
result with shieldSwapActions adds the DEX methods.
import { loadNetwork } from '@provablehq/veil-aleo-sdk'
import { shieldSwapActions } from '@provablehq/shield-swap-sdk'
const aleo = await loadNetwork('testnet')
const scanner = aleo.createRemoteScanner({
url: 'https://api.provable.com/scanner',
consumerId: CONSUMER_ID,
apiKey: DPS_API_KEY,
})
const { walletClient } = aleo.createAleoClient({
privateKey: PRIVATE_KEY,
networkUrl: 'https://api.provable.com/v2',
provingMode: 'delegated',
proverUrl: 'https://api.provable.com/prove/testnet',
apiKey: DPS_API_KEY,
consumerId: CONSUMER_ID,
records: scanner,
})
const client = walletClient.extend(
shieldSwapActions({ api: { baseUrl: 'https://amm-api.dev.provable.com' } }),
)
Delegated proving and the hosted scanner authenticate with a consumer id and
API key issued by the Provable API; registration is a one-time step against
the Provable API's registration and JWT-issuance endpoints. See
createRemoteScanner for the
scanner's registration behavior.
The composed client has two surfaces, split by provenance. Chain-direct
reads and writes sit flat on the client (client.getPool, client.swap) —
these values come from the node and gate money movement. The off-chain DEX
API is namespaced under client.api (client.api.getPools), so a call site
always shows which world a value came from. Both default to
shield_swap_v3.aleo and the Provable API; override either with
shieldSwapActions({ program, api: { baseUrl } }).
In a browser dApp, the connected wallet replaces all of this configuration:
it holds the keys and records and proves on the user's behalf, so the app
carries no private key, proving config, or scanner. Build the client from
the wallet adapter's account and transport instead — the wallet-account
examples on swap show the connect-time setup,
including the SHIELD_SWAP_ALGORITHM_GRANTS the wallet needs to derive
blinded identities.
Pools and tokens
Pool discovery goes through the DEX API. Each entry carries the pool key — an Aleo field literal that every read and swap takes — plus metadata for both tokens.
const { data: pools } = await client.api.getPools()
const pool = pools[0]
pool.key // '4719…field'
pool.token0 // token id, a field literal
pool.token0_info.decimals
pool.token0_info.wrapper_program // e.g. 'ethx_5a095e.aleo'
The wrapper_program matters: private token balances live as records inside
each token's wrapper program, so the swap and mint calls need to know which
program to look in.
On-chain state comes in two parts. getPool
returns static configuration — token pair, fee tier, decimal scales — and
getSlot returns live trading state: the
current sqrt price, active tick, and in-range liquidity.
const config = await client.getPool({ poolKey: pool.key })
const slot = await client.getSlot({ poolKey: pool.key })
Program imports
shield_swap calls token programs through a dynamic dispatch interface, so
the prover cannot discover them by static analysis. Every write takes an
imports map of program id to program source for the tokens involved. Fetch
the sources once and reuse them across calls:
import { getProgram } from '@provablehq/veil-core'
const imports = {
[pool.token0_info.wrapper_program]: await getProgram(walletClient, { programId: pool.token0_info.wrapper_program }),
[pool.token1_info.wrapper_program]: await getProgram(walletClient, { programId: pool.token1_info.wrapper_program }),
}
Quote, then swap
Quote the trade first — the quote feeds the on-chain slippage check: the
swap reverts if the output falls more than slippageBps below
expectedOut. Omitting expectedOut falls back to a spot-price estimate,
which ignores fees and price impact, so pass a real quote for anything
beyond a tiny trade.
The API's route estimate is a display decimal in the output token's units;
expectedOut wants raw base units (u128), so scale by the token's decimals:
const amountIn = 1_000_000n
const route = await client.api.getRoute({
token_in: pool.token0,
token_out: pool.token1,
amount_in: amountIn,
})
const expectedOut = BigInt(
Math.floor(Number(route.data.estimated_amount_out ?? 0) * 10 ** pool.token1_info.decimals),
)
swap submits the request — phase one. On the
local-signer path the client auto-selects an unspent record covering
amountIn from the token's wrapper program and derives the single-use claim
identity from the account's view key. It returns a SwapHandle, a plain
serializable object that is the key to claiming the output. Persist it if
there is any chance the process dies before the claim.
const handle = await client.swap({
poolKey: pool.key,
tokenInId: pool.token0,
amountIn, // raw atomic amount, bigint
expectedOut, // scaled to base units above
slippageBps: 50, // 0.5%
tokenInProgram: pool.token0_info.wrapper_program,
imports,
})
A wallet never exposes its records, so the wallet path drops
tokenInProgram and passes tokenRecord as a record InputRequest
instead — the wallet resolves it against its own records and fills the
blinding slots itself. The handle then comes back without swapId or
blindedAddress; the wallet-account section of
swap shows the exact shape.
Claim the output
Claiming — phase two — reads the chain-computed output from the
swap_outputs mapping and collects it as private records.
claimSwapOutput throws
SwapOutputNotFinalizedError when the request transaction has not finalized
yet; retry after a few blocks. The same error after a successful claim means
the output was already collected — claiming consumes the on-chain entry.
import { SwapOutputNotFinalizedError } from '@provablehq/shield-swap-sdk'
let claimed
for (let i = 0; i < 20; i++) {
try {
claimed = await client.claimSwapOutput({ handle, imports })
break
} catch (err) {
if (!(err instanceof SwapOutputNotFinalizedError)) throw err
await new Promise((r) => setTimeout(r, 3_000))
}
}
console.log(`received ${claimed!.amountOut}, refunded ${claimed!.amountRemaining}`)
Claims are mandatory: a swap's tokens are not the trader's until claimed.
amountRemaining is non-zero when the swap partially filled at the price
limit — the unspent input comes back in the same claim. To inspect the
chain-computed result without claiming, read it directly with
getSwapOutput.
On the wallet path the handle needs swapId and blindedAddress set before
claiming — recover them from the confirmed request transaction (the swap id
is the transition's first public output; the blinded address is also
readable from api.getSwap(...).recipient). The wallet re-derives the
blinding factor from the blinded address, so the dApp never holds it. See
claimSwapOutput for the full recovery
flow.
Liquidity
Positions are concentrated-liquidity ranges, held as private records.
createPool opens a pair at a fee tier —
a single public transaction, identical on both signer paths.
mint opens a position over a tick range; ticks
round to the pool's tick spacing automatically, and the range is fixed at
mint. Mint and increaseLiquidity
both spend token records, so they split by signer the same way swap does:
a local key auto-selects records, a wallet supplies them as record
InputRequests.
const slot = await client.getSlot({ poolKey: pool.key })
const { positionTokenId } = await client.mint({
poolKey: pool.key,
tickLower: slot.tick - slot.tick_spacing * 10,
tickUpper: slot.tick + slot.tick_spacing * 10,
amount0Desired: 10n ** 18n,
amount1Desired: 2_000_000n,
token0Program: pool.token0_info.wrapper_program,
token1Program: pool.token1_info.wrapper_program,
imports,
})
Unwinding a position runs in three steps:
decreaseLiquidity burns liquidity
and accrues the withdrawn amounts as owed;
collect sweeps owed tokens back as private
records; burn closes the emptied position. Each
mirrors the funding entry points' local-versus-wallet split.
Read-only access
Pool state and DEX API queries need neither a key nor proving nor a scanner
— a PublicClient over a transport is enough. This is the setup for price
feeds, dashboards, and any consumer that only reads.
import { createPublicClient, http } from '@provablehq/veil-core'
import { shieldSwapActions } from '@provablehq/shield-swap-sdk'
const client = createPublicClient({
transport: http('https://api.provable.com/v2', { network: 'testnet' }),
}).extend(shieldSwapActions({ api: { baseUrl: 'https://amm-api.dev.provable.com' } }))
const pool = await client.getPool({ poolKey }) // static config: token pair, fee, decimals
const slot = await client.getSlot({ poolKey }) // live state: price, tick, liquidity
Balances
Private balances are not an account field on chain — they are the sum of the caller's unspent records, which only the caller's view key can decrypt. The SDK offers three views:
// Private — summed from the caller's unspent records (spendable privately).
await client.getPrivateBalances({ programs: ['ethx_5a095e.aleo'] })
// { 'ethx_5a095e.aleo': 3000000000000000000n }
// Public — the API's public/authorized balances for any address.
await client.api.getPublicBalances({ user: address })
// Combined — public + private + total per token, keyed by token id.
await client.getBalances()
// { '1223…045field': { symbol: 'ETHx', decimals: 18, public: 5n, private: 3n, total: 8n }, … }
getBalances composes the other two: it pulls the token registry from the
API (so no program list is needed), reads public balances, sums the caller's
private records, and joins them per token. It defaults to the client's own
account address and, unless given a tokens filter, returns only tokens the
account actually holds.
Units and formats
- Token amounts are raw atomic units, typed
bigint(u128). Ticks and fees fit innumber. - Fees are in pips (
3000= 0.30%). Slippage is in basis points (50= 0.5%). - Pool keys and token ids are Aleo field literals including the suffix,
e.g.
'4719…field'. - Fields read from chain keep their wire names (
amount_out,tick_spacing).
The /api/shield-swap reference pages document
every action's parameters, return shape, and errors, with both signer paths
per action; the package page covers installation
and the agent/MCP subpaths.