Skip to content

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

stellar-dvn

An independent LayerZero V2 Decentralized Verifier Network (DVN) for Stellar / Soroban.

This was built for the fun of it. Not for a client, not for a launch. The question was whether you could actually build a working DVN on Stellar, and this is the answer. It is real code against real contracts, and it is also a hobby project: unaudited, no mainnet deployment, no operational commitment behind it. Treat it accordingly.

LayerZero V2's Stellar implementation lets applications compose a Security Stack from multiple independent DVNs under an X-of-Y-of-N model. This is one such verifier: a Soroban contract plus the off-chain service that feeds it.

Status

Working end to end against LayerZero's real Stellar testnet contracts. 85 tests. Not audited. No mainnet deployment.

cargo test                           # 85 tests across contracts and verifier
stellar contract build               # → target/wasm32v1-none/release/
./scripts/e2e-security-stack.sh      # the full receive flow, 2-of-3 quorum
./scripts/e2e-real-layerzero.sh      # fee + attestation against real contracts
./scripts/e2e-testnet.sh             # the same loop against local mocks, offline

What has actually happened on chain

LayerZero V2 is deployed on Stellar, at testnet eid 40600 and mainnet eid 30600. The scripts resolve addresses from LayerZero's metadata API; nothing is hardcoded.

Our DVN CDI3EXK2MMOIXU22HSE4UUOZQIAJJLT3EMGHQDJ5BIFEPK5J7MW5MJKB
Our OApp CD2O6PTE4NVLZOSOFPEKGKUSCUTRBB23ZK4T2AKKONM4ET2A2EMKW4UJ
EndpointV2 CALTBA5S6GRJEHAXFP45LGGLKWWAF7HTZCPNUBUJF2HWWRRLQNV35AIV (LayerZero)
ULN302 CCMLPCAWCPIIMXOHJJKU3NZLOFTT2O6QTB2UUFPN6SEHLK35QRHVKKMB (LayerZero)
DvnFeeLib CCNLXA7DYMUERWQS2GS3JRFIXRCQL7Q76I6LGTLTD3ROOQIMUFQ5NI5G (LayerZero)
PriceFeed CAIKTIJD4APTPPLJ6K5XKLNGQBXX7JA4CZJLIBP7YQUC6B54TD6EZVK7 (LayerZero)

Receiving. An OApp names this DVN as its only required verifier. A 2-of-3 quorum attests; ULN302.verifiable() returns true; commit_verification makes EndpointV2 emit PacketVerified; lz_receiveclear emits PacketDelivered and the application holds the message. Delivering it twice is rejected, because clear consumed the payload hash. A single signature against a 2-of-3 threshold is rejected on chain.

Sending. A real message was sent from Stellar through LayerZero's endpoint, with this DVN in the send security stack:

packet_sent        src_eid 40600 → dst_eid 40161, nonce 1
dvn_fee_paid       this DVN, via assign_job
executor_fee_paid
fee                70,187,649 stroops (~7 XLM), quoted through the real DvnFeeLib
guid               016f14a37687179d976c01c1379714ee9efe3e13eec308a8388adfe18a8fe9df
tx                 79a946b2f58a80e638e52c4b03e1c200eee0cb1540865d50e9d29b3f65d10fe3

The verifier then ingested that packet from Stellar RPC and produced an attestation for it. The unrelated send_library_set event in the same range was rejected loudly (no packet bytes in event body) rather than silently skipped, which is what the ingestion path is designed to do.

Layout

contracts/dvn/src/
  dvn.rs        entrypoints: get_fee, assign_job, execute_transaction, admin, upgrade
  auth.rs       __check_auth: the Abstract Account authorization path
  multisig.rs   secp256k1 recovery and K-of-N quorum verification
  storage.rs    storage layout and TTL policy
  fee_lib.rs    client for the external DVN fee library
  types.rs      wire types, structurally identical to the endpoint's

contracts/mock-oapp/     a peer-enforcing OApp that sends, receives, and picks its DVNs
contracts/mock-uln/      a verification sink, for offline runs
contracts/mock-endpoint/ a packet source, for offline runs

offchain/verifier/src/
  verifier.rs   pipeline: observe → decode → attest → submit
  digest.rs     quorum digest, computed locally (see below)
  evm.rs        source-chain ingestion: PacketSent via eth_getLogs
  packet.rs     LayerZero V2 packet header decoding
  signer.rs     quorum signing behind a KMS-swappable trait
  source.rs     Stellar RPC ingestion, with retention-gap detection
  submit_rpc.rs transaction assembly, simulation, signing, submission
  retry.rs      what is worth retrying, and what never is
  cursor.rs     persisted ingestion position
  telemetry.rs  Prometheus counters and latency percentiles

types.rs is the compatibility surface. Field names and types there determine the XDR encoding ULN302 sees; changing them breaks the integration silently.

Design

The attestation path

Soroban prohibits reentrancy, so a DVN cannot self-call to reach ULN302. Instead the contract is an account (CustomAccountInterface), and the quorum authorizes an outbound invocation:

