Version 0.1.0 -- Draft
Lattice is a hierarchical multi-chain blockchain protocol. A single root chain (the nexus) can spawn child chains via genesis transactions. Child chains inherit security from their parent through parent chain anchoring and support trustless cross-chain value transfer through a three-phase deposit/receipt/withdrawal protocol.
All state is content-addressed using IPLD/CID. Blocks reference state via Merkle roots, enabling light client verification without full state replication.
H(x)-- SHA-256 hash ofxCID(x)-- Content Identifier of serializedx(IPLD DAG-CBOR + SHA-256)SMT-- Sparse Merkle TreeB[i]-- Block at indexion a given chain||-- concatenation>>-- arithmetic right shiftU256-- 256-bit unsigned integer
A block B is a tuple:
B = (
previousBlock: CID(Block) | nil,
transactions: CID(MerkleDictionary<CID(Transaction)>),
difficulty: U256,
nextDifficulty: U256,
spec: CID(ChainSpec),
parentHomestead: CID(LatticeState),
homestead: CID(LatticeState),
frontier: CID(LatticeState),
childBlocks: CID(MerkleDictionary<CID(Block)>),
index: uint64,
timestamp: int64,
nonce: uint64
)
Transaction = (
signatures: Map<PublicKeyHex, SignatureHex>,
body: CID(TransactionBody)
)
TransactionBody = (
accountActions: [AccountAction],
actions: [Action],
swapActions: [SwapAction],
swapClaimActions: [SwapClaimAction],
genesisActions: [GenesisAction],
peerActions: [PeerAction],
settleActions: [SettleAction],
signers: [CID(PublicKey)],
fee: uint64,
nonce: uint64,
chainPath: [string],
matchedOrders: [MatchedOrder],
claimedOrders: [MatchedOrder],
postOrders: [SignedOrder],
cancelOrders: [OrderCancellation],
orderFills: [MatchedOrder]
)
The world state is an 8-tuple of Sparse Merkle Tree roots:
LatticeState = (
accountState: SMT<CID(PublicKey) -> uint64>,
generalState: SMT<string -> string>,
swapState: SMT<SwapKey -> uint64>,
peerState: SMT<CID(PublicKey) -> PeerInfo>,
genesisState: SMT<string -> CID(Block)>,
settleState: SMT<SettleKey -> uint64>,
transactionState: SMT<uint64 -> CID(TransactionBody)>,
orderLockState: SMT<OrderLockKey -> uint64>
)
ChainSpec = (
directory: string,
maxNumberOfTransactionsPerBlock: uint64,
maxStateGrowth: int,
premine: uint64,
targetBlockTime: uint64, // milliseconds
initialRewardExponent: uint8,
transactionFilters: [string], // JavaScript expressions
actionFilters: [string] // JavaScript expressions
)
Derived constants:
halvingExponent = 64 - initialRewardExponent
halvingInterval = 2^halvingExponent
initialReward = 2^initialRewardExponent
maxDifficultyChange = 2 // protocol constant
totalHalvings = initialRewardExponent
maxSupply = UInt64.max
AccountAction = (owner: CID(PublicKey), oldBalance: uint64, newBalance: uint64)
Validity: oldBalance != newBalance
Action = (key: string, oldValue: string?, newValue: string?)
Validity: key != "" AND (oldValue != nil OR newValue != nil)
DepositAction = (nonce: uint128, demander: CID(PublicKey), amountDemanded: uint64, amountDeposited: uint64)
WithdrawalAction = (withdrawer: CID(PublicKey), nonce: uint128, demander: CID(PublicKey), amountDemanded: uint64, amountWithdrawn: uint64)
ReceiptAction = (withdrawer: CID(PublicKey), nonce: uint128, demander: CID(PublicKey), amountDemanded: uint64, directory: string)
GenesisAction = (directory: string, block: Block)
PeerAction = (owner: CID(PublicKey), IpAddress: string, refreshed: int64, fullNode: bool, type: PeerActionType)
PeerActionType = insert | update | delete
SwapAction = (nonce: uint128, sender: CID(PublicKey), recipient: CID(PublicKey), amount: uint64, timelock: uint64)
Locks amount tokens in swap state. The sender's account is debited and the tokens become claimable by the recipient after settlement proof, or refundable by the sender after timelock expiry.
SwapClaimAction = (nonce: uint128, sender: CID(PublicKey), recipient: CID(PublicKey), amount: uint64, timelock: uint64, isRefund: bool)
Unlocks a swap. If isRefund == false, the recipient claims after settlement proof. If isRefund == true, the sender reclaims after blockIndex > timelock.
SettleAction = (nonce: uint128, senderA: CID(PublicKey), senderB: CID(PublicKey), swapKeyA: string, directoryA: string, swapKeyB: string, directoryB: string)
Records a settlement on the nexus chain. Both parties must be signers. Settlement proofs in settle state are later used to authorize cross-chain claims.
SwapOrder = (
maker: CID(PublicKey),
sourceChain: string,
sourceAmount: uint64,
destChain: string,
destAmount: uint64,
timelock: uint64,
nonce: uint128,
fee: uint64
)
A maker's intent to exchange sourceAmount on sourceChain for at least destAmount on destChain. The fee is a per-order fee that the maker agrees to pay, proportional to the fill amount.
SignedOrder = (order: SwapOrder, publicKey: string, signature: string)
An order signed by the maker's private key. signature covers doubleSha256(JSON(order)). The makerAddress (CID of publicKey) must equal order.maker.
MatchedOrder = (
orderA: SignedOrder,
orderB: SignedOrder,
nonce: uint128,
fillAmountA: uint64,
fillAmountB: uint64
)
A fill between two crossing orders. Both signed orders are verified at consensus. The match derives swap, settle, claim, and account actions automatically (see section 8).
OrderCancellation = (
orderNonce: uint128,
maker: CID(PublicKey),
publicKey: string,
signature: string,
amount: uint64 // Remaining locked amount (verified against state)
)
A signed cancellation of a previously posted order. signature covers doubleSha256("cancel:" || orderNonce). The amount field must exactly match the value stored in orderLockState -- the state proof verifies this at consensus.
OrderPostAction = (maker: CID(PublicKey), nonce: uint128, lockAmount: uint64)
Derived from postOrders. Inserts lockAmount (= sourceAmount + fee) into orderLockState.
OrderReleaseAction = (maker: CID(PublicKey), nonce: uint128, releaseAmount: uint64)
Derived from orderFills. Reduces the locked amount in orderLockState by releaseAmount (= fillAmount + proportionalFee). If the remaining amount reaches 0, the entry is deleted.
OrderCancelAction = (maker: CID(PublicKey), nonce: uint128, amount: uint64)
Derived from cancelOrders. Deletes the entry from orderLockState after verifying the declared amount matches the stored value.
SwapKey = sender || "/" || recipient || "/" || amount || "/" || timelock || "/" || nonce
Used to index SwapState. The swap amount includes the proportional fee so that the full locked value (fill + fee) can be refunded on timeout.
SettleKey = directory || ":" || swapKey
Used to index SettleState. Associates a swap on a specific chain with a settlement record on the nexus.
OrderLockKey = maker || "/" || nonce
Used to index OrderLockState. Tracks the remaining locked amount for a posted order.
BlockMeta = (
blockInfo: BlockInfoImpl,
parentChainBlocks: Map<ParentBlockHash, ParentBlockIndex?>,
childBlockHashes: [string]
)
Reorganization = (
mainChainBlocksAdded: Map<BlockHash, BlockIndex>,
mainChainBlocksRemoved: Set<BlockHash>
)
Chains form a rooted tree:
Nexus
/ \
A B
/ \
A1 A2
Each chain is identified by a directory name defined in its ChainSpec. The nexus chain is the root. Child chains are created by including a GenesisAction in a transaction on the parent chain.
Each chain is managed by a ChainLevel:
ChainLevel = (
chain: ChainState, // consensus for this chain
children: Map<directory, ChainLevel>
)
Block processing is recursive: if a block does not belong to the current chain's difficulty target, it is offered to child chains.
A genesis block B is valid if and only if ALL of the following hold:
B.previousBlock == nilB.index == 0B.timestamp <= now()B.homestead == CID(emptyState())- All transactions in
B.transactionsare fully resolvable - For each transaction
tx:tx.validateTransactionForGenesis()returns true- Signatures are valid secp256k1 ECDSA signatures over
CID(tx.body) - Signers match signature public keys
- Account debits are authorized by signers
- No withdrawal actions present
- Signatures are valid secp256k1 ECDSA signatures over
B.spec.directorymatches the expected directory name- All transaction bodies pass
transactionFiltersandactionFilters |transactions| <= spec.maxNumberOfTransactionsPerBlocksum(stateDelta(tx) for tx in transactions) <= spec.maxStateGrowth- Balance conservation (genesis):
totalCredits <= premineAmount + totalFees - totalSwapLocked - All
GenesisActionblocks are themselves valid genesis blocks (recursive) - Frontier correctness: Applying all actions to
homestead(empty state) producesfrontier:proveAndUpdateState(homestead, allActions) == frontier
A non-genesis nexus block B with previous block P is valid if and only if:
Pis resolvableB.spec == P.spec(chain spec continuity)B.homestead == P.frontier(state continuity)B.index == P.index + 1P.timestamp < B.timestamp <= now()B.nextDifficulty < calculateMinimumDifficulty(B.difficulty, B.timestamp, P.timestamp)- All transactions pass
validateTransactionForNexus():- Signatures valid (secp256k1 over
CID(tx.body)) - Signers match signature public keys
- Account debits authorized by signers
- Swap action senders are signers
- Settle action parties are signers
- Swap claim authorization: refunds require sender as signer, claims require recipient as signer
- Matched orders: signatures valid, orders compatible, not expired (
timelock > blockIndex) - Claimed orders: signatures valid, orders compatible, settlement proofs exist
- Signatures valid (secp256k1 over
- Transaction/action filters pass
- Transaction count within limits
- State delta within limits
- Balance conservation (non-genesis):
Where
totalCredits <= totalDebits + reward(B.index) + totalFees + totalSwapClaimed - totalSwapLocked + totalOrderReleased - totalOrderLockedtotalFeesis the sum of explicit transaction fees (not order fees -- see section 8.4),totalOrderLockedis the sum of allOrderPostAction.lockAmount, andtotalOrderReleasedis the sum of allOrderReleaseAction.releaseAmount+OrderCancelAction.amount. - All genesis actions valid
- Frontier correctness
Nexus validation does not validate child blocks. The childBlocks field is committed to via CID(B.childBlocks) in the difficulty hash (section 5.4), so the miner commits to a specific set of child blocks when mining. However, child blocks are validated independently after the nexus block is accepted (section 5.3). An invalid child block does not affect the nexus block's validity, other child chains, or the nexus chain's state. This means a nexus-only miner only needs to compute the nexus portion of the block -- child block validation is deferred to nodes that participate in those child chains.
Child blocks embedded in a nexus block via the childBlocks field are optional. They are processed independently after the parent nexus block is accepted onto the main chain. Invalid child blocks are silently skipped without affecting the parent block or sibling child chains.
A child chain block B with previous block P and parent chain block Q is valid if and only if:
- All nexus validation rules (5.2, items 1-10, 12-13) apply, including the same balance conservation equation
B.timestamp == Q.timestamp(child block timestamp synchronized with parent)- Swap claim validation: non-refund claims require settlement proof in
parentHomestead.settleState; refund claims requireblockIndex > timelock
The difficulty hash of a block is computed as:
difficultyHash(B) = U256(H(
CID(B.previousBlock) ||
CID(B.transactions) ||
hex(B.difficulty) ||
hex(B.nextDifficulty) ||
CID(B.spec) ||
CID(B.parentHomestead) ||
CID(B.homestead) ||
CID(B.frontier) ||
CID(B.childBlocks) ||
str(B.index) ||
str(B.timestamp) ||
str(B.nonce)
))
For genesis blocks, CID(B.previousBlock) is omitted from the hash input.
A block satisfies proof-of-work if: difficultyHash(B) < B.difficulty
Given previous difficulty D, block timestamp T, and previous timestamp T':
actualTime = T - T'
targetTime = spec.targetBlockTime
if actualTime < targetTime:
factor = min(maxDifficultyChange, targetTime / actualTime)
newMinDifficulty = D / factor
elif actualTime > targetTime:
factor = min(maxDifficultyChange, actualTime / targetTime)
newMinDifficulty = D * factor
else:
newMinDifficulty = D
Validity requires: B.nextDifficulty < newMinDifficulty
Given a block's homestead state and all actions from its transactions:
frontier = proveAndUpdateState(homestead, actions)
This operation:
- Partitions actions by type into 8 groups
- For each sub-state, concurrently:
a. Generates Sparse Merkle proofs that current values match
homesteadb. Applies mutations (inserts, updates, deletions) c. Returns new Merkle root - Assembles the 8 new roots into a new
LatticeState
For each AccountAction(owner, oldBalance, newBalance):
- Proof: Verify
homestead.accountState[owner] == oldBalance - Update:
- If
newBalance > 0: setaccountState[owner] = newBalance - If
newBalance == 0: deleteaccountState[owner]
- If
For each Action(key, oldValue, newValue):
- Proof: Verify
homestead.generalState[key] == oldValue - Update:
- If
newValue != nil: setgeneralState[key] = newValue - If
newValue == nil: deletegeneralState[key]
- If
See section 8.6 for full details. Swap locks use insertion proofs; swap claims use mutation proofs (existence + deletion).
See section 8.7 for full details. Settlement entries use insertion proofs (two entries per settle action).
For each GenesisAction:
- Key:
action.directory - Proof: Verify key does not exist in
homestead.genesisState(insertion proof) - Update:
genesisState[directory] = CID(action.block)
For each PeerAction:
- Key:
action.owner - Depending on
action.type:insert: Prove non-existence, then insertupdate: Prove existence (mutation proof), then updatedelete: Prove existence (mutation proof), then delete
For each TransactionBody with nonce n:
- Key:
n - Proof: Verify key does not exist (insertion proof)
- Update:
transactionState[n] = CID(transactionBody)
For each OrderPostAction:
- Key:
OrderLockKey(maker, nonce)=maker/nonce - Proof: Verify key does NOT exist (insertion proof -- prevents duplicate posts)
- Update:
orderLockState[key] = lockAmount
For each OrderReleaseAction:
- Key:
OrderLockKey(maker, nonce) - Proof: Verify key EXISTS (mutation proof)
- Update:
remaining = current - releaseAmount- If
remaining > 0:orderLockState[key] = remaining - If
remaining == 0: deleteorderLockState[key] - If
current < releaseAmount: reject (insufficient locked balance)
For each OrderCancelAction:
- Key:
OrderLockKey(maker, nonce) - Proof: Verify key EXISTS (deletion proof)
- Validation:
orderLockState[key] == amount(declared amount must match stored value) - Update: delete
orderLockState[key]
Multiple releases against the same order lock are aggregated (e.g., partial fills across multiple orderFills entries). Posts and releases/cancels for the same key in the same block are rejected as conflicting.
Each action type reports a state delta in bytes:
| Action Type | Delta |
|---|---|
AccountAction (create) |
+len(owner) + 8 |
AccountAction (delete) |
-(len(owner) + 8) |
AccountAction (update) |
0 |
Action (insert) |
+len(key) + len(newValue) |
Action (delete) |
-(len(key) + len(oldValue)) |
Action (update) |
len(newValue) - len(oldValue) |
SwapAction |
+len(SwapKey) + 8 |
SwapClaimAction |
-(len(SwapKey) + 8) |
SettleAction |
+2 * (len(SettleKey) + 8) |
GenesisAction |
+genesisSize(block) + len(directory) |
PeerAction |
+len(owner) + len(IpAddress) + 13 |
OrderPostAction |
+len(OrderLockKey) + 8 |
OrderCancelAction |
-(len(OrderLockKey) + 8) |
OrderReleaseAction (partial) |
0 |
OrderReleaseAction (full) |
-(len(OrderLockKey) + 8) |
Total delta per block must not exceed spec.maxStateGrowth.
For each (publicKeyHex, signatureHex) in tx.signatures:
valid = P256_ECDSA_Verify(
message: CID(tx.body),
signature: signatureHex,
publicKey: publicKeyHex
)
All signatures must verify. All signers listed in tx.body.signers must have corresponding valid signatures.
For each AccountAction where newBalance < oldBalance (debit):
action.ownerMUST be intx.body.signers
Credits (newBalance > oldBalance) do not require signer authorization.
Transaction filters evaluate a JavaScript function transactionFilter(json) on the JSON-serialized TransactionBody. Action filters evaluate actionFilter(json) on each JSON-serialized Action. Both must return true for the transaction to be valid.
| Context | Swaps | Settles | Swap Claims | Matched Orders | Claimed Orders | Post Orders | Cancel Orders | Order Fills |
|---|---|---|---|---|---|---|---|---|
| Genesis | No | No | No | No | No | No | No | No |
| Nexus | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Child chain | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
The exchange protocol enables trustless cross-chain atomic swaps between any two chains in the lattice hierarchy. Makers sign orders off-chain; matchers (typically miners) include matched orders in blocks; consensus enforces correctness.
Settlement is recorded on the lowest common ancestor (LCA) of the two source chains, not always the nexus. The transaction's chainPath determines which chain processes it and where settlement is recorded. Claim verification checks both the chain's own settleState and parentHomestead.settleState, so claims succeed regardless of whether settlement was placed on the current chain or its parent.
A cross-chain swap proceeds in three phases across multiple chains:
Phase 1 -- Lock (on each maker's source chain):
A MatchedOrder in a transaction derives SwapActions that lock tokens in swap state. Both makers' accounts are debited fillAmount + fee on their respective source chains. The fee is locked alongside the fill amount so it can be refunded on timeout.
Phase 2 -- Settle (on the nexus chain):
The same MatchedOrder derives a SettleAction recorded on the nexus chain's settle state. This serves as a cross-chain coordination point -- child chains can later prove settlement occurred by checking parentHomestead.settleState.
Phase 3 -- Claim (on each maker's source chain):
A MatchedOrder in claimedOrders derives SwapClaimActions. Each counterparty claims the other maker's locked tokens. Claim validation requires a settlement proof: the SettleKey must exist in the chain's settleState (nexus) or parentHomestead.settleState (child chain).
Matched orders automatically derive several action types. Consensus validates the signed orders and then applies these derived actions as if they were explicitly included in the transaction.
For a MatchedOrder(orderA, orderB, nonce, fillAmountA, fillAmountB):
Lock phase (from matchedOrders):
| Derived Action | Chain | Description |
|---|---|---|
AccountAction(orderA.maker, -(fillAmountA + feeA)) |
A's sourceChain | Debit maker A |
AccountAction(orderB.maker, -(fillAmountB + feeB)) |
B's sourceChain | Debit maker B |
SwapAction(nonceA, makerA, makerB, fillAmountA + feeA, timelock) |
A's sourceChain | Lock A's tokens + fee |
SwapAction(nonceB, makerB, makerA, fillAmountB + feeB, timelock) |
B's sourceChain | Lock B's tokens + fee |
SettleAction(nonce, makerA, makerB, swapKeyA, dirA, swapKeyB, dirB) |
nexus | Record settlement |
Claim phase (from claimedOrders):
| Derived Action | Chain | Description |
|---|---|---|
AccountAction(orderB.maker, +fillAmountA) |
A's sourceChain | Credit counterparty B |
AccountAction(orderA.maker, +fillAmountB) |
B's sourceChain | Credit counterparty A |
SwapClaimAction(nonceA, makerA, makerB, fillAmountA + feeA, timelock) |
A's sourceChain | Unlock A's swap |
SwapClaimAction(nonceB, makerB, makerA, fillAmountB + feeB, timelock) |
B's sourceChain | Unlock B's swap |
Proportional fee: feeA = floor(orderA.fee * fillAmountA / orderA.sourceAmount), computed via UInt128 to avoid overflow.
A MatchedOrder is valid if and only if:
- Cross-chain:
orderA.sourceChain == orderB.destChainand vice versa - No same-chain:
orderA.sourceChain != orderA.destChain - Different makers:
orderA.maker != orderB.maker - Matching timelocks:
orderA.timelock == orderB.timelock - Positive timelock:
orderA.timelock > 0 - Positive fills:
fillAmountA > 0andfillAmountB > 0 - Fill within order:
fillAmountA <= orderA.sourceAmountandfillAmountB <= orderB.sourceAmount - Int64-safe debits:
fillAmountA + feeA <= Int64.maxandfillAmountB + feeB <= Int64.max - A's rate satisfied:
fillAmountB * orderA.sourceAmount >= fillAmountA * orderA.destAmount - B's rate satisfied:
fillAmountA * orderB.sourceAmount >= fillAmountB * orderB.destAmount - Order not expired:
orderA.timelock > blockIndexandorderB.timelock > blockIndex - Uniform clearing price: All matches in the same directed pair within a block must execute at the same rate (verified via cross-multiplication)
- Cumulative fill limit: Total fills per order (by order hash) within a block must not exceed the order's
sourceAmount - Signature validity: Both
SignedOrders have valid secp256k1 signatures andmakerAddress == order.maker
Order fees are fully refundable on swap timeout. This is achieved by locking the fee alongside the fill amount in swap state:
swapLockAmount = fillAmount + proportionalFee
Lock phase: No order fee is collected. The fee is escrowed in swap state. derivedOrderFees(lockPhase) = 0.
Claim phase: The full fee is available from the excess in swapClaimed over the counterparty credit. The miner includes derivedOrderFees in the coinbase. derivedOrderFees(claimPhase) = sum(proportionalFees).
Refund (timeout): The sender reclaims the full swapLockAmount (fill + fee). The maker loses nothing.
This means order fees do NOT appear in the totalFees term of the balance equation. Instead, they flow through the swapClaimed term:
swapClaimed (fill + fee) = counterpartyCredit (fill) + minerFee (fee)
Orders include a timelock field. At consensus, matched orders are rejected if timelock <= blockIndex. This prevents stale orders from being filled long after the maker intended them to expire.
Claimed orders are NOT subject to the expiry check -- once tokens are locked, the claim is valid regardless of the original timelock. (The timelock governs refund eligibility, not claim eligibility.)
For each SwapAction:
- Key:
SwapKey(action)=sender/recipient/amount/timelock/nonce - Proof: Verify key does NOT exist in
swapState(insertion proof -- prevents duplicate locks) - Update:
swapState[key] = action.amount
For each SwapClaimAction:
- Key:
SwapKey(action)(same key format as the corresponding swap) - Proof: Verify key EXISTS in
swapState(mutation proof -- proves lock exists) - Update: Delete
swapState[key]
Settlement is recorded on the LCA chain (the chain where the matched order transaction lives). Settle actions are applied to that chain's settleState.
For each SettleAction:
- Key A:
SettleKey(directoryA, swapKeyA) - Key B:
SettleKey(directoryB, swapKeyB) - Proof: Verify both keys do NOT exist (insertion proofs)
- Update:
settleState[keyA] = nonce,settleState[keyB] = nonce
Claim verification: Non-refund swap claims check for settlement existence in both the chain's own settleState (for LCA settlements on the current chain) and parentHomestead.settleState (for settlements on the parent chain). The first successful proof satisfies the requirement.
For any block at index i:
totalCredits <= totalDebits + reward(i) + totalFees + totalSwapClaimed - totalSwapLocked + totalOrderReleased - totalOrderLocked
Where:
totalCredits= sum of all positive account action deltas (including derived from orders)totalDebits= sum of all negative account action deltas (absolute values)totalFees= sum of explicit transactionbody.feevalues (NOT order fees)totalSwapLocked= sum of allSwapAction.amountvalues (fill + fee)totalSwapClaimed= sum of allSwapClaimAction.amountvalues (fill + fee)totalOrderLocked= sum of allOrderPostAction.lockAmountvaluestotalOrderReleased= sum of allOrderReleaseAction.releaseAmount+OrderCancelAction.amountvalues
After timelock expiry (blockIndex > timelock), the original sender can submit a SwapClaimAction with isRefund = true. This deletes the swap state entry and credits the full locked amount (fill + fee) back to the sender's account via an explicit AccountAction.
No value creation: The balance equation guarantees that tokens entering accounts (totalCredits) cannot exceed tokens leaving accounts (totalDebits) plus block reward plus the net swap flow. Order fees are zero-sum within the swapClaimed term.
No double-fill: Cumulative fills per order are tracked by doubleSha256(JSON(order)) within each block. Cross-block double-fills are prevented by SwapKey uniqueness in swap state (insertion proofs).
No stale execution: Order expiry (timelock > blockIndex) prevents matching orders whose maker no longer intends to trade.
Refund safety: Refunds require blockIndex > timelock, so a swap cannot be both claimed and refunded -- the claim window (before expiry) and refund window (after expiry) are disjoint, mediated by the settlement proof requirement for claims.
Cross-chain atomicity: Settlement on the nexus provides coordination. If party A's tokens are locked on chain X and party B's tokens are locked on chain Y, both claims require the same settlement proof. Either both claims succeed (after settlement) or both parties eventually refund (after timeout).
In addition to the instant matching protocol (section 8), Lattice supports a persistent on-chain order book where funds are locked at post time. This enables orders to persist across blocks and be filled later, unlike matchedOrders which require both sides to be matched in the same transaction.
The order lifecycle has three phases:
- Post -- A maker submits a
SignedOrderinpostOrders. The maker's account is debitedsourceAmount + feeand the locked amount is recorded inorderLockState. - Fill -- A matcher includes a
MatchedOrderinorderFillsreferencing two previously posted orders. The locked amounts are released fromorderLockStateand converted into swap locks (same as the instant matching protocol). - Cancel -- The maker signs an
OrderCancellationincluded incancelOrders. The locked amount is returned to the maker's account after verifying the declared amount matches state.
A SignedOrder in postOrders triggers:
// Account debit
AccountAction(owner: maker, delta: -(sourceAmount + fee))
// Order lock insertion
orderLockState[maker/nonce] = sourceAmount + fee
Validation:
- Signature verifies:
verify(signature, doubleSha256(JSON(order)), publicKey)andCID(publicKey) == maker - Maker is a signer of the transaction
sourceAmount > 0anddestAmount > 0timelock > 0sourceChain != destChain(no same-chain orders)sourceAmount + feefits in Int64 (safe for account delta)
Proof: Insertion proof -- OrderLockKey must NOT exist in orderLockState.
Balance effect: totalDebits increases by sourceAmount + fee; totalOrderLocked increases by sourceAmount + fee. Net: 0.
A MatchedOrder in orderFills triggers the same derived actions as matchedOrders (swap locks, settle actions, account debits), plus:
// Release order lock for maker A
OrderReleaseAction(maker: orderA.maker, nonce: orderA.nonce, releaseAmount: fillAmountA + feeA)
// Release order lock for maker B
OrderReleaseAction(maker: orderB.maker, nonce: orderB.nonce, releaseAmount: fillAmountB + feeB)
The release reduces the locked amount in orderLockState. If the remaining amount reaches 0, the entry is deleted (full fill). Otherwise it is updated (partial fill).
Fills follow the same matching rules as matchedOrders (section 8.3), including order expiry, uniform clearing price, and cumulative fill limits.
Key difference from instant matches: The account debit already happened at post time, not at fill time. The fill converts locked funds (order lock) into swap-locked funds (swap state), so the fill itself produces no net account movement -- it releases from orderLockState and locks into swapState.
Balance effect: totalOrderReleased increases by fillAmount + fee; totalSwapLocked increases by fillAmount + fee. Account debits from swap actions are offset by the order release. Net: 0.
An OrderCancellation in cancelOrders triggers:
// Credit maker the remaining locked amount
AccountAction(owner: maker, delta: +amount)
// Delete order lock
orderLockState.delete(maker/orderNonce)
Validation:
- Cancellation signature verifies:
verify(doubleSha256("cancel:" || orderNonce), signature, publicKey)andCID(publicKey) == maker - Maker is a signer of the transaction
- Declared
amountexactly equals the value stored inorderLockState[maker/orderNonce]
Proof: Deletion proof -- OrderLockKey must exist in orderLockState, and the stored value must equal amount.
Balance effect: totalCredits increases by amount; totalOrderReleased increases by amount. Net: 0.
Within a single block, the following combinations for the same OrderLockKey are rejected:
- Post + Release (cannot post and fill in the same block)
- Post + Cancel (cannot post and cancel in the same block)
- Release + Cancel (cannot partially fill and cancel in the same block)
- Multiple Posts (cannot post the same order twice)
- Multiple Cancels (cannot cancel the same order twice)
Multiple Releases for the same key are allowed (multiple partial fills in one block) and are aggregated by summing releaseAmount values.
Fill transactions (orderFills) may have empty signatures and signers -- the signed orders themselves provide authorization. This enables miners to fill orders without being a party to the trade. However, signer-less transactions must have fee == 0 to prevent supply inflation (fees inflate the balance equation's available pool, and without a signer debit to back the fee, a miner could create tokens).
No cancel inflation: The amount field in OrderCancellation must exactly match the stored value in orderLockState. If a canceller declares an inflated amount, the state proof rejects the transaction. This prevents crediting more than was locked.
Order lock uniqueness: Each OrderLockKey can only be inserted once (insertion proof). Combined with the order nonce, this prevents duplicate posts for the same order.
Post-fill atomicity: When a fill releases from orderLockState and locks into swapState, both state transitions are proven in the same block. The fill cannot release without also locking -- the balance equation would not balance.
Given two competing chain tips with work metrics (highestIndex_L, parentIndex_L) and (highestIndex_R, parentIndex_R):
function rightIsBetter(left, right):
if right.parentIndex != nil:
if left.parentIndex != nil:
return right.parentIndex < left.parentIndex
return true // anchored beats unanchored
if left.parentIndex == nil:
return right.highestIndex > left.highestIndex // longer chain
return false // unanchored cannot beat anchored
Priority order:
- Parent chain anchoring (anchored > unanchored)
- Earlier parent anchor index (lower > higher)
- Chain length (longer > shorter)
- First-seen (incumbent holds on tie)
Each chain maintains:
ChainState = actor {
chainTip: string, // hash of best known block
mainChainHashes: Set<string>, // all hashes on main chain
indexToBlockHash: Map<uint64, Set<string>>,
hashToBlock: Map<string, BlockMeta>,
parentChainBlockHashToBlockHash: Map<string, string>
}
When a new nexus block arrives, processing happens in two phases:
Phase 1: Nexus validation and submission (required)
- Validate the block via
validateNexus()(section 5.2) -- child blocks are NOT validated here - Verify proof-of-work:
difficultyHash(B) < B.difficulty - Submit to
ChainState: a. Ifblock.index + RECENT_BLOCK_DISTANCE < highestBlockIndex, discard (too old) b. If block hash already known, handle as duplicate (may add parent chain reference) c. Insert intohashToBlockandindexToBlockHashd. If previous block is current chain tip, extend main chain e. If previous block is unknown and block is recent, request the missing parent f. Otherwise, evaluate fork choice viacheckForReorg()
Phase 2: Child block extraction (deferred, independent)
Only after the nexus block is accepted onto the main chain:
- Extract child blocks from
B.childBlocksMerkle dictionary - For each child block, validate independently against its child chain's rules (section 5.3)
- Invalid child blocks are silently skipped -- they do not affect the nexus block or other children
- Newly discovered child chains (genesis blocks) are registered in the chain hierarchy
This two-phase design means nexus miners only need to perform nexus-level validation and mining. Child block validation is entirely the responsibility of nodes that participate in those child chains.
When a fork beats the current main chain:
- Find the earliest orphan block connected to the main chain (the fork point)
- Compute
mainChainWorkfrom the fork point using current main chain - Compute
forkWorkfrom the fork point through the new fork - If
rightIsBetter(mainChainWork, forkWork): a. UpdatechainTipto the new fork's tip b. Remove old main chain blocks frommainChainHashes(above fork point) c. Add new fork blocks tomainChainHashesd. ReturnReorganizationdescribing added/removed blocks e. Propagate to child chains
When a child chain block is included in a parent chain block at index P_i:
- Record
parentChainBlockHashToBlockHash[P_hash] = C_hash - Record
hashToBlock[C_hash].parentChainBlocks[P_hash] = P_i
The parentIndex of a BlockMeta is the minimum of all known parent chain indices:
parentIndex = min(parentChainBlocks.values.compactMap { $0 })
When the parent chain reorganizes:
- For each removed parent block hash: clear the corresponding anchoring reference in the child chain's block
- For each added parent block hash: update the anchoring reference with the new parent index
- Find affected child chain blocks that are not on the main chain
- For each, evaluate fork choice -- the changed anchoring may trigger a child chain reorg
When the chain tip advances, blocks at index < (tipIndex - RECENT_BLOCK_DISTANCE) are pruned from memory. RECENT_BLOCK_DISTANCE = 1000.
Block weights for fork comparison are encoded as a 2-element array:
weights(block) =
if block.parentIndex != nil:
[UInt64.max - block.parentIndex, block.blockIndex]
else:
[0, block.blockIndex]
This enables lexicographic comparison where parent-anchored blocks always sort higher than unanchored blocks (due to the UInt64.max - parentIndex term being very large).
rewardAtBlock(index) = initialReward >> ((index + premine) / halvingInterval)
Where:
initialReward = 2^initialRewardExponenthalvingInterval = 2^(64 - initialRewardExponent)premineoffsets the halving clock
The reward halves every halvingInterval blocks. After initialRewardExponent halvings, the reward reaches 0.
The premine represents blocks conceptually "mined" by chain creators before public mining begins. The premine amount is:
premineAmount = premine * initialReward
Public mining starts at block index 0, but the halving schedule treats it as block premine. This means the first public halving occurs at block halvingInterval - premine.
totalRewards(n) = sum(rewardAtBlock(i) for i in 0..<n)
Computed efficiently via geometric series in O(log n) time by iterating through halving periods.
A ChainSpec is valid if:
maxNumberOfTransactionsPerBlock > 0
maxStateGrowth > 0
targetBlockTime > 0
0 < initialRewardExponent < 64
premine < halvingInterval
| Primitive | Algorithm | Usage |
|---|---|---|
| Hash | SHA-256 | Block hashes, Merkle trees, addresses, difficulty |
| Signature | secp256k1 ECDSA | Transaction and order authorization |
| Content addressing | CID (DAG-CBOR + SHA-256) | All data structure references |
| Sparse proofs | Sparse Merkle Tree | State inclusion/exclusion proofs |
address(publicKey) = "1" || sha256(ripemd160(publicKey))[:32]
Note: In the current implementation, ripemd160 delegates to sha256.
The following invariants MUST hold at all times:
For any consecutive blocks B[i] and B[i+1] on the same chain:
B[i].frontier == B[i+1].homestead
For any valid block:
totalCredits <= totalDebits + reward + totalFees + totalSwapClaimed - totalSwapLocked + totalOrderReleased - totalOrderLocked
No tokens are created or destroyed by swaps or order locks. Lock-phase debits are exactly offset by swap/order state growth; claim-phase swap state reduction is exactly offset by counterparty credits plus miner fees; order releases are exactly offset by swap locks or cancel credits.
- The chain tip is always on the main chain
- The chain tip block always exists in the block map
- The genesis block is always on the main chain (never removed by reorg)
- Main chain blocks form a connected path from genesis to tip
mainChainBlocksAddedandmainChainBlocksRemovedin aReorganizationare disjoint sets
- Each
SwapKeyis unique in swap state (insertion proof prevents duplicate locks) - A swap claim requires the corresponding
SwapKeyto exist (mutation proof) - A non-refund claim requires a settlement proof in settle state
- A refund claim requires
blockIndex > timelock(swap has expired) - Settlement entries are never deleted -- they persist as permanent coordination proofs
- Cumulative fills per order within a block cannot exceed the order's
sourceAmount - Matched orders must not be expired (
timelock > blockIndex) - Order fees are fully refundable:
swapLockAmount = fillAmount + proportionalFee - Each
OrderLockKeyis unique in order lock state (insertion proof prevents duplicate posts) - Order cancel amount must exactly match stored lock value (prevents cancel inflation)
- Order post and release/cancel for the same key in the same block are rejected (conflict rule)
compareWorkis irreflexive: no fork is better than itselfcompareWorkis asymmetric: if A beats B, B does not beat A- Parent chain anchoring strictly dominates chain length
- Among equally anchored forks, lower parent index wins
- Among unanchored forks, strictly longer chain wins
| Constant | Value | Description |
|---|---|---|
RECENT_BLOCK_DISTANCE |
1000 | Blocks older than this are pruned from memory |
maxDifficultyChange |
2 | Maximum difficulty adjustment factor per block |
totalExponent |
64 | Bit width of the reward/halving system |