Skip to content

Nkovaturient/Lattice

Repository files navigation

Lattice

Private solver mesh powered by Js-libp2p. Intents settled before the chain sees them.

Status js-libp2p Arbitrum Latency

Intro

Lattice is an intent-based DeFi coordination layer built on js-libp2p.

Users sign what they want. Solvers compete privately to fill it. The best solution lands on-chain.

No mempool exposure. No MEV leaks. No trusted relayer.

It earns the name on three levels — gossip (GossipSub is the propagation engine), lattice as in ultra-fine mesh fabric (the solver subnet topology), and the connotation of something fast and nearly invisible.

That last part is exactly what good infrastructure feels like.

The problem it solves

  • Modern DeFi leaks intent data too early. The moment a swap hits a public RPC or mempool, searchers can front-run it. Existing solver networks patch this with centralized off-chain APIs — which defeats the point.

  • Lattice moves coordination entirely peer-to-peer: intents propagate through an encrypted solver mesh, auctions resolve in under 100ms, and only the winning solution touches the chain.

DEMO

Watch Demo

For demonstration, Lattice runs as a local multi-node simulation entirely in the terminal — spin up one bootstrap node, two or three solver nodes, and a user node using scripts, then fire a test intent through the gossip mesh using a simple CLI script that calls buildAndSignIntent() with a hardcoded wallet, publishes it to the GossipSub mesh, and prints each hop in real-time: intent received → validated → solver bids → auction winner → settlement tx hash. That's the demo — watching an intent travel from user signature to on-chain settlement in under 100ms, fully logged in the terminal, no browser needed.

libp2p Lattice in Action

How it works

flowchart TD
    A[User signs Intent<br/>EIP-712] --> B[GossipSub Propagation<br/>across Solver Mesh]
    B --> C[Solvers compute solutions privately]
    C --> D[80ms Auction Window]
    D --> E[Winning Solver submits solution]
    E --> F[IntentSettlement.sol<br/>Verifies + Executes]
Loading
  • The Networking layer is js-libp2p — Noise-encrypted connections, yamux multiplexing, Kademlia DHT for discovery, GossipSub for intent propagation.
  • The Trust layer is EVM — solver staking, slashing, and on-chain settlement via EIP-712 verified signatures.

Architecture at a glance

Layer Component Role
Transport WebSocket + Noise XX + yamux Encrypted, multiplexed peer connections
Discovery Kademlia DHT + Bootstrap Solvers find each other at startup
Propagation GossipSub (2 topic tiers) Intent gossip — public and tier-1 meshes
Validation Topic validators Sig check + deadline + registry, ~1.3ms
Negotiation /defi/rfq/1.0.0 streams Direct solver-to-solver bid exchange
Settlement IntentSettlement.sol On-chain verify, execute, pay solver
Trust anchor SolverRegistry.sol Stake, register, slash — PeerID ↔ EVM binding

Latency budget (80ms auction window)

Intent propagation to solvers     10–20ms
Solver pathfinding + compute      20–40ms
Bid return to coordinator         10–20ms
Auction resolution                 5–10ms
Buffer                            10–15ms
─────────────────────────────────────────
Total                             ~80ms

Pre-warmed libp2p connections reduce dial time from ~50ms cold to ~2ms. This is non-negotiable for the budget to hold.

Roadmap

Phase 1 — Foundation

  • 1.1 js-libp2p solver node — Noise, yamux, WebSocket, GossipSub, DHT
  • 1.2 Peer discovery — bootstrap list, Kademlia DHT, connection pre-warming
  • 1.3 SolverRegistry.sol — stake, register, slash, PeerID → EVM address binding

Phase 2 — Intent Gossip & Validation

  • 2.1 Intent schema — EIP-712 typed struct, protobuf wire format, intentId as GossipSub messageId
  • 2.2 GossipSub topology — 2-tier topic routing (public / tier-1), mesh tuned for sub-100ms
  • 2.3 Validation pipeline — sig verify → deadline → registry cache (60s TTL + event invalidation)