sequenceDiagram
    autonumber
    participant SRC as Source chain
    participant V as Off-chain verifier
    participant R as Relayer
    participant DVN as DVN contract
    participant ULN as ULN302

    SRC-->>V: PacketSent
    Note over V: decode the 81-byte header,<br/>hash the payload
    V->>V: each signer rebuilds and signs<br/>keccak256(network_id + vid + expiration + xdr(calls))
    V-->>R: quorum signatures (2 of 3)
    R->>DVN: execute_transaction(inner_calls)
    Note over DVN: require_auth() on itself
    DVN->>DVN: __check_auth<br/>vid, expiration, replay, then signatures
    DVN->>ULN: verify(dvn, header, payload_hash, confirmations)
    ULN-->>DVN: recorded
Loading

Signers sign the outer invocation, so the digest commits to every inner call and argument. A relayer cannot substitute, reorder, or append calls. Relaying is permissionless by design, since the quorum signature is the security boundary, not the submitter's identity.

Three decisions worth explaining

Replay protection that survives eviction. Soroban entries expire, which makes the obvious "used hash" set unsafe: once evicted, a read is indistinguishable from "never seen", and a captured payload with valid signatures replays. (This pattern was reported against the reference LzDVN during the April 2026 Code4rena audit. That is a third-party write-up; how it was judged isn't public.) Here, expiration is bounded to 7 days and the used-hash entry goes to temporary storage with a TTL that provably outlives it. An entry can only be evicted after the payload it protects has expired on its own terms.

The digest is bound to network_id. This departs from the reference. Because relaying is permissionless there is no admin keypair signing the host's signature_payload, which is normally what binds an authorization to a network. Without an explicit binding, a payload signed for testnet could be replayed against a mainnet deployment sharing a VID and address.

The off-chain signer computes the digest locally, never asking a node for it. A signer that asks "what should I sign?" can be induced to sign a digest for call data it never inspected. The two implementations must then agree byte-for-byte, so tests/digest_matches_contract.rs compares them against the real contract, including the nested attestation shape, rather than restating the encoding rules.

Stellar-specific handling

Constraint Handling
Read cap (200/tx) The auth path performs a fixed, small number of reads independent of signature count.
Resource exhaustion Signature count is capped at the signer set size, and verification stops the moment quorum is met.
Instance eviction Signer set and threshold are bumped on every state-changing call. Losing them would brick the contract.
TTL as security Never relied on. Anyone can extend any entry's TTL, so an explicit expiration bounds validity.
External fee lib get_fee delegates, then range-checks the result. A compromised fee lib would otherwise quote straight through.
Signer identity 20-byte Ethereum-style addresses, so an operator's existing EVM key material works unchanged.
Finality Stellar finalizes at ledger close with no reorgs, so confirmations maps to a small constant.
No msg.value The endpoint measures the fee by reading its own balance, so a sender transfers first and is refunded the remainder.
Event retention A cursor outside the RPC's window halts the loop and alerts. A missed event is a missed verification.

Durability

Idempotency. Before submitting, the verifier reads confirmations off the message library. An attestation already on chain is skipped rather than repeated. Verified live: a second attestation of the same packet returns already-attested:<hash> with no transaction.

Retry. retry.rs separates failed from never accepted. Timeouts and transport errors back off and retry; contract errors and rejected simulations do not, because they fail identically every time. Each attempt also pays a fee, so a hot loop on a permanent failure drains the fee account.

Cursor. cursor.rs persists position and advances only after a whole batch, so a crash replays a batch rather than skipping its tail. A corrupt cursor is a hard error, not a silent reset: restarting from zero re-attests everything, and restarting from config skips whatever came before it.

Running the verifier

export DVN_RPC_URL=https://soroban-testnet.stellar.org
export DVN_ENDPOINT_CONTRACT=C...          # contract emitting PacketSent
export DVN_CONTRACT_ID=<32-byte hex>       # this DVN
export DVN_ULN_CONTRACT_ID=<32-byte hex>   # message library to attest to
export DVN_VID=42
export DVN_SIGNING_KEYS=<hex>,<hex>        # development only; use a KMS in production
export DVN_SOURCE_SECRET=S...              # pays fees; omit to run dry
cargo run -p dvn-verifier --bin dvn-verifier

Without DVN_SOURCE_SECRET it still ingests, decodes, and signs, which is a usable dry run. DVN_RUN_ONCE=1 processes a single cycle and exits.

Source-chain ingestion, which is how inbound messages are actually observed:

EVM_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com \
EVM_ENDPOINT=0x6edce65403992e310a62460808c4b910d972f10f \
cargo run -p dvn-verifier --bin watch-evm -- --dry-run

Against live Sepolia this decodes real production traffic (238 packets in a recent scan) and reports where it is going. None of it is bound for Stellar yet, which the tool states plainly rather than looking like a failure.

What is not done

  • Executor economics. Delivery is driven manually in the scripts. A real executor prices gas, batches deliveries, and reports failures via lz_receive_alert. That is a separate service from a DVN and it is not built here.
  • Inbound traffic from other chains. Nothing on Sepolia is addressed to Stellar yet, so inbound packets in the receive-side scripts are constructed. The outbound direction, by contrast, is now genuinely organic.
  • Getting listed. Applications can name this DVN today, as the scripts prove, but being discoverable means appearing in LayerZero's DVN directory, which is a conversation and not code.
  • Static analysis (cargo scout-audit), then a security audit, then more than one node, before anyone should treat this as infrastructure.

Site

site/ is a Next.js landing page describing the above.

npm run dev --prefix site

License

Apache-2.0

About

An independent LayerZero V2 DVN for Stellar. Soroban contract plus off-chain verifier, working end to end against LayerZero's real Stellar testnet contracts. Built for the fun of it.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages