UCS03 - ZKGM
ucs03-zkgm-0
is the most advanced and recommended protocol to use for
- message passing
- transfers (assets and NFTs)
- intents
- storage proofs
- staking and governance
It’s the most gas-efficient version and suitable for almost all use cases.
A groundbreaking protocol improvement on IBC and trust-minimized bridging in general, is that ucs03-zkgm-0
allows arbitrary filling of orders by any party.
For packet submissions and transfers, the protocol allows a different filler from the Union IBC contracts. In the case of an alternative filler, the assets are not minted but transferred from the filler’s account. This allows a transfer to be filled before chains have been finalized or client updates processed. Instead, fillers can rely on preconfirmations to reduce the risk of reorgs.
Theoretically, a filler can submit the transfer to the destination side before the transaction is included on the source, given that they protect themselves against double-spend attacks.
The Acknowledgement
, which may contain arbitrary payloads, is used to encode information on the filler and repay the filler for the service by unlocking assets from the vault.
Open filling is opt-in for any protocol, allowing for the same optimizations that ucs03-zkgm-0
leverages to increase transfer speeds.
ZKGM incorporates rate limiting capabilities through a token bucket mechanism to manage token consumption:
- Each token has a configurable capacity and refill rate
- Transactions are rate-limited based on token amounts
- Rate limiting can be toggled on/off for specific deployment needs
- Administrators can adjust bucket parameters as needed
A fundamental design principle of the ZKGM protocol is that packets are fully constructed off-chain and remain immutable once sent. Unlike traditional cross-chain messaging protocols, ZKGM never injects or modifies fields during on-chain processing—it only verifies the packet’s integrity and processes it as submitted.
This immutability offers several powerful capabilities:
-
Mempool Inspection and Intent Filling: Market makers can observe pending transactions in the mempool and identify ZKGM packets before they’re included in a block. This enables near-instant filling of orders, reducing latency to the theoretical minimum.
-
Deterministic Multi-Chain Execution: The packet’s immutability combined with deterministic salt derivation creates a verifiable and predictable execution chain. When a packet contains a Forward instruction:
- The next packet’s salt is derived deterministically:
deriveForwardSalt(salt) = tintForwardSalt(keccak256(salt))
- This derived salt is used for the next hop’s packet
- Each subsequent forwarded packet’s salt is similarly derived
- The entire chain of packet execution becomes deterministic and verifiable
This determinism allows market makers to:
- Predict and validate the entire multi-chain execution path
- Simulate the outcome across all chains before execution
- Execute packets atomically across chains with confidence
- Verify the authenticity of packets in a chain without requiring on-chain storage
- The next packet’s salt is derived deterministically:
Flash Loans Across Chains: A trader can construct a sequence of packets representing a complex cross-chain arbitrage:
- Packet 1: Borrow assets on Chain A
- Packet 2: Execute swap on Chain B
- Packet 3: Repay loan on Chain A with profit
A market maker can detect these packets in the mempool, validate the profitability, and execute all steps atomically, effectively enabling cross-chain flash loans.
Cross-Chain Limit Orders: A user can create a packet representing a limit order with specific execution parameters. Market makers can monitor the mempool and execute only when market conditions match the user’s requirements, providing decentralized cross-chain limit order capabilities.
Preemptive Bridging: Services can monitor user interactions with dApps and predict upcoming cross-chain transfers. By the time the user initiates the transfer, the assets can already be pre-positioned on the destination chain through intent filling, creating an instant bridging experience.
The zkgm protocol abstracts away multiple facets of IBC assets transfer protocol (ics20). We employ versioning in this protocol to ensure backward compatibility with future upgrades (not relying on the IBC channel upgrade feature). Each instruction has a version and opcode to allow for protocol evolution. Its features include:
- batching
- forward/callback envelopes
- channel multiplexing
- fungible assets transfer
- non-fungible assets transfer
- staking and governance
The zkgm protocol uses two main structures for packet handling:
struct ZkgmPacket { bytes32 salt; // Unique packet identifier uint256 path; // Channel routing information Instruction instruction; // The instruction to execute}
Fields:
-
salt
: A uniquebytes32
identifier used for deterministic packet hashing- For regular packets:
keccak256(abi.encodePacked(sender, user_provided_salt))
- For forwarded packets: Uses a tinting mechanism to track packet chain
- Magic value:
0xC0DE00000000000000000000000000000000000000000000000000000000BABE
- Tinting applied as:
salt | magic_value
(bitwise OR) - previous_salt is the salt of the packet being forwarded
- Next hop salt derived as:
keccak256(previous_salt) | magic_value
- This creates a verifiable chain of salts across hops while preventing salt collisions
- Magic value:
- For batch instructions: Each sub-instruction uses
keccak256(index, batch_salt)
- Where index is the instruction’s position in the batch (0-based)
- And batch_salt is the parent packet’s salt
- For regular packets:
-
path
: Auint256
that tracks packet routing and asset origins- Composed of compressed
uint32
channel IDs in 32-bit segments - Format:
prevDstChannel0 | nextSrcChannel0 << 32 | prevDstChannel1 << 64 ...
- Supports up to 3 hops (256/32/2 - 1), one hop is a placeholder for the final channel ID.
- Updated during:
- Packet forwarding (appending channel pairs)
- Asset origin tracking
- Return path validation
- Composed of compressed
-
instruction
: The Instruction to execute
struct Instruction { uint8 version; // Protocol version uint8 opcode; // Instruction type bytes operand; // Instruction-specific data}
Fields:
-
version
: Protocol version number asuint8
- Required for backward compatibility
- Allows dispatching between different versions
- Each instruction type specifies required version
- Current supported versions:
INSTR_VERSION_0
(0x00)INSTR_VERSION_1
(0x01)INSTR_VERSION_2
(0x02)
-
opcode
: Instruction type identifier asuint8
- 0x00: Forward
- 0x01: Call
- 0x02: Batch
- 0x03: TokenOrder
- 0x04: Stake
- 0x05: Unstake
- 0x06: WithdrawStake
- 0x07: WithdrawRewards
-
operand
: Instruction-specific data asbytes
- Forward: Path, timeouts and instruction to forward
- Call: Sender, callback mode and contract call data
- Batch: Array of instructions to execute atomically
- TokenOrder: Transfer details like tokens, amounts and parties
- Stake/Unstake/WithdrawStake/WithdrawRewards: Staking operation parameters
The forward instruction uses opcode 0x00
and requires version INSTR_VERSION_0
.
struct Forward { uint256 path; // Channel sequence as (prevDst,nextSrc) pairs uint64 timeoutHeight; // Block height timeout uint64 timeoutTimestamp; // Unix timestamp timeout Instruction instruction; // Instruction to forward}
Fields:
-
path
: Auint256
that encodes the forwarding route- Composed of (prevDst,nextSrc) channel ID pairs
- Each pair uses 64 bits (32 bits per channel ID)
- Must match valid channel connections
- Used to verify packet routing path
-
timeoutHeight
: Block height timeout asuint64
- After this height, packet cannot be executed
- Set to 0 to disable height timeout
- Must be greater than current height when executed
-
timeoutTimestamp
: Unix timestamp timeout asuint64
- After this time, packet cannot be executed
- Set to 0 to disable timestamp timeout
- Must be greater than current time when executed
-
instruction
: The Instruction to forward- Can be Call, TokenOrder, or Batch
- Will be executed on final destination chain
- Cannot be another Forward instruction
The call instruction uses opcode 0x01
and requires version INSTR_VERSION_0
.
struct Call { bytes sender; // Source chain sender address (must match msg.sender) bool eureka; // Whether to use IBC-style callbacks bytes contractAddress; // Target contract address on destination bytes contractCalldata; // Call data for the target contract}
Fields:
-
sender
: Source chain sender address asbytes
- Must match transaction sender (msg.sender)
- Prevents address impersonation
- Used for callback routing in eureka mode
-
eureka
: Callback mode flag asbool
- false: Standard fire-and-forget mode
- true: IBC-style callback mode
- Determines target contract interface
- Controls acknowledgement handling
-
contractAddress
: Target contract address asbytes
- Must be valid contract on destination chain
- Must implement required interface based on eureka flag
- Where message will be delivered
-
contractCalldata
: Contract call data asbytes
- Arbitrary data passed to target contract
- Interpreted by target contract’s implementation
- Available in both standard and eureka modes
The multiplexcall instruction has two modes:
-
Standard Mode (eureka = false):
- Target must implement IZkgmable interface
- Calls
onZkgm(path, sourceChannel, destChannel, sender, calldata)
on target - Returns success acknowledgement immediately
- Fire-and-forget style, no callback to sender
-
IBC Mode (eureka = true):
- Calls
onRecvPacket(packet, relayer, relayerMsg)
on target - Packet contains path, sender and calldata
- Target must return non-empty acknowledgement
- Acknowledgement forwarded back to original sender
- Calls
If the target contract is invalid or calls fail:
- Standard mode returns failure acknowledgement
- IBC mode propagates target’s error response
The batch instruction uses opcode 0x02
and requires version INSTR_VERSION_0
.
struct Batch { Instruction[] instructions; // Array of instructions to execute}
Fields:
instructions
: Array of Instructions to execute atomically- Only specific instructions allowed (Call, TokenOrder, Stake, Unstake, WithdrawStake)
- Executed in sequential order
- All must succeed or entire batch reverts
- Individual acknowledgements collected in array
- Minimum 2 instructions required
This allows atomic composition of transfers, contract calls, and staking operations in a single transaction.
The token order instruction has two versions:
- Version 1 (INSTR_VERSION_1) - DEPRECATED:
struct TokenOrderV1 { bytes sender; // Source chain sender address bytes receiver; // Destination chain receiver address bytes baseToken; // Token being sent uint256 baseAmount; // Amount being sent string baseTokenSymbol; // Token symbol for wrapped asset string baseTokenName; // Token name for wrapped asset uint8 baseTokenDecimals; // Token decimals for wrapped asset uint256 baseTokenPath; // Origin path for unwrapping bytes quoteToken; // Token requested in return uint256 quoteAmount; // Minimum amount requested}
- Version 2 (INSTR_VERSION_2):
struct TokenOrderV2 { bytes sender; // Source chain sender address bytes receiver; // Destination chain receiver address bytes baseToken; // Token being sent uint256 baseAmount; // Amount being sent bytes quoteToken; // Token requested in return uint256 quoteAmount; // Minimum amount requested uint8 kind; // Type of metadata (initialize, escrow, unescrow, solve) bytes metadata; // Token metadata or solver metadata based on type}
TokenOrderV2: Advanced Token Mapping and Customization
Section titled “TokenOrderV2: Advanced Token Mapping and Customization”The V2 version introduces a significantly more flexible metadata system with four types:
TOKEN_ORDER_KIND_INITIALIZE
(0x00): Provides full metadata implementation and initializer for custom token deployment
struct TokenMetadata { bytes implementation; // Implementation contract address bytes initializer; // Initialization data for proxy}
TOKEN_ORDER_KIND_ESCROW
(0x01): Uses a metadata image hash for existing token identificationTOKEN_ORDER_KIND_UNESCROW
(0x02): Specifically for unwrapping operationsTOKEN_ORDER_KIND_SOLVE
(0x03): Routes the order to a solver contract for custom fulfillment logic
struct SolverMetadata { bytes solverAddress; // Address of the solver contract bytes metadata; // Additional metadata for the solver}
The key innovation in V2 is the ability to support 1:N token mappings, allowing the same source token to be represented by different implementations on the destination chain. This enables several powerful use cases:
-
Custom Token Implementations: Projects can map a token to their own implementation with specific features or behaviors. For example, a project could map USDC to a custom token that includes additional functionality like rebasing or built-in protocol-specific mechanics.
-
Upgradeability Management: Tokens can be deployed with specific upgradeability patterns chosen by the implementing protocol rather than being fixed to a single implementation pattern.
-
Enhanced Security Controls: Custom implementations can include additional security features like transfer limits, allowlists, or compliance mechanisms tailored to specific regulatory requirements.
This flexibility is achieved by allowing the specification of:
- A custom implementation contract address
- Custom initialization data
- A deterministic salt based on the metadata
Similarly to V1, in V2 the protocol uses CREATE3 to deploy the token contracts at deterministic addresses, ensuring that the same token metadata always results in the same deployed address.
Common Fields (both versions):
-
sender
: Source chain sender address asbytes
- Must be valid address on source chain
- Used for refunds on failure/timeout
-
receiver
: Destination chain receiver address asbytes
- Must be valid address on destination chain
- Where quote tokens will be sent on success
- Must be specified by sender
-
baseToken
: Token being sent asbytes
- Token identifier on source chain
- Used to identify/create wrapped token
- Must exist on source chain
-
baseAmount
: Amount being sent asuint256
- Must be available from sender
- Maximum amount to exchange
-
quoteToken
: Requested token asbytes
- Token identifier on destination chain
- What sender wants in exchange
- Must exist on destination chain
-
quoteAmount
: Minimum amount requested asuint256
- Minimum acceptable exchange amount
- Difference (if less than
baseAmount
) is taken as fee by the relayer
The order can be filled in three ways:
-
Protocol Fill - If the quote token matches the wrapped version of the base token and base amount >= quote amount:
- For new assets: Deploy wrapped token contract and mint quote amount to receiver
- For returning assets: Unwrap base token and transfer quote amount to receiver
- Any difference between baseAmount and quoteAmount is minted/transferred to the relayer as a fee
- Rate limiting may be applied based on configuration
-
Market Maker Fill - Any party can fill the order by providing the quote token:
- Market maker is specified in acknowledgement
- Base token is transferred/minted to market maker
- Market maker must handle quote token transfer on behalf of the protocol
-
Solver Fill - Smart contracts that implement the solver interface can programmatically fill orders:
- Solver addresses are encoded in the order metadata when
kind == TOKEN_ORDER_KIND_SOLVE
(0x03) - The
quoteToken
field specifies what token the solver should provide to the receiver - Solvers receive full context: packet, order, path, caller, relayer, relayerMsg, and intent flag
- Solvers can implement complex logic like multi-hop swaps, arbitrage, or liquidity aggregation
- On solver failure, the protocol returns
ACK_ERR_ONLYMAKER
to allow other market makers to fill - Solvers use the same
FILL_TYPE_MARKETMAKER
acknowledgment type as regular market makers - The solver must return the market maker address as
bytes
to receive the base tokens (typically a preconfigured beneficiary address controlled by the solver, not the relayerMsg) - The address is returned as
bytes
to support different chain address formats (e.g., 20-byte Ethereum addresses, Cosmos bech32 addresses, etc.)
- Solver addresses are encoded in the order metadata when
The acknowledgement includes:
- Fill type (Protocol =
0xB0CAD0
or MarketMaker =0xD1CEC45E
) - Market maker address for MM fills (empty for protocol fills)
If the order fails or times out:
- For new assets: Base token is minted back to sender
- For returning assets: Base token is transferred back to sender
- Outstanding balances are decreased
TokenOrderV2 introduces support for solver contracts - smart contracts that can programmatically fill orders with custom logic. This enables sophisticated market-making strategies beyond simple token transfers.
Solidity Implementation:
interface ISolver { function solve( IBCPacket calldata packet, TokenOrderV2 calldata order, uint256 path, address caller, address relayer, bytes calldata relayerMsg, bool intent ) external returns (bytes memory);
// Reserved for future use - not currently called by the protocol function allowMarketMakers() external returns (bool);}
Solver contracts must:
- Implement the
ISolver
interface - Handle the
solve()
function call with all order context - Return the market maker address (as
bytes
) that will receive the base tokens on the source chain- Must return
bytes
to support different address formats across chains - For example, Ethereum addresses are 20 bytes, but other chains may use different formats
- Must return
CosmWasm Implementation:
pub enum SolverMsg { DoSolve { packet: Packet, order: CwTokenOrderV2, path: Uint256, caller: Addr, relayer: Addr, relayer_msg: Bytes, intent: bool, },}
CosmWasm solvers must:
- Implement the
SolverMsg::DoSolve
execute handler - Perform the necessary token transfers or logic
- Emit a
solver
event withmarket_maker
attribute containing the recipient address
Solver Invocation:
When an order has kind == TOKEN_ORDER_KIND_SOLVE
(0x03):
- Solidity: The protocol decodes
SolverMetadata
from the order’s metadata field to extract the solver address - CosmWasm: The protocol extracts the solver address from the metadata and invokes
SolverMsg::DoSolve
- The solver contract is called with the full order context
- The solver must return the market maker address that will receive the base tokens
- In CosmWasm, the market maker address is communicated via a
solver
event with amarket_maker
attribute
Execution Flow:
When an order has kind == TOKEN_ORDER_KIND_SOLVE
:
- The protocol extracts the solver address from the
SolverMetadata
in the order’s metadata field - The solver’s
solve()
function is called with full context including the path parameter - The solver performs its custom logic and returns the market maker address to receive base tokens
- On success, a market maker acknowledgment is returned with the solver-provided address
- On failure or if no market maker address is provided,
ACK_ERR_ONLYMAKER
is returned, allowing other market makers to attempt filling
Use Cases:
- Multi-hop Swaps: Solvers can execute complex swap routes across multiple DEXs
- Liquidity Aggregation: Combine liquidity from multiple sources for better rates
- Arbitrage: Execute arbitrage opportunities while fulfilling user orders
- Custom Logic: Implement protocol-specific market making strategies
- Cross-chain Coordination: Coordinate actions across multiple chains
Important Implementation Detail:
Solvers typically configure different beneficiary addresses per path/channel/token combination. This allows them to:
- Route base tokens to different vaults or addresses on different chains
- Manage liquidity separately for different trading pairs
- Coordinate with counterparty contracts on the source chain
Example Solver Implementation:
contract MultiDEXSolver is ISolver { // Beneficiary addresses configured per path/channel/token // Using bytes to support addresses from different chains (not just 20-byte addresses) mapping(uint256 => mapping(uint32 => mapping(bytes => bytes))) public beneficiaries;
struct DEXParams { uint256 maxSlippage; address[] routePath; uint256 deadline; }
function solve( IBCPacket calldata packet, TokenOrderV2 calldata order, uint256 path, address caller, address relayer, bytes calldata relayerMsg, bool intent ) external override returns (bytes memory) { require(!intent, "only finalized txs are currently supported");
// Decode the SolverMetadata from the order SolverMetadata memory solverMeta = abi.decode(order.metadata, (SolverMetadata));
// Decode custom parameters from the solver metadata's metadata field DEXParams memory params = abi.decode(solverMeta.metadata, (DEXParams));
// Validate deadline require(block.timestamp <= params.deadline, "Order expired");
// The quoteToken from the order specifies what token to provide address quoteToken = address(bytes20(order.quoteToken)); address receiver = address(bytes20(order.receiver));
// Validate that we support this quote token require(isSupported(quoteToken), "Unsupported quote token");
// Perform the trade to obtain the quote tokens uint256 outputAmount = findBestRouteAndTrade( order.baseToken, order.baseAmount, quoteToken, order.quoteAmount, params );
// Ensure minimum output is met require(outputAmount >= order.quoteAmount, "Insufficient output");
// Transfer the quote tokens to the receiver IERC20(quoteToken).transfer(receiver, order.quoteAmount);
// Keep any excess as profit uint256 profit = outputAmount - order.quoteAmount; if (profit > 0) { IERC20(quoteToken).transfer(relayer, profit); }
// Return the market maker address that should receive the base tokens // This is typically configured per path/channel/token combination bytes memory beneficiary = beneficiaries[path][packet.destinationChannelId][order.baseToken]; require(beneficiary.length > 0, "No beneficiary configured"); return beneficiary; }
function allowMarketMakers() external pure returns (bool) { return false; // This solver handles everything itself }}
Token Order Kinds: Protocol Rules and Solver Communication
Section titled “Token Order Kinds: Protocol Rules and Solver Communication”The kind
field in TokenOrderV2 serves two critical purposes: protocol-level validation and solver communication. Understanding when and how to use each kind is essential for proper protocol operation.
When kind == TOKEN_ORDER_KIND_SOLVE
(0x03), the order is routed to a solver contract instead of being processed by the protocol or regular market makers. This enables sophisticated custom fulfillment logic:
-
Metadata Structure: The order’s
metadata
field must contain an encodedSolverMetadata
struct:solverAddress
: The address of the solver contract to invokemetadata
: Additional arbitrary data for the solver to use
-
Routing: The protocol extracts the solver address from the metadata and calls the solver’s
solve()
function -
Return Value: The solver must return the market maker address that should receive the base tokens on the source chain
-
Fallback: If the solver fails or doesn’t return a valid address,
ACK_ERR_ONLYMAKER
is returned, allowing other market makers to attempt filling
The protocol enforces strict validation rules based on the token order kind:
TOKEN_ORDER_KIND_INITIALIZE (0x00):
- Required when: Deploying a new wrapped token with custom metadata
- Metadata: Must contain valid
TokenMetadata
with implementation contract and initializer data - Validation: Protocol verifies the wrapped token doesn’t already exist
- Use case: First-time token bridging with custom token contracts
TOKEN_ORDER_KIND_ESCROW (0x01):
- Required when: Sending tokens that will be escrowed (locked) on the source chain to receive quote tokens on the destination
- Metadata: Can contain arbitrary data for solver interpretation
- Validation: Protocol checks if the token has a V1 (metadata image = 0) or V2 (non-zero metadata image) representation
- Use case: Regular token transfers where base tokens are locked on source chain
TOKEN_ORDER_KIND_UNESCROW (0x02):
- Required when: Unwrapping tokens back to their original form
- Metadata: Can contain arbitrary data for solver interpretation
- Validation: Protocol verifies the token is being sent back through its origin path
- Use case: Returning wrapped tokens to their source chain
When using TOKEN_ORDER_KIND_SOLVE
, the SolverMetadata
structure enables powerful communication between users and solvers:
Arbitrary Metadata Threading:
// Example: User wants specific slippage tolerance for a solverstruct SlippageParams { uint256 maxSlippage; uint256 deadline; string preferredDEX;}
// Encode custom parameters for the solverSolverMetadata memory solverMeta = SolverMetadata({ solverAddress: abi.encodePacked(address(customSolver)), metadata: abi.encode(SlippageParams({ maxSlippage: 300, // 3% deadline: block.timestamp + 3600, preferredDEX: "uniswap" }))});
TokenOrderV2 memory order = TokenOrderV2({ sender: abi.encodePacked(msg.sender), receiver: abi.encodePacked(recipient), baseToken: abi.encodePacked(token), baseAmount: amount, quoteToken: abi.encodePacked(desiredOutputToken), // Token the solver should provide quoteAmount: minOutput, kind: TOKEN_ORDER_KIND_SOLVE, metadata: abi.encode(solverMeta)});
Solver Interpretation:
contract CustomSolver is ISolver { // Beneficiary addresses configured per path/channel // Using bytes to support addresses from different chains mapping(uint256 => mapping(uint32 => bytes)) public beneficiaries;
struct SlippageParams { uint256 maxSlippage; uint256 deadline; string preferredDEX; }
function solve( IBCPacket calldata packet, TokenOrderV2 calldata order, uint256 path, address caller, address relayer, bytes calldata relayerMsg, bool intent ) external override returns (bytes memory) { // First decode the SolverMetadata from the order SolverMetadata memory solverMeta = abi.decode(order.metadata, (SolverMetadata));
// Then decode user preferences from the solver metadata's metadata field SlippageParams memory params = abi.decode(solverMeta.metadata, (SlippageParams));
// Use the metadata to customize execution if (keccak256(bytes(params.preferredDEX)) == keccak256("uniswap")) { executeUniswapTrade(order, params); } else { executeBestRouteTrade(order, params); }
// Return the market maker address to receive base tokens // Lookup the beneficiary for this specific path/channel bytes memory beneficiary = beneficiaries[path][packet.destinationChannelId]; require(beneficiary.length > 0, "No beneficiary for this route"); return beneficiary; }}
1. Creating a Solver Order:
// First, encode the solver metadataSolverMetadata memory solverMeta = SolverMetadata({ solverAddress: abi.encodePacked(address(mySolverContract)), metadata: abi.encode(CustomSolverParams({ maxSlippage: 300, // 3% deadline: block.timestamp + 3600, preferredDEX: "uniswap", minLiquidity: 100000e6 }))});
// Create the order with TOKEN_ORDER_KIND_SOLVETokenOrderV2 memory order = TokenOrderV2({ sender: abi.encodePacked(msg.sender), receiver: abi.encodePacked(recipient), baseToken: abi.encodePacked(USDC), baseAmount: 10000e6, quoteToken: abi.encodePacked(address(uToken)), // U token that solver will mint quoteAmount: 9900e6, // Minimum U tokens expected kind: TOKEN_ORDER_KIND_SOLVE, // 0x03 metadata: abi.encode(solverMeta)});
2. Complex Solver Logic with Multi-Step Execution:
struct MultiStepParams { uint8 steps; address[] intermediateTokens; uint256[] minOutputs; bytes[] customData;}
// Create a solver order with multi-step instructionsSolverMetadata memory solverMeta = SolverMetadata({ solverAddress: abi.encodePacked(address(multiStepSolver)), metadata: abi.encode(MultiStepParams({ steps: 3, intermediateTokens: [USDC, WETH, TARGET_TOKEN], minOutputs: [1000e6, 0.5e18, 100e18], customData: [swapParams1, swapParams2, swapParams3] }))});
TokenOrderV2 memory order = TokenOrderV2({ sender: abi.encodePacked(msg.sender), receiver: abi.encodePacked(recipient), baseToken: abi.encodePacked(USDC), baseAmount: 1000e6, quoteToken: abi.encodePacked(TARGET_TOKEN), // Final token solver should provide quoteAmount: 100e18, // Minimum TARGET_TOKEN output expected kind: TOKEN_ORDER_KIND_SOLVE, metadata: abi.encode(solverMeta)});
3. Conditional Execution Logic:
struct ConditionalParams { uint256 triggerPrice; bool triggerAbove; uint256 maxDelay; bytes fallbackAction;}
// Solver can implement limit order logicSolverMetadata memory solverMeta = SolverMetadata({ solverAddress: abi.encodePacked(address(limitOrderSolver)), metadata: abi.encode(ConditionalParams({ triggerPrice: 2000e18, // Execute only if ETH > $2000 triggerAbove: true, maxDelay: 3600, fallbackAction: abi.encode("refund") }))});
TokenOrderV2 memory order = TokenOrderV2({ sender: abi.encodePacked(msg.sender), receiver: abi.encodePacked(recipient), baseToken: abi.encodePacked(WETH), baseAmount: 1e18, quoteToken: abi.encodePacked(USDC), // USDC that solver should provide quoteAmount: 2000e6, // Minimum USDC expected kind: TOKEN_ORDER_KIND_SOLVE, metadata: abi.encode(solverMeta)});
For Regular Token Orders (non-solver):
TOKEN_ORDER_KIND_INITIALIZE
:metadata
containsTokenMetadata
with implementation and initializerTOKEN_ORDER_KIND_ESCROW
:metadata
can be empty or contain protocol-specific data (not for custom execution)TOKEN_ORDER_KIND_UNESCROW
:metadata
can be empty or contain protocol-specific data (not for custom execution)
For Solver Orders:
TOKEN_ORDER_KIND_SOLVE
:metadata
must containSolverMetadata
with:solverAddress
: The solver contract to invokemetadata
: Arbitrary data for the solver to interpret
For Protocol Compliance:
- Always use
TOKEN_ORDER_KIND_UNESCROW
when unwrapping tokens - Use
TOKEN_ORDER_KIND_INITIALIZE
only for first deployment with custom metadata - Use
TOKEN_ORDER_KIND_ESCROW
for regular transfers (without solver logic) - Use
TOKEN_ORDER_KIND_SOLVE
when routing to a solver contract
For Solver Integration:
- When creating orders for solvers, set
kind = TOKEN_ORDER_KIND_SOLVE
(0x03) - Encode the solver address and any additional metadata in
SolverMetadata
struct - Place the encoded
SolverMetadata
in the order’s metadata field - Ensure your solver returns a valid market maker address to receive base tokens
- Validate all inputs in your solver’s
solve()
function - Provide clear error messages when solver execution fails
- Implement
allowMarketMakers()
for future compatibility (currently not used by protocol) - In CosmWasm, emit a
solver
event withmarket_maker
attribute containing the recipient address
Security Considerations:
- Solvers should validate all metadata fields before execution
- Never trust metadata without proper validation
- Implement bounds checking for numeric parameters
- Consider gas costs when decoding complex metadata structures
- Provide clear error messages when metadata validation fails
This dual-purpose design makes TokenOrderV2 both protocol-compliant and highly extensible, enabling sophisticated use cases while maintaining security and predictability.
If any of the order in the orders
list is failing on execution, the whole packet is reverted and a failure acknowledgement will be yield.
The stake instruction uses opcode 0x04
and requires version INSTR_VERSION_0
.
struct Stake { uint256 tokenId; // NFT token ID for the staking position bytes governanceToken; // Governance token address bytes governanceTokenWrapped; // Wrapped governance token address bytes sender; // Source chain sender address bytes beneficiary; // Address that will receive the staking NFT bytes validator; // Validator address to stake with uint256 amount; // Amount to stake}
The stake instruction allows cross-chain staking of governance tokens with validators. The process:
- Tokens are locked on the source chain
- Staking is initiated on the destination chain
- A staking position NFT is minted to the beneficiary
- The NFT represents ownership of the staked position and can be used to manage it
The unstake instruction uses opcode 0x05
and requires version INSTR_VERSION_0
.
struct Unstake { uint256 tokenId; // NFT token ID for the staking position bytes governanceToken; // Governance token address bytes governanceTokenWrapped; // Wrapped governance token address bytes sender; // Source chain sender address bytes validator; // Validator address to unstake from}
The unstake instruction initiates the unbonding process for staked tokens. When successful:
- The staking position enters the UNSTAKING state
- The unstakingCompletion time is set
- The NFT is returned to the sender
- After completion time, tokens can be withdrawn
The withdraw stake instruction uses opcode 0x06
and requires version INSTR_VERSION_0
.
struct WithdrawStake { uint256 tokenId; // NFT token ID for the staking position bytes governanceToken; // Governance token address bytes governanceTokenWrapped; // Wrapped governance token address bytes sender; // Source chain sender address bytes beneficiary; // Address that will receive the tokens}
The withdraw stake instruction allows a user to claim their tokens after the unbonding period. On success:
- The staking position enters the UNSTAKED state
- The staked tokens are transferred to the beneficiary
- Any rewards are also transferred
- If there was slashing, the appropriate amount is burned
The withdraw rewards instruction uses opcode 0x07
and requires version INSTR_VERSION_0
.
struct WithdrawRewards { uint256 tokenId; // NFT token ID for the staking position bytes governanceToken; // Governance token address bytes governanceTokenWrapped; // Wrapped governance token address bytes validator; // Validator address bytes sender; // Source chain sender address bytes beneficiary; // Address that will receive the rewards}
The withdraw rewards instruction allows claiming rewards without unstaking. On success:
- The staking position remains in the STAKED state
- Any accumulated rewards are transferred to the beneficiary
- The NFT is returned to the sender