diff --git a/README.md b/README.md index 5515864d..8cbd0496 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The service is configured via environment variables: |-----------------|-----------------|-----------| | `CHAIN_ID` | Chain ID | `unicity` | | `CHAIN_VERSION` | Chain version | `1.0` | -| `CHAIN_FORK_ID` | Chain's Fork ID | `mainnet` | +| `CHAIN_FORK_ID` | Chain's Fork ID | `testnet` | ### Server Configuration | Variable | Description | Default | @@ -122,6 +122,8 @@ The service is configured via environment variables: | `ENABLE_TLS` | Enable HTTPS/TLS | `false` | | `TLS_CERT_FILE` | TLS certificate file path | `` | | `TLS_KEY_FILE` | TLS private key file path | `` | +| `ENABLE_H2C` | Serve HTTP/2 cleartext (h2c) alongside HTTP/1.1 | `true` | +| `HTTP2_MAX_CONCURRENT_STREAMS` | Max concurrent HTTP/2 streams per connection | `4096` | ### Database Configuration | Variable | Description | Default | @@ -157,11 +159,23 @@ The service is configured via environment variables: | `LOG_ENABLE_JSON` | Enable JSON formatted logs | `true` | | `LOG_ENABLE_ASYNC` | Enable asynchronous logging for better performance | `true` | | `LOG_ASYNC_BUFFER_SIZE` | Buffer size for async logging | `10000` | +| `LOG_FILE_PATH` | Log file path; empty disables file logging and rotation | `` | +| `LOG_MAX_SIZE_MB` | Rotate the log file once it reaches this size | `100` | +| `LOG_MAX_BACKUPS` | Rotated log files to retain | `30` | +| `LOG_MAX_AGE_DAYS` | Days to retain rotated log files | `30` | +| `LOG_COMPRESS_BACKUPS` | Compress rotated log files | `true` | ### Processing Configuration | Variable | Description | Default | |----------|-------------|---------| -| `BATCH_LIMIT` | Maximum number of commitments to process per batch | `1000` | +| `BATCH_LIMIT` | Batch-size hint; currently only logged at startup, not enforced | `1000` | +| `MAX_COMMITMENTS_PER_ROUND` | Cap on commitments collected per precollected round (child mode; standalone/`bft-shard` only when `USE_REDIS_FOR_COMMITMENTS=true`) | `20000` | +| `COLLECT_PHASE_DURATION` | Fixed collection window before proposing a round (non-child modes, non-precollected rounds) | `200ms` | +| `COLLECT_MINI_BATCH_SIZE` | SMT/proposal staging mini-batch size during collection | `500` | +| `COMMITMENT_STREAM_BUFFER_SIZE` | Buffer between the queue streamer and round collection | `50000` | +| `PRECOLLECTOR_GRACE_PERIOD` | Extra wait before cutting a precollected round snapshot | `0s` | +| `SKIP_DUPLICATE_CHECK` | Skip the finalized-record lookup on submit | `true` | +| `PARENT_COLLECT_PHASE_DURATION` | Collection window before proposing a round in `parent` mode | `200ms` | ### Storage Configuration | Variable | Description | Default | @@ -174,6 +188,16 @@ The service is configured via environment variables: | `REDIS_STREAM_NAME` | Redis stream name for commitments (allows multiple shards to share a Redis instance) | `commitments` | | `REDIS_FLUSH_INTERVAL` | Interval for flushing pending commitments to Redis | `100ms` | | `REDIS_MAX_BATCH_SIZE` | Maximum batch size before forcing flush | `5000` | +| `REDIS_ACK_BATCH_SIZE` | Commitments acknowledged per XACK batch | `1000` | +| `REDIS_DELETE_AFTER_ACK` | Delete stream entries once acknowledged | `true` | +| `REDIS_MAX_STREAM_LENGTH` | Stream length before trimming | `1000000` | +| `REDIS_CLEANUP_INTERVAL` | Interval between stream trim checks | `5m` | +| `REDIS_POOL_SIZE` | Connection pool size | `10` | +| `REDIS_MIN_IDLE_CONNS` | Minimum idle connections kept in the pool | `2` | +| `REDIS_MAX_RETRIES` | Retries per Redis command | `3` | +| `REDIS_DIAL_TIMEOUT` | Connection dial timeout | `5s` | +| `REDIS_READ_TIMEOUT` | Read timeout | `3s` | +| `REDIS_WRITE_TIMEOUT` | Write timeout | `3s` | #### SMT Backend @@ -194,6 +218,8 @@ go build -tags rocksdb ./cmd/aggregator | `SMT_ROCKSDB_BLOOM_BITS` | Bloom filter bits per key | `10` | | `SMT_ROCKSDB_MEMTABLE_MB` | RocksDB write buffer size in MB | `64` | | `SMT_MATERIALIZE_WORKERS` | Parallel workers for SMT materialization | `16` | +| `SMT_PRECOMPUTE_PROOFS` | Precompute inclusion proof responses at finalization | `false` | +| `SMT_PROOF_METADATA_CACHE_ENTRIES` | Cached proof metadata entries | `250000` | RocksDB SMT with HA is supported only in `bft-shard` mode. It is rejected for application-level `parent`/`child` sharding modes; use `SMT_BACKEND=memory` there. Changing `SMT_NODE_KEY_FORMAT` requires a fresh or separately seeded `SMT_DISK_PATH`; an existing database opened with the wrong layout fails startup. @@ -233,7 +259,7 @@ make run | `BFT_BOOTSTRAP_CONNECT_RETRY_DELAY` | Delay between bootstrap connection retries (in seconds). | `5` | | `BFT_HEARTBEAT_INTERVAL` | How often the BFT client checks for inactivity. | `1s` | | `BFT_INACTIVITY_TIMEOUT` | Duration of inactivity before the BFT client sends a new handshake. | `5s` | -| `BFT_KEY_CONF_FILE` | Path to the BFT key configuration file. | `bft-config/keys.json` | +| `SIGNING_KEY_FILE` | Path to the aggregator's signing key file (`keys.json`); also supplies the BFT key conf. | `""` | | `BFT_SHARD_CONF_FILE` | Path to the aggregator shard configuration file. | `bft-config/shard-conf-7_0.json` | | `BFT_TRUST_BASE_FILES` | Comma-separated list of paths to trust base files. | `bft-config/trust-base.json` | @@ -280,7 +306,8 @@ type CertificationRequest struct { // CertificationData represents the necessary cryptographic data needed for a state transition CertificationRequest. type CertificationData struct { _ struct{} `cbor:",toarray"` - Version types.Version + // Version must be 2; any other value is rejected at decode. + Version types.Version `json:"version"` // OwnerPredicate is the owner predicate in format: CBOR[engine: uint, code: byte[], params: byte[]]. // @@ -325,13 +352,15 @@ type CertificationData struct { - `SUCCESS` - Certification request accepted and will be included in next block - `INVALID_PUBLIC_KEY_FORMAT` - Invalid secp256k1 public key - `INVALID_SIGNATURE_FORMAT` - Invalid signature format or length -- `SIGNATURE_VERIFICATION_FAILED` - Signature doesn't match transaction hash and public key -- `STATE_ID_MISMATCH` - StateID doesn't match SHA256(CBOR[publicKey, sourceStateHash]) +- `SIGNATURE_VERIFICATION_FAILED` - Witness doesn't verify against SHA256(CBOR[sourceStateHash, transactionHash]) and the predicate's public key +- `STATE_ID_MISMATCH` - StateID doesn't match SHA256(CBOR[ownerPredicate, sourceStateHash]) - `INVALID_SOURCE_STATE_HASH_FORMAT` - SourceStateHash is not exactly 32 bytes - `INVALID_TRANSACTION_HASH_FORMAT` - TransactionHash is not exactly 32 bytes - `INVALID_SHARD` - The certification request was sent to the wrong shard - `REQUEST_EXPIRED` - The round reference time has reached the request's exclusive deadline - `SERVICE_NOT_READY` - Consensus reference time is not yet available +- `STATE_ID_EXISTS` - A record for this stateId was already finalized (returned only when `SKIP_DUPLICATE_CHECK=false`; the check is off by default) +- `UNKNOWN` - The owner predicate is malformed (engine is not `1`, or code is not the single byte `0x01`) #### `get_inclusion_proof.v2` Retrieve the v2 inclusion proof for a submitted certification request. @@ -374,10 +403,12 @@ the reference time at which its leaf was created, independently of request-deadl **Hash rules (Yellowpaper-aligned):** - Value: `SHA-256(CBOR([transactionHash, referenceTime]))` for every inclusion proof - Leaf: `H(0x00 || key || value)` -- Inner node (two children): `H(0x01 || depth_byte || left || right)` +- Inner node (two children): `H(0x01 || depth_byte || region(key, depth) || left || right)` - Inner node (one child): passthrough (child hash unchanged) -**Key encoding:** 32 bytes, LSB-first bit addressing. `bit(key, d) = (key[d/8] >> (d%8)) & 1`. +`region(key, depth)` is the 32-byte key prefix addressing the node: the first `depth` bits of the key with all lower-significance bits cleared. Omitting it verifies only for proofs with no siblings. See [docs/inclusion-proof-wire.md](docs/inclusion-proof-wire.md). + +**Key encoding:** 32 bytes, big-endian (MSB-first) bit addressing. `bit(key, d) = (key[d/8] >> (7 - d%8)) & 1`. **Verification pseudocode:** ``` @@ -386,10 +417,11 @@ j = len(siblings) for d in 255..=0: if bitmap bit d is not set: continue j -= 1 - if bit(key, d) == 1: - h = H(0x01 || d || siblings[j] || h) + r = region(key, d) # 32 bytes: first d bits of key, rest cleared + if bit(key, d) == 1: # descent went right, sibling is the left child + h = H(0x01 || d || r || siblings[j] || h) else: - h = H(0x01 || d || h || siblings[j]) + h = H(0x01 || d || r || h || siblings[j]) assert j == 0 and h == UC.IR.h ``` @@ -440,14 +472,16 @@ Retrieve detailed information about a specific block. "block": { "index": "123", "chainId": "unicity", - "version": "1.0.0", - "forkId": "main", + "shardId": 0, + "version": "1.0", + "forkId": "testnet", "rootHash": "0000b67ebbbb3a8369f93981b9d8b510a7b8e72fc1e1b8a83b7c0d8a3c9f7e4d", "previousBlockHash": "0000a1b2c3d4e5f6789012345678901234567890123456789012345678901234", - "noDeletionProofHash": "0000c7d8e9f0123456789abcdef0123456789abcdef0123456789abcdef012345", + "noDeletionProofHash": "", "createdAt": "1734435600000", "unicityCertificate": "d903ef8701d903f08a01190146005844303030303936613239366432323466323835633637626565393363333066386133303931353766306461613335646335623837653431306237383633306130396366633758443030303039366132393664323234663238356336376265653933633330663861333039313537663064616133356463356238376534313062373836333061303963666337401a68553075f600f65820d4b5491031d8a9365555a01fa4d9805e32a4205c15fa19e53dc7f27ad4c534e058204296135d76b6345cdffaf57b434f6bd5c3579f3843731fab79e1e5a74a6091c982418080d903f683010780d903e9880103190737001a685530a658200b98a86c69c788bc54773d62cfd053ef54cf495bdb9a4b8298ad6c99966de7e058201d2b93c6e36694c316302b9cf9bf3c6ca076b085d6aaeb1d1874cd23301fa3f4a3783531365569753248416d326857486d66794a36484143696476367934686f377655323778504365436f5253515873694443595937654358417661160bc40a6a8722bd025ab49449dec2cee4a4680cc20f9f4fb2e1328c2f2e511a0390678a911b81a26d0171bfc43e813a01da7458c15558abb954bd2a52e501783531365569753248416d3665514d72327351566263575a73505062706332537537416e6e4d5647487043323350557a47544141546e7058410be3d9a494027aaed1d052145f8bd78ec5f909c1eeaa62e4a0aa79de1aef6108483aa8ff9253fb1d1c73407f49f428d246813780ed3648a92efa4c674fb5531401783531365569753248416d424a394c733865333662776b6a4c3574677737327a6b533578346479636a625a665956614e52676e7447317258415bd2c3b0ca0683c39e66129027eee216a66fc35eca1c58b5ba3e5a99dae4e97357893f88f7e91f70a16cccdfc7bfc9fa46757e2e1b1126bd5145af70a39e4bdb00" - } + }, + "totalCommitments": "1" }, "id": 4 } @@ -477,17 +511,22 @@ Retrieve all certification requests included in a specific block. { "stateId": "c7aa6962316c0eeb1469dc3d7793e39e140c005e6eea0e188dcc73035d765937", "certificationData": { - "publicKey": "027c4fdf89e8138b360397a7285ca99b863499d26f3c1652251fcf680f4d64882c", - "signature": "65ed0261e093aa2df02c0e8fb0aa46144e053ea705ce7053023745b3626c60550b2a5e90eacb93416df116af96872547608a31de1f8ef25dc5a79104e6b69c8d00", + "version": 2, + "ownerPredicate": { + "engine": 1, + "code": "AQ==", + "params": "AnxP34noE4s2A5enKFypm4Y0mdJvPBZSJR/PaA9NZIgs" + }, "sourceStateHash": "539cb40d7450fa842ac13f4ea50a17e56c5b1ee544257d46b6ec8bb48a63e647", "transactionHash": "c5f9a1f02e6475c599449250bb741b49bd8858afe8a42059ac1522bff47c6297", - "expiresAt": 1755003600 + "expiresAt": 1755003600, + "witness": "65ed0261e093aa2df02c0e8fb0aa46144e053ea705ce7053023745b3626c60550b2a5e90eacb93416df116af96872547608a31de1f8ef25dc5a79104e6b69c8d00" }, "referenceTime": 1755000000, + "aggregateRequestCount": "1", "blockNumber": "123", "leafIndex": "0", - "createdAt": "1734435600000", - "finalizedAt": "1734435601000" + "createdAt": "1734435600000" } ] }, @@ -496,7 +535,7 @@ Retrieve all certification requests included in a specific block. ``` #### `get_no_deletion_proof` -Retrieve the global no-deletion proof for the aggregator data structure. +Retrieve the global no-deletion proof for the aggregator data structure. **Not implemented yet** — in standalone/child mode this returns a fixed placeholder proof, and in parent mode it returns an error. **Request:** ```json @@ -514,9 +553,8 @@ Retrieve the global no-deletion proof for the aggregator data structure. "jsonrpc": "2.0", "result": { "noDeletionProof": { - "proofHash": "0000c7d8e9f0123456789abcdef0123456789abcdef0123456789abcdef012345", - "blockNumber": "123", - "timestamp": "1734435600000" + "proof": "6d6f636b5f6e6f5f64656c6574696f6e5f70726f6f66", + "createdAt": "1734435600000" } }, "id": 6 @@ -534,9 +572,15 @@ Returns the health status and role of the service. "status": "ok", "role": "leader", "serverId": "hostname-1234", + "sharding": { + "mode": "standalone", + "shardIdLen": 4, + "shardId": 0 + }, "details": { "database": "connected", - "commitment_queue": "42" + "commitment_queue": "42", + "commitment_queue_status": "healthy" } } ``` @@ -545,7 +589,9 @@ Returns the health status and role of the service. Adds trust base to the trust base store. The request body must be a valid trust base in json format. Example curl request -```curl -X PUT -H 'Content-Type: application/json' -d @./test-nodes/trust-base-1.json http://localhost:3000/api/v1/trustbases``` +```bash +curl -X PUT -H 'Content-Type: application/json' -d @./bft-config/trust-base.json http://localhost:3000/api/v1/trustbases +``` **If trust base was stored successfully then status 200 with empty response body is returned:** ```json @@ -555,7 +601,7 @@ Example curl request **If trust base is invalid error then status 400 with error cause is returned:** ```json { - "error":"failed to store trust base: trust base already exists" + "error":"failed to store trust base: trust base already exist for epoch 1: trust base already exists" } ``` @@ -567,9 +613,9 @@ The documentation includes: - **📋 cURL export** - Copy commands for terminal use - **⌨️ Keyboard shortcuts** - Ctrl+Enter to send requests - **🎯 Status indicators** - Response times and success/error status -- **↻ Reset functionality** - Restore original examples +- **🗑️ Clear** - Reset the response panel for a method - **📱 Responsive design** - Works on desktop and mobile -- **💾 Real-time responses** - JSON formatted with syntax highlighting +- **💾 Real-time responses** - JSON pretty-printed in a monospace response panel ## Development @@ -638,7 +684,7 @@ The service creates and manages the following MongoDB collections: - **`aggregator_records`** - Finalized certification request records with proofs - **`blocks`** - Blockchain blocks with metadata - **`smt_nodes`** - Sparse Merkle Tree leaf nodes -- **`block_records`** - Block number to state ID mappings +- **`trust_bases`** - Root trust base documents (BFT network trust base) - **`leadership`** - High availability leader election state All collections include proper indexes for efficient querying. @@ -648,11 +694,12 @@ All collections include proper indexes for efficient querying. The service includes a built-in performance testing tool that generates cryptographically valid commitments: ```bash -# Run performance test (requires aggregator running on localhost:3000) +# Run performance test (defaults to a single shard target at https://localhost:3001; +# set SHARD_TARGETS to point at your aggregator, e.g. SHARD_TARGETS="http://localhost:3000:1") make performance-test # Run performance test against a remote endpoint with authentication -make performance-test-auth URL=http://localhost:8080 AUTH='Bearer supersecret' +SHARD_TARGETS="http://localhost:8080:1" AUTH_HEADER='Bearer supersecret' make performance-test # Sharded performance test (provide shard targets with shardID suffix) SHARD_TARGETS="http://localhost:3001:3,http://localhost:3002:2" TEST_DURATION=10s REQUESTS_PER_SEC=100 go run ./cmd/performance-test @@ -661,7 +708,7 @@ SHARD_TARGETS="http://localhost:3001:3,http://localhost:3002:2" TEST_DURATION=10 **Performance Test Features:** - ✅ **Cryptographically Valid Data** - Real secp256k1 key pairs and signatures - ✅ **Raw v2 Hash Format** - 32-byte SHA256 state and transaction hashes -- ✅ **Deterministic StateIDs** - Calculated as SHA256(publicKey || sourceStateHash) +- ✅ **Deterministic StateIDs** - Calculated as SHA256(CBOR[ownerPredicate, sourceStateHash]) - ✅ **High Concurrency** - Configurable worker count and request rate - ✅ **Block Monitoring** - Tracks certification requests per block and throughput - ✅ **Real-time Metrics** - Success rate, failure rate, and RPS tracking @@ -669,17 +716,22 @@ SHARD_TARGETS="http://localhost:3001:3,http://localhost:3002:2" TEST_DURATION=10 **Sample Output:** ``` Starting aggregator performance test... -Target: http://localhost:3000 -Duration: 10s -Workers: 100 -Target RPS: 5000 +Sharding mode: app +Targets (1 shards): + - shard-7 (https://localhost:3001) shardMask=7 +Duration: 30s +Submission workers: 20 +Proof scheduling: exact per-submission timer (PROOF_WORKERS ignored, value=10) +Proof initial delay: 2.5s +Proof retry delay: 1s +Server proof-readiness metric: aggregator_proof_readiness_seconds_bucket (direct /metrics scrape) +HTTP client pool size: 4 +H2C: enabled (HTTP/2 cleartext for plain HTTP) +Target RPS: 2000 ---------------------------------------- -✓ Connected successfully -Starting block monitoring from block 1 -[2s] Total: 9847, Success: 9832, Failed: 15, Exists: 0, RPS: 4923.5 -Block 1: 1456 commitments -[4s] Total: 19736, Success: 19684, Failed: 52, Exists: 0, RPS: 4934.0 -Block 2: 1523 commitments +Testing connectivity to https://localhost:3001... +✓ Connected successfully to https://localhost:3001 +✓ Starting block number for https://localhost:3001: 42 ... ``` @@ -697,6 +749,9 @@ The service implements a MongoDB-based leader election system: - **`leader`** - Actively creating blocks and managing consensus - **`follower`** - Processing API requests, monitoring for leadership - **`standalone`** - Single server mode (HA disabled) +- **`parent-leader`** - Parent aggregator actively aggregating child shard roots +- **`parent-follower`** - Parent aggregator processing API requests, monitoring for leadership +- **`parent-standalone`** - Single parent aggregator (HA disabled) ## Sharding @@ -705,7 +760,7 @@ The aggregator supports two orthogonal sharding strategies, selected by `SHARDIN - **Application-level sharding** (`parent` / `child`) — aggregator-layer split: one parent aggregator aggregates the SMTs of multiple children. Described in the rest of this section. - **BFT-side sharding** (`bft-shard`) — BFT-layer split: multiple aggregators act as shard validators of a single multi-shard BFT partition, and shard-inclusion is proved directly by the `ShardTreeCertificate` embedded in the `UnicityCertificate`. Described in [BFT-side sharding](#bft-side-sharding-sharding_modebft-shard). -The two modes use different routing inputs and different shard-ID semantics; they are not interchangeable. +The two modes share the same routing input (the raw 32-byte `stateId`, read MSB-first) but use different shard-ID semantics; they are not interchangeable. ### Application-level sharding (`SHARDING_MODE=parent` / `child`) @@ -718,12 +773,12 @@ For a more detailed technical explanation of the sharded SMT structure, please r ### Certification Request Routing -The requests are assigned to a shard based on the least significant bits of their state identifier. +The requests are assigned to a shard based on the most significant (leading) bits of their state identifier. The number of bits used to determine the shard is defined by the `SHARD_ID_LENGTH` configuration. -For example `SHARD_ID_LENGTH: 1` means that the rightmost `1` bits of state identifier determines -the correct shard. In this case there would be 2 shards e.g. certification requests ending with bit `0` would go to -`shard-1`, and certification requests ending with bit `1` would go to the `shard-2`. +For example `SHARD_ID_LENGTH: 1` means that the leftmost `1` bits of state identifier determines +the correct shard. In this case there would be 2 shards e.g. certification requests starting with bit `0` would go to +the shard whose `shardID` is `0b10`, and certification requests starting with bit `1` would go to the shard whose `shardID` is `0b11`. In sharded setup only the parent aggregator talks to the BFT node. @@ -753,14 +808,14 @@ The following diagram illustrates a sharded setup with one parent and two child +----------------+ +----------------+ | Child Agg. #1 | | Child Agg. #2 | | ShardID = 0b10 | | ShardID = 0b11 | -| (handles *...0)| | (handles *...1)| +| (handles 0...) | | (handles 1...) | +----------------+ +----------------+ ^ ^ | | +----------------+ +----------------+ | Agent sends | | Agent sends | | commitment | | commitment | -| ID = ...xxx0 | | ID = ...xxx1 | +| ID = 0xxx... | | ID = 1xxx... | +----------------+ +----------------+ ``` @@ -781,7 +836,7 @@ Shard-1: ```yaml environment: SHARDING_MODE: "child" - SHARDING_CHILD_SHARD_ID: 2 # (binary 0b10) + SHARDING_CHILD_SHARD_ID: 3 # (binary 0b11) SHARDING_CHILD_PARENT_RPC_ADDR: http://aggregator-root:3000 ``` @@ -789,15 +844,17 @@ Shard-2: ```yaml environment: SHARDING_MODE: "child" - SHARDING_CHILD_SHARD_ID: 3 # (binary 0b11) + SHARDING_CHILD_SHARD_ID: 2 # (binary 0b10) SHARDING_CHILD_PARENT_RPC_ADDR: http://aggregator-root:3000 + SHARDING_CHILD_PARENT_POLL_INTERVAL: 100ms # default + SHARDING_CHILD_PARENT_POLL_TIMEOUT: 5s # default ``` ### BFT-side sharding (`SHARDING_MODE=bft-shard`) In `bft-shard` mode, multiple aggregators are deployed as shard validators of a single multi-shard BFT partition. Each aggregator owns one shard, talks directly to the BFT rootchain, and the `UnicityCertificate` it receives embeds a `ShardTreeCertificate` that binds its local SMT root into the partition root. There is no parent aggregator — shard-inclusion is proved by the embedded certificate rather than by a per-round polling loop. -This mode is orthogonal to `parent`/`child`: it uses a different routing key (raw 32-byte `stateId`), a different shard-ID encoding (bit-strings, MSB-first), and a different admission rule. +This mode is orthogonal to `parent`/`child`: it uses the same routing key (the raw 32-byte `stateId`, read MSB-first) but a different shard-ID encoding (bit-strings instead of sentinel-prefixed integers) and a different admission rule. #### Routing semantics @@ -939,7 +996,7 @@ The service implements complete secp256k1 signature validation: - **✅ Signature Verification** - 65-byte signatures (64 bytes + recovery byte) - **✅ StateID Validation** - Deterministic calculation over owner predicate and source state hash - **✅ Raw Hash Support** - 32-byte source state and transaction hashes -- **✅ Transaction Signing** - Signatures verified against transaction hash data +- **✅ Transaction Signing** - Signatures verified against SHA256(CBOR array [sourceStateHash, transactionHash]) ### Supported Algorithms - **secp256k1** - Full implementation with btcec library @@ -947,13 +1004,13 @@ The service implements complete secp256k1 signature validation: - **Raw v2 Hashes** - 32-byte SHA256 hashes without per-field algorithm prefixes ### Validation Process -1. **Algorithm Check** - Verify "secp256k1" algorithm support +1. **Owner Predicate Check** - Verify the pay-to-public-key predicate (engine `1`, code `0x01`) and extract the public key from its params 2. **Public Key Format** - Validate compressed secp256k1 public key (33 bytes) 3. **State Hash Format** - Validate raw 32-byte source state hash 4. **StateID Verification** - Ensure StateID matches the owner predicate and source state hash 5. **Signature Format** - Validate 65-byte signature length 6. **Transaction Hash Format** - Validate raw 32-byte transaction hash -7. **Signature Verification** - Cryptographically verify signature against transaction hash +7. **Signature Verification** - Cryptographically verify the signature against SHA256(CBOR array [sourceStateHash, transactionHash]) ## Architecture Notes @@ -970,7 +1027,7 @@ The service implements complete secp256k1 signature validation: ## Limitations -- **Receipt Signing**: Returns unsigned receipts (cryptographic signing planned) +- **Submission Receipts**: `certification_request` returns only `{"status": ...}` — there is no submission receipt object, signed or unsigned. ## Contributing diff --git a/docs/inclusion-proof-wire.md b/docs/inclusion-proof-wire.md new file mode 100644 index 00000000..858b2522 --- /dev/null +++ b/docs/inclusion-proof-wire.md @@ -0,0 +1,206 @@ +# Inclusion proof wire specification (v2) + +Wire format for `get_inclusion_proof.v2`. Three source comments cite this +document as normative: `pkg/api/types.go` (`InclusionProofV2`), and +`pkg/api/inclusion_cert.go` (`InclusionCert`, `ExclusionCert`). + +Corresponds to the Unicity yellowpaper's inclusion proof +$\pi^{\mathsf{inc}} = (\mathsf{sid}, v, C^{\mathsf{inc}}, UC)$. **The yellowpaper +is authoritative.** This document describes what the Go implementation actually +emits, and where the two differ it says so explicitly and names the paper as +correct -- it does not present an implementation gap as a specification. + +## CBOR tags + +| Tag | Structure | +|-----|-----------| +| 39030 | `CertificationRequest` | +| 39031 | `CertificationData` | +| 39032 | `Predicate` | +| 39033 | `InclusionProofV2` | + +## RPC response + +The `result` field of `get_inclusion_proof.v2` is a hex-encoded CBOR array: + +``` +[blockNumber, #39033([version, certificationDataOrNull, referenceTime, certificateBytes, unicityCertificate])] +``` + +`InclusionProofV2` is a tagged 5-element array: + +| Index | Field | Type | Notes | +|-------|-------|------|-------| +| 0 | `version` | uint | `1` | +| 1 | `certificationData` | `#39031([...])` \| null | null ⇒ non-inclusion proof | +| 2 | `referenceTime` | uint \| null | round reference time τ; null only for non-inclusion | +| 3 | `certificateBytes` | bstr | `InclusionCert` or `ExclusionCert`, raw (below) | +| 4 | `unicityCertificate` | raw CBOR | the UC as received from the BFT Core | + +**Discriminator.** `certificationData != null` ⇒ inclusion, and +`certificateBytes` is an `InclusionCert`. `certificationData == null` ⇒ +non-inclusion, and `certificateBytes` is an `ExclusionCert`. Non-inclusion is +neither generated nor verified in Go, and the `ExclusionCert` layout below +diverges from the yellowpaper -- do not build against it yet. + +### `CertificationData` + +A tagged 6-element array. The element count never varies with the payload: + +| Index | Field | Type | +|-------|-------|------| +| 0 | `version` | uint, `2` | +| 1 | `ownerPredicate` | `#39032([engine: uint, code: bstr, params: bstr])` | +| 2 | `sourceStateHash` | bstr(32) | +| 3 | `transactionHash` | bstr(32) | +| 4 | `expiresAt` | uint \| null | +| 5 | `witness` | bstr(65) | + +`ownerPredicate` is **tagged**, not a bare array: `Predicate.MarshalCBOR` emits +tag 39032 and `Predicate.UnmarshalCBOR` requires it. A predicate with engine 1, +code `0x01` and params `0x02` encodes as `d99878 83 01 4101 4102`. + +`expiresAt` is the exclusive request deadline τ_Q. It holds its position and is +written as CBOR `null` when the requester supplied no deadline, so the array +length never depends on the payload. Absence is distinct from zero: zero is a +legal instant. Both forms are specified — the yellowpaper's request timeout is +optional, and `⊥` is written as CBOR null at a fixed position. + +## Leaf value + +``` +v = SHA-256( CBOR([ transactionHash, referenceTime ]) ) +``` + +Raw 32 bytes, no algorithm-id prefix. Concretely the preimage is +`0x82 0x58 0x20 <32-byte transactionHash> `. + +The leaf value binds the reference time the request was validated under, not the +transaction hash alone. The tree is append-only, so a leaf can be certified +afresh against any later root and a later inclusion proof carries a later round's +`UC.IR.t`. Reference time is therefore a property of the leaf, not of the proof. + +**Do not recover τ from `UC.IR.t`.** Use the `referenceTime` element. They +coincide only for the proof issued in the leaf's own round. + +## `InclusionCert` + +Raw binary, no framing: + +``` +bitmap[32] || s_1[32] || ... || s_n[32] n = popcount(bitmap) +``` + +Siblings are in generation order, root-to-leaf: `s_1` is the sibling at the +shallowest depth with a bitmap bit set, `s_n` at the deepest. Verification walks +depths 255..0 and consumes siblings from the end of the slice. + +The certificate carries no root, no key and no value. All three come from +outside it: + +| Input | Source | +|-------|--------| +| key (sid) | the RPC request parameter | +| value | `SHA-256(CBOR([transactionHash, referenceTime]))` | +| root | `UC.IR.h` — never a field of the certificate | + +Decoding rejects: fewer than 32 bytes (truncated), a remainder not a multiple of +32 (misaligned), and a sibling count disagreeing with the bitmap popcount. + +## `ExclusionCert` — diverges from the yellowpaper, and is unimplemented + +The Go type encodes: + +``` +k_l[32] || h_l[32] || bitmap[32] || s_1[32] || ... || s_n[32] +``` + +`appendix-hashtrees.tex` specifies the **opposite order**: + +``` +bitmap[32] || s_1[32] || ... || s_n[32] || k'[32] || v' +``` + +These are not interchangeable: one logical certificate encodes to two different +byte strings, and the Go decoder rejects the spec layout with a bitmap/popcount +mismatch. The spec puts `v'` last so the remainder after the fixed-size terminal +key is the value; with the fixed 32-byte field leading, a variable-length `v'` is +structurally unencodable here. The aggregation profile does permit +`len(v') = 32`, so only the ordering diverges — but `h_l` names the leaf **value** +`v'`, not a hash of it, which the field name obscures. + +The spec's empty-tree certificate `C^exc_empty` (the empty byte string) is also +undecodable: `UnmarshalBinary(nil)` returns a truncation error, so a genesis tree +has no encodable certificate. + +**Nothing generates or verifies these.** `internal/smt` exposes only +`GetInclusionCert`; `ExclusionCert.Verify` returns `ErrExclusionNotImpl`; and a +non-inclusion response carries `certificateBytes` as CBOR null (`f6`) rather than +the spec's empty byte string (`40`). Neither of the two security-critical checks +the spec names — `k' ≠ k`, and `k[d] = k'[d]` at every junction depth, with the +region taken from the authenticated terminal key `k'` — exists in this repo. + +This is fail-closed: no forged absence proof is accepted because none is +accepted. But absence and not-yet-certified are indistinguishable on the wire, +and this layout should not be treated as frozen until it is reconciled with +`appendix-hashtrees.tex`. + +## Hash rules + +- Leaf: `H(0x00 || key || value)` +- Inner node, two children: `H(0x01 || depth_byte || region(key, depth) || left || right)` +- Inner node, one child: passthrough, child hash unchanged + +`depth_byte` is the absolute branching depth as a single byte. `region(key, depth)` +is the 32-byte key prefix addressing the node: the first `depth` bits of the key, +with every bit at position ≥ `depth` cleared. At depth 0 it is 32 zero bytes; for +key `0xFFFF…` at depth 12 it is `fff00000…`. + +**The region is not optional.** Omitting it reproduces the correct root only for a +tree with no binary inner node — that is, a proof with zero siblings. Any proof +carrying a sibling will verify against a different root. Inner nodes commit to +their absolute depth *and* to the region addressing them, which is what pins each +node to its position in the key space. + +## Bit ordering + +Big-endian (MSB-first) per the yellowpaper: + +``` +bit(key, d) = (key[d/8] >> (7 - d%8)) & 1 +``` + +So bit 0 is the most significant bit of `key[0]`. Descent at depth `d` goes right +when `bit(key, d) == 1`, and the sibling supplied at that depth is then the left +child. + +## Verification + +`InclusionProofV2.Verify` performs, in order: + +1. Non-nil proof, request, verifier context and trust base. +2. `certificationData != null`, else non-inclusion (unimplemented). +3. Request `transactionHash` present, and equal to the proof's. +4. The proof and request have equal `expiresAt`, owner predicate, source state + hash and witness fields. +5. `UC.IR.h` extractable and exactly 32 bytes. +6. The request `stateId` is exactly the value derived from the certification + data's owner predicate and source state hash. +7. `referenceTime` present. +8. If `expiresAt` is present, `referenceTime < expiresAt`. **Exclusive**: a leaf + created at exactly the deadline is expired. When `expiresAt` is absent this + check cannot run — the service-assigned deadline is not carried in the proof, + is not signed, and is not checkable by any later verifier. +9. `InclusionCert.Verify(key, LeafValue(txhash, referenceTime), UC.IR.h)`. +10. The UC's certified shard is a bit prefix of `stateId`. +11. The UC seal network equals the trust-base network. +12. Unicity Certificate verification against the expected partition, shard, + optional shard-configuration hash, and trust base. + +The nil-guard error strings are part of the public contract so reference +verifiers in other languages can pin them. + +The shard and network checks match the JavaScript, Java and Rust state-transition +SDK verifiers. `ExpectedShardID` remains an additional caller policy check for +backward compatibility; it does not replace deriving the state ID or checking +that the UC's certified shard contains it. diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index eda4d4f0..1721a5e1 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -187,6 +187,27 @@ var ( CommitmentsDroppedDuplicate = CommitmentsDroppedTotal.WithLabelValues("duplicate") CommitmentsDroppedRejected = CommitmentsDroppedTotal.WithLabelValues("rejected") + // CertificationRequestsByDeadline splits accepted requests by whether the + // requester supplied an exclusive deadline or the service assigned one from + // DEFAULT_REQUEST_TTL. Both forms are specified -- the yellowpaper's request + // timeout is optional -- so this is operational visibility, not a migration + // counter: it shows what share of traffic depends on the service's default + // lifetime, which is the knob that decides how long those requests stay + // admissible. + CertificationRequestsByDeadline = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "aggregator_certification_requests_by_deadline_total", + Help: "Accepted certification requests by deadline origin (explicit or service_assigned).", + }, + []string{"origin"}, + ) + + // Resolved once, as for the drop reasons above. Increment these only after + // the request is actually accepted -- an expired or duplicate request was + // never admitted and must not be counted. + DeadlineOriginExplicit = CertificationRequestsByDeadline.WithLabelValues("explicit") + DeadlineOriginServiceAssigned = CertificationRequestsByDeadline.WithLabelValues("service_assigned") + BFTCertificationDuration = promauto.NewHistogram( prometheus.HistogramOpts{ Name: "aggregator_bft_certification_duration_seconds", diff --git a/internal/service/aggregate_test.go b/internal/service/aggregate_test.go index d156bd46..c3945dfc 100644 --- a/internal/service/aggregate_test.go +++ b/internal/service/aggregate_test.go @@ -75,6 +75,7 @@ func TestGetBlockTotalCommitments(t *testing.T) { TransactionHash: api.RequireNewImprintV2("e1b2c3d4e5f67890e1b2c3d4e5f67890e1b2c3d4e5f67890e1b2c3d4e5f67891"), }, AggregateRequestCount: 1000, + ReferenceTime: 1755000000, BlockNumber: api.NewBigInt(big.NewInt(1)), LeafIndex: api.NewBigInt(big.NewInt(0)), CreatedAt: api.Now(), @@ -82,5 +83,8 @@ func TestGetBlockTotalCommitments(t *testing.T) { apiRecord := modelToAPIAggregatorRecord(modelRecord) require.Equal(t, uint64(1000), apiRecord.AggregateRequestCount) + // The reference time is what a consumer needs to rebuild the leaf value, + // so it must survive the conversion rather than being dropped. + require.Equal(t, uint64(1755000000), apiRecord.ReferenceTime) }) } diff --git a/internal/service/block_records_shape_test.go b/internal/service/block_records_shape_test.go new file mode 100644 index 00000000..abf69c42 --- /dev/null +++ b/internal/service/block_records_shape_test.go @@ -0,0 +1,137 @@ +package service + +import ( + "context" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + bfttypes "github.com/unicitynetwork/bft-go-base/types" + + "github.com/unicitynetwork/aggregator-go/internal/config" + "github.com/unicitynetwork/aggregator-go/internal/logger" + "github.com/unicitynetwork/aggregator-go/internal/metrics" + "github.com/unicitynetwork/aggregator-go/internal/models" + "github.com/unicitynetwork/aggregator-go/internal/signing" + "github.com/unicitynetwork/aggregator-go/pkg/api" +) + +// The get_block_records response is documented in README.md. A consumer needs +// referenceTime to rebuild the certified leaf value and expiresAt to check the +// request deadline, so silently dropping either makes the record unverifiable. +// This pins the exact key set the endpoint emits. +func TestBlockRecordWireShape(t *testing.T) { + expiresAt := uint64(1755003600) + record := &models.AggregatorRecord{ + StateID: api.RequireNewImprintV2("c7aa6962316c0eeb1469dc3d7793e39e140c005e6eea0e188dcc73035d765937"), + CertificationData: models.CertificationData{ + OwnerPredicate: api.Predicate{Engine: 1, Code: []byte{0x01}, Params: []byte{0x02, 0x03}}, + SourceStateHash: api.RequireNewImprintV2("539cb40d7450fa842ac13f4ea50a17e56c5b1ee544257d46b6ec8bb48a63e647"), + TransactionHash: api.RequireNewImprintV2("c5f9a1f02e6475c599449250bb741b49bd8858afe8a42059ac1522bff47c6297"), + ExpiresAt: &expiresAt, + Witness: []byte{0x04, 0x05}, + }, + ReferenceTime: 1755000000, + BlockNumber: api.NewBigInt(big.NewInt(123)), + LeafIndex: api.NewBigInt(big.NewInt(0)), + CreatedAt: api.NewTimestamp(time.UnixMilli(1734435600000).UTC()), + } + + encoded, err := json.Marshal(modelToAPIAggregatorRecord(record)) + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(encoded, &decoded)) + + require.ElementsMatch(t, + []string{"stateId", "certificationData", "referenceTime", "blockNumber", "leafIndex", "createdAt"}, + keysOf(decoded), + "get_block_records record keys changed; update README.md to match") + + certData, ok := decoded["certificationData"].(map[string]any) + require.True(t, ok) + require.ElementsMatch(t, + []string{"version", "ownerPredicate", "sourceStateHash", "transactionHash", "expiresAt", "witness"}, + keysOf(certData), + "certificationData keys changed; update README.md to match") + + require.EqualValues(t, 1755000000, decoded["referenceTime"]) + require.EqualValues(t, 1755003600, certData["expiresAt"]) + require.EqualValues(t, api.CertificationDataVersion, certData["version"]) + // finalizedAt is deliberately not emitted: nothing persists a finalization + // timestamp, and the block's CreatedAt is proposal time. + require.NotContains(t, decoded, "finalizedAt") + + // An absent deadline stays absent rather than becoming zero: the service + // assigns its own, but that value is not part of the certified record. + record.CertificationData.ExpiresAt = nil + encoded, err = json.Marshal(modelToAPIAggregatorRecord(record)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(encoded, &decoded)) + certData, ok = decoded["certificationData"].(map[string]any) + require.True(t, ok) + require.Nil(t, certData["expiresAt"]) +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// The deadline-origin counter reports the share of traffic relying on the +// service-assigned deadline, so it must count only requests that were actually +// accepted. An expired request is rejected and must not be counted. +func TestDeadlineOriginCountsOnlyAcceptedRequests(t *testing.T) { + ctx := context.Background() + log, err := logger.New("error", "text", "stdout", false) + require.NoError(t, err) + + const referenceTime uint64 = 1755000000 + read := func(origin string) float64 { + return testutil.ToFloat64(metrics.CertificationRequestsByDeadline.WithLabelValues(origin)) + } + + newService := func(queue *recordingCommitmentQueue) *AggregatorService { + shardingCfg := config.ShardingConfig{Mode: config.ShardingModeBFTShard} + return &AggregatorService{ + config: &config.Config{ + Processing: config.ProcessingConfig{SkipDuplicateCheck: true, DefaultRequestTTL: time.Hour}, + Sharding: shardingCfg, + }, + logger: log, + commitmentQueue: queue, + roundManager: &stubRoundManager{referenceTime: referenceTime}, + certificationRequestValidator: signing.NewCertificationRequestValidator(shardingCfg, bfttypes.ShardID{}), + } + } + + // An accepted request with no deadline counts as service_assigned. + before := read("service_assigned") + queue := &recordingCommitmentQueue{} + accepted := createTestCertificationRequests(t, 1)[0] + accepted.CertificationData.ExpiresAt = nil + resp, err := newService(queue).CertificationRequest(ctx, accepted) + require.NoError(t, err) + require.Equal(t, "SUCCESS", resp.Status) + require.Len(t, queue.stored, 1) + require.Equal(t, before+1, read("service_assigned")) + + // An expired request is rejected and must not be counted at all. + beforeExplicit := read("explicit") + beforeAssigned := read("service_assigned") + queue = &recordingCommitmentQueue{} + expired := createTestCertificationRequests(t, 1)[0] + expired.CertificationData.ExpiresAt = api.Uint64Ptr(referenceTime) + resp, err = newService(queue).CertificationRequest(ctx, expired) + require.NoError(t, err) + require.Equal(t, api.CertificationStatusRequestExpired, resp.Status) + require.Empty(t, queue.stored) + require.Equal(t, beforeExplicit, read("explicit"), "a rejected request must not be counted") + require.Equal(t, beforeAssigned, read("service_assigned")) +} diff --git a/internal/service/service.go b/internal/service/service.go index 584fe4d0..69130e46 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -12,6 +12,7 @@ import ( "github.com/unicitynetwork/aggregator-go/internal/config" "github.com/unicitynetwork/aggregator-go/internal/logger" + "github.com/unicitynetwork/aggregator-go/internal/metrics" "github.com/unicitynetwork/aggregator-go/internal/models" "github.com/unicitynetwork/aggregator-go/internal/round" "github.com/unicitynetwork/aggregator-go/internal/signing" @@ -85,15 +86,21 @@ type LeaderSelector interface { // Conversion functions between API and internal model types +// modelToAPIAggregatorRecord converts a stored record for the wire. func modelToAPIAggregatorRecord(modelRecord *models.AggregatorRecord) *api.AggregatorRecord { return &api.AggregatorRecord{ StateID: modelRecord.StateID, CertificationData: api.CertificationData{ + Version: api.CertificationDataVersion, OwnerPredicate: modelRecord.CertificationData.OwnerPredicate, Witness: modelRecord.CertificationData.Witness, SourceStateHash: modelRecord.CertificationData.SourceStateHash, TransactionHash: modelRecord.CertificationData.TransactionHash, + // Without ExpiresAt a consumer cannot perform the request-deadline + // check; without ReferenceTime it cannot rebuild the leaf value. + ExpiresAt: modelRecord.CertificationData.ExpiresAt, }, + ReferenceTime: modelRecord.ReferenceTime, AggregateRequestCount: modelRecord.AggregateRequestCount, BlockNumber: modelRecord.BlockNumber, LeafIndex: modelRecord.LeafIndex, @@ -188,11 +195,19 @@ func (as *AggregatorService) CertificationRequest(ctx context.Context, req *api. if referenceTime == 0 { return &api.CertificationResponse{Status: api.CertificationStatusServiceNotReady}, nil } - // An explicit deadline is used verbatim and is covered by the witness. When - // the requester omitted one, the service derives a deadline from consensus - // reference time; that value is service metadata and is never recorded in the - // leaf, signed, or checked by a later verifier. + // This implements the yellowpaper's effective timeout: for a request + // Q = (predicate, sourceStateHash, txhash, tau_Q_bar, u) with an optional + // tau_Q_bar, the effective timeout is tau_a + Delta when the requester + // omitted one and tau_Q_bar otherwise, where tau_a is the latest + // consensus-derived reference time at admission and Delta the service's + // default request lifetime (DEFAULT_REQUEST_TTL). The assigned value is + // service metadata: it does not alter txhash and is not recorded in the leaf. + // + // The check below is the admission check, which the paper permits but does + // not accept as sufficient -- the authoritative one runs at leaf + // materialisation against that round's pinned reference time. var effectiveTimeout uint64 + deadlineOrigin := metrics.DeadlineOriginExplicit if expiresAt := req.CertificationData.ExpiresAt; expiresAt != nil { effectiveTimeout = *expiresAt } else { @@ -201,6 +216,7 @@ func (as *AggregatorService) CertificationRequest(ctx context.Context, req *api. return nil, errors.New("default request deadline overflows uint64") } effectiveTimeout = referenceTime + ttl + deadlineOrigin = metrics.DeadlineOriginServiceAssigned } certificationRequest.EffectiveTimeout = effectiveTimeout @@ -235,6 +251,11 @@ func (as *AggregatorService) CertificationRequest(ctx context.Context, req *api. return nil, fmt.Errorf("failed to store certificationRequest: %w", err) } + // Counted here rather than at assignment: expired, duplicate and + // failed-to-store requests are never admitted, and counting them would + // misreport the share of traffic relying on the service-assigned deadline. + deadlineOrigin.Inc() + as.logger.WithContext(ctx).Log(ctx, logger.LevelTrace, "CertificationData submitted successfully", "stateId", req.StateID) return &api.CertificationResponse{Status: "SUCCESS"}, nil diff --git a/pkg/api/aggregate_count_test.go b/pkg/api/aggregate_count_test.go index a8f66b76..6aeb15e1 100644 --- a/pkg/api/aggregate_count_test.go +++ b/pkg/api/aggregate_count_test.go @@ -81,7 +81,6 @@ func TestAggregateRequestCountSerialization(t *testing.T) { BlockNumber: blockNumber, LeafIndex: leafIndex, CreatedAt: Now(), - FinalizedAt: Now(), } // Marshal to JSON diff --git a/pkg/api/inclusion_cert.go b/pkg/api/inclusion_cert.go index eea4702f..70c2af46 100644 --- a/pkg/api/inclusion_cert.go +++ b/pkg/api/inclusion_cert.go @@ -98,7 +98,8 @@ func (c *InclusionCert) UnmarshalBinary(data []byte) error { // // Parameters: // - key: 32-byte SMT key, big-endian bit layout. -// - value: raw leaf value bytes (v2 inclusion proofs use the tx hash). +// - value: raw leaf value bytes. For v2 inclusion proofs this is +// LeafValue(transactionHash, referenceTime), not the transaction hash. // - expectedRoot: raw 32-byte root hash, taken from UC.IR.h. // - algo: hash algorithm used by the SMT. func (c *InclusionCert) Verify(key, value, expectedRoot []byte, algo HashAlgorithm) error { diff --git a/pkg/api/inclusion_cert_hashrule_test.go b/pkg/api/inclusion_cert_hashrule_test.go new file mode 100644 index 00000000..505ab5fc --- /dev/null +++ b/pkg/api/inclusion_cert_hashrule_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The hash rules in docs/inclusion-proof-wire.md and README.md are what an +// independent client implements. This builds a root from the DOCUMENTED formula +// -- spelled out here rather than reusing the production helpers -- and requires +// InclusionCert.Verify to accept it. +// +// Both documents previously omitted region(key, depth) from the inner-node +// preimage, which reproduces the correct root only for a proof with no siblings. +// TestDocumentedInnerNodeRuleRequiresRegion below pins that the region is +// load-bearing, so dropping it again fails here rather than in a foreign client. +func TestDocumentedHashRulesReproduceTheRoot(t *testing.T) { + const algo = InclusionProofV2HashAlgorithm + + key := make([]byte, StateTreeKeyLengthBytes) + key[0] = 0x80 // bit 0 set under MSB-first addressing => descent goes right at depth 0 + value := LeafValue(RequireNewImprintV2( + "2222222222222222222222222222222222222222222222222222222222222222").DataBytes(), 1755000000) + + // Leaf: H(0x00 || key || value) + leaf := NewDataHasher(algo).Reset(). + AddData([]byte{0x00}). + AddData(key). + AddData(value). + GetHash().RawHash + + sibling := make([]byte, SiblingSize) + for i := range sibling { + sibling[i] = 0xAB + } + + // Inner node at depth 0, two children: + // H(0x01 || depth_byte || region(key, depth) || left || right) + // bit(key, 0) == 1, so descent went right and the sibling is the LEFT child. + region := make([]byte, StateTreeKeyLengthBytes) // depth 0 => 32 zero bytes + root := NewDataHasher(algo).Reset(). + AddData([]byte{0x01, byte(0)}). + AddData(region). + AddData(sibling). + AddData(leaf). + GetHash().RawHash + + cert := &InclusionCert{Siblings: [][SiblingSize]byte{{}}} + copy(cert.Siblings[0][:], sibling) + SetBitBE(cert.Bitmap[:], 0) // one binary node, at depth 0 + + require.NoError(t, cert.Verify(key, value, root, algo), + "the documented hash rules must reproduce the root the verifier computes") +} + +// region(key, depth) is load-bearing: a root built without it is rejected. +// A doc that omits it would mislead an independent implementer into computing +// a different root for every proof that carries a sibling. +func TestDocumentedInnerNodeRuleRequiresRegion(t *testing.T) { + const algo = InclusionProofV2HashAlgorithm + const depth = 8 // a depth where the region is non-zero + + key := make([]byte, StateTreeKeyLengthBytes) + key[0] = 0xFF + key[1] = 0x80 // bit 8 set => descent goes right at depth 8 + value := make([]byte, SiblingSize) + + leaf := NewDataHasher(algo).Reset(). + AddData([]byte{0x00}).AddData(key).AddData(value).GetHash().RawHash + + sibling := make([]byte, SiblingSize) + for i := range sibling { + sibling[i] = 0xCD + } + + region := RegionFromKeyBytes(key, depth) + require.NotEqual(t, make([]byte, StateTreeKeyLengthBytes), region, + "pick a depth where the region is non-zero or this test proves nothing") + + withRegion := NewDataHasher(algo).Reset(). + AddData([]byte{0x01, byte(depth)}).AddData(region). + AddData(sibling).AddData(leaf).GetHash().RawHash + + withoutRegion := NewDataHasher(algo).Reset(). + AddData([]byte{0x01, byte(depth)}). + AddData(sibling).AddData(leaf).GetHash().RawHash + + require.NotEqual(t, withRegion, withoutRegion) + + cert := &InclusionCert{Siblings: [][SiblingSize]byte{{}}} + copy(cert.Siblings[0][:], sibling) + SetBitBE(cert.Bitmap[:], depth) + + require.NoError(t, cert.Verify(key, value, withRegion, algo)) + require.ErrorIs(t, cert.Verify(key, value, withoutRegion, algo), ErrCertRootMismatch, + "a root computed without the region must be rejected") +} diff --git a/pkg/api/inclusion_proof_v2_verify_test.go b/pkg/api/inclusion_proof_v2_verify_test.go index 68ccce7a..0ce61025 100644 --- a/pkg/api/inclusion_proof_v2_verify_test.go +++ b/pkg/api/inclusion_proof_v2_verify_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "crypto" "errors" "testing" @@ -206,7 +207,7 @@ func TestInclusionProofV2Verify_RejectsInvalidUCInputRecordHash(t *testing.T) { // same root is placed into InputRecord.Hash and flows through the shard tree // and unicity tree verbatim. No aggregator plumbing is involved — this builds // the exact cryptographic objects that a real deployment would emit. -func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( +func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID, sealNetwork types.NetworkID) ( *InclusionProofV2, *CertificationRequest, types.PartitionID, @@ -214,16 +215,37 @@ func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( ) { t.Helper() - // A deterministic 32-byte state ID and tx hash. Content is irrelevant - // — Verify checks the cryptographic chain, not the values. - stateID := RequireNewImprintV2("1111111111111111111111111111111111111111111111111111111111111111") txHash := RequireNewImprintV2("2222222222222222222222222222222222222222222222222222222222222222") + certData := CertificationData{ + Version: CertificationDataVersion, + OwnerPredicate: Predicate{ + Engine: 1, + Code: []byte{1}, + Params: bytes.Repeat([]byte{0x02}, 33), + }, + SourceStateHash: bytes.Repeat([]byte{0x33}, StateTreeKeyLengthBytes), + TransactionHash: txHash, + Witness: bytes.Repeat([]byte{0x44}, 65), + } + var ( + stateID StateID + err error + ) + for candidate := 0; candidate < 256; candidate++ { + certData.SourceStateHash[len(certData.SourceStateHash)-1] = byte(candidate) + stateID, err = certData.CreateStateID() + require.NoError(t, err) + if stateID.DataBytes()[0]&0x80 == 0 { + break + } + } + // This fixture intentionally routes to shard 0. Passing ownerShard=shard 1 + // creates a fully signed wrong-shard proof for the regression test below. + require.Zero(t, stateID.DataBytes()[0]&0x80) req := &CertificationRequest{ - StateID: stateID, - CertificationData: CertificationData{ - TransactionHash: txHash, - }, + StateID: stateID, + CertificationData: certData, } // Single-leaf root: H(0x00 || key || value) under the v2 hash algorithm, @@ -247,10 +269,19 @@ func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( sid0, sid1 := types.ShardID{}.Split() const partitionID types.PartitionID = 0x0f0f0f0f + ir0Hash := test.RandomBytes(32) + ir1Hash := test.RandomBytes(32) + if ownerShard.Equal(sid0) { + ir0Hash = leafRoot + } else if ownerShard.Equal(sid1) { + ir1Hash = leafRoot + } else { + t.Fatalf("owner shard %s is outside the fixture's two-shard scheme", ownerShard) + } ir0 := &types.InputRecord{ Version: 1, PreviousHash: []byte{0, 0, 1}, - Hash: leafRoot, + Hash: ir0Hash, BlockHash: []byte{0, 0, 3}, SummaryValue: []byte{0, 0, 4}, Timestamp: types.NewTimestamp(), @@ -258,9 +289,6 @@ func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( Epoch: 0, SumOfEarnedFees: 0, } - // Shard 1 needs its own (non-degenerate) IR so the shard tree is - // well-formed; its hash is irrelevant to this test. - ir1Hash := test.RandomBytes(32) ir1 := &types.InputRecord{ Version: 1, PreviousHash: []byte{0, 0, 5}, @@ -312,6 +340,7 @@ func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( seal := &types.UnicitySeal{ Version: 1, + NetworkID: sealNetwork, RootChainRoundNumber: 1, Timestamp: types.NewTimestamp(), PreviousHash: test.RandomBytes(32), @@ -345,7 +374,7 @@ func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( func TestInclusionProofV2Verify_HappyPath_FullySignedUC(t *testing.T) { sid0, _ := types.ShardID{}.Split() - proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0) + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0, types.NetworkMainNet) vctx := &VerifierContext{ TrustBase: tb, @@ -360,7 +389,7 @@ func TestInclusionProofV2Verify_HappyPath_FullySignedUC(t *testing.T) { func TestInclusionProofV2Verify_ShardMismatch_Rejected(t *testing.T) { sid0, sid1 := types.ShardID{}.Split() - proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0) + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0, types.NetworkMainNet) vctx := &VerifierContext{ TrustBase: tb, @@ -372,3 +401,170 @@ func TestInclusionProofV2Verify_ShardMismatch_Rejected(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "invalid shard ID") } + +// A validly signed shard-1 UC does not certify a state ID whose first bit +// routes it to shard 0, even when the caller also says it expects shard 1. +func TestInclusionProofV2Verify_StateIDMustBelongToCertifiedShard(t *testing.T) { + _, sid1 := types.ShardID{}.Split() + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid1, types.NetworkMainNet) + + err := proof.Verify(req, &VerifierContext{ + TrustBase: tb, + PartitionID: partitionID, + ExpectedShardID: sid1, + }) + require.EqualError(t, err, "stateId does not belong to certified shard") +} + +// The seal is signed by a key accepted by the trust base, but its network is +// different. Signature validity must not substitute for network binding. +func TestInclusionProofV2Verify_SealNetworkMustMatchTrustBase(t *testing.T) { + sid0, _ := types.ShardID{}.Split() + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0, types.NetworkLocal) + + err := proof.Verify(req, &VerifierContext{ + TrustBase: tb, + PartitionID: partitionID, + ExpectedShardID: sid0, + }) + require.EqualError(t, err, "unicity seal network does not match trust base") +} + +func TestInclusionProofV2Verify_StateIDMustMatchCertificationData(t *testing.T) { + sid0, _ := types.ShardID{}.Split() + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0, types.NetworkMainNet) + req.StateID = RequireNewImprintV2("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + + err := proof.Verify(req, &VerifierContext{ + TrustBase: tb, + PartitionID: partitionID, + ExpectedShardID: sid0, + }) + require.EqualError(t, err, "stateId does not match certification data") +} + +func TestInclusionProofV2Verify_CertificationDataMustMatchRequest(t *testing.T) { + sid0, _ := types.ShardID{}.Split() + tests := []struct { + name string + mutate func(*CertificationData) + errText string + }{ + { + name: "owner predicate", + mutate: func(cd *CertificationData) { + cd.OwnerPredicate.Params = append([]byte(nil), cd.OwnerPredicate.Params...) + cd.OwnerPredicate.Params[0] ^= 0xff + }, + errText: "proof certification data owner predicate does not match certification request owner predicate", + }, + { + name: "source state hash", + mutate: func(cd *CertificationData) { + cd.SourceStateHash = append([]byte(nil), cd.SourceStateHash...) + cd.SourceStateHash[0] ^= 0xff + }, + errText: "proof certification data source state hash does not match certification request source state hash", + }, + { + name: "witness", + mutate: func(cd *CertificationData) { + cd.Witness = append([]byte(nil), cd.Witness...) + cd.Witness[0] ^= 0xff + }, + errText: "proof certification data witness does not match certification request witness", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0, types.NetworkMainNet) + detached := *proof.CertificationData + proof.CertificationData = &detached + tt.mutate(proof.CertificationData) + + err := proof.Verify(req, &VerifierContext{ + TrustBase: tb, + PartitionID: partitionID, + ExpectedShardID: sid0, + }) + require.EqualError(t, err, tt.errText) + }) + } +} + +// The request deadline is exclusive: a leaf created at exactly the deadline is +// expired, one created a second earlier is not. The deadline does not enter the +// leaf value, so the cryptographic chain is unaffected either way and this +// isolates the boundary itself. +func TestInclusionProofV2Verify_ExpiryBoundaryIsExclusive(t *testing.T) { + sid0, _ := types.ShardID{}.Split() + + tests := []struct { + name string + expiresAt func(referenceTime uint64) uint64 + accept bool + }{ + {"deadline one past the reference time", func(rt uint64) uint64 { return rt + 1 }, true}, + {"deadline equal to the reference time", func(rt uint64) uint64 { return rt }, false}, + {"deadline before the reference time", func(rt uint64) uint64 { return rt - 1 }, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0, types.NetworkMainNet) + require.NotNil(t, proof.ReferenceTime) + + // Both copies must agree or Verify rejects on the equality check + // before it ever reaches the deadline comparison. + deadline := tt.expiresAt(*proof.ReferenceTime) + proof.CertificationData.ExpiresAt = &deadline + req.CertificationData.ExpiresAt = &deadline + + err := proof.Verify(req, &VerifierContext{ + TrustBase: tb, + PartitionID: partitionID, + ExpectedShardID: sid0, + }) + if tt.accept { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, "expired") + }) + } +} + +// A deadline present on one side and absent on the other is a mismatch, not a +// silent pass: absence is a value in its own right, and zero is a legal instant. +// +// buildSignedSingleLeafProof aliases the proof's certification data to the +// request's, so the two must be separated before they can disagree at all. +func TestInclusionProofV2Verify_ExpiryPresenceMustMatch(t *testing.T) { + sid0, _ := types.ShardID{}.Split() + + for _, tt := range []struct { + name string + onProof, onRequest *uint64 + }{ + {"absent on the proof, present on the request", nil, Uint64Ptr(1755003600)}, + {"present on the proof, absent on the request", Uint64Ptr(1755003600), nil}, + {"present on both but different", Uint64Ptr(1755003600), Uint64Ptr(1755003601)}, + } { + t.Run(tt.name, func(t *testing.T) { + proof, req, partitionID, tb := buildSignedSingleLeafProof(t, sid0, types.NetworkMainNet) + + detached := *proof.CertificationData + proof.CertificationData = &detached + proof.CertificationData.ExpiresAt = tt.onProof + req.CertificationData.ExpiresAt = tt.onRequest + + err := proof.Verify(req, &VerifierContext{ + TrustBase: tb, + PartitionID: partitionID, + ExpectedShardID: sid0, + }) + require.ErrorContains(t, err, "expiry") + }) + } +} diff --git a/pkg/api/types.go b/pkg/api/types.go index 98da3a67..296a5363 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -57,13 +57,23 @@ func (t *Timestamp) UnmarshalJSON(data []byte) error { // AggregatorRecord represents a finalized certification request with proof data type AggregatorRecord struct { - StateID StateID `json:"stateId"` - CertificationData CertificationData `json:"certificationData"` - AggregateRequestCount uint64 `json:"aggregateRequestCount,omitempty,string"` - BlockNumber *BigInt `json:"blockNumber"` - LeafIndex *BigInt `json:"leafIndex"` - CreatedAt *Timestamp `json:"createdAt"` - FinalizedAt *Timestamp `json:"finalizedAt"` + StateID StateID `json:"stateId"` + CertificationData CertificationData `json:"certificationData"` + // ReferenceTime is the reference time of the round this record's leaf was + // created in. A verifier needs it to reproduce the certified leaf value, + // LeafValue(CertificationData.TransactionHash, ReferenceTime), so it is part + // of the record rather than something to be recovered from a later proof. + ReferenceTime uint64 `json:"referenceTime"` + AggregateRequestCount uint64 `json:"aggregateRequestCount,omitempty,string"` + BlockNumber *BigInt `json:"blockNumber"` + LeafIndex *BigInt `json:"leafIndex"` + CreatedAt *Timestamp `json:"createdAt"` + // FinalizedAt is intentionally absent. Nothing persists a finalization + // timestamp: models.Block.CreatedAt is stamped when the block is + // constructed at proposal time, before the certification request is sent to + // BFT, so returning it would underreport finalization by the whole BFT + // round trip. Adding a real one means persisting it on the block at + // finalization; until then the field is omitted rather than wrong. } // Block represents a blockchain block @@ -375,8 +385,9 @@ type VerifierContext struct { } // Verify checks a v2 inclusion proof end-to-end against the outer -// CertificationRequest and VerifierContext: local SMT path, UnicityCertificate -// (shard tree → unicity tree → seal), and ShardTreeCertificate.Shard equality. +// CertificationRequest and VerifierContext: request and state-ID binding, local +// SMT path, certified-shard binding, network binding, and UnicityCertificate +// verification (shard tree → unicity tree → seal). // // The nil-guard error strings below are part of the public contract so // reference verifiers in other languages can pin them. @@ -408,6 +419,17 @@ func (p *InclusionProofV2) Verify(v2 *CertificationRequest, vctx *VerifierContex if !equalExpiresAt(p.CertificationData.ExpiresAt, v2.CertificationData.ExpiresAt) { return errors.New("proof certification data expiry does not match certification request expiry") } + if p.CertificationData.OwnerPredicate.Engine != v2.CertificationData.OwnerPredicate.Engine || + !bytes.Equal(p.CertificationData.OwnerPredicate.Code, v2.CertificationData.OwnerPredicate.Code) || + !bytes.Equal(p.CertificationData.OwnerPredicate.Params, v2.CertificationData.OwnerPredicate.Params) { + return errors.New("proof certification data owner predicate does not match certification request owner predicate") + } + if !bytes.Equal(p.CertificationData.SourceStateHash, v2.CertificationData.SourceStateHash) { + return errors.New("proof certification data source state hash does not match certification request source state hash") + } + if !bytes.Equal(p.CertificationData.Witness, v2.CertificationData.Witness) { + return errors.New("proof certification data witness does not match certification request witness") + } rootRaw, err := p.UCInputRecordHashRaw() if err != nil { @@ -422,6 +444,13 @@ func (p *InclusionProofV2) Verify(v2 *CertificationRequest, vctx *VerifierContex if err != nil { return fmt.Errorf("failed to derive SMT key from stateId: %w", err) } + expectedStateID, err := p.CertificationData.CreateStateID() + if err != nil { + return fmt.Errorf("failed to derive stateId from certification data: %w", err) + } + if !bytes.Equal(key, expectedStateID.DataBytes()) { + return errors.New("stateId does not match certification data") + } if p.ReferenceTime == nil { return errors.New("missing inclusion proof reference time") } @@ -439,6 +468,16 @@ func (p *InclusionProofV2) Verify(v2 *CertificationRequest, vctx *VerifierContex if err := types.Cbor.Unmarshal(p.UnicityCertificate, &uc); err != nil { return fmt.Errorf("failed to decode unicity certificate: %w", err) } + if uc.ShardTreeCertificate.Shard.Length() > uint(len(key)*8) || + !uc.ShardTreeCertificate.Shard.Comparator()(key) { + return errors.New("stateId does not belong to certified shard") + } + if uc.UnicitySeal == nil { + return errors.New("unicity certificate missing unicity seal") + } + if uc.UnicitySeal.NetworkID != vctx.TrustBase.GetNetworkID() { + return errors.New("unicity seal network does not match trust base") + } if err := uc.Verify(vctx.TrustBase, crypto.SHA256, vctx.PartitionID, vctx.ExpectedShardID, vctx.ShardConfHash); err != nil { return fmt.Errorf("unicity certificate verification failed: %w", err) }