Phase 3 — Solver RFQ & Auction Engine

  • 3.1 /defi/rfq/1.0.0 stream protocol — direct encrypted solver negotiation, sealed bids
  • 3.2 Auction coordinator — 80ms hard deadline, Promise.race, parallel RFQ broadcast
  • 3.3 Solver compute engine — DEX pathfinding (Uniswap v3 / Curve), route encoding

Phase 4 — EVM Settlement & Incentives

  • 4.1 IntentSettlement.sol — verify EIP-712 intent + bid, execute route, pay solver
  • 4.2 Solver incentive model — fee split, slashing conditions, reputation → tier access

Phase 5 — Hardening & Launch

  • 5.1 Latency benchmarking harness — per-hop timing, p99 profiling, geo simulation
  • 5.2 MEV resistance audit — timing attacks, solver collusion vectors, commit-reveal analysis

Design decisions (locked)

Decision Choice Rationale
Partial fills No (v1) Complexity without volume justification
Auction window Protocol-fixed 80ms Users can't reason about latency
Solver nodes Server-only (always-on) Browsers can't hit sub-100ms reliably
Topic tiers 2 (public + tier-1) Single leaks; 3 premature pre-scale
Registry check Cache + event invalidation Fresh RPC (50–200ms) kills budget

Arbitrum Sepolia — mesh → settlement

End-to-end flow is user (scripts/run-user.js) over GossipSub → solver (scripts/run-solver.js) → optional IntentSettlement.settle.

Prerequisites (one-time per user wallet)

Token approval is required before your first intent settles. The solver calls settle() which pulls tokens from your wallet via transferFrom. Approve once (or with a high allowance) and you won't need to repeat until exhausted:

# Approve USDC (6 decimals) for IntentSettlement
cast send 0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d \
  "approve(address,uint256)" \
  $SETTLEMENT_CONTRACT_ADDRESS \
  1000000000 \  # 1000 USDC — adjust as needed
  --rpc-url "$ARB_SEPOLIA_RPC" \
  --private-key "$PRIVATE_KEY"

Environment (repo-root .env)

PRIVATE_KEY=0x...
ARB_SEPOLIA_RPC=https://arb-sepolia.g.alchemy.com/v2/YOUR_KEY
ARB_SEPOLIA_CHAIN_ID=421614

# Contract addresses (both required)
SETTLEMENT_CONTRACT_ADDRESS=0x...   # IntentSettlement — nonces + settle
REGISTRY_CONTRACT_ADDRESS=0x...     # SolverRegistry — solver stakes

# Optional solver tuning
# USE_QUOTER=1                      # call QuoterV2 for honest bids (adds ~10-30ms)
# SOLVER_MARGIN_BPS=10              # shade bids by 0.10% for heterogeneity
# RFQ_DIAL_TIMEOUT_MS=60            # default 60ms WS; set 40 for QUIC

Run the solver

node scripts/run-solver.js
# Logs its PeerID and multiaddr — copy for user's BOOTSTRAP_PEERS

With SETTLEMENT_CONTRACT_ADDRESS set and AUTO_SETTLE not false, the winning bid auto-submits on-chain.

Run the user

BOOTSTRAP_PEERS=/ip4/127.0.0.1/tcp/9000/ws/p2p/<solverPeerId> \
  node scripts/run-user.js

The user reads settlement.nonces(wallet) to sign the correct nonce. If the deployed contract lacks the nonces() passthrough (pre-v1.1), set REGISTRY_CONTRACT_ADDRESS as fallback.

Demo

Goal Contract Notes
Full settle tx on Sepolia without SwapRouter failures MockIntentSettlement Deploy via forge script ... deployMock(address) (see contracts/README.md). Say: coordination + trust layer is real; AMM execution is pluggable.
Real Uniswap path IntentSettlement Often hits empty / illiquid pools on testnet — use for infra debugging, not as the only demo.
Credible mesh evidence 2–3 solvers Different SOLVER_PORT, shared BOOTSTRAP_PEERS to first solver’s multiaddr. One solver + one user is RFQ/local-compute valid but not a full mesh story.

