@soyaradex/sdk
v0.3.0
Published
Agentic DEX SDK for GenLayer: natural-language intent parsing, best-route aggregation, live pool analysis, and consensus-gated settlement (per-order verdicts or consensus-issued mandates) whose authorisation you can verify on-chain.
Maintainers
Readme
@soyaradex/sdk
Build agents that trade on Soyara, an AI-native DEX on GenLayer Bradbury where
every trade settles through AgentExecutor under an authority only GenLayer
consensus can write.
Install
npm install @soyaradex/sdk viemviem is a peer dependency, so install it alongside. Node 18 or newer.
Upgrading from 0.2.x: 0.2.0 targeted the retired 2026-09-07 contract pair (
AgentExecutor 0x0F1E9857...,AgentValidator 0xf47492A9...) and described an attestor rail the executor no longer accepts. 0.3.0 targets the pair in service (AgentExecutor 0x1BCBad3d...,AgentValidator 0xd1D809A1...), adds the mandate rail, andsettleSwapnow takes the result ofvalidate().INTELLIGENT_CONTRACTS.liquidityValidatoris gone: that contract is retired and authorised nothing.
Do you need a server?
Partly, and it is worth being precise, because it decides how you build.
| What | Needs a key? | Needs a server? |
|---|---|---|
| parseIntent: natural language to structured intent | no | no |
| quoteBestRouteMultiHop: live best-route pricing | no | no |
| analyseMarket: pool depth and price integrity | no | no |
| buildProgram / buildMultiHopProgram: settlement calldata | no | no |
| readSettlementPlan: which rail settles, and how fast | no | no |
| verifyBindings: prove the verdict binds your order | no | no |
| readMandate / mandateCoversOrder: what a mandate covers | no | no |
| validate: a GenLayer consensus round | yes, a funded GenLayer account | yes |
| requestMandate: a consensus round that issues a mandate | yes, a funded GenLayer account | yes |
| settleSwap / addLiquidity / removeLiquidity | yes, an authorised agent | yes |
Everything an agent needs to think (understand a request, find the best route,
price it, judge whether the price is sound, build the calldata, and verify that
the authorisation really covers what it is about to do) runs anywhere with a
public RPC and no key at all. Only the steps that sign transactions need a
backend, because those keys must never reach client-side JavaScript. Point
baseUrl at your own deployment of the Soyara API routes.
How a trade is authorised
Not by an agent key. This is what an agent can independently check rather than trust.
Every swap settles through AgentExecutor, under exactly one of two
authorities, and the GenLayer AgentValidator Intelligent Contract is the only
address that can write either (recordVerdict and recordMandate are
onlyValidator, reached over the validator's ghost contract; the owner's key
cannot write one either):
| Rail | Authority | Settled by | When |
|---|---|---|---|
| consensus | a single-use verdict for this order's commitment | executeSwap | when the round finalizes (the appeal window) |
| mandate | a mandate an earlier round issued for this user, pair and direction | executeSwapUnderMandate | one transaction, seconds |
The server chooses the rail when you call validate, before any round is
opened, so one trade never has both. There is no direct rail: an order no
verdict or mandate covers does not settle.
The commitment. AgentExecutor derives it from the whole order:
user · tokenIn · tokenOut · amountIn · minAmountOut · quotedAmountOut
slippageBps · deadline · router · feeBps · feeCollector · routeHash · nonceSettlement re-derives the commitment from the order it is handed and consumes
the matching verdict. Change any field and you get a commitment no verdict
backs, so the transaction reverts with NoConsensusVerdict. Verdicts are single
use (CommitmentAlreadyUsed).
The mandate. Consensus sets a per-trade ceiling, a total budget, a slippage and fee ceiling, the fee collector, the router, an expiry, and the one route it covers: the single-hop V2 pool the validators build themselves. The executor checks every trade against all of it and prices the trade from that pool's live reserves, so the relayer chooses only the size, inside those ceilings. A trade whose best route is somewhere else (V3, multi-hop, native GEN) settles on its own verdict instead, because Soyara always takes the best route.
verifyBindings asks the deployed contract to prove the consensus-rail binding
per trade:
import { verifyBindings } from '@soyaradex/sdk';
const result = await verifyBindings({
order, // verdict.order from validate()
program, // verdict.program: the aggregator route bytes
commitment, // verdict.commitment: what consensus approved
user, // who should receive the output
});
if (!result.bound) throw new Error('This verdict does not cover this order');
for (const c of result.checks) console.log(c.passed ? 'ok' : 'FAIL', c.name, c.detail);The package's test suite proves the property against the live contract: mutate any one of the eleven mutable fields and the commitment changes.
Quick start
import { understand, SoyaraClient, verifyBindings, readSettlementPlan } from '@soyaradex/sdk';
// 1. Understand, price and judge the market. No key needed.
const { intent, quote, analysis, redirect } = await understand('swap 50 USDC to USDT');
if (redirect) return reply(redirect.message); // liquidity goes to the pools app
if (!intent.confident) {
// Never guess: a wrong guess here spends real funds.
return ask(`I need: ${intent.needs.join(', ')}`);
}
if (!analysis.safeToTrade) {
// The quote can be arithmetically perfect and still come off a mispriced pool.
for (const c of analysis.concerns) console.warn(c.severity, c.message);
}
// 2. Validate. The server picks the rail before opening anything.
const soyara = new SoyaraClient({ baseUrl: 'https://your-deployment.example' });
const verdict = await soyara.validate({
action: 'SWAP', user, tokenIn: 'USDC', tokenOut: 'USDT', amountIn: '50', slippageBps: 50,
mandateIds, // optional: ids from requestMandate(); a covering one skips the round
}, {
onProgress: ({ attempt, phase }) => console.log(`round in flight (${phase}) #${attempt}`),
});
if (!verdict.approved) return reply(verdict.reason); // verdict.retryable: run it again
// 3. Check what you are about to rely on, and how long it takes.
const plan = await readSettlementPlan({
commitment: verdict.commitment, mandateId: verdict.mandateId,
order: verdict.order, program: verdict.program,
});
console.log(`settles via ${plan.rail} (~${plan.etaSeconds}s): ${plan.rationale}`);
if (verdict.rail === 'consensus') {
const bindings = await verifyBindings({
order: verdict.order, program: verdict.program, commitment: verdict.commitment, user,
});
if (!bindings.bound) throw new Error('verdict does not bind this order');
}
// 4. Settle on that rail, and only that one.
try {
const receipt = await soyara.settleSwap(verdict);
console.log('settled:', receipt.explorerUrl);
} catch (err) {
if (err.pending) scheduleRetry(); // verdict still in its appeal window
else if (err.mandateUnavailable) revalidateWithout(verdict.mandateId);
else throw err;
}Settling in seconds: mandates
A consensus-rail trade waits for its round to finalize, which on Bradbury is tens of minutes. An agent that trades one pair repeatedly asks consensus once, for a mandate, and settles each later trade in one transaction:
const req = await soyara.requestMandate({
user, tokenIn: USDC, tokenOut: USDT, // ERC-20 addresses, one direction
maxAmountIn: 2n * 10n ** 18n, // per trade, raw units
totalBudgetIn: 20n * 10n ** 18n, // across all trades
maxSlippageBps: 100,
});
// The mandate reaches the executor when its round finalizes. Each status check
// also nudges that round toward finalization.
let s;
do { await sleep(60_000); s = await soyara.mandateStatus(req); } while (!s.live);
// From now on, pass it with each trade. A covered trade opens no round.
const v = await soyara.validate({ ...proposal, mandateIds: [req.mandateId] });
v.rail; // 'mandate'
await soyara.settleSwap(v);readMandate(id) reads a mandate straight from the executor, and
mandateCoversOrder applies the executor's own rules to an order, so an agent
can see why a trade did or did not qualify.
Liquidity
The aggregator routes and settles swaps. Adding or removing liquidity is handled at https://app.soyara.xyz/pools.
understand() recognises a liquidity request and returns a redirect instead
of a quote, so a deposit can never be priced, or settled, as a trade:
const { intent, quote, redirect } = await understand('add liquidity 10 USDC and USDT');
if (redirect) return reply(redirect.message); // quote is null hereSoyaraClient.addLiquidity / removeLiquidity settle V2 deposits and
withdrawals under the same gate: the server opens a validate_liquidity_v2_add
or validate_liquidity_v2_remove round for the exact operation, and
executeAddLiquidityV2 / executeRemoveLiquidityV2 consume its verdict. There
is no V3 liquidity path: the AgentValidator has no V3 liquidity validator, so
no verdict for a V3 mint or burn can exist, and the executor's V3 entry points
revert. Use normaliseAction() if you route by action name: an unrecognised
value resolves to 'UNKNOWN' and goes nowhere, never to 'SWAP'.
The parser asks rather than guesses
An under-specified request returns confident: false and a needs list. This is
deliberate: an earlier version defaulted the missing side of a trade, and
"add 10 usdt and usdc" was read as a swap that really did sell the user's USDT.
parseIntent('swap 50 USDC to USDT') // → SWAP, confident
parseIntent('add 10 usd and usdt liquidity') // → ADD_LIQUIDITY (usd → USDC)
parseIntent('remove 50% liquidity from usdc usdt pool') // → REMOVE_LIQUIDITY, percent 50
parseIntent('wrap 5 gen') // → WRAP
parseIntent('swap 34 udc to usdt') // → needs: which token to swap from
parseIntent('add 10 usdc to usdt') // → needs: SWAP or ADD LIQUIDITY?Swaps always take the best route
quoteBestRouteMultiHop prices direct and two-hop paths and returns whichever
fills best. Venue is an outcome, never an input: pinning V2 or V3 can only match
or worsen the fill.
// WBTC/USDT has no direct pool; routing through WGEN makes it tradeable.
const q = await quoteBestRouteMultiHop(WBTC, USDT, amountInWei, 'best');
q.isMultiHop // true
q.hops // [{pool, poolType, tokenIn, tokenOut}, ...]: feed to buildMultiHopProgramPass q.hops to buildMultiHopProgram so settlement executes exactly the path
that was quoted and validated, rather than re-deriving a possibly different one.
What "consensus-gated" actually means
validatesubmits a write to theAgentValidatorIntelligent Contract (validate_swap). Validators are selected by VRF, each independently re-executes the proposal against live pools, then they commit and reveal. This takes tens of seconds; that is the design, not a bug.- The verdict is recorded in contract state (read back with
get_validation) and, when the round finalizes, the Intelligent Contract delivers it toAgentExecutoritself.recordVerdictisonlyValidator: no key an operator holds can write one, the owner's included. - Settlement re-derives the commitment from the exact order being settled
(route, fee, fee collector, recipient, quote, deadline, nonce) and consumes
the matching verdict. Change any field and it reverts with
NoConsensusVerdict; a second attempt reverts withCommitmentAlreadyUsed. - Repeated trades in one direction can instead settle under a mandate an
earlier round issued (
issue_trading_mandate). The executor checks each trade against it (size, budget, fee, route) and prices it from the pool itself.
A slow or undecided round is a network condition, not a rejection. validate
reports { approved: false, retryable: true } and never throws for it.
Keeping the SDK in step with the app
parseIntent, dexQuote, programBuilder and mandateCoverage are the app's
own implementations, copied rather than rewritten. npm run sync copies them
from the app; npm run sync:check fails when they differ.
Notes
- Every deployed test token uses 18 decimals, including USDC/USDT/WBTC. They are testnet mocks, so the usual 6/8-decimal assumptions do not hold.
- Pools are shallow. Check
quote.priceImpactPctbefore executing size. GENis native; pools holdWGEN.understand()wraps automatically.
Licence
MIT
