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.
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, offlineLayerZero 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_receive → clear 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.
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.
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
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.
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.
| 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. |
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.
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-verifierWithout 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-runAgainst 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.
- 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/ is a Next.js landing page describing the above.
npm run dev --prefix siteApache-2.0