Projects over posts
Back to projects

Designing A Move Router Module For Deterministic Swap Execution

shipped · 2026

A technical case study on Aqueducta's open-source on-chain routing module for Movement L1.

MoveMovementDeFiSmart ContractsRouting

Designing A Move Router Module For Deterministic Swap Execution

The on-chain routing module is the part of Aqueducta I most want people to be able to inspect directly.

The reason is simple: execution code should be inspectable. If a quote API hands a wallet a payload and says “this is the route,” the code that decodes, validates, and executes that payload should not be a black box. The Move module is where off-chain route selection becomes an on-chain action, so its design has to be boring in the best possible way: explicit inputs, explicit validation, explicit failure modes, and stable hashing.

This article covers the Move router module design. The Rust quote API that produces route payloads is covered separately.

The Boundary Between Quote And Execution

Aqueducta’s backend computes routes off-chain. The Move module does not try to discover liquidity, search paths, or choose the best route. It receives an already-selected exact-in route and enforces the parts that must be true at execution time:

  • the payload is well-formed
  • the chain ID matches
  • the deadline has not passed
  • the hop list is non-empty and bounded
  • hop token flow is internally consistent
  • the route cannot be replayed with the same nonce
  • the final output meets min_amount_out
  • optional partner fees and recipient settlement are explicit
flowchart LR
  Quote["Off-chain quote engine"] --> Payload["BCS route payload"]
  Payload --> Move["Move router module"]
  Move --> Validate["Decode and validate"]
  Validate --> Execute["Execute each hop"]
  Execute --> Settle["Settle recipient + optional fee"]
  Settle --> Event["Emit route result event"]

That boundary keeps the on-chain module small. The chain should verify and execute a route, not reproduce the backend’s search engine.

Payload Shape

The router accepts a BCS-encoded exact-in request. In simplified form:

struct SwapExactInRequestV3 has drop {
    chain_id: u64,
    token_in: vector<u8>,
    token_out: vector<u8>,
    amount_in: u64,
    min_amount_out: u64,
    deadline_unix_secs: u64,
    recipient: address,
    partner_fee_bps: u16,
    partner_fee_recipient: address,
    hops: vector<Hop>,
    nonce: u64,
}

struct Hop has copy, drop, store {
    dex_id: u8,
    pool_addr: address,
    token_in: vector<u8>,
    token_out: vector<u8>,
    fee_hint_bps: u16,
    dex_extra_bcs: vector<u8>,
}

I like this shape because it is direct. There are no hidden globals that decide where the output goes. The recipient is in the request. The partner fee is in the request. Each hop carries the DEX identifier, pool address, token flow, fee hint, and DEX-specific adapter data.

The backend can produce the payload deterministically, and clients can inspect the same fields before submitting the transaction.

Execution Flow

The public entry point is intentionally short:

public entry fun swap_exact_in(account: &signer, request_bcs: vector<u8>) acquires NonceStore {
    let request = decode_swap_exact_in_request_v3(request_bcs);
    let route_hash = route_hash_from_request_v3(&request);
    let recipient = resolve_recipient(account, request.recipient);

    validate_request(&request, timestamp::now_seconds());
    remember_nonce(account, request.nonce);

    let gross_amount_out = execute_path(account, request.amount_in, &request.hops);
    let (partner_fee_amount, net_amount_out) = settle_output(
        account,
        &request.token_out,
        gross_amount_out,
        request.partner_fee_bps,
        request.partner_fee_recipient,
        recipient,
    );
    assert_min_amount_out(net_amount_out, request.min_amount_out, route_hash, request.nonce);

    event::emit(SwapExecuted {
        route_hash,
        amount_in: request.amount_in,
        gross_amount_out,
        net_amount_out,
        partner_fee_amount,
        recipient,
        nonce: request.nonce,
    });
}

The flow is:

sequenceDiagram
  autonumber
  participant User as User transaction
  participant Router as Move router
  participant Nonce as NonceStore
  participant Adapter as DEX adapter
  participant Events as Event stream

  User->>Router: swap_exact_in(request_bcs)
  Router->>Router: decode request
  Router->>Router: compute route_hash
  Router->>Router: validate request
  Router->>Nonce: remember nonce
  loop each hop
    Router->>Adapter: execute swap hop
    Adapter-->>Router: amount_out
  end
  Router->>Router: settle output and partner fee
  Router->>Router: assert net output >= min_amount_out
  Router->>Events: emit SwapExecuted

This is a good place for Move to shine. The module can make invalid states hard to smuggle through because every meaningful check is an assertion before or immediately after the operation it protects.

Route Hashing

The route hash is domain-separated by router version:

public fun route_hash_from_request_v3(request: &SwapExactInRequestV3): vector<u8> {
    let preimage = b"aqueducta_router_v3";
    vector::append(&mut preimage, bcs::to_bytes(request));
    hash::sha3_256(preimage)
}

The versioned domain string matters. A payload for one router version should not accidentally share identity with a payload for a different execution contract. It also gives clients and indexers a stable identifier to connect:

  • the off-chain quote
  • the submitted transaction
  • the emitted execution event
flowchart TD
  Request["SwapExactInRequestV3"] --> BCS["BCS bytes"]
  Domain["aqueducta_router_v3"] --> Preimage["hash preimage"]
  BCS --> Preimage
  Preimage --> Hash["sha3_256 route_hash"]
  Hash --> Quote["quote response"]
  Hash --> Event["SwapExecuted event"]

