commit 700da7c158278af551822853674d4fc8956aa55d parent e8032d20476814334359bd111831fba382964186 Author: Joris Hartog <jorishartog@hotmail.com> Date: Tue, 1 Sep 2026 12:49:03 +0200 Queue burns for one block Diffstat:
33 files changed, 431 insertions(+), 118 deletions(-)
diff --git a/docs/protocol.md b/docs/protocol.md @@ -150,7 +150,7 @@ From height `300`, fallback finalization invalidates missed ticket opportunities Every normal block must include at least one burn. This keeps the future ticket pool alive even during quiet periods. A node that may finalize prepares a local anchor burn for the next block from the finalizer wallet, and that anchor burn appears directly in the block. -From height `1000`, every burn is bound to the block parent hash. The parent hash is part of the signed transaction payload, and validators require it to equal the `prev_hash` of the block containing the burn. A burn left in a mempool after any normal, fallback, or recovery block therefore expires immediately. An online wallet can sign a replacement against the new tip, while an offline wallet cannot keep creating tickets from previously broadcast burns. Earlier history retains the unanchored burn format. +From height `1000`, burns use a one-block admission pipeline. A public burn is signed against the current chain tip, but is queued while the child of that tip is produced and may only appear in the following block. Validators therefore require its signed anchor to equal the containing block's grandparent hash (the containing block's parent `prev_hash`). This gives the burn a full VDF interval plus the next burn-collection window to propagate without allowing the finalizer to change the transaction list after starting its VDF. A queued burn survives the first tip change and expires after its single inclusion height if it was not included. The finalizer's mandatory local burn is signed directly for the next block using the same grandparent anchor rule. Earlier history retains the unanchored burn format. The anchor burn is not a fairness mechanism. By itself, it would mostly help the current finalizer keep creating future tickets. Fairness against self-serving finalizers comes from the burn inclusion committee described below. @@ -293,7 +293,7 @@ Recovery blocks pay `100%` to the recovery finalizer. Only attestations actually included in the block earn a committee share. If no extra committee attestation is required, the finalizer receives the full reward. Integer amounts are rounded down into the committee half (`reward / 2`), so the finalizer receives the remainder when the reward is odd. Committee reward outputs do not create UTXO lineage; lineage selection remains based on mature mine-action descendants. -A committee member can sign one burn bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid fee-paying pending burns ordered by absolute fee, with signature as the deterministic tie-breaker. Honest committee policy is to include every valid burn it selects by that canonical ordering, or to sign an empty bundle only when the signer knows no valid burn for that height. Empty bundles are an honest-policy signal, not something validators can prove from their own mempools. Consensus checks committee membership, signature validity, lineage assignment, ordering, and threshold. +A committee member can sign one burn bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid fee-paying pending burns eligible at that height, ordered by absolute fee with signature as the deterministic tie-breaker. Burns queued for the following height are not included yet. Honest committee policy is to include every valid burn it selects by that canonical ordering, or to sign an empty bundle only when the signer knows no valid burn for that height. Empty bundles are an honest-policy signal, not something validators can prove from their own mempools. Consensus checks committee membership, signature validity, lineage assignment, ordering, and threshold. Automatic nodes wait about `30 seconds` after seeing pending burns for the next height before signing a burn bundle or starting the burn-list-bound VDF. This gives burn gossip time to settle and avoids locking in an underfilled bundle from the first partial batch a node received. diff --git a/e2e/snapshots/first-objective-checkpoint/bootstrap/chain.sqlite3 b/e2e/snapshots/first-objective-checkpoint/bootstrap/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/bootstrap/ui_data.sqlite3 b/e2e/snapshots/first-objective-checkpoint/bootstrap/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/manifest.json b/e2e/snapshots/first-objective-checkpoint/manifest.json @@ -2,66 +2,66 @@ "format": 1, "name": "first-objective-checkpoint", "height": 1001, - "tip_hash": "52673c26456682f7b153b40e25b1cd91b4a7ecdff81c5a46bb96c5a1be30923b", + "tip_hash": "b6f08f7764dc38be106d9a83341a3e9d204060a3827087cf422457c78368a81f", "profile_id": "iuna-local-e2e-5s-v1", "target_block_ms": 5000, - "source_height": 1002, + "source_height": 1005, "nodes": { "bootstrap": { "height": 1001, - "tip_hash": "52673c26456682f7b153b40e25b1cd91b4a7ecdff81c5a46bb96c5a1be30923b", - "updated_at_ms": 1788207421380 + "tip_hash": "b6f08f7764dc38be106d9a83341a3e9d204060a3827087cf422457c78368a81f", + "updated_at_ms": 1788256616336 }, "node2": { "height": 1001, - "tip_hash": "52673c26456682f7b153b40e25b1cd91b4a7ecdff81c5a46bb96c5a1be30923b", - "updated_at_ms": 1788207421380 + "tip_hash": "b6f08f7764dc38be106d9a83341a3e9d204060a3827087cf422457c78368a81f", + "updated_at_ms": 1788256616336 }, "node3": { "height": 1001, - "tip_hash": "52673c26456682f7b153b40e25b1cd91b4a7ecdff81c5a46bb96c5a1be30923b", - "updated_at_ms": 1788207421380 + "tip_hash": "b6f08f7764dc38be106d9a83341a3e9d204060a3827087cf422457c78368a81f", + "updated_at_ms": 1788256616336 }, "node4": { "height": 1001, - "tip_hash": "52673c26456682f7b153b40e25b1cd91b4a7ecdff81c5a46bb96c5a1be30923b", - "updated_at_ms": 1788207421380 + "tip_hash": "b6f08f7764dc38be106d9a83341a3e9d204060a3827087cf422457c78368a81f", + "updated_at_ms": 1788256616336 }, "node5": { "height": 1001, - "tip_hash": "52673c26456682f7b153b40e25b1cd91b4a7ecdff81c5a46bb96c5a1be30923b", - "updated_at_ms": 1788207421380 + "tip_hash": "b6f08f7764dc38be106d9a83341a3e9d204060a3827087cf422457c78368a81f", + "updated_at_ms": 1788256616336 }, "node6": { "height": 1001, - "tip_hash": "52673c26456682f7b153b40e25b1cd91b4a7ecdff81c5a46bb96c5a1be30923b", - "updated_at_ms": 1788207421380 + "tip_hash": "b6f08f7764dc38be106d9a83341a3e9d204060a3827087cf422457c78368a81f", + "updated_at_ms": 1788256616336 } }, "sha256": { - "bootstrap/chain.sqlite3": "3336dc33f3650d66b5069c75b083d4db1f26a6fe32a1ce1bfc128601a0578007", + "bootstrap/chain.sqlite3": "87154a2fe5982f9da0f249c4b39aeccc04d9b80e56314df2c526acde8d801603", "bootstrap/config.json": "ee490011fee4bad2a08641cca4710efad5ee0cc7ff0f418e9b6b3b97f6d64c8e", - "bootstrap/ui_data.sqlite3": "ad838956f4527735714c5458954b618a6fa6cc7086b55538381ec8e39b014d28", + "bootstrap/ui_data.sqlite3": "1dec1d8375624c38e92f912e8554c3fa92f8afc4c61a842af47d2f6dded678e7", "bootstrap/wallet.json": "9e545dee2d7d8c430a301fd9cc4530467c6ba36787d2407ec31eee96eee88874", - "node2/chain.sqlite3": "3336dc33f3650d66b5069c75b083d4db1f26a6fe32a1ce1bfc128601a0578007", + "node2/chain.sqlite3": "87154a2fe5982f9da0f249c4b39aeccc04d9b80e56314df2c526acde8d801603", "node2/config.json": "5f38677b8e290f412a2ff5833518a1b1fec75d9c8ab9dbc72b1762fd889e33c8", - "node2/ui_data.sqlite3": "ad838956f4527735714c5458954b618a6fa6cc7086b55538381ec8e39b014d28", + "node2/ui_data.sqlite3": "1dec1d8375624c38e92f912e8554c3fa92f8afc4c61a842af47d2f6dded678e7", "node2/wallet.json": "80c81d94fa958deb3212c3ae52bcfcdcf1f2ce446ec3c4addcfb8ffa6e669a4b", - "node3/chain.sqlite3": "3336dc33f3650d66b5069c75b083d4db1f26a6fe32a1ce1bfc128601a0578007", + "node3/chain.sqlite3": "87154a2fe5982f9da0f249c4b39aeccc04d9b80e56314df2c526acde8d801603", "node3/config.json": "63a8c0d8b06091dbfb899bba7f6603497c44e7af1bee9f21cc5152a3fafc037d", - "node3/ui_data.sqlite3": "ad838956f4527735714c5458954b618a6fa6cc7086b55538381ec8e39b014d28", + "node3/ui_data.sqlite3": "1dec1d8375624c38e92f912e8554c3fa92f8afc4c61a842af47d2f6dded678e7", "node3/wallet.json": "15d4b71da0e31f6045fc6b98b7d3414f7925556932de42b52bacd35e670c1c86", - "node4/chain.sqlite3": "3336dc33f3650d66b5069c75b083d4db1f26a6fe32a1ce1bfc128601a0578007", + "node4/chain.sqlite3": "87154a2fe5982f9da0f249c4b39aeccc04d9b80e56314df2c526acde8d801603", "node4/config.json": "f9bed39ec29fbff17fb6155e718454706991603d5993632c6f6b18cbf3d80fa4", - "node4/ui_data.sqlite3": "ad838956f4527735714c5458954b618a6fa6cc7086b55538381ec8e39b014d28", + "node4/ui_data.sqlite3": "1dec1d8375624c38e92f912e8554c3fa92f8afc4c61a842af47d2f6dded678e7", "node4/wallet.json": "204ff6327b0c5da43418f704322fcfe8de09f799239896146bfb0320d42e3780", - "node5/chain.sqlite3": "3336dc33f3650d66b5069c75b083d4db1f26a6fe32a1ce1bfc128601a0578007", + "node5/chain.sqlite3": "87154a2fe5982f9da0f249c4b39aeccc04d9b80e56314df2c526acde8d801603", "node5/config.json": "0bd460df616c8effb1091625887cab053d95e9f30afbc4237e7e5923c0cd6b73", - "node5/ui_data.sqlite3": "ad838956f4527735714c5458954b618a6fa6cc7086b55538381ec8e39b014d28", + "node5/ui_data.sqlite3": "1dec1d8375624c38e92f912e8554c3fa92f8afc4c61a842af47d2f6dded678e7", "node5/wallet.json": "4612e7e2143a11b5834b89e1dbaebb30d9c898930bfeb3eeedb347771b827808", - "node6/chain.sqlite3": "3336dc33f3650d66b5069c75b083d4db1f26a6fe32a1ce1bfc128601a0578007", + "node6/chain.sqlite3": "87154a2fe5982f9da0f249c4b39aeccc04d9b80e56314df2c526acde8d801603", "node6/config.json": "1d6aaba498ee1f273c89d7f84806318cf5f15c36088346460236e114a0fd73bb", - "node6/ui_data.sqlite3": "ad838956f4527735714c5458954b618a6fa6cc7086b55538381ec8e39b014d28", + "node6/ui_data.sqlite3": "1dec1d8375624c38e92f912e8554c3fa92f8afc4c61a842af47d2f6dded678e7", "node6/wallet.json": "1eaf0a87b89c5a36a3f43162894922176412817f468e907faa5970f1656a04f7" } } diff --git a/e2e/snapshots/first-objective-checkpoint/node2/chain.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node2/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node2/ui_data.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node2/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node3/chain.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node3/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node3/ui_data.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node3/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node4/chain.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node4/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node4/ui_data.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node4/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node5/chain.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node5/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node5/ui_data.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node5/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node6/chain.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node6/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/first-objective-checkpoint/node6/ui_data.sqlite3 b/e2e/snapshots/first-objective-checkpoint/node6/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/bootstrap/chain.sqlite3 b/e2e/snapshots/objective-finality/bootstrap/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/bootstrap/ui_data.sqlite3 b/e2e/snapshots/objective-finality/bootstrap/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/manifest.json b/e2e/snapshots/objective-finality/manifest.json @@ -2,66 +2,66 @@ "format": 1, "name": "objective-finality", "height": 1000, - "tip_hash": "71d11cbb11e5c5f0be26686ca16170a4acb2d7bda23057657279a34e7fcd66e6", + "tip_hash": "3a825d265a576b6400a9899d91628887dcfb72a22a6f770e9b2c5b684acd4e53", "profile_id": "iuna-local-e2e-5s-v1", "target_block_ms": 5000, "source_height": 1001, "nodes": { "bootstrap": { "height": 1000, - "tip_hash": "71d11cbb11e5c5f0be26686ca16170a4acb2d7bda23057657279a34e7fcd66e6", - "updated_at_ms": 1788207376222 + "tip_hash": "3a825d265a576b6400a9899d91628887dcfb72a22a6f770e9b2c5b684acd4e53", + "updated_at_ms": 1788256552044 }, "node2": { "height": 1000, - "tip_hash": "71d11cbb11e5c5f0be26686ca16170a4acb2d7bda23057657279a34e7fcd66e6", - "updated_at_ms": 1788207376222 + "tip_hash": "3a825d265a576b6400a9899d91628887dcfb72a22a6f770e9b2c5b684acd4e53", + "updated_at_ms": 1788256552044 }, "node3": { "height": 1000, - "tip_hash": "71d11cbb11e5c5f0be26686ca16170a4acb2d7bda23057657279a34e7fcd66e6", - "updated_at_ms": 1788207376222 + "tip_hash": "3a825d265a576b6400a9899d91628887dcfb72a22a6f770e9b2c5b684acd4e53", + "updated_at_ms": 1788256552044 }, "node4": { "height": 1000, - "tip_hash": "71d11cbb11e5c5f0be26686ca16170a4acb2d7bda23057657279a34e7fcd66e6", - "updated_at_ms": 1788207376222 + "tip_hash": "3a825d265a576b6400a9899d91628887dcfb72a22a6f770e9b2c5b684acd4e53", + "updated_at_ms": 1788256552044 }, "node5": { "height": 1000, - "tip_hash": "71d11cbb11e5c5f0be26686ca16170a4acb2d7bda23057657279a34e7fcd66e6", - "updated_at_ms": 1788207376222 + "tip_hash": "3a825d265a576b6400a9899d91628887dcfb72a22a6f770e9b2c5b684acd4e53", + "updated_at_ms": 1788256552044 }, "node6": { "height": 1000, - "tip_hash": "71d11cbb11e5c5f0be26686ca16170a4acb2d7bda23057657279a34e7fcd66e6", - "updated_at_ms": 1788207376222 + "tip_hash": "3a825d265a576b6400a9899d91628887dcfb72a22a6f770e9b2c5b684acd4e53", + "updated_at_ms": 1788256552044 } }, "sha256": { - "bootstrap/chain.sqlite3": "968db77500c274d3a377d048c3f3f34ae7467f68ac4e36154112a86e7f1ceec7", + "bootstrap/chain.sqlite3": "db3a06e213d4ef130cb80a09a624e0480588f0356889201d4b78c7a3a3a5dffc", "bootstrap/config.json": "ee490011fee4bad2a08641cca4710efad5ee0cc7ff0f418e9b6b3b97f6d64c8e", - "bootstrap/ui_data.sqlite3": "69b49246151d28a807e34a17faacc259c14a0a3d978a2a5cb5b7facd7f33b576", + "bootstrap/ui_data.sqlite3": "ca6fa0233a85d74021295c8ff9890ac580906638dfaed75cf52811ece4a7bf44", "bootstrap/wallet.json": "9e545dee2d7d8c430a301fd9cc4530467c6ba36787d2407ec31eee96eee88874", - "node2/chain.sqlite3": "968db77500c274d3a377d048c3f3f34ae7467f68ac4e36154112a86e7f1ceec7", + "node2/chain.sqlite3": "db3a06e213d4ef130cb80a09a624e0480588f0356889201d4b78c7a3a3a5dffc", "node2/config.json": "5f38677b8e290f412a2ff5833518a1b1fec75d9c8ab9dbc72b1762fd889e33c8", - "node2/ui_data.sqlite3": "69b49246151d28a807e34a17faacc259c14a0a3d978a2a5cb5b7facd7f33b576", + "node2/ui_data.sqlite3": "ca6fa0233a85d74021295c8ff9890ac580906638dfaed75cf52811ece4a7bf44", "node2/wallet.json": "80c81d94fa958deb3212c3ae52bcfcdcf1f2ce446ec3c4addcfb8ffa6e669a4b", - "node3/chain.sqlite3": "968db77500c274d3a377d048c3f3f34ae7467f68ac4e36154112a86e7f1ceec7", + "node3/chain.sqlite3": "db3a06e213d4ef130cb80a09a624e0480588f0356889201d4b78c7a3a3a5dffc", "node3/config.json": "63a8c0d8b06091dbfb899bba7f6603497c44e7af1bee9f21cc5152a3fafc037d", - "node3/ui_data.sqlite3": "69b49246151d28a807e34a17faacc259c14a0a3d978a2a5cb5b7facd7f33b576", + "node3/ui_data.sqlite3": "ca6fa0233a85d74021295c8ff9890ac580906638dfaed75cf52811ece4a7bf44", "node3/wallet.json": "15d4b71da0e31f6045fc6b98b7d3414f7925556932de42b52bacd35e670c1c86", - "node4/chain.sqlite3": "968db77500c274d3a377d048c3f3f34ae7467f68ac4e36154112a86e7f1ceec7", + "node4/chain.sqlite3": "db3a06e213d4ef130cb80a09a624e0480588f0356889201d4b78c7a3a3a5dffc", "node4/config.json": "f9bed39ec29fbff17fb6155e718454706991603d5993632c6f6b18cbf3d80fa4", - "node4/ui_data.sqlite3": "69b49246151d28a807e34a17faacc259c14a0a3d978a2a5cb5b7facd7f33b576", + "node4/ui_data.sqlite3": "ca6fa0233a85d74021295c8ff9890ac580906638dfaed75cf52811ece4a7bf44", "node4/wallet.json": "204ff6327b0c5da43418f704322fcfe8de09f799239896146bfb0320d42e3780", - "node5/chain.sqlite3": "968db77500c274d3a377d048c3f3f34ae7467f68ac4e36154112a86e7f1ceec7", + "node5/chain.sqlite3": "db3a06e213d4ef130cb80a09a624e0480588f0356889201d4b78c7a3a3a5dffc", "node5/config.json": "0bd460df616c8effb1091625887cab053d95e9f30afbc4237e7e5923c0cd6b73", - "node5/ui_data.sqlite3": "69b49246151d28a807e34a17faacc259c14a0a3d978a2a5cb5b7facd7f33b576", + "node5/ui_data.sqlite3": "ca6fa0233a85d74021295c8ff9890ac580906638dfaed75cf52811ece4a7bf44", "node5/wallet.json": "4612e7e2143a11b5834b89e1dbaebb30d9c898930bfeb3eeedb347771b827808", - "node6/chain.sqlite3": "968db77500c274d3a377d048c3f3f34ae7467f68ac4e36154112a86e7f1ceec7", + "node6/chain.sqlite3": "db3a06e213d4ef130cb80a09a624e0480588f0356889201d4b78c7a3a3a5dffc", "node6/config.json": "1d6aaba498ee1f273c89d7f84806318cf5f15c36088346460236e114a0fd73bb", - "node6/ui_data.sqlite3": "69b49246151d28a807e34a17faacc259c14a0a3d978a2a5cb5b7facd7f33b576", + "node6/ui_data.sqlite3": "ca6fa0233a85d74021295c8ff9890ac580906638dfaed75cf52811ece4a7bf44", "node6/wallet.json": "1eaf0a87b89c5a36a3f43162894922176412817f468e907faa5970f1656a04f7" } } diff --git a/e2e/snapshots/objective-finality/node2/chain.sqlite3 b/e2e/snapshots/objective-finality/node2/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node2/ui_data.sqlite3 b/e2e/snapshots/objective-finality/node2/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node3/chain.sqlite3 b/e2e/snapshots/objective-finality/node3/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node3/ui_data.sqlite3 b/e2e/snapshots/objective-finality/node3/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node4/chain.sqlite3 b/e2e/snapshots/objective-finality/node4/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node4/ui_data.sqlite3 b/e2e/snapshots/objective-finality/node4/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node5/chain.sqlite3 b/e2e/snapshots/objective-finality/node5/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node5/ui_data.sqlite3 b/e2e/snapshots/objective-finality/node5/ui_data.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node6/chain.sqlite3 b/e2e/snapshots/objective-finality/node6/chain.sqlite3 Binary files differ. diff --git a/e2e/snapshots/objective-finality/node6/ui_data.sqlite3 b/e2e/snapshots/objective-finality/node6/ui_data.sqlite3 Binary files differ. diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -335,13 +335,13 @@ impl NodeCore { .min_by_key(|(_, output)| output.amount) .map(|(outpoint, _)| outpoint); match outpoint { - Some(outpoint) => ledger.build_burn_with_inputs( + Some(outpoint) => ledger.build_burn_for_next_block_with_inputs( wallet, anchor_burn_amount, fee, std::slice::from_ref(outpoint), ), - None => ledger.build_burn(wallet, anchor_burn_amount, fee), + None => ledger.build_burn_for_next_block(wallet, anchor_burn_amount, fee), } }) { Ok((burn, _)) => burn, @@ -601,7 +601,9 @@ impl NodeCore { return (ledger, Some(burn.signature().to_string())); } if ledger.pending().iter().any(|transaction| { - transaction.is_burn() && transaction.sender() == self.wallet.address() + transaction.is_burn() + && transaction.sender() == self.wallet.address() + && ledger.transaction_is_eligible_for_next_block(transaction) }) { return (ledger, None); } diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -88,7 +88,7 @@ impl Ledger { bail!("duplicate transaction in block"); } self.validate_transaction_terms(tx)?; - self.validate_transaction_anchor_for_height(tx, block.height, &block.prev_hash)?; + self.validate_transaction_anchor_for_block(tx, block.height)?; apply_transaction_with_lineage( tx, block.height, @@ -124,7 +124,6 @@ impl Ledger { if let Some(checkpoint) = certified_parent { self.objective_finality_checkpoint = Some(checkpoint); } - let next_signing_domain = self.transaction_signing_domain(); let available = self.utxos.clone(); let pending = std::mem::take(&mut self.pending); self.pending = pending @@ -133,14 +132,10 @@ impl Ledger { !mined_signatures.contains(tx.signature()) && transaction_inputs_available(tx, &available) && self.validate_transaction_terms(tx).is_ok() - && self - .validate_transaction_anchor_for_height( - tx, - self.height().saturating_add(1), - self.tip_hash(), - ) + && self.validate_transaction_anchor_for_pending(tx).is_ok() + && tx + .verify_signature(&self.transaction_signing_domain_for_pending(tx)) .is_ok() - && tx.verify_signature(&next_signing_domain).is_ok() }) .collect(); let orphans = std::mem::take(&mut self.orphans); @@ -149,14 +144,10 @@ impl Ledger { .filter(|tx| { !mined_signatures.contains(tx.signature()) && self.validate_transaction_terms(tx).is_ok() - && self - .validate_transaction_anchor_for_height( - tx, - self.height().saturating_add(1), - self.tip_hash(), - ) + && self.validate_transaction_anchor_for_pending(tx).is_ok() + && tx + .verify_signature(&self.transaction_signing_domain_for_pending(tx)) .is_ok() - && tx.verify_signature(&next_signing_domain).is_ok() }) .collect(); self.refresh_pending_pool_byte_counters()?; @@ -431,7 +422,7 @@ mod tests { eligible_until_height: TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, }]; - let burn = ledger.build_burn(&wallet, 1, 1).unwrap(); + let burn = ledger.build_burn_for_next_block(&wallet, 1, 1).unwrap(); ledger.submit_transaction(burn).unwrap(); let mine = ledger.build_mine(wallet.address()).unwrap(); let replayed_id = mine.signature().to_string(); @@ -453,6 +444,93 @@ mod tests { } #[test] + fn queued_burn_survives_one_applied_block_and_is_included_in_the_following_block() { + let finalizer = Wallet::from_seed("queued-burn-finalizer"); + let burner = Wallet::from_seed("queued-burn-wallet"); + let mut ledger = Ledger::new_with_genesis_burns( + BTreeMap::from([ + (finalizer.address().to_string(), 10 * MICRO_IUNA), + (burner.address().to_string(), 10 * MICRO_IUNA), + ]), + vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + ledger.chain.last_mut().unwrap().height = + super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 2; + ledger.tickets = vec![BurnTicket { + id: "6".repeat(64), + owner: finalizer.address().to_string(), + amount: MICRO_IUNA, + eligible_from_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, + eligible_until_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, + }]; + + let queued_burn = ledger.build_burn(&burner, 1, 1).unwrap(); + assert_eq!(queued_burn.burn_anchor(), Some(ledger.tip_hash())); + ledger.submit_transaction(queued_burn.clone()).unwrap(); + let legacy_anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap(); + assert_eq!(legacy_anchor.burn_anchor(), None); + ledger.submit_transaction(legacy_anchor).unwrap(); + + let prepared = ledger.prepare_next_block(finalizer.address(), 2).unwrap(); + let first_block = prepared.finish(&finalizer, "first-vdf-output".to_string()); + assert!( + first_block + .transactions + .iter() + .all(|transaction| transaction.signature() != queued_burn.signature()) + ); + ledger + .apply_preverified_block_at(first_block, u64::MAX) + .unwrap(); + + assert!( + ledger + .pending() + .iter() + .any(|transaction| transaction.signature() == queued_burn.signature()) + ); + assert!(ledger.transaction_is_eligible_for_next_block(&queued_burn)); + + ledger.tickets.push(BurnTicket { + id: "7".repeat(64), + owner: finalizer.address().to_string(), + amount: MICRO_IUNA, + eligible_from_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT, + eligible_until_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT, + }); + let activated_anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap(); + assert_eq!( + activated_anchor.burn_anchor(), + Some(ledger.tip().prev_hash.as_str()) + ); + ledger.submit_transaction(activated_anchor).unwrap(); + + let prepared = ledger.prepare_next_block(finalizer.address(), 3).unwrap(); + let activated_block = prepared.finish(&finalizer, "second-vdf-output".to_string()); + assert_eq!( + activated_block.height, + super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT + ); + assert!( + activated_block + .transactions + .iter() + .any(|transaction| transaction.signature() == queued_burn.signature()) + ); + ledger + .apply_preverified_block_at(activated_block, u64::MAX) + .unwrap(); + assert!( + ledger + .pending() + .iter() + .all(|transaction| transaction.signature() != queued_burn.signature()) + ); + } + + #[test] fn applied_blocks_and_snapshot_restore_index_mined_transaction_ids() { let wallet = Wallet::from_seed("replay-index-genesis-wallet"); let mut ledger = Ledger::new_with_genesis_burns( diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -91,7 +91,14 @@ impl Ledger { .checked_add(fee) .context("burn amount plus fee overflows")?; let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; - self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total) + self.build_burn_from_inputs( + wallet, + amount, + fee, + inputs, + input_total, + self.public_burn_anchor(), + ) } pub fn build_burn_with_inputs( @@ -106,7 +113,56 @@ impl Ledger { .context("burn amount plus fee overflows")?; let (inputs, input_total) = self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; - self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total) + self.build_burn_from_inputs( + wallet, + amount, + fee, + inputs, + input_total, + self.public_burn_anchor(), + ) + } + + pub(crate) fn build_burn_for_next_block( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + ) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("burn amount plus fee overflows")?; + let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; + self.build_burn_from_inputs( + wallet, + amount, + fee, + inputs, + input_total, + self.next_block_burn_anchor(), + ) + } + + pub(crate) fn build_burn_for_next_block_with_inputs( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + outpoints: &[OutPoint], + ) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("burn amount plus fee overflows")?; + let (inputs, input_total) = + self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; + self.build_burn_from_inputs( + wallet, + amount, + fee, + inputs, + input_total, + self.next_block_burn_anchor(), + ) } fn build_burn_from_inputs( @@ -116,7 +172,13 @@ impl Ledger { fee: Amount, inputs: Vec<UnsignedTxInput>, input_total: Amount, + anchor: Option<String>, ) -> Result<Transaction> { + let signing_height = if anchor.as_deref() == Some(self.tip_hash()) { + self.height().saturating_add(2) + } else { + self.height().saturating_add(1) + }; let required = amount .checked_add(fee) .context("burn amount plus fee overflows")?; @@ -136,14 +198,27 @@ impl Ledger { change, amount, fee, - anchor: (self.height().saturating_add(1) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT) - .then(|| self.tip().hash.clone()), + anchor, } - .sign(wallet, &self.transaction_signing_domain())?; + .sign(wallet, &self.transaction_signing_domain_at(signing_height))?; self.validate_new_transaction(&transaction)?; Ok(transaction) } + fn public_burn_anchor(&self) -> Option<String> { + // Public burns enter a one-block queue: a burn signed at tip H is + // eligible in the block after H's direct child. + (self.height().saturating_add(2) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT) + .then(|| self.tip().hash.clone()) + } + + fn next_block_burn_anchor(&self) -> Option<String> { + // A finalizer learns its role only after the parent exists, so its + // mandatory local burn is signed directly against that parent's parent. + (self.height().saturating_add(1) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT) + .then(|| self.tip().prev_hash.clone()) + } + pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { let recipient = recipient.into(); validate_address(&recipient, "mine recipient")?; diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs @@ -66,14 +66,10 @@ impl Ledger { return Ok(TransactionSubmitOutcome::AlreadyKnown); } - let signing_domain = self.transaction_signing_domain(); - transaction.verify_signature(&signing_domain)?; self.validate_transaction_terms(&transaction)?; - self.validate_transaction_anchor_for_height( - &transaction, - self.height().saturating_add(1), - self.tip_hash(), - )?; + self.validate_transaction_anchor_for_pending(&transaction)?; + let signing_domain = self.transaction_signing_domain_for_pending(&transaction); + transaction.verify_signature(&signing_domain)?; ensure_transaction_fits_empty_block( compact_block_context(self), &transaction, diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs @@ -29,7 +29,6 @@ pub(crate) const MINE_ANCHOR_LIMIT_REACHED: &str = "mine transaction anchor limi impl Ledger { pub(super) fn valid_pending_transactions(&self) -> Vec<Transaction> { let mut utxos = self.utxos.clone(); - let signing_domain = self.transaction_signing_domain(); let mut valid = Vec::new(); let mut remaining = self.pending.iter().collect::<Vec<_>>(); let mut selected_mine_anchor_counts = BTreeMap::new(); @@ -53,14 +52,13 @@ impl Ledger { } if transaction_inputs_available(tx, &utxos) && self.validate_transaction_terms(tx).is_ok() - && self - .validate_transaction_anchor_for_height( - tx, - self.height().saturating_add(1), - self.tip_hash(), - ) - .is_ok() - && apply_transaction(tx, &mut utxos, &signing_domain).is_ok() + && self.validate_transaction_anchor_for_pending(tx).is_ok() + && apply_transaction( + tx, + &mut utxos, + &self.transaction_signing_domain_for_pending(tx), + ) + .is_ok() { if let Some(anchor) = mine_anchor(tx) { selected_mine_anchor_counts @@ -120,7 +118,11 @@ impl Ledger { let block_context = compact_block_context(self); let signing_domain = self.transaction_signing_domain(); let mut utxos = self.utxos.clone(); - let mut remaining = self.valid_pending_transactions(); + let mut remaining = self + .valid_pending_transactions() + .into_iter() + .filter(|transaction| self.transaction_is_eligible_for_next_block(transaction)) + .collect::<Vec<_>>(); let mut selected = Vec::new(); let required_burn_signatures = burn_bundle_section @@ -314,11 +316,7 @@ impl Ledger { pub(super) fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { self.validate_transaction_terms(transaction)?; - self.validate_transaction_anchor_for_height( - transaction, - self.height().saturating_add(1), - self.tip_hash(), - )?; + self.validate_transaction_anchor_for_pending(transaction)?; ensure_transaction_fits_empty_block( compact_block_context(self), transaction, @@ -326,7 +324,11 @@ impl Ledger { )?; self.validate_mine_anchor_available(transaction)?; let mut utxos = self.utxos_after_spendable_pending()?; - apply_transaction(transaction, &mut utxos, &self.transaction_signing_domain()) + apply_transaction( + transaction, + &mut utxos, + &self.transaction_signing_domain_for_pending(transaction), + ) } pub(super) fn validate_mine_anchor_available(&self, transaction: &Transaction) -> Result<()> { @@ -355,7 +357,6 @@ impl Ledger { } pub(super) fn promote_orphan_transactions(&mut self) -> Result<()> { - let signing_domain = self.transaction_signing_domain(); loop { if self.pending.len() >= MAX_PENDING_TRANSACTIONS { return Ok(()); @@ -370,7 +371,12 @@ impl Ledger { continue; } if self.validate_new_transaction(transaction).is_ok() - && apply_transaction(transaction, &mut utxos, &signing_domain).is_ok() + && apply_transaction( + transaction, + &mut utxos, + &self.transaction_signing_domain_for_pending(transaction), + ) + .is_ok() { let transaction_bytes = pending_pool_item_bytes(transaction)?; let promoted_bytes = self @@ -462,11 +468,10 @@ impl Ledger { Ok(()) } - pub(super) fn validate_transaction_anchor_for_height( + pub(super) fn validate_transaction_anchor_for_block( &self, transaction: &Transaction, height: u64, - parent_hash: &str, ) -> Result<()> { if !transaction.is_burn() { return Ok(()); @@ -479,30 +484,89 @@ impl Ledger { } let anchor = transaction .burn_anchor() - .context("burn transaction is missing its parent anchor")?; - if anchor != parent_hash { - bail!("burn transaction anchor does not match the block parent"); + .context("burn transaction is missing its chain anchor")?; + if anchor != self.tip().prev_hash { + bail!("burn transaction anchor does not match the block grandparent"); } Ok(()) } + pub(super) fn validate_transaction_anchor_for_pending( + &self, + transaction: &Transaction, + ) -> Result<()> { + if !transaction.is_burn() { + return Ok(()); + } + + let next_height = self.height().saturating_add(1); + let following_height = self.height().saturating_add(2); + let anchor = transaction.burn_anchor(); + + // Keep both pipeline stages: burns anchored to the tip's parent are + // eligible now, while burns anchored to the tip wait one more block. + if next_height < super::TIP_BOUND_BURN_ACTIVATION_HEIGHT && anchor.is_none() { + return Ok(()); + } + if next_height >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT + && anchor == Some(self.tip().prev_hash.as_str()) + { + return Ok(()); + } + if following_height >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT + && anchor == Some(self.tip_hash()) + { + return Ok(()); + } + + bail!("burn transaction anchor is not valid for either of the next two block heights") + } + + pub(crate) fn transaction_is_eligible_for_next_block(&self, transaction: &Transaction) -> bool { + self.validate_transaction_anchor_for_block(transaction, self.height().saturating_add(1)) + .is_ok() + } + + pub(super) fn transaction_signing_domain_for_pending( + &self, + transaction: &Transaction, + ) -> super::TransactionSigningDomain { + let height = if transaction.is_burn() + && transaction.burn_anchor() == Some(self.tip_hash()) + && self.height().saturating_add(2) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT + { + self.height().saturating_add(2) + } else { + self.height().saturating_add(1) + }; + self.transaction_signing_domain_at(height) + } + pub(super) fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { let mut utxos = self.utxos.clone(); - let signing_domain = self.transaction_signing_domain(); for pending in self.valid_pending_transactions() { - apply_transaction(&pending, &mut utxos, &signing_domain)?; + apply_transaction( + &pending, + &mut utxos, + &self.transaction_signing_domain_for_pending(&pending), + )?; } Ok(utxos) } pub(super) fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { let mut utxos = self.utxos.clone(); - let signing_domain = self.transaction_signing_domain(); for pending in self.valid_pending_transactions() { if matches!(pending, Transaction::Mine { .. }) { continue; } - if apply_spendable_pending_transaction(&pending, &mut utxos, &signing_domain).is_err() { + if apply_spendable_pending_transaction( + &pending, + &mut utxos, + &self.transaction_signing_domain_for_pending(&pending), + ) + .is_err() + { continue; } } @@ -563,31 +627,86 @@ mod tests { } #[test] - fn burns_become_tip_bound_at_activation_and_expire_after_tip_change() { + fn burns_queue_for_one_block_then_expire_after_their_inclusion_height() { let wallet = Wallet::from_seed("tip-bound-burn-wallet"); let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); extend_synthetic_chain_to( &mut ledger, - super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 2, + super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 3, ); let legacy_burn = ledger.build_burn(&wallet, 1, 1).unwrap(); assert_eq!(legacy_burn.burn_anchor(), None); extend_synthetic_chain_to( &mut ledger, - super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, + super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 2, ); let anchored_burn = ledger.build_burn(&wallet, 1, 1).unwrap(); assert_eq!(anchored_burn.burn_anchor(), Some(ledger.tip_hash())); assert!(ledger.submit_transaction(anchored_burn.clone()).unwrap()); - assert_eq!(ledger.valid_pending_transactions(), vec![anchored_burn]); + assert_eq!( + ledger.valid_pending_transactions(), + vec![anchored_burn.clone()] + ); + assert!( + ledger + .select_block_transactions_with_required_burn_owner( + None, + None, + &BurnBundleSection::default(), + ) + .unwrap() + .transactions + .is_empty() + ); + + extend_synthetic_chain_to( + &mut ledger, + super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, + ); + assert_eq!( + ledger.valid_pending_transactions(), + vec![anchored_burn.clone()] + ); + assert_eq!( + ledger + .select_block_transactions_with_required_burn_owner( + None, + None, + &BurnBundleSection::default(), + ) + .unwrap() + .transactions, + vec![anchored_burn] + ); extend_synthetic_chain_to(&mut ledger, super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT); assert!(ledger.valid_pending_transactions().is_empty()); } #[test] + fn finalizer_burn_for_next_block_anchors_to_the_current_tip_parent() { + let wallet = Wallet::from_seed("next-block-burn-wallet"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); + extend_synthetic_chain_to( + &mut ledger, + super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, + ); + + let future_burn = ledger.build_burn(&wallet, 1, 1).unwrap(); + let next_block_burn = ledger.build_burn_for_next_block(&wallet, 1, 1).unwrap(); + + assert_eq!(future_burn.burn_anchor(), Some(ledger.tip_hash())); + assert_eq!( + next_block_burn.burn_anchor(), + Some(ledger.tip().prev_hash.as_str()) + ); + assert!(!ledger.transaction_is_eligible_for_next_block(&future_burn)); + assert!(ledger.transaction_is_eligible_for_next_block(&next_block_burn)); + } + + #[test] fn burn_signature_commits_to_parent_anchor() { let wallet = Wallet::from_seed("tip-bound-burn-signature-wallet"); let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -12,7 +12,9 @@ use super::{ impl Ledger { pub fn burn_bundle_attestations_required_for_next_block(&self) -> bool { - self.pending.iter().any(Transaction::is_burn) + self.pending.iter().any(|transaction| { + transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction) + }) } pub fn explicit_burn_bundle_signatures_required_for_next_block( @@ -49,7 +51,9 @@ impl Ledger { let mut burns = self .valid_pending_transactions() .into_iter() - .filter(Transaction::is_burn) + .filter(|transaction| { + transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction) + }) .collect::<Vec<_>>(); burns.sort_by(|left, right| { right @@ -444,6 +448,7 @@ impl Ledger { bail!("burn bundle contains a non-burn transaction"); } self.validate_transaction_terms(burn)?; + self.validate_transaction_anchor_for_block(burn, expected_height)?; let key = (burn.fee(), burn.signature().to_string()); if let Some((previous_fee, previous_signature)) = &previous_key { if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature { @@ -687,6 +692,44 @@ mod tests { } #[test] + fn burn_bundle_rejects_a_burn_queued_for_the_following_height() { + let member = Wallet::from_seed("future-burn-bundle-member"); + let mut ledger = funded_ledger(std::slice::from_ref(&member)); + let tip = ledger.chain.last_mut().unwrap(); + tip.height = super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1; + tip.prev_hash = "a".repeat(64); + tip.hash = "b".repeat(64); + let future_burn = ledger.build_burn(&member, 1, 1).unwrap(); + let bundle = member.burn_bundle(BurnBundlePayload { + height: ledger.height() + 1, + prev_hash: ledger.tip_hash().to_string(), + slot: 1, + member: member.address().to_string(), + burns: vec![future_burn], + }); + let committee = BTreeMap::from([( + 1, + BurnCommitteeMember { + slot: 1, + root: "c".repeat(64), + owner: member.address().to_string(), + weight: 1, + }, + )]); + + let error = ledger + .precheck_burn_bundle_for_block( + ledger.height() + 1, + ledger.tip_hash(), + &committee, + &bundle, + ) + .unwrap_err(); + + assert!(error.to_string().contains("block grandparent")); + } + + #[test] fn invalid_committee_bundle_signature_is_rejected() { let alice = Wallet::from_seed("bundle-signature-alice"); let bob = Wallet::from_seed("bundle-signature-bob");