Cross-chain Portfolio Rebalancer
What are we cooking?
An agent that holds addresses on an EVM network, on Sui and on Solana under one account, and rebalances between its legs toward a target allocation. One account, one login, one policy record. The runner below ships with two legs, EVM and Solana; the Sui leg is one defs entry away, and the note in the code says what else changes when you add it.
The part that is genuinely new is not the rebalancing. It is that a single account signs on two different curves: the EVM leg signs with the sqd1 secp256k1 dWallet, and the Sui and Solana legs both sign with the sqd2 ed25519 dWallet, the same key on two networks. All three are evaluated by the same policy engine before any signature is released.
Two dWallets, three addresses, one login. No other recipe on this site demonstrates a single account signing across chains.
Keep yourself safe
- Start on testnet. Base Sepolia (
evm:84532), Sui testnet and Solana devnet. Only go to mainnet once everything works. Note that the USD spend limit does not engage on testnet, because testnet assets have no price. Risk, malicious-address and escalation checks do.- Run your agent in Docker. Agents execute arbitrary code; a container isolates it from your host.
- Set a low daily spend limit. This recipe defaults to $10/day.
- Enable escalation before funding. Set your approval channel before sending anything.
- Don’t store API keys in code. Use
.env, and add.envto.gitignore.- Use a separate account for testing. Do not point this at an account holding real value.
What you’ll need
- Node.js 18+
- Docker (recommended)
- An approval channel configured on your WaaP account
- Base Sepolia ETH from a faucet, Sui testnet SUI, and devnet SOL from
solana airdrop
Setup
npm install -g @human.tech/waap-cli@2.2.0
waap-cli signup
waap-cli policy set --daily-spend-limit 10
waap-cli squid init
waap-cli squid addressessquid init provisions the two dWallets: sqd1 (secp256k1, EVM) and sqd2 (ed25519, Sui and Solana). squid addresses prints the three addresses that belong to the one account.
⚠️ squid init is asynchronous. It returns before the dWallets exist, and squid addresses and squid status --json show no addresses until provisioning completes. Poll either rather than assuming one call is enough. The toolkit’s squidInit() does the polling for you, and its squidAddresses() reads squid status --json and fails closed if nothing is provisioned yet. Re-running init is safe: it reconciles pending operations rather than starting a second key generation.
Chain is a property of each call, passed as --chain. There is no persistent chain setting, so do not reach for chain set.
Three things about reading balances
⚠️ wallet-balance reads the wrong addresses. It resolves the standard t1/t2 addresses, not sqd1/sqd2, so it reports a different account. Use squid addresses plus a raw RPC read.
⚠️ request is EVM-only. A Sui or Solana transaction cannot be looked up by hash from the CLI, so confirmation automates on EVM only. The other two legs need the agent’s own RPC client.
⚠️ Decimals are per chain. SOL and SUI carry 9, not 18. A rebalancer that divides them by 1e18 values a funded leg at zero and reports 100% drift against a leg that is perfectly fine.
The loop
- Read balances on all three legs, from the addresses
squid addressesgives you. - Price all three sides and compute drift from the target allocation.
- If drift exceeds the band, size the corrective transfer.
- Move value from the over-weight leg to the under-weight one.
- Log the decision and the policy outcome: allowed, escalated to you for approval, or denied.
On step 5: what you will actually see is an approval request when the day’s total goes past your limit. A denial is rarer and comes from two places: a terminal finding (critical risk, such as a malicious-flagged recipient), or a request that needs a human factor on an account with no usable 2FA factor. A rebalance between your own addresses on an account with 2FA configured hits neither, so log all three outcomes but expect the ask.
The settlement leg
Moving value between chains is the one part of this that WaaP does not do for you: the account signs, an aggregator routes. This recipe uses LI.FI behind a thin lib/lifi.ts wrapper exposing two functions.
quote({ fromChain, toChain, fromToken, toToken, fromAmount, fromAddress, toAddress })
// → { tool, feeUSD, toAmount, transactionRequest }
settle({ ...same })
// → { quote, srcTxHash, bridge: { destTxHash, status, elapsedMs } }Four things will bite you when you write it:
- LI.FI serves zero routes on any testnet. It lists four EVM testnets, and every quote against them returns
1002 No available quotes. This is not a token-compatibility problem. There is nothing to route. The settlement leg can only be exercised on mainnet. - Ask for the non-EVM chains explicitly. The default
/v1/chainsresponse is EVM-only, which reads as “EVM plus Solana at best”. PassingchainTypes=EVM,SVM,MVM,UTXOreturns Solana and Sui too, so one aggregator covers all three chains this account signs on. transactionRequest.valuecan come back as'0x', not'0x0'and not'0'.BigInt('0x')throwsCannot convert 0x to a BigInt, surfacing as a cryptic mid-settlement crash. Empty, absent and'0x'all mean no native value.- Convert base units to whole units before
--value.squidSendTxpasses--valuestraight to the CLI, which takes whole native units. Handing it a quote’s wei figure asks to send 529000000000000 ETH instead of 0.000529.
⚠️ A DONE status from the aggregator is not proof of delivery. A cross-chain move is two independent events: a source transaction you sign, and a destination delivery you can only observe. Confirm the destination balance on its own RPC.
⚠️ Route economics: bridge fees are fixed, not proportional. The Sui route costs a flat ~$0.90, which is 18% of a $5 transfer and 10.2% of a $9 one, while Base and Solana routes run 0.4% to 0.5%. Bridge once and large, and size the rebalance band so it never triggers on an amount the fee would eat.
The runner
This imports the shared lib/squid.ts helper. See Squid Mode agent toolkit.
// cross-chain-rebalancer/agent.ts
import { CHAINS, decimalsFor, squidAddresses, squidSendTx, nativeBalance, type Chain } from '../lib/squid'
import { settle, NATIVE_TOKEN } from '../lib/lifi'
const TAG = '[cross-chain-rebalancer]'
const POLL_MS = Number(process.env.POLL_INTERVAL_MS ?? '60000')
const MAX_TICKS = Number(process.env.MAX_TICKS ?? '0')
/** How far a leg may drift from its target share before we act, in percentage points. */
const DRIFT_TOLERANCE_PP = Number(process.env.DRIFT_TOLERANCE_PP ?? '5')
/**
* `bridge` moves value between legs through LI.FI; `same-chain` self-sends on one leg and
* proves signing only. Default stays `same-chain` — crossing chains with real money is opt-in,
* never inherited from a default.
*/
const SETTLE_MODE = process.env.SETTLE_MODE ?? 'same-chain'
const log = (level: string, event: string, data?: Record<string, unknown>) =>
console.log(JSON.stringify({ ts: new Date().toISOString(), level, agent: TAG, event, ...data }))
interface Leg {
name: string
chain: Chain
address: string
/** Target share of the portfolio, 0..1. Shares across all legs must sum to 1. */
target: number
balance: bigint
price: number
}
/** Replace with a real oracle read. Kept explicit so the recipe never hides its price source. */
async function priceOf(leg: string): Promise<number> {
const env = process.env[`PRICE_${leg.toUpperCase()}`]
if (!env) throw new Error(`No price source for ${leg} — set PRICE_${leg.toUpperCase()} or wire an oracle`)
return Number(env)
}
async function readLegs(): Promise<Leg[]> {
const a = await squidAddresses()
// Chains come from env so the same runner drives testnet and mainnet. Hardcoding them means a
// mainnet run silently reads empty testnet legs and reports a portfolio worth zero — a failure
// that looks exactly like a funding problem.
const evmChain = (process.env.LEG_EVM_CHAIN as Chain) ?? CHAINS.baseSepolia
const solChain = (process.env.LEG_SOL_CHAIN as Chain) ?? CHAINS.solanaDevnet
const defs = [
{ name: 'evm', chain: evmChain, address: a.evm!, target: Number(process.env.TARGET_EVM ?? '0.5') },
// The Solana and Sui legs share sqd2 — one ed25519 dWallet, two networks. Their balance
// reads go through their own RPCs.
{ name: 'sol', chain: solChain, address: a.sol!, target: Number(process.env.TARGET_SOL ?? '0.5') },
// A Sui leg is one more entry here: `{ name: 'sui', chain: CHAINS.suiTestnet, address: a.sui!, target }`
// with a PRICE_SUI source and targets that still sum to 1. Two things then change below: the
// src/dst pairing in tick() must pick the most over- and under-weight rows rather than "the other
// leg", and LI.FI only routes Sui on mainnet. The verified run at the bottom of this page did
// not exercise a Sui leg.
]
const legs: Leg[] = []
for (const d of defs) {
const balance = await nativeBalance(d.address, d.chain)
legs.push({ ...d, balance, price: await priceOf(d.name) })
}
return legs
}
function drift(legs: Leg[]): { total: number; rows: Array<{ leg: Leg; share: number; delta: number }> } {
// Per-chain decimals, not a flat 1e18.
const values = legs.map((l) => (Number(l.balance) / 10 ** decimalsFor(l.chain)) * l.price)
const total = values.reduce((a, b) => a + b, 0)
const rows = legs.map((l, i) => ({
leg: l,
share: total ? values[i] / total : 0,
delta: (total ? values[i] / total : 0) - l.target,
}))
return { total, rows }
}
async function tick(n: number) {
const legs = await readLegs()
const { total, rows } = drift(legs)
log('info', 'portfolio', {
tick: n, totalUsd: total.toFixed(2),
legs: rows.map((r) => ({ leg: r.leg.name, share: (r.share * 100).toFixed(1) + '%', driftPp: (r.delta * 100).toFixed(1) })),
})
const worst = rows.slice().sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))[0]
if (!worst || Math.abs(worst.delta) * 100 < DRIFT_TOLERANCE_PP) {
log('info', 'within_tolerance', { tick: n, tolerancePp: DRIFT_TOLERANCE_PP })
return
}
// The over-weight leg funds the under-weight one. `worst` is the leg furthest from target:
// positive delta = holding too much, so it is the source; negative = the destination.
const other = rows.find((r) => r.leg.name !== worst.leg.name)!
const [src, dst] = worst.delta > 0 ? [worst, other] : [other, worst]
log('info', 'rebalance_signal', {
tick: n, leg: worst.leg.name, driftPp: (worst.delta * 100).toFixed(1),
from: src.leg.name, to: dst.leg.name, mode: SETTLE_MODE,
})
if (process.env.EXECUTE !== '1') { log('info', 'dry_run', { tick: n }); return }
// A self-send on one leg. Proves signing and nothing else.
if (SETTLE_MODE === 'same-chain') {
const r = await squidSendTx({ to: worst.leg.address, chain: worst.leg.chain, value: process.env.REBALANCE_UNIT ?? '0.0001' })
log('info', 'rebalanced', { tick: n, leg: worst.leg.name, txHash: r.txHash, mode: 'same-chain' })
return
}
// SETTLE_MODE=bridge — value actually crosses chains.
const moveUsd = Math.min(Math.abs(src.delta), Math.abs(dst.delta)) * total
const srcUnits = BigInt(Math.floor((moveUsd / src.leg.price) * 10 ** decimalsFor(src.leg.chain)))
log('info', 'settling', { tick: n, moveUsd: moveUsd.toFixed(2), from: src.leg.chain, to: dst.leg.chain })
const out = await settle({
fromChain: src.leg.chain,
toChain: dst.leg.chain,
fromToken: NATIVE_TOKEN[src.leg.chain],
toToken: NATIVE_TOKEN[dst.leg.chain],
fromAmount: srcUnits.toString(),
fromAddress: src.leg.address,
toAddress: dst.leg.address,
})
log('info', 'rebalanced', {
tick: n, mode: 'bridge', tool: out.quote.tool,
srcTxHash: out.srcTxHash, destTxHash: out.bridge.destTxHash,
feeUsd: out.quote.feeUSD, bridgeStatus: out.bridge.status, elapsedMs: out.bridge.elapsedMs,
note: 'a DONE status is not proof — confirm the destination balance over its own RPC',
})
}
async function main() {
const a = await squidAddresses()
log('info', 'agent_start', { evm: a.evm, sui: a.sui, sol: a.sol, note: 'one account, two dWallets, three addresses' })
let n = 0
for (;;) {
if (MAX_TICKS && n >= MAX_TICKS) break
n++
try { await tick(n) } catch (e) { log('error', 'tick_failed', { tick: n, message: (e as Error).message }) }
await new Promise((r) => setTimeout(r, POLL_MS))
}
log('info', 'agent_stop', { ticks: n })
}
main().catch((e) => { log('error', 'fatal', { message: (e as Error).message }); process.exit(1) })What just happened
Your agent signed on two different curves from one account, secp256k1 for EVM and ed25519 for Sui and Solana, and was never given a signing key. The right to sign lives in a DWalletCap: an object your own Sui address owns, transferable and enforced by the chain. Nothing signs without it.
Before any signature existed, the policy gate simulated the transaction, scored the destination address, and checked it against your limit.
Spend is aggregated by authenticated account over the UTC day, across every chain address on the account. It is one budget, not a counter per address.
Verified on mainnet
Run on @human.tech/waap-cli@2.2.0 across Base and Solana mainnet:
| Detect | +37.6pp drift across two legs of one account |
| Move | $1.30 Base → Solana via Mayan, 61s end to end, fee $0.0239 |
| Source | 0x213ba912…5bc230, block 50647937, status 0x1 |
| Destination | 4VwnoBfa…CWMtk, slot 442854987, err: null |
| Re-read | 0.0pp, within tolerance |
The destination was confirmed on Solana’s own RPC rather than on the aggregator’s status field. Portfolio $3.46 → $3.43; the delta is the bridge fee plus gas.
The Sui leg has been exercised for reading and signing, but not yet as the source or destination of a bridged settlement.
Related
- Squid Mode agent toolkit: the shared
lib/squid.tsthis runner imports - Multi-chain payouts: one budget, three settlement networks
- Scoped trading agent: running under a short-lived Privilege
- Portfolio Rebalancer (Sui): the single-chain version, via Cetus