This is one of those small design choices that pays off later. Debugging execution is much easier when the same route identity appears at every layer.

Nonce Replay Protection

The module stores used nonces per account:

struct NonceStore has key {
    used_nonces: vector<u64>
}

Before execution, the router records the nonce and rejects reuse. The exact nonce is produced off-chain from deterministic route material, but the on-chain rule is simple: this account cannot execute the same nonce twice.

stateDiagram-v2
  [*] --> Received
  Received --> Decode
  Decode --> Validate
  Validate --> Rejected: invalid request
  Validate --> NonceCheck
  NonceCheck --> Rejected: nonce already used
  NonceCheck --> Execute
  Execute --> Rejected: swap failure or min out failure
  Execute --> Settled
  Settled --> [*]

Replay protection is important because route payloads are otherwise portable bytes. If a payload has a valid deadline and enough allowance, the module still needs a local memory of what has already been consumed.

Validation Philosophy

The module validates things that must be true for safe execution:

CheckWhy it exists
chain IDprevents submitting a payload intended for another network
non-zero inputprevents meaningless swaps
non-empty hopsprevents payloads that do not execute anything
bounded hop countlimits complexity and gas exposure
hop token continuityensures hop N output feeds hop N+1 input
supported DEX IDskeeps dispatch explicit
valid pool addressprevents adapter calls to zero or malformed addresses
deadlineavoids executing stale quotes
nonce uniquenessprevents replay
min outputprotects against worse-than-quoted settlement
fee boundskeeps partner fee behavior explicit and capped

This is defensive programming, but not defensive in the vague sense. Each check corresponds to a concrete failure mode.

Adapter Boundary

The router is not a DEX. It dispatches to adapter modules.

flowchart LR
  Router["router_v3"] --> Dispatch{"dex_id"}
  Dispatch --> Yuzu["Yuzu adapter"]
  Dispatch --> Mosaic["Mosaic adapter"]
  Dispatch --> Meridian["Meridian adapter"]
  Dispatch --> WarpGate["WarpGate adapter"]

  Yuzu --> PoolA["DEX pool"]
  Mosaic --> PoolB["DEX pool"]
  Meridian --> PoolC["DEX pool"]
  WarpGate --> PoolD["DEX pool"]

The dex_extra_bcs field is what lets each adapter receive its own shape of extra data without forcing the router core to understand every DEX-specific encoding. The router validates common route invariants; adapters handle venue-specific execution details.

This is the boundary I would expect to evolve as new DEXes come online. The core router should stay boring while adapters absorb integration-specific complexity.

Recipient And Partner Fee Settlement

Earlier router designs often start with “send output back to the signer.” That is enough for a prototype but not enough for real integrations. Wallets, aggregators, and app flows often need an explicit recipient. Some integrations also need an explicit partner fee.

The v3 payload makes both fields part of the signed route intent:

recipient: address,
partner_fee_bps: u16,
partner_fee_recipient: address,

Settlement happens after the route produces gross output:

flowchart TD
  Gross["gross_amount_out"] --> Fee{"partner_fee_bps > 0?"}
  Fee -->|yes| Split["calculate fee and net output"]
  Fee -->|no| Net["net output = gross output"]
  Split --> Partner["transfer fee to partner recipient"]
  Split --> User["transfer net output to recipient"]
  Net --> User

The important part is that partner fees are output-side and visible. The user can compare gross output, net output, and fee amount. The event includes those numbers so post-trade accounting does not have to infer them.

Events As The Execution Receipt

The module emits execution events that include route identity and settlement amounts:

#[event]
struct SwapExecuted has drop, store {
    route_hash: vector<u8>,
    amount_in: u64,
    gross_amount_out: u64,
    net_amount_out: u64,
    partner_fee_amount: u64,
    recipient: address,
    nonce: u64,
}

Events are the bridge back to off-chain systems. They let a portfolio tracker, backend reconciliation job, or block explorer connect an execution result to the route hash the user saw before submitting.

What Belongs On-Chain

The module deliberately does not try to do everything.

It does not:

  • discover pools
  • rank routes
  • split routes
  • perform broad search
  • hide DEX-specific behavior behind a magical universal pool interface

It does:

  • decode a route
  • validate route intent
  • enforce replay protection
  • dispatch exact-in hops
  • settle the output
  • emit an auditable receipt

That boundary is the design. On-chain execution should be minimal, auditable, and strict. Off-chain routing can be richer and faster, but the final transaction still passes through a small verification surface.

Tradeoffs

The module makes a few intentional tradeoffs:

DecisionBenefitCost
exact-in onlysimpler payloads and slippage checksexact-out routing is future work
single routeeasier validation and event semanticsno split-route execution yet
versioned route hashstable identity per router versionclients must track router version
adapter modulescore router stays smalleach DEX needs careful adapter work
explicit partner fee fieldstransparent settlementpayload has more fields

I am comfortable with these tradeoffs because they make the first public execution surface easier to audit. A router can always grow. It is much harder to make an overgrown router feel simple later.

The Part I Like Most

My favorite part of the design is that the route hash connects the whole story.

The backend can say: “I quoted this route.” The client can display: “I am about to submit this route.” The Move module can emit: “I executed this route.” Those are all the same identity.

That makes the system feel less like a pile of services and more like a pipeline with a receipt at the end.

For financial infrastructure, that is the kind of boring I want.