Troubleshooting

Error Cause Fix
execution reverted (no data) on nonces() Deployed contract lacks nonces() passthrough Redeploy contract OR set REGISTRY_CONTRACT_ADDRESS
Nonce mismatch Intent signed with stale nonce Re-run run-user.js (reads fresh nonce)
transferFrom reverts User hasn't approved settlement Run approval command above
Solver not registered Solver wallet not in registry Run node scripts/register-solver.js
HTTP 429 from RPC Rate limited Use keyed RPC; increase RPC_429_EXTRA_MS

Operations notes

Public Sepolia gateways aggressively 429 rate-limit. Prefer a keyed provider (Infura / Alchemy / QuickNode).

The repo configures a gentler ethers client in node/rpc-provider.js (batchMaxCount=1, tunable RPC_POLLING_INTERVAL_MS, RPC_429_EXTRA_MS default long sleep on HTTP 429). submitSettlement wraps critical calls in back-off retries.

If logs show settle revert with require(false) or a selector-only hex, the chain often surfaced no Error(string) — commonly SwapRouter.exactInput (path / liquidity / token quirks), not gossip. Prefer keyed RPC + cast call … settle.staticCall (or Tenderly) to isolate. To drop one RPC hop on flaky endpoints, set SETTLE_GAS_LIMIT (e.g. 800000) so estimateGas is skipped after a successful staticCall.

Maintainership narrative when things fail: mesh + signing can succeed while settle fails — that’s orthogonal layers (P2P vs EVM infra). Showing decoded revert where possible, 429 retries, and a commercial RPC proves production thinking.


Learn-Test-and-Fix

Multi-solver mesh on Arbitrum Sepolia (MockIntentSettlement) — problems hit during bring-up and how we fixed them.

Is commit-reveal doing what we think?

Yes. Solvers hash keccak256(intentId, outputAmount, routeHash, salt) in phase 1 and only reveal the full bid in phase 2. A solver cannot change its quote after seeing a competitor's bid — it can only reveal what it committed to at t=0. That is the anti-sniping property; your logs show it working:

[auction] … — commit-reveal with 2 remote solver(s)
[commit] phase 1 done — 2 commitments in 1205ms
[commit] phase 2 done — 2 reveals in 1209ms
[auction] … — closed at 1209ms, 2 bid(s)

Streams are Noise XX + yamux over WebSocket (/defi/rfq/1.0.0); bids never touch public mempool.

Sepolia multi-solver — working?

Functionally yes; latency not production-tight yet.

Check Status Evidence
3-node topology (coordinator + 2 workers) OK worker links 2/2 (2 inbound, 2 outbound dial)
Gossip intent → coordinator auction OK User publish → [auction] commit-reveal with 2 remote solver(s)
Both workers bid OK 2 commitments, 2 reveals, 2 bid(s)
Winner selection OK winner: solver 0xbbb6f72686…
On-chain settle (mock) OK Worker 2: [settle] tx: …/0xf75e48ca… confirmed block 270795168
80ms auction budget Not yet ~1.2s round-trip (local dev; COMMIT_WINDOW_MS=2000, compute + EIP-712 sign)
Bid heterogeneity Weak Both workers quoted identical 54238200487945951 — set SOLVER_MARGIN_BPS per worker to differentiate

Verdict: Lattice private mesh + commit-reveal auction + remote winner settle is E2E proven on Sepolia. Optimize latency and bid diversity before calling it production-efficient.

