Status: Stable · Contract: LedgerLensScoreContract · introduced in
CONTRACT_VERSION 3.
submit_scores_batch_attested is the cryptographic-payload-integrity
companion to submit_scores_batch. The legacy batch entry point only
enforces Soroban's native service.require_auth() check — the transaction
was sent by the authorised service key is verified, but this specific
batch payload was produced by the off-chain detection pipeline is not. A
compromised service key (or unauthorised relayer) can therefore submit
arbitrary scores in a batch.
The attested batch path closes that gap with a Merkle tree whose root is signed once with the existing service secp256k1 key, and whose per-entry inclusion proofs the contract walks on-chain.
- One signature per batch rather than one per entry. A 20-entry batch
needs one
secp256k1_recovercall on-chain instead of 20 — a meaningful gas saving. - Forward-compatibility with the existing attestation key. The same
secp256k1 key registered via
set_service_pubkeysigns both single-scoreScoreAttestations and the newBatchAttestationroot signatures, so no key rotation is needed to opt in. - Backward-compatible surface.
submit_scores_batchis unchanged;submit_scores_batch_attestedis a new entry point that callers opt into. Both paths coexist indefinitely.
/// Merkle-root attestation for a complete batch: a single secp256k1
/// signature over the Merkle root of every leaf commitment in the batch.
pub struct BatchAttestation {
pub merkle_root: BytesN<32>,
/// 65-byte signature, identical byte layout to ScoreAttestation:
/// 32-byte `r`, 32-byte `s`, 1-byte recovery id (must be `0` or `1`).
pub signature: BytesN<65>,
}
/// A single batch entry that carries its own Merkle inclusion proof.
pub struct ScoreSubmissionWithProof {
pub submission: ScoreSubmission,
pub proof: Vec<BytesN<32>>,
/// Bit i (LSB = 0) is 0 if the sibling at level i is to the right of
/// the node being walked up, 1 if to the left.
pub proof_flags: u32,
}The contract uses the RFC 9162 / RFC 6962 style explicit preimage prefix scheme to distinguish leaves from internal nodes, rather than the alternative sorted-pair scheme:
- Leaf:
Le = SHA-256(0x00 || compute_commitment(submission))— 33-byte preimage (1-byte prefix + 32-byte commitment). - Internal node:
N = SHA-256(0x01 || left || right)— 65-byte preimage (1-byte prefix + 32-byte left + 32-byte right).
The 0x00 leaf marker can never collide with the 0x01 internal-node
marker, so a leaf hash can never equal an internal-node hash regardless
of the input distribution. There is no need for an additional sort step
to randomize sibling ordering — sibling position is conveyed cleanly by
the explicit proof_flags field below.
- Sorted-pair is incompatible with this contract's
proof_flagsdesign.proof_flagsrecords whether the sibling at each level sits to the left or right of the node being walked up; a sorted-pair scheme derives sibling ordering from the hash values themselves (the smaller hash always goes left), making the position-flag redundant and incorrect. - Sorted-pair leaves a proof ambiguous: another tree that produces the same root via a different shape would still verify, because what matters to a sorted-pair verifier is just the multiset of nodes, not the shape. With explicit prefixes, the shape is part of the protocol.
compute_commitmentalready returns the SHA-256 of a 175-byte preimage. The Merkle leaf could plausibly be that digest unmodified, since leaves (32 bytes) and internal nodes (32 bytes) collide on output length and would need separate prefixes anyway. We hash once more with the0x00prefix instead of skipping the prefix; this keeps the shape of the protocol symmetrical (every level hashes exactly one prefixed preimage) and leaves a single obvious way to compute the preimage.
For a batch of N entries (the contract enforces N ≤ MAX_BATCH_SIZE =
20, but the algorithm works for any power-of-two leaf count up to
2^30):
- For each entry
i, computecommit_i = compute_commitment(submission_i)— the same 175-byte preimagesubmit_scorealready binds. - For each entry, hash it into a leaf:
Le_i = SHA-256(0x00 || commit_i). - Pad the leaf list to a power of two (
leaf_countis rounded up to2^ceil(log2(N))) by duplicating the last leaf. This is the standard Merkle "tail duplication" shape — stronger than zero-padding against known-leaf attacks because no extra zero-derived subtrees are introduced. The contract does not need to know whether you padded by zero-fill or tail-duplication, because the proof's reconstruction is unambiguous. - Build the tree bottom-up. At each non-bottom level, for each pair
(L, R)(left to right), computeN = SHA-256(0x01 || L || R). Use the resulting nodes as the next level's input. - Stop when one 32-byte root remains.
The pipeline then signs the root with the off-chain secp256k1 signing key
registered via set_service_pubkey, producing a 65-byte signature with
the same byte layout as ScoreAttestation.
Suppose we have four entries whose underlying commitments are C0, C1, C2, C3.
The leaves are:
L0 = SHA-256(0x00 || C0)
L1 = SHA-256(0x00 || C1)
L2 = SHA-256(0x00 || C2)
L3 = SHA-256(0x00 || C3)
The internal nodes are:
N0 = SHA-256(0x01 || L0 || L1)
N1 = SHA-256(0x01 || L2 || L3)
R = SHA-256(0x01 || N0 || N1) ← merkle_root
The per-entry proofs are:
| Index | proof |
proof_flags |
|---|---|---|
| 0 | [L1, N1] |
0 |
| 1 | [L0, N1] |
1 |
| 2 | [L3, N0] |
2 |
| 3 | [L2, N0] |
3 |
Walk-through for index 2 (proof = [L3, N0], flags = 2):
level 0: bit 0 of flags (value 2 → binary 10) is 0 → sibling on right.
current = SHA-256(0x01 || L2 || L3) = N1
level 1: bit 1 of flags (value 2 → binary 10) is 1 → sibling on left.
current = SHA-256(0x01 || N0 || N1) = R ✓ matches root
submit_scores_batch_attested performs the following steps in order:
-
Contract-state guards.
NotInitialized,ContractPaused, thenServicePubkeyNotSet(the new entry point has no "skip attestation" mode — if no pubkey is configured, the call fails fast before signature recovery). -
Service authorization. Either M-of-N (
signers.len() >= thresholdand each signer is inServiceSetand individuallyrequire_auths) or legacy single-servicerequire_auth, exactly mirroringsubmit_score. -
Batch-shape guards.
EmptyBatch(size 0) orBatchTooLarge(sizeMAX_BATCH_SIZE). -
Root signature verification. One
secp256k1_recoveroverSHA256(attestation.merkle_root)— not overmerkle_rootdirectly — recovered public key compared against the registered service pubkey. The 65-byte signature format is byte-identical toScoreAttestation, so this is the same code path as the per-score verifier, just with a different digest.Why the SHA-256 wrap? soroban-sdk 21.x's
env.crypto().secp256k1_recovertakes an opaqueHash<32>, andHash<N>has no public constructor — it can only be built via a host crypto function (sha256,keccak256). To get an opaque handle from a 32-bytemerkle_rootpassed in asBytesN<32>, the contract wraps once viaenv.crypto().sha256. The off-chain pipeline signsSHA256(root), so both sides agree. The wrap is purely a compatibility shim — no security property changes. (A direct signing ofrootwould be equivalent.) If a future soroban-sdk release adds a publicHash::from_arrayconstructor, this wrap can be dropped.If the root signature fails verification, the entire batch is rejected with
Error::InvalidAttestation— a bad root signature means no entry can be trusted to have come from the off-chain pipeline. -
Per-entry Merkle proof verification. For each entry
i:proof.len() > MAX_MERKLE_PROOF_DEPTH(currently 30) → reject withError::InvalidAttestation. The contract cannot afford an unbounded number of SHA-256 invocations.- Walking the full proof from the recomputed leaf (
compute_merkle_leaf) to the suppliedmerkle_rootyields the same root → entry may proceed. Any mismatch → that entry alone is rejected withError::InvalidAttestation; the rest of the batch still processes.
-
Existing per-entry validation. Mirrors
submit_scores_batch:InvalidScore(score > 100),InvalidConfidence(confidence > 100),InvalidTimestamp(timestamp == 0),RateLimitExceeded(per-pair cooldown). Rejection code is recorded in the entry'sBatchEntryResult— the batch is never aborted by a single bad entry.
The proof loop runs to completion on every entry regardless of whether an intermediate hash diverges — so the gas cost is always bounded and no timing-style side channel is exposed.
A minimal reference for the off-chain detection pipeline. Uses pyca/cryptography
for secp256k1.
import hashlib
from dataclasses import dataclass
from typing import List, Tuple
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.backends import default_backend
LEAF_PREFIX = b"\x00"
INNER_PREFIX = b"\x01"
def commitment(submission: dict) -> bytes:
"""Reproduces the contract's `compute_commitment` layout."""
preimage = b""
# (wallet strkey, pair zero-padded to 9, score LE u32, bf, ml,
# ts LE u64, confidence LE u32, model_version LE u32,
# contract_address strkey, network_id — see docs/attestation-spec.md.)
# ... omitted for brevity; see attestation-spec.md for the full layout.
return hashlib.sha256(preimage).digest()
def merkle_leaf(commit: bytes) -> bytes:
return hashlib.sha256(LEAF_PREFIX + commit).digest()
def merkle_internal(left: bytes, right: bytes) -> bytes:
assert len(left) == len(right) == 32
return hashlib.sha256(INNER_PREFIX + left + right).digest()
def build_tree(leaves: List[bytes]) -> bytes:
# Caller is responsible for padding to a power of two.
assert len(leaves) & (len(leaves) - 1) == 0
level = leaves
while len(level) > 1:
level = [merkle_internal(level[i], level[i+1]) for i in range(0, len(level), 2)]
return level[0]
def proof_for(leaves: List[bytes], index: int) -> Tuple[List[bytes], int]:
assert len(leaves) & (len(leaves) - 1) == 0
proof, flags, level, idx = [], 0, leaves, index
while len(level) > 1:
sibling_idx = idx ^ 1
if idx & 1:
flags |= 1 << len(proof)
proof.append(level[sibling_idx])
level = [merkle_internal(level[i], level[i+1]) for i in range(0, len(level), 2)]
idx //= 2
return proof, flags
def sign_root(root: bytes, private_key) -> bytes:
# **Sign `SHA256(root)`, not `root` directly.** The on-chain verifier
# wraps `merkle_root` through SHA-256 once before
# `secp256k1_recover` because soroban-sdk 21.x's `Hash<32>` is
# opaque and has no public constructor — only host crypto
# functions can build it. We mirror that wrap here so both sides
# agree on the digest bytes.
verified_digest = hashlib.sha256(root).digest()
sig = private_key.sign(verified_digest, ec.ECDSA(utils.Prehashed(hashes.SHA256())))
r = sig[0:32]
s = sig[32:64]
# recovery_id is determined out-of-band (e.g. by trying both 0/1
# during signing). See cryptography's recoverable ECDSA for
# higher-level helpers.
raise NotImplementedError("see the pipeline's signing utility")Same algorithm, native Web Crypto API.
import { createHash } from "node:crypto";
const LEAF_PREFIX = Buffer.from([0x00]);
const INNER_PREFIX = Buffer.from([0x01]);
function merkleLeaf(commit: Buffer): Buffer {
return createHash("sha256").update(Buffer.concat([LEAF_PREFIX, commit])).digest();
}
function merkleInternal(left: Buffer, right: Buffer): Buffer {
return createHash("sha256").update(Buffer.concat([INNER_PREFIX, left, right])).digest();
}
function buildTree(leaves: Buffer[]): Buffer {
// leaves must be padded to a power of two
if ((leaves.length & (leaves.length - 1)) !== 0) throw new Error("not power of two");
let level = leaves;
while (level.length > 1) {
const next: Buffer[] = [];
for (let i = 0; i < level.length; i += 2) next.push(merkleInternal(level[i], level[i + 1]));
level = next;
}
return level[0];
}
function proofFor(leaves: Buffer[], index: number): { proof: Buffer[]; flags: number } {
let proof: Buffer[] = [];
let flags = 0;
let level = leaves;
let idx = index;
while (level.length > 1) {
const siblingIdx = idx ^ 1;
if (idx & 1) flags |= 1 << proof.length;
proof.push(level[siblingIdx]);
const next: Buffer[] = [];
for (let i = 0; i < level.length; i += 2) next.push(merkleInternal(level[i], level[i + 1]));
[level, idx] = [next, idx >> 1];
}
return { proof, flags };
}
function signRoot(root: Buffer, privateKey: crypto.KeyObject): Buffer {
// **Sign `SHA256(root)`, not `root` directly.** The on-chain verifier
// wraps `merkle_root` through SHA-256 once before
// `secp256k1_recover` because soroban-sdk 21.x's `Hash<32>` is opaque
// and can only be built via host crypto functions. The pipeline
// mirrors that wrap here so both sides agree on the digest bytes.
const verifiedDigest = createHash("sha256").update(root).digest();
// Higher-level recoverable-ECDSA helpers vary by Node version;
// a production pipeline should use a library that exports
// `(r || s || recovery_id)` as 65 bytes directly.
throw new Error("see the pipeline's signing utility");
}
## 8. Edge cases and limits
| Edge case | Behaviour |
|------------------------------------------|------------------------------------------------------------------------------------------------------------|
| 1-entry batch | `proof = []`, `proof_flags = 0`. `verify_merkle_proof` skips its loop and matches `leaf == root`. |
| `proof.len() > MAX_MERKLE_PROOF_DEPTH` (`30`) | Reject with `Error::InvalidAttestation` regardless of whether the root would otherwise match. |
| Tampered `merkle_root` in `BatchAttestation` | `secp256k1_recover` returns the wrong key → entire batch rejected with `Error::InvalidAttestation`. |
| Tampered entry-level proof | Just that entry gets `Error::InvalidAttestation` in `BatchEntryResult.rejection_code`; rest of batch proceeds. |
| Service pubkey never set | Reject with `Error::ServicePubkeyNotSet` before signature recovery (clearer than `InvalidAttestation`). |
| No matching `service_set`/`threshold` config | Legacy single-service `require_auth` path runs — same behaviour as `submit_score`. |
## 9. Acceptance criteria
Cross-references the issue ([#40](https://github.com/Ledger-Lenz/Ledgerlens-contract/issues/40)):
1. `submit_scores_batch_attested` is implemented with per-entry Merkle proof verification.
2. `verify_merkle_proof` and `sha256_pair` (here called `hash_internal_node`)
are pure, testable functions, callable from sibling modules in the crate.
3. Domain separation (leaf prefix `0x00`, internal prefix `0x01`) is documented
and implemented.
4. `supports_interface("batch_attested")` returns `true`.
5. All 12 mandatory tests in `test_batch_attestation.rs` pass.
6. `cargo clippy -- -D warnings` and `cargo fmt --check` pass on the change.