Issues and fixes (chronological)

  • Solo mesh only (solverPeers: []) — Coordinator never RFQ'd workers. Added SOLVER_PEER_ADDRS, AUCTION_ROLE=coordinator|worker, commit-reveal RFQ on workers.
  • PeerID mismatch after restart — Random libp2p keys changed every boot; coordinator dialed stale IDs → RFQ timeout. Persist keys in .lattice/libp2p-keys/<evm-address>.key; register after first boot; REGISTER_ACTION=deregister + peer-id in register-solver.js.
  • multiaddr().getPeerId is not a function — libp2p v3 API change. Parse /p2p/ via getComponents().
  • pre-warmed 0/2 / "workers not listening" — Misleading: workers connect inbound; outbound dial can fail while links are fine. Log worker links N/M (inbound, outbound); poll up to 30s.
  • Port 9001 EADDRINUSE — Stale node process. Clear with lsof -ti :9001 | xargs kill; clearer error in node/solver.js.
  • stream.sink is not a function — libp2p v3 uses stream.send(), not it-pipe sink. Updated rfq-internal.js.
  • Stream ended after 1 bytes, expected 4 — Async iteration on v3 streams is wrong; reads must use @libp2p/utils byteStream() (message events).
  • commit response timeout (300ms) — Window too tight for local compute + sign. Default COMMIT_WINDOW_MS=2000, REVEAL_WINDOW_MS=1000.
  • RFQ handler signature — libp2p v3 handler is (stream, connection), not ({ stream, connection }).
  • Preflight L117 invalid bid signature — Coordinator tried settle() with its key while winner was worker 0x9D48… / 0xbbB6…. msg.sender must equal bid.solver. Added /defi/settle-win/1.0.0: coordinator notifies remote winner; winning worker submits tx (see worker log: [settle-win] auction won — submitting settle()).

Verified E2E log chain (reference run)

# Coordinator
[auction] commit-reveal with 2 remote solver(s)
[commit] phase 1 done — 2 commitments in 1205ms
[commit] phase 2 done — 2 reveals in 1209ms
[auction] winner: solver 0xbbb6f72686… output: 54238200487945951
[auction] remote winner 0xbbB6F726… — notifying worker to settle
[auction] remote settle complete

# Worker 2 (winner)
[settle-win] auction won — submitting settle()
[settle] tx: https://sepolia.arbiscan.io/tx/0xf75e48caeee42362b83ab7a601692f25a83dca2747583e0330aa7f99613ad020
[settle] confirmed block 270795168; gasUsed 136769

Multi-solver quick start (stable PeerIDs)

# 1) Workers first (note settle-win handler registered)
SOLVER_INDEX=1 SOLVER_PORT=9001 AUCTION_ROLE=worker BOOTSTRAP_PEERS=/ip4/127.0.0.1/tcp/9000/ws/p2p/<coord> node scripts/run-solver.js
SOLVER_INDEX=2 SOLVER_PORT=9002 AUCTION_ROLE=worker BOOTSTRAP_PEERS=/ip4/127.0.0.1/tcp/9000/ws/p2p/<coord> node scripts/run-solver.js

# 2) Coordinator
PRIVATE_KEY=$PRIVATE_KEY SOLVER_PEER_ADDRS=/ip4/127.0.0.1/tcp/9001/ws/p2p/<w1>,/ip4/127.0.0.1/tcp/9002/ws/p2p/<w2> node scripts/run-solver.js

# 3) User
BOOTSTRAP_PEERS=/ip4/127.0.0.1/tcp/9000/ws/p2p/<coord> node scripts/run-user.js

Register workers with persisted PeerIDs: REGISTER_ACTION=peer-id SOLVER_INDEX=N then PEER_ID=… node scripts/register-solver.js.


Tech stack

Concern Choice
P2P networking js-libp2p v1.x
Transport WebSocket (TCP)
Encryption Noise XX
Multiplexing yamux
Pub/sub GossipSub
Wire format Protobuf (protobufjs)
EVM signing ethers v6 (EIP-712)
Smart contracts Solidity 0.8.34
Runtime Node.js 20+ (ESM)

Lattice — the mesh is the protocol.