iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 0b6c5a352ee3c09593a5a1fdb86e0081a34be465
parent 42931365e2b8ac38588cfbf8fea1c692f83da5c5
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 13:24:43 +0200

Reset protocol around public burn bundles

Diffstat:
MCargo.lock | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 1+
Mdocs/protocol.md | 146++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/adapters/chain_store.rs | 3---
Msrc/adapters/chain_store/compact.rs | 87+++++++++++++++++--------------------------------------------------------------
Dsrc/adapters/chain_store/tests.rs | 207-------------------------------------------------------------------------------
Msrc/adapters/http.rs | 33++-------------------------------
Msrc/adapters/http/api.rs | 109++++++-------------------------------------------------------------------------
Msrc/adapters/http/index_html.rs | 61++++++++++++++++++++++---------------------------------------
Msrc/adapters/http/metrics.rs | 2--
Msrc/adapters/http/request_auth.rs | 6++----
Msrc/adapters/http/state.rs | 4+---
Dsrc/adapters/http/tests.rs | 2363-------------------------------------------------------------------------------
Msrc/adapters/http/types.rs | 29+++++++----------------------
Msrc/adapters/http/ui.rs | 355++++++++++++++++++++++---------------------------------------------------------
Dsrc/adapters/http/wallet_persistence.rs | 52----------------------------------------------------
Msrc/adapters/p2p/fetch.rs | 78------------------------------------------------------------------------------
Msrc/adapters/p2p/line_codec.rs | 268+++++++++++++++++++++----------------------------------------------------------
Msrc/adapters/p2p/metrics.rs | 30++++++++++++++----------------
Msrc/adapters/p2p/network.rs | 19-------------------
Msrc/adapters/p2p/peer_addr.rs | 93-------------------------------------------------------------------------------
Msrc/adapters/p2p/process.rs | 96++++++++++++-------------------------------------------------------------------
Msrc/adapters/p2p/sync.rs | 297-------------------------------------------------------------------------------
Msrc/adapters/p2p/test_support.rs | 2+-
Msrc/adapters/stratum.rs | 146-------------------------------------------------------------------------------
Msrc/adapters/ui_data_store.rs | 359+++----------------------------------------------------------------------------
Dsrc/adapters/ui_data_store/tests.rs | 499-------------------------------------------------------------------------------
Msrc/adapters/ui_index.rs | 269++++++++++---------------------------------------------------------------------
Msrc/adapters/wallet_store.rs | 379++-----------------------------------------------------------------------------
Msrc/app.rs | 527+------------------------------------------------------------------------------
Msrc/app/automatic_mining.rs | 298++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Dsrc/app/automatic_mining/tests.rs | 618-------------------------------------------------------------------------------
Msrc/app/gossip.rs | 87++++++++++++++++++++++++++++++++-----------------------------------------------
Msrc/app/helpers.rs | 87++++++++++++++-----------------------------------------------------------------
Msrc/app/in_memory_network.rs | 580+------------------------------------------------------------------------------
Msrc/app/ledger_view.rs | 30+-----------------------------
Msrc/app/node_lifecycle.rs | 34+++++++++++-----------------------
Dsrc/app/owned_blinded.rs | 461-------------------------------------------------------------------------------
Msrc/app/receive.rs | 420+++++++++++++------------------------------------------------------------------
Msrc/app/status.rs | 21+--------------------
Msrc/app/types.rs | 22+++++++---------------
Msrc/app/wallet.rs | 329++++++++++++-------------------------------------------------------------------
Msrc/domain.rs | 123++++++++++++-------------------------------------------------------------------
Asrc/domain/adversarial_tests.rs | 1061+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/domain/blinded.rs | 373-------------------------------------------------------------------------------
Msrc/domain/block.rs | 60++++++++++++++++++++++++------------------------------------
Msrc/domain/genesis.rs | 11+++++------
Dsrc/domain/history.rs | 60------------------------------------------------------------
Msrc/domain/ledger_apply.rs | 303++++---------------------------------------------------------------------------
Msrc/domain/ledger_builders.rs | 139+++----------------------------------------------------------------------------
Msrc/domain/ledger_chain.rs | 37-------------------------------------
Msrc/domain/ledger_lineage.rs | 41-----------------------------------------
Msrc/domain/ledger_mempool.rs | 80+++----------------------------------------------------------------------------
Msrc/domain/ledger_ops.rs | 225++++++-------------------------------------------------------------------------
Msrc/domain/ledger_pending.rs | 432+++++++++++++++++++++++--------------------------------------------------------
Msrc/domain/ledger_prepare.rs | 60+++++++++++++++++++++++++++++-------------------------------
Msrc/domain/ledger_queries.rs | 220++++++++-----------------------------------------------------------------------
Msrc/domain/ledger_reveal.rs | 489++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/domain/ledger_state.rs | 9++-------
Msrc/domain/mine_policy.rs | 21+++++++++++----------
Msrc/domain/protocol.rs | 18+++---------------
Msrc/domain/reveal.rs | 212++++++++++++++++++++++++-------------------------------------------------------
Msrc/domain/selection.rs | 61+------------------------------------------------------------
Dsrc/domain/tests.rs | 4190-------------------------------------------------------------------------------
Msrc/domain/transaction.rs | 156-------------------------------------------------------------------------------
Msrc/domain/wallet.rs | 8++++----
Msrc/main.rs | 37+++++++++----------------------------
Msrc/main_tests.rs | 8++++----
Dtests/iuna.rs | 2702-------------------------------------------------------------------------------
Dtests/properties.rs | 2222-------------------------------------------------------------------------------
Mwww/assets/iuna-ui.js | 53+++++++++++++----------------------------------------
71 files changed, 2758 insertions(+), 20326 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -118,6 +118,21 @@ dependencies = [ ] [[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] name = "bitcoin_hashes" version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -220,7 +235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -337,6 +352,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -401,13 +422,25 @@ dependencies = [ [[package]] name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -554,6 +587,7 @@ dependencies = [ "num-bigint", "num-traits", "pbkdf2", + "proptest", "rusqlite", "serde", "serde_json", @@ -743,6 +777,15 @@ dependencies = [ ] [[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] name = "proc-macro2" version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -752,6 +795,31 @@ dependencies = [ ] [[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] name = "quote" version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -762,11 +830,37 @@ dependencies = [ [[package]] name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -776,6 +870,24 @@ dependencies = [ ] [[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -785,6 +897,12 @@ dependencies = [ ] [[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] name = "rusqlite" version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -821,6 +939,18 @@ dependencies = [ ] [[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -937,7 +1067,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1117,6 +1247,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1154,12 +1290,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1175,6 +1329,12 @@ dependencies = [ ] [[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] name = "zerocopy" version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -21,5 +21,6 @@ rusqlite = { version = "0.32.1", features = ["bundled"] } tokio = { version = "1.45.1", features = ["full"] } [dev-dependencies] +proptest = "1.6.0" tempfile = "3.20.0" tower = { version = "0.5.2", features = ["util"] } diff --git a/docs/protocol.md b/docs/protocol.md @@ -19,8 +19,6 @@ iuna uses a UTXO-style ledger. The main transaction types are: 1. **Transfer:** moves IUNA from one address to another and pays a sender-chosen fee. 2. **Burn:** destroys an amount of IUNA, pays a sender-chosen fee, and creates a future lottery ticket. 3. **Mine action:** proves SHA-256-style PoW against the current chain tip. A valid mine action mints a fixed `1 IUNA` reward to its recipient and pays a fixed `1 IUNA` fee to the block finalizer. -4. **Blinded transaction envelope:** commits encrypted transaction content to a block before the finalizer can inspect whether it is a burn or transfer. -5. **Blinded reveal:** publishes the decryption key for a previously committed envelope so nodes can validate and execute the hidden transaction. Burn and transfer fees are chosen by the sender. Mine action reward and mine action fee are deterministic protocol values. @@ -46,13 +44,11 @@ For each block height, eligible tickets are ranked: The selected finalizer must prove ownership of the selected ticket, respect its rank time slot, and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, has a valid timestamp for its rank, includes a valid VDF output, and follows the transaction selection rules. -Starting at height `300`, fallback finalization invalidates missed ticket opportunities. If a ticket block is finalized by rank `1` or higher, nodes invalidate all tickets ranked from `0` through the finalizing rank for that height. They also invalidate any other currently eligible tickets owned by those same addresses. Future tickets from those addresses that are not yet eligible remain pending. Rank `0` ticket blocks continue to consume only the winning ticket. +Fallback finalization invalidates missed ticket opportunities. If a ticket block is finalized by rank `1` or higher, nodes invalidate all tickets ranked from `0` through the finalizing rank for that height. They also invalidate any other currently eligible tickets owned by those same addresses. Future tickets from those addresses that are not yet eligible remain pending. Rank `0` ticket blocks continue to consume only the winning ticket. -Every normal block must include at least one plaintext burn. A blinded transaction envelope does not satisfy that rule, because the finalizer and validators cannot know whether the encrypted payload is a burn until reveal. A node that may finalize prepares a local plaintext anchor burn for the next block from the finalizer wallet. This anchor burn is not gossiped as normal wallet traffic. +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. -This mandatory anchor burn is a liveness rule for the ticket pool, not a fairness rule for ticket distribution. It guarantees that normal block production keeps creating future tickets. Fairness against self-serving finalizers comes from blinded third-party burns. - -Wallet-created transfers and burns are not gossiped as plaintext. Their blinded envelopes expose and lock UTXO inputs before reveal, so declared fees are backed by spendable coins. Mine actions are public mempool items, because they do not reveal burn or transfer intent and must be possible without owning coins. The local plaintext anchor burn for finalization is separate from the configured automatic blinded burn per block. When automatic burning is enabled, the configured burn amount enters the network as a blinded envelope like other wallet-created burns. When a blinded payload is revealed and executed, `35%` of its fee goes to the finalizer that originally committed the envelope, up to `35%` goes to the reveal-block finalizer, and `10%` goes to each included explicit signed reveal-list maker. Even with full reveal participation, at most `90%` of the fee is paid out and the remaining baseline `10%` is burned. Missing reveal-list shares, missing reveal-finalizer shares, and rounding dust are burned as well. The local plaintext anchor burn required for block liveness is part of the block reward like other plaintext block items. +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. ## VDF Timing @@ -92,7 +88,7 @@ The future drift limit is `2 minutes`. A finalizer can lie within that small mar ## Recovery Blocks -If selected ticket finalizers do not publish for long enough, recovery finalization becomes available. The recovery delay is `6` target block times. +If the ticket path does not produce a valid block for long enough, recovery finalization becomes available. This can happen because selected ticket finalizers do not publish, or because they cannot satisfy the rules needed for a valid ticket block. The recovery delay is `6` target block times. A recovery block: @@ -114,7 +110,7 @@ Difficulty targets about one mine action per block: - The retarget window is `10` blocks. - The target is `10` mine actions per window. - Difficulty can move by at most `2` bits per window. -- Difficulty is clamped between `10` and `32` bits in the devnet profile. +- Difficulty has a minimum of `10` bits in the devnet profile. - Mine actions expire when their anchor is too old. This keeps issuance separate from finalization. PoW miners compete to create mine actions; burn-ticket finalizers decide blocks. @@ -125,28 +121,33 @@ A block may contain at most `2` mine actions for the same anchor. This leaves ro The central censorship risk is simple: what if a finalizer only includes its own burns and ignores everyone else's burns? -The blinded mempool protects transfers and third-party burns by making them indistinguishable before inclusion. It does not try to hide mine actions. Mine actions are public because they do not reveal burn intent, do not spend existing coins, and must remain available to participants with no IUNA balance. +Burn fairness is enforced by a burn inclusion committee. The committee gives independently observed burns a path into the next block, even when the block finalizer would rather ignore them. + +The idea is: -Transfer and burn mempool traffic uses blinded transaction content. A wallet encrypts a transfer or burn payload and gossips a `BlindedTransaction` envelope. The plaintext payload is not exposed before reveal. The envelope exposes only: +1. Burns are ordinary mempool transactions. +2. Committee members look at the valid burns they have seen. +3. Each committee member signs a burn bundle for the next block height. +4. The finalizer must include enough committee attestations for its rank. +5. Every burn in the required burn list must appear in the block. -- a commitment hash; -- the visible UTXO inputs that lock the fee and transaction spend; -- the declared fee; -- encrypted payload size; -- expiry height; -- nonce, ciphertext, and plaintext payload hash. +So a censoring finalizer cannot simply leave out third-party burns that the committee witnessed. To keep censoring, it must either keep those burns away from committee members, control enough committee influence, or disrupt the normal ticket path until weaker liveness rules take over. -The visible inputs are signed for the blinded envelope itself and are not repeated inside the encrypted payload. The encrypted payload contains only the hidden transfer outputs or burn amount/change plus the transaction signature. When an envelope is included in a block, the visible inputs are locked immediately and cannot be spent by other pending transactions. The finalizer can rank the envelope by fee per visible envelope byte, but cannot see whether the encrypted payload is a transfer or a burn before committing it to a block. Mine actions are public and are not valid inside blinded envelopes. +Each block has one burn-selected finalizer and up to two additional independent burn committee members. The finalizer is committee slot `0`; the finalizer's block signature counts as its slot `0` burn-list attestation. The additional committee slots are selected from mature UTXO lineages, not from burn tickets, so committee capture requires a different resource from block production. -Reveal is a later step. A `BlindedReveal` carries only the commitment and decryption key. Reveals are not included as loose block items. They are carried in signed reveal bundles. +Why not choose the whole committee from burn tickets? Because then a large burner could buy both block production and the inclusion watchdog. Instead, the extra committee members are selected from UTXO lineages that originate in PoW mine actions. Burn weight chooses who can finalize blocks; mature mined coin lineages help choose who witnesses burn inclusion. -Before height `1500`, nodes compute a reveal committee from the burn leader ranking. Slot `0` is assigned to the rank `0` block finalizer, so the selected finalizer can sign a reveal list for its own block. Before height `500`, the remaining slots are assigned to the two lowest-ranked eligible tickets. Starting at height `500`, the remaining slots are assigned to the next highest-ranked eligible tickets with owners that are not already in the committee, up to three unique owners total. +A UTXO lineage is a lightweight ancestry tag: -Starting at height `1500`, each block has one burn-selected finalizer and up to two additional independent reveal committee members. The finalizer remains reveal committee slot `0`, but does not need to include a separate reveal bundle signature: the finalizer already signs the block header, and that block signature commits to the selected reveal-bundle section. The additional reveal committee slots are selected from mature UTXO lineages, not from burn tickets, so block production remains proportional to burn while reveal witnessing is Sybil-resistant. +1. A mine action output starts a lineage root. +2. Transfer and change outputs carry that root forward. +3. If a transaction spends outputs from multiple roots, descendants inherit the newest root. +4. Deterministic tie-breaks handle roots with the same age. +5. Outputs with no mine root have no burn-committee lineage weight. -A UTXO lineage root is the newest mine action output in an output's ancestry. Transfer and change outputs carry that single root tag forward, even before the root is mature, so early transfers do not lose their ancestry. When outputs from multiple roots are merged and spent, descendants inherit the newest root among the spent inputs; ties are broken deterministically by root outpoint. If none of the spent inputs has a mine root, the new output has no reveal-committee lineage weight. A lineage root is eligible for reveal committee selection only when its mine action is at least `20` blocks old at the parent tip. +A lineage root is eligible for burn committee selection only when its mine action is at least `20` blocks old at the parent tip. -Nodes cache this root tag on every UTXO and maintain an incremental index from root to total unspent value. Normal block validation updates that cache only for the inputs spent and outputs created by the block. A broad ancestry search is only a rebuild or migration tool, not part of the consensus hot path. +Node implementation note: nodes cache this root tag on every UTXO and maintain an index from root to total unspent value, so normal validation does not need to search full ancestry. Committee selection is root-first. For each eligible lineage root, validators sum the unspent value currently tagged with that root: @@ -156,35 +157,53 @@ The root's committee weight is: `root_weight = floor(log2(1 + root_value))` -Splitting one large root across many addresses does not multiply committee influence, because the root is weighted once and can win at most one additional reveal slot. Merging roots deliberately collapses future descendant lineage to the newest root; lineage is a Sybil-resistance tag, not full coin-provenance accounting. +Splitting one large root across many addresses does not multiply committee influence, because the root is weighted once and can win at most one additional burn committee slot. Merging roots deliberately collapses future descendant lineage to the newest root; lineage is a Sybil-resistance tag, not full coin-provenance accounting. + +The lineage weight is logarithmic. A larger root has more chance to be selected, but doubling value does not double influence forever. This keeps committee selection from becoming a simple rich-get-richer vote while still giving larger, older mined lineages some weight. + +For a target height, validators derive a deterministic committee seed from the parent hash and height. Slot `0` is assigned to the block finalizer. Slots `1` and `2` are assigned without replacement by weighted deterministic draws over eligible lineage roots using `root_weight`. + +After a lineage root wins, validators deterministically choose one representative owner from the unspent outputs tagged with that root. The additional slots are meant to be independent from the finalizer, so the finalizer's address and any address already selected for an earlier additional burn committee slot are skipped when choosing representatives. If no eligible non-finalizer representative remains for a winning root, that root is skipped and the draw continues to the next eligible root. + +The protocol can detect addresses and lineage roots, not hidden common control, so a finalizer using unrelated addresses is still a social and economic risk rather than something this rule can perfectly identify. + +If fewer than two eligible non-finalizer lineages exist, the committee has the finalizer plus one or zero additional members. If no eligible non-finalizer lineage exists, burn inclusion quorum falls back to `1-of-1` through the finalizer's implicit slot `0` attestation. -For a target height, validators derive a deterministic committee seed from the parent hash and height. Slot `0` is assigned to the block finalizer. Slots `1` and `2` are assigned without replacement by weighted deterministic draws over eligible lineage roots using `root_weight`. Lineage roots currently owned by the block finalizer are excluded from these additional draws, and after any other root wins a slot, that root is removed from the next slot draw. After a root is selected, validators choose one representative owner for that root from the unspent outputs tagged with it. The block finalizer's address and any address already selected for an earlier additional reveal slot are also skipped for additional slots. If fewer than two eligible non-finalizer lineages exist, the committee has the finalizer plus one or zero additional members. If no eligible non-finalizer lineage exists, reveal quorum falls back to `1-of-1` through the finalizer's implicit slot `0` attestation. +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 bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid pending reveals ordered by visible fee rate. Starting at height `1500`, once a blinded envelope is active, committee members also gossip empty bundles when they know no valid reveal for the next height; the empty signature is an attestation that keeps the reveal layer explicit without forcing a reveal to exist. +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. -Automatic nodes wait about `30 seconds` after seeing pending reveals for the next height before signing a reveal bundle or starting the reveal-bound VDF. Starting at height `1500`, active blinded envelopes also trigger this wait so empty attestations can be collected. This gives reveal gossip time to settle and avoids locking in an underfilled bundle from the first partial batch a node received. +A block contains transfers, burns, mine actions, and one compact burn-bundle section. The finalizer's local anchor burn is still reserved as the first block item. -A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext anchor burn, public mine actions, and blinded transaction envelopes. +The compact burn-bundle section stores: -The compact reveal-bundle section stores: +- up to two explicit burn committee bundle signatures for non-finalizer slots, in slot order; +- one deduplicated required burn list; +- a small bitmask per burn saying which of the included committee bundles contained that burn. -- up to two explicit reveal committee bundle signatures for non-finalizer slots, in slot order; -- one deduplicated reveal list; -- a small bitmask per reveal saying which of the included committee bundles contained that reveal. +The required burn list is the union of fee-paying burns contained in the attestations the block uses. If one committee member signs an empty bundle and another signs a bundle with burns, the required list still includes the burns from the non-empty bundle. -Validators reconstruct each signed committee bundle from this compact section before checking signatures, bundle size, slot assignment, lineage-based slot assignment, and fee ordering. The finalizer's block signature is treated as its committee attestation for slot `0`. Slot `0` does not have a separate reveal bundle payload or reveal-list-maker fee after height `1500`; it attests to the block's deduplicated reveal list as included by the finalizer. This keeps consensus bound to the independent reveal attestations without storing the same reveal payload multiple times when multiple committee members selected it. +Required burns must fit in the block. Nodes reject burn bundles or attestation sets whose deduplicated required burn list cannot fit within the block size and transaction count limits. -A block may contain at most one bundle per slot. If a node sees two different signed bundles for the same height and slot before block assembly, it treats that slot as locally equivocated and does not use either bundle for that round. +Validators reconstruct each signed committee bundle from this compact section before checking signatures, bundle size, slot assignment, lineage-based slot assignment, and fee ordering. The finalizer's block signature is treated as its committee attestation for slot `0`. Slot `0` does not have a separate burn bundle signature; it attests to the block's deduplicated required burn list as included by the finalizer. This keeps consensus bound to the independent burn attestations without storing the same burn payload multiple times when multiple committee members selected it. -Before height `1500`, reveal-list signatures are optional for chain compatibility. Starting at height `1500`, if there are no active blinded envelopes before a block, no reveal-list threshold is required. If active blinded envelopes exist, ticket blocks must carry enough reveal-list signatures for their finalizer rank: +Block validity is not allowed to depend on a validator's local mempool. Validators decide the required burn-list threshold from deterministic chain and block data only. Pending burns can affect local relay, bundle-signing, and block-building policy, but they cannot make the same block valid on one node and invalid on another. -- rank `0` needs all available reveal committee attestations (`3-of-3`, `2-of-2`, or `1-of-1`), where the finalizer's block signature counts as the slot `0` attestation; -- rank `1` needs two reveal committee attestations when possible (`2-of-3`, `2-of-2`, or `1-of-1`), where the finalizer's block signature counts as the slot `0` attestation; -- rank `2` and later ticket finalizers may publish without reveal committee signatures, but must include any valid signatures already bound into their VDF seed. +A block may contain at most one bundle per slot. If a block includes one valid bundle for a slot, validators check that included bundle and do not need to know whether another bundle for the same slot existed elsewhere. If a block builder sees two different signed bundles for the same height and slot before block assembly, it ignores that slot's bundles for the round as local safety policy. The current protocol does not have a separate slashing rule for this. -Recovery blocks do not require reveal-list signatures; their job is chain liveness after the ticket path has failed. A valid signed bundle may be empty. Honest committee policy is to sign an empty bundle only when the signer knows no valid reveal for that height, and to include every valid reveal it selects by the canonical fee ordering. The consensus rule checks committee membership, signature validity, lineage assignment, ordering, and threshold; it does not depend on a validator's local mempool contents. +Ticket blocks must carry enough burn-list attestations for their finalizer rank. Rank `0` has the strictest rule because it is the preferred path. Fallback ranks relax the threshold so the chain can keep moving if the primary finalizer or some committee members are unavailable. If the primary cannot gather the required attestations in time, a lower-ranked finalizer eventually gets a looser threshold. -The ticket-block VDF seed is bound to the reveal attestation hashes: +That is a deliberate liveness tradeoff. A lower-ranked finalizer can use fewer attestations, so its required burn list may omit burns that appeared only in committee bundles it did not use. This is weaker for fairness than the rank `0` path, but stronger for liveness when the strict path is stuck. + +The available committee size is the finalizer plus the selected non-finalizer committee members for that height: + +- rank `0` needs all available burn committee attestations (`3-of-3`, `2-of-2`, or `1-of-1`), where the finalizer's block signature counts as the slot `0` attestation; +- rank `1` needs one fewer than all available attestations, with at least one attestation required (`2-of-3`, `1-of-2`, or `1-of-1`); +- rank `2` and later ticket finalizers need one valid burn-list attestation from the available committee (`1-of-3`, `1-of-2`, or `1-of-1`), where the finalizer's block signature still counts if no non-finalizer bundle is included. + +Recovery blocks do not require burn-list signatures. They are the last liveness escape hatch after the ticket path has failed, so committee failure must not be able to stop the chain forever. Recovery is weaker for fairness and is not meant to be the normal block path. + +The ticket-block VDF seed is bound to the burn-list attestation hashes: `seed = hash(parent hash || height || attestation_hash[0] || attestation_hash[1] || attestation_hash[2])` @@ -192,45 +211,42 @@ Recovery blocks additionally bind the block timestamp into the VDF seed: `seed = hash(parent hash || height || timestamp_ms || attestation_hash[0] || attestation_hash[1] || attestation_hash[2])` -Before height `1500`, each attestation hash is the signed reveal-bundle hash for that slot, or a fixed default hash when the slot has no included bundle. Starting at height `1500`, slot `0` uses a synthetic finalizer attestation hash derived from the parent, height, finalizer address, and the block's canonical deduplicated reveal list. Slot `0` therefore attests to every reveal executed by the block, independent of which explicit bundles also contained it. Slots `1` and `2` use the signed reveal-bundle hash or the fixed default hash when absent. This means the finalizer must choose the reveal-attestation set before doing the VDF work. A finalizer can still claim that a bundle arrived too late, but it cannot secretly swap or remove a timely bundle after computing the VDF without changing the seed. +The burn-list attestation hashes are part of the VDF seed. This forces the finalizer to choose the included burn-attestation set before doing the delay work. After the VDF is computed, changing that attestation set changes the seed and invalidates the work. -When a valid bundled reveal executes, nodes decrypt the earlier payload, check the commitment and payload hash, and decode the transfer or burn. The decrypted transaction inputs must match the visible inputs locked by the envelope, and the transaction executes against that locked value. If the reveal bitmask says multiple committee bundles contained the same reveal, the reveal is still executed only once. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, not the earlier envelope-commit height. +Slot `0` uses a synthetic finalizer attestation hash derived from the parent, height, finalizer address, and the block's canonical deduplicated required burn list. Slots `1` and `2` use the signed burn-bundle hash or the fixed default hash when absent. -Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. `35%` goes to the envelope committer. Up to `35%` goes to the reveal-block finalizer, scaled by included reveal attestations divided by the available committee slots for that height. Before height `1500`, signed reveal bundles define those attestations. Starting at height `1500`, the denominator is the total available committee size including implicit slot `0` (`3`, `2`, or `1`). The finalizer's implicit slot `0` attestation counts for the scaled reveal-finalizer share for every reveal in the block, so the reveal-block finalizer always receives at least one slot's share when it includes a valid reveal. Slot `0` does not earn the separate reveal-list-maker share. `10%` goes to each included explicit signed reveal-list maker for non-finalizer slots. A fully attributed reveal can therefore pay at most `90%` of the fee (`35% + 35% + 10% + 10%`); the remaining baseline `10%` is burned. Missing reveal-list shares, the missing reveal-finalizer share, and rounding dust are burned instead of redistributed. +For recovery blocks, missing burn-list attestations use the fixed default hashes. A recovery block may include available burn bundles, but it does not need burn bundles in order to be valid. -Starting at height `750`, reveal fee attribution is per reveal mask. A signed reveal-list maker earns the `10%` share for a revealed payload only if that maker's signed bundle actually contained that reveal. The reveal-block finalizer's scaled share is also based on the number of attestations for that reveal, not merely the number of bundle signatures included somewhere in the block. Before height `1500`, those attestations are signed bundles. Starting at height `1500`, slot `0` is counted as attesting to every reveal in the block through the finalizer's block signature, while slots `1` and `2` count only when their signed bundle contained the reveal. Before height `750`, all included signed reveal-list makers are treated as participating in every revealed payload in that block. +Every burn in the deduplicated required burn list must appear in the block. A required burn is executed only once even if multiple committee bundles contained it. The block may also include additional valid burns not present in the required list, but those additional burns do not count toward the committee attestation threshold. -Expiry is exclusive: a blinded envelope with expiry height `H` can be included only in blocks below height `H`, and revealed only while the current chain height is below `H`. The expiry height must be within `20` blocks of the node's current chain height when the envelope is accepted or selected. If an envelope expires unrevealed, its declared fee is burned and any remaining locked value returns as deterministic change to the owner of the first visible input. Expired local envelopes and reveals are dropped from local selection. +Every transaction that consumes normal block space must pay a fee. Transfers and burns with zero fee are invalid; the finalizer's local anchor burn is an ordinary fee-paying burn. Mine actions pay the deterministic mine action fee. Genesis burns are the only zero-fee burn transactions, and they are valid only inside the genesis block. -Starting at height `750`, blinded envelopes must lock at least one visible input. This rejects free, unauthenticated zero-input envelopes while preserving compatibility with historical devnet blocks before the activation height. - -Starting at height `750`, every item that consumes block space must pay a fee, with one exception: a block may include one zero-fee plaintext burn from the block finalizer as its local anchor burn. Other plaintext transactions, additional finalizer burns, blinded envelopes, and revealed blinded payloads must carry a non-zero fee. This keeps historical devnet blocks valid while removing free blockspace spam after activation. - -This does not make censorship impossible. A finalizer can still ignore all blinded traffic, or censor based on network metadata. But it removes the cheap strategy of inspecting plaintext mempool transactions and excluding third-party burns while including other fee-paying transactions. +This does not make censorship impossible. A finalizer can still censor burns that no committee member has seen, and a well-funded attacker can try to control enough UTXO lineage weight to dominate the burn committee. But the attack is no longer just "win the block and leave out everyone else." The attacker also has to beat the independent inclusion layer, or force the chain onto weaker liveness paths. ## P2P Mempool Gossip -The P2P mempool gossips only: +The P2P mempool gossips: -- blinded transaction envelopes; -- public mine actions; -- blinded reveal keys; -- signed reveal bundles; +- transfers; +- burns; +- mine actions; +- signed burn bundles; - block inventory and blocks. -It does not gossip plaintext transfers or burns. Wallet-created transfers and burns enter the network as blinded envelopes first, and are only decoded after a reveal. Mine actions are gossiped as public transactions. The one plaintext anchor burn required for every normal block is prepared locally by the finalizer and appears in the block itself. +Anchor burns are prepared locally by the finalizer and are not normal wallet traffic. -Nodes only keep blinded reveal keys in their local mempool when the reveal references an active blinded envelope and decrypts successfully. Unknown, stale, or wrong-key reveals are rejected before they consume pending reveal capacity. Pending transaction, blinded-envelope, reveal, and orphan pools are bounded by both item count and serialized byte size. +Nodes only keep transactions in their local mempool when they are valid, fee-paying, and unexpired. Pending transaction, burn-bundle, and orphan pools are bounded by both item count and serialized byte size. ## Block Selection -When a node builds a block, it selects transactions in this order: +When a node builds a block, the flow is: -1. Collect valid signed reveal bundles for the next height. -2. Reserve the local plaintext anchor burn as the first plaintext block item. -3. For recovery blocks, ensure at least one plaintext anchor burn is from the recovery finalizer. -4. Fill remaining envelope space with valid fee-paying public mine actions and blinded transaction envelopes ordered by fee rate. Public mine actions are limited to `2` actions per anchor. -5. Bind the VDF seed to the three reveal-attestation slot hashes, using default hashes for missing slots. Starting at height `1500`, slot `0` uses the synthetic finalizer attestation hash instead of a separate reveal-bundle hash. +1. Collect valid signed burn bundles for the next height. +2. Reserve the local anchor burn as the first block item. +3. For recovery blocks, ensure at least one anchor burn is from the recovery finalizer. +4. Include every burn required by the selected burn-bundle attestations. +5. Fill remaining block space with valid fee-paying transfers, additional burns, and mine actions ordered by fee rate. Mine actions are limited to `2` actions per anchor. +6. Bind the VDF seed to the three burn-attestation slot hashes, using default hashes for missing slots. Slot `0` uses the synthetic finalizer attestation hash instead of a separate burn-bundle signature. Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes. @@ -238,6 +254,8 @@ Blocks are bounded by transaction count and serialized byte size. The devnet max Nodes fully validate candidate blocks or snapshots before considering a reorg. A candidate chain must share the same genesis and cannot rewrite history deeper than the finality depth. In the devnet profile, forks whose common ancestor is below `local height - 6` are rejected. +Burn inclusion is part of block validity. If a ticket block carries burn-list attestations but omits a burn required by those attestations, nodes reject the block before fork choice. The fork choice rule only compares chains made of valid blocks. + Within that finality window, a taller valid candidate chain wins over the local chain. If the candidate and local chains have the same height but different tips, nodes compare the first divergent blocks by leader score: ticket blocks beat recovery blocks, lower finalizer rank beats higher rank, and the leader proof rank breaks remaining ties. Equal quality keeps the local chain. ## Genesis and Joining @@ -255,6 +273,6 @@ iuna is trying to make these things true at the same time: - Burns should have real opportunity cost. - Burn timing power should expire rather than accumulate into permanent control. - Block timing should be hard to rush. -- Finalizers should have a consensus-level reason to include burn traffic they cannot inspect before committing. +- Finalizers should have a consensus-level reason to include independently witnessed burn traffic. The design is intentionally small and still evolving. The devnet exists to find out where these assumptions hold and where they break. diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -165,6 +165,3 @@ fn unix_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } - -#[cfg(test)] -mod tests; diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -1,13 +1,12 @@ use anyhow::{Context, Result, bail}; use crate::domain::{ - BlindedReveal, BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile, - LeaderProof, MaskedBlindedReveal, OutPoint, RevealBundleSection, RevealBundleSignature, - Transaction, TxInput, TxOutput, + Block, BurnBundleSection, BurnBundleSignature, ChainSnapshot, FinalizerMode, LaunchProfile, + LeaderProof, MaskedBurn, OutPoint, Transaction, TxInput, TxOutput, }; const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT"; -const COMPACT_SNAPSHOT_VERSION: u8 = 3; +const COMPACT_SNAPSHOT_VERSION: u8 = 4; pub(super) fn encode_compact_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<u8>> { let mut writer = CompactWriter::default(); @@ -114,11 +113,7 @@ fn encode_block_body(writer: &mut CompactWriter, block: &Block) -> Result<()> { writer.hex(&proof.public_key)?; writer.hex(&proof.signature)?; } - writer.varint(block.blinded_transactions.len() as u64); - for transaction in &block.blinded_transactions { - encode_blinded_transaction(writer, transaction)?; - } - encode_reveal_bundle_section(writer, &block.reveal_bundle_section)?; + encode_burn_bundle_section(writer, &block.burn_bundle_section)?; writer.varint(block.transactions.len() as u64); for transaction in &block.transactions { encode_transaction(writer, transaction)?; @@ -152,8 +147,7 @@ fn decode_block_body( } else { None }; - let blinded_transactions = decode_vec(reader, decode_blinded_transaction)?; - let reveal_bundle_section = decode_reveal_bundle_section(reader)?; + let burn_bundle_section = decode_burn_bundle_section(reader)?; let transactions = decode_vec(reader, decode_transaction)?; let hash = reader.hex()?; Ok(Block { @@ -167,57 +161,15 @@ fn decode_block_body( vdf_rounds, vdf_output, leader_proof, - blinded_transactions, - reveal_bundle_section, + burn_bundle_section, transactions, hash, }) } -fn encode_blinded_transaction( - writer: &mut CompactWriter, - transaction: &BlindedTransaction, -) -> Result<()> { - writer.hex(&transaction.commitment)?; - encode_inputs(writer, &transaction.inputs)?; - writer.varint(transaction.fee); - writer.varint(u64::from(transaction.encrypted_size)); - writer.varint(transaction.expires_at_height); - writer.hex(&transaction.nonce)?; - writer.hex(&transaction.ciphertext)?; - writer.hex(&transaction.payload_hash)?; - Ok(()) -} - -fn decode_blinded_transaction(reader: &mut CompactReader<'_>) -> Result<BlindedTransaction> { - Ok(BlindedTransaction { - commitment: reader.hex()?, - inputs: decode_inputs(reader)?, - fee: reader.varint()?, - encrypted_size: reader.u32()?, - expires_at_height: reader.varint()?, - nonce: reader.hex()?, - ciphertext: reader.hex()?, - payload_hash: reader.hex()?, - }) -} - -fn encode_blinded_reveal(writer: &mut CompactWriter, reveal: &BlindedReveal) -> Result<()> { - writer.hex(&reveal.commitment)?; - writer.hex(&reveal.key)?; - Ok(()) -} - -fn decode_blinded_reveal(reader: &mut CompactReader<'_>) -> Result<BlindedReveal> { - Ok(BlindedReveal { - commitment: reader.hex()?, - key: reader.hex()?, - }) -} - -fn encode_reveal_bundle_section( +fn encode_burn_bundle_section( writer: &mut CompactWriter, - section: &RevealBundleSection, + section: &BurnBundleSection, ) -> Result<()> { writer.varint(section.signatures.len() as u64); for signature in &section.signatures { @@ -225,32 +177,29 @@ fn encode_reveal_bundle_section( writer.hex(&signature.member)?; writer.hex(&signature.signature)?; } - writer.varint(section.reveals.len() as u64); - for masked in &section.reveals { - encode_blinded_reveal(writer, &masked.reveal)?; + writer.varint(section.burns.len() as u64); + for masked in &section.burns { + encode_transaction(writer, &masked.burn)?; writer.u8(masked.bundle_mask); } Ok(()) } -fn decode_reveal_bundle_section(reader: &mut CompactReader<'_>) -> Result<RevealBundleSection> { +fn decode_burn_bundle_section(reader: &mut CompactReader<'_>) -> Result<BurnBundleSection> { let signatures = decode_vec(reader, |reader| { - Ok(RevealBundleSignature { - slot: u8::try_from(reader.varint()?).context("reveal bundle slot does not fit u8")?, + Ok(BurnBundleSignature { + slot: u8::try_from(reader.varint()?).context("burn bundle slot does not fit u8")?, member: reader.hex()?, signature: reader.hex()?, }) })?; - let reveals = decode_vec(reader, |reader| { - Ok(MaskedBlindedReveal { - reveal: decode_blinded_reveal(reader)?, + let burns = decode_vec(reader, |reader| { + Ok(MaskedBurn { + burn: decode_transaction(reader)?, bundle_mask: reader.u8()?, }) })?; - Ok(RevealBundleSection { - signatures, - reveals, - }) + Ok(BurnBundleSection { signatures, burns }) } fn encode_transaction(writer: &mut CompactWriter, transaction: &Transaction) -> Result<()> { diff --git a/src/adapters/chain_store/tests.rs b/src/adapters/chain_store/tests.rs @@ -1,207 +0,0 @@ -use std::collections::BTreeMap; - -use tempfile::tempdir; - -use crate::domain::{GenesisBurn, Ledger, Wallet, run_vdf}; - -use super::{SqliteChainStore, decode_compact_snapshot, encode_compact_snapshot}; - -#[test] -fn sqlite_chain_store_roundtrips_snapshot() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("nested/chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - store.save(&ledger.snapshot()).unwrap(); - - assert_eq!(store.load().unwrap(), Some(ledger.snapshot())); - store - .with_connection(|connection| { - let columns = connection - .prepare("PRAGMA table_info(chain_snapshots)")? - .query_map([], |row| row.get::<_, String>(1))? - .collect::<std::result::Result<Vec<_>, _>>()?; - assert!(columns.contains(&"snapshot_blob".to_string())); - assert!(!columns.contains(&"snapshot_json".to_string())); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn sqlite_chain_store_does_not_create_ui_projection_tables() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - - store - .with_connection(|connection| { - for table in [ - "block_metrics", - "ui_cache_meta", - "ui_output_index", - "ui_revealed_transactions", - "ui_burn_leader_ranks", - "ui_burn_leader_rank_blocks", - ] { - let count = connection.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", - [table], - |row| row.get::<_, u64>(0), - )?; - assert_eq!(count, 0, "{table} should live in ui_data.sqlite3"); - } - Ok(()) - }) - .unwrap(); -} - -#[test] -fn sqlite_chain_store_clear_chain_removes_snapshot() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("clear-chain-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - store.save(&ledger.snapshot()).unwrap(); - assert!(store.load().unwrap().is_some()); - - store.clear_chain().unwrap(); - - assert!(store.load().unwrap().is_none()); -} - -#[test] -fn compact_snapshot_roundtrips_and_is_smaller_than_json() { - let alice = Wallet::from_seed("compact-alice"); - let bob = Wallet::from_seed("compact-bob"); - let carol = Wallet::from_seed("compact-carol"); - let wallets = [alice.clone(), bob.clone()]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 10_000_000); - genesis.insert(bob.address().to_string(), 10_000_000); - genesis.insert(carol.address().to_string(), 10_000_000); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1_000_000), - GenesisBurn::new(bob.address(), 1_000_000), - ], - 1, - ) - .unwrap(); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(wallet, 1).unwrap(); - assert_eq!( - block.blinded_transactions, - vec![blinded.transaction.clone()] - ); - ledger.apply_locally_mined_block(block).unwrap(); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == member.owner) - .unwrap(); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - assert!(!bundles.is_empty()); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles) - .unwrap(); - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - let block = prepared.finish(wallet, vdf_output); - assert_eq!(block.all_blinded_reveals().len(), 1); - ledger.apply_locally_mined_block(block).unwrap(); - - let snapshot = ledger.snapshot(); - let compact = encode_compact_snapshot(&snapshot).unwrap(); - let json = serde_json::to_vec(&snapshot).unwrap(); - - assert_eq!(decode_compact_snapshot(&compact).unwrap(), snapshot); - assert!( - compact.len() < json.len(), - "compact snapshot should be smaller than JSON: compact={} JSON={}", - compact.len(), - json.len() - ); -} - -#[test] -fn sqlite_chain_store_overwrites_latest_snapshot() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 2); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - store.save(&ledger.snapshot()).unwrap(); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - store.save(&ledger.snapshot()).unwrap(); - - let restored = store.load().unwrap().unwrap(); - assert_eq!(restored.blocks.last().unwrap().height, 1); - assert_eq!(restored, ledger.snapshot()); -} - -#[test] -fn sqlite_chain_store_reports_invalid_compact_snapshot() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - store - .with_connection(|connection| { - connection.execute( - r#" -INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) -VALUES (1, 9, 'bad-tip', x'00010203', 0) -"#, - [], - )?; - Ok(()) - }) - .unwrap(); - - let error = store.load().unwrap_err(); - - assert!( - format!("{error:#}").contains("failed to parse compact chain snapshot from database"), - "{error:#}" - ); -} diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -35,13 +35,6 @@ mod state; mod static_assets; mod ui; mod wallet; -mod wallet_persistence; -#[cfg(test)] -use actions::{ - add_peer, persist_burn_settings_config, persist_pow_mining_config, remove_address_book_entry, - remove_peer, reset_local_chain, set_burn_settings, set_keep_track_of_metrics, - set_p2p_accept_inbound, set_p2p_announce_addr, upsert_address_book_entry, -}; use actions::{ api_address_book_delete_form, api_address_book_form, api_burn_fee_estimate_form, api_burn_per_block_form, api_chain_reset_form, api_metrics_settings_form, @@ -54,39 +47,25 @@ use api::{ api_blocks, api_config, api_mempool, api_metrics, api_network_health, api_p2p_metrics, api_peers, api_status, api_wallet_selectable_utxos, api_wallet_transactions, api_wallet_utxos, }; -#[cfg(test)] -use api::{page_items, selectable_wallet_utxo_rows, wallet_utxo_rows}; -#[cfg(test)] -use auth::{hash_password, hex_encode, pbkdf2_sha256, validate_password, verify_password}; use auth_routes::{ api_auth_change_password_form, api_auth_login_form, api_auth_logout_form, api_auth_setup_form, api_auth_status, require_auth_middleware, }; use index_html::INDEX_HTML; -#[cfg(test)] -use metrics::network_health_at; use metrics::{metrics_response, network_health}; use request_auth::wallet_password_for_request; -#[cfg(test)] -use request_auth::{auth_client_key, login_auth_password, same_origin_request}; pub use state::ServeOptions; use state::{AuthClientKey, AuthSession, HttpState, UiChainCache, UiChainView}; use static_assets::{alpine_js, app_js, favicon, index}; use ui::{ - add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, ui_blinded_reveal, - ui_blinded_transaction, ui_blocks_from_indexes, ui_pending_revealed_transaction, + add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, ui_blocks_from_indexes, ui_transaction, wallet_transaction_row, wallet_transaction_rows, }; -#[cfg(test)] -use ui::{known_output_index, revealed_transactions_by_height, ui_block, ui_blocks}; use wallet::{ api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, fee_estimate_json, import_setup_wallet_seed, replace_setup_wallet_with_generated_seed, required_fee_per_byte_burn, setup_requires_peer, transfer, wallet_setup_json, }; -#[cfg(test)] -use wallet::{dev_seed_verify_bypass_allowed, validate_transfer_form}; -use wallet_persistence::run_owned_blinded_outbox_persistence; const EXPLORER_LIMIT: usize = 50; const EXPLORER_PAGE_LIMIT: usize = 20; @@ -107,8 +86,6 @@ use types::{ NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, }; -#[cfg(test)] -use types::{BurnSettingsForm, TransferForm}; pub async fn serve( node: SharedNode, @@ -142,14 +119,12 @@ pub async fn serve( { let cache = state.ui_cache.lock().await; println!( - "UI data cache ready in {:.2}s (outputs: {}, revealed heights: {}, burn-rank blocks: {})", + "UI data cache ready in {:.2}s (outputs: {}, burn-rank blocks: {})", ui_data_started.elapsed().as_secs_f64(), cache.outputs.len(), - cache.revealed_by_height.len(), cache.burn_leader_ranks_by_hash.len() ); } - tokio::spawn(run_owned_blinded_outbox_persistence(state.clone())); let app = Router::new() .route("/", get(index)) .route("/favicon.ico", get(favicon)) @@ -255,7 +230,6 @@ async fn prewarm_chain_view_cache(state: HttpState) -> Result<()> { let mut cache = state.ui_cache.lock().await; cache.tip_hash = index.tip_hash; cache.outputs = index.outputs; - cache.revealed_by_height = index.revealed_by_height; cache.burn_leader_ranks_by_hash = index.burn_leader_ranks_by_hash; return Ok(()); } @@ -311,6 +285,3 @@ fn now_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } - -#[cfg(test)] -mod tests; diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -6,12 +6,10 @@ use axum::{ extract::{Query, State}, }; -#[cfg(test)] -use crate::domain::Ledger; use crate::{ adapters::p2p::P2pMetrics, app::{NodeStatus, PeerInfo}, - domain::{BlindedTransaction, OutPoint, Transaction, TxOutput}, + domain::{OutPoint, Transaction, TxOutput}, }; use super::types::{LeaderboardEntry, MetricsLeaderboards}; @@ -24,8 +22,7 @@ use super::{ use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, metrics_response, - network_health, ui_blinded_reveal, ui_blinded_transaction, ui_blocks_from_indexes, - ui_pending_revealed_transaction, ui_transaction, wallet_transaction_row, + network_health, ui_blocks_from_indexes, ui_transaction, wallet_transaction_row, wallet_transaction_rows, }; @@ -70,7 +67,6 @@ pub(super) async fn api_blocks( Json(ui_blocks_from_indexes( blocks, &view.outputs, - &view.revealed_by_height, &view.burn_leader_ranks_by_hash, )) } @@ -88,27 +84,12 @@ pub(super) async fn api_mempool( Query(query): Query<PageQuery>, ) -> Json<Page<UiTransaction>> { let ui_data_ready = ensure_ui_data_current(&state).await.is_ok(); - let (pending, pending_blinded, pending_reveals, pending_revealed) = { + let pending = { let node = state.node.lock().await; - let pending = node.pending_transactions(); - let pending_blinded = node.pending_blinded_transactions(); - let pending_reveals = node.pending_blinded_reveals(); - let pending_revealed = node - .pending_revealed_blinded_transactions() - .into_iter() - .map(|revealed| (revealed.commitment.clone(), revealed)) - .collect::<BTreeMap<_, _>>(); - (pending, pending_blinded, pending_reveals, pending_revealed) + node.pending_transactions() }; let mut required_outputs = BTreeSet::new(); collect_transaction_input_outpoints(pending.iter(), &mut required_outputs); - collect_blinded_input_outpoints(pending_blinded.iter(), &mut required_outputs); - collect_transaction_input_outpoints( - pending_revealed - .values() - .map(|revealed| &revealed.transaction), - &mut required_outputs, - ); let mut outputs = if ui_data_ready { load_outputs_for_outpoints(&state, required_outputs) .await @@ -121,17 +102,6 @@ pub(super) async fn api_mempool( .iter() .map(|tx| ui_transaction(tx, &outputs)) .collect::<Vec<_>>(); - items.extend( - pending_blinded - .iter() - .map(|transaction| ui_blinded_transaction(transaction, &outputs)), - ); - items.extend(pending_reveals.iter().map(|reveal| { - pending_revealed - .get(&reveal.commitment) - .map(|revealed| ui_pending_revealed_transaction(revealed, &outputs)) - .unwrap_or_else(|| ui_blinded_reveal(reveal)) - })); items.reverse(); Json(page_items(items, query)) } @@ -157,30 +127,21 @@ pub(super) async fn api_wallet_transactions( next_offset: None, }); } - let (wallet, pending, owned_blinded) = { + let (wallet, pending) = { let node = state.node.lock().await; ( node.wallet_address().to_string(), node.pending_transactions(), - node.owned_blinded_payloads(), ) }; let mut pending_required_outputs = BTreeSet::new(); collect_transaction_input_outpoints(pending.iter(), &mut pending_required_outputs); - collect_transaction_input_outpoints(owned_blinded.iter(), &mut pending_required_outputs); let mut pending_outputs = load_outputs_for_outpoints(&state, pending_required_outputs) .await .unwrap_or_default(); add_pending_outputs(&mut pending_outputs, &pending); - let pending_rows = wallet_transaction_rows( - &wallet, - pending.clone(), - owned_blinded.clone(), - &[], - &BTreeMap::new(), - &pending_outputs, - filters, - ); + let pending_rows = + wallet_transaction_rows(&wallet, pending.clone(), &[], &pending_outputs, filters); let pending_total = pending_rows.len(); let mut items = pending_rows .into_iter() @@ -227,7 +188,6 @@ pub(super) async fn api_wallet_transactions( block_height: Some(row.block_height), timestamp_ms: Some(row.timestamp_ms), block_finalizer: Some(row.block_finalizer), - blinded: row.blinded, }, ) })); @@ -326,20 +286,6 @@ fn collect_transaction_input_outpoints<'a>( } } -fn collect_blinded_input_outpoints<'a>( - transactions: impl IntoIterator<Item = &'a BlindedTransaction>, - outpoints: &mut BTreeSet<OutPoint>, -) { - for transaction in transactions { - outpoints.extend( - transaction - .inputs - .iter() - .map(|input| input.outpoint.clone()), - ); - } -} - pub(super) async fn api_wallet_utxos( State(state): State<HttpState>, Query(query): Query<PageQuery>, @@ -430,37 +376,6 @@ fn wallet_transaction_filter_kinds(filters: WalletTransactionFilters) -> Vec<&'s kinds } -#[cfg(test)] -pub(super) fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { - let spendable_outpoints = ledger - .available_utxos_for_address(wallet) - .unwrap_or_default() - .into_iter() - .map(|(outpoint, _)| outpoint) - .collect::<BTreeSet<_>>(); - let mut utxos = ledger - .utxos_for_address(wallet) - .into_iter() - .map(|(outpoint, output)| { - let spendable = spendable_outpoints.contains(&outpoint); - WalletUtxoRow { - outpoint, - address: output.address, - amount: output.amount, - spendable, - } - }) - .collect::<Vec<_>>(); - utxos.sort_by(|left, right| { - right - .amount - .cmp(&left.amount) - .then_with(|| left.outpoint.txid.cmp(&right.outpoint.txid)) - .then_with(|| left.outpoint.index.cmp(&right.outpoint.index)) - }); - utxos -} - fn wallet_utxo_rows_from_ui_data( utxos: Vec<(crate::domain::OutPoint, crate::domain::TxOutput)>, pending_spent: &BTreeSet<crate::domain::OutPoint>, @@ -479,14 +394,6 @@ fn wallet_utxo_rows_from_ui_data( .collect() } -#[cfg(test)] -pub(super) fn selectable_wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { - wallet_utxo_rows(ledger, wallet) - .into_iter() - .filter(|utxo| utxo.spendable) - .collect() -} - pub(super) async fn api_peers( State(state): State<HttpState>, Query(query): Query<PageQuery>, @@ -576,8 +483,6 @@ pub(super) async fn api_network_health( let node = state.node.lock().await; let mempool = MempoolCounts { plain_transactions: node.pending_transactions().len(), - blinded_transactions: node.pending_blinded_transactions().len(), - blinded_reveals: node.pending_blinded_reveals().len(), }; ( NetworkHealthLocalState { diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -397,7 +397,6 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .mempool-item.before-last-block { opacity: .56; } .mempool-item.new-since-block { background: #151a12; opacity: 1; } .mempool-item.new-since-block::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; border-radius: 8px 0 0 8px; background: #d5f55f; } - .mempool-item.blinded-hidden { background: linear-gradient(135deg, #141218, #101316); border-color: #353040; } .mempool-state { color: #d5f55f; font-size: 10px; font-weight: 850; text-transform: uppercase; } .mempool-time { color: #8d989f; font-size: 11px; font-weight: 700; } .mempool-top { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; min-width: 0; } @@ -408,8 +407,6 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .pill.burn { background: #332918; color: #ffd070; } .pill.transfer { background: #17312a; color: #8de9cd; } .pill.mine { background: #172a34; color: #8bdcff; } - .pill.blinded { background: #272433; color: #c8b8ff; } - .pill.reveal, .pill.revealed { background: #2b2f20; color: #d5f55f; } .pill.error { background: #341918; color: #ffb1a8; } .mempool-panel { min-width: 0; overflow: hidden; } .mempool-strip { width: 100%; min-width: 0; display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; padding: 1px 0 10px; scroll-snap-type: x proximity; } @@ -812,8 +809,6 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="peer-summary-item"><div class="peer-summary-label">Banned</div><div class="peer-summary-value" x-text="networkHealth.banned_peers ?? '-'"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Mempool</div><div class="peer-summary-value" x-text="networkHealth.pending_transactions ?? '-'"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Plain Tx</div><div class="peer-summary-value" x-text="networkHealth.pending_plain_transactions ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Commits</div><div class="peer-summary-value" x-text="networkHealth.pending_blinded_transactions ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Reveals</div><div class="peer-summary-value" x-text="networkHealth.pending_blinded_reveals ?? '-'"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Time Offset</div><div class="peer-summary-value" x-text="networkTimeOffsetLabel()"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Clock Warnings</div><div class="peer-summary-value" x-text="networkHealth.bad_clock_peers ?? '-'"></div></div> </div> @@ -877,10 +872,10 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="metric"><div class="label">Hello Rx</div><div class="value" x-text="p2pMetrics.hello_envelopes_received ?? 0"></div></div> <div class="metric"><div class="label">Inventory Rx</div><div class="value" x-text="p2pMetrics.inventory_envelopes_received ?? 0"></div></div> <div class="metric"><div class="label">Data Rx</div><div class="value" x-text="p2pMetrics.data_envelopes_received ?? 0"></div></div> - <div class="metric"><div class="label">Commit Rx</div><div class="value" x-text="p2pMetrics.blinded_transactions_received ?? 0"></div></div> - <div class="metric"><div class="label">Commit Batches Rx</div><div class="value" x-text="p2pMetrics.blinded_transaction_envelopes_received ?? 0"></div></div> - <div class="metric"><div class="label">Reveal Rx</div><div class="value" x-text="p2pMetrics.blinded_reveals_received ?? 0"></div></div> - <div class="metric"><div class="label">Reveal Batches Rx</div><div class="value" x-text="p2pMetrics.blinded_reveal_envelopes_received ?? 0"></div></div> + <div class="metric"><div class="label">Tx Rx</div><div class="value" x-text="p2pMetrics.transactions_received ?? 0"></div></div> + <div class="metric"><div class="label">Tx Envelopes Rx</div><div class="value" x-text="p2pMetrics.transaction_envelopes_received ?? 0"></div></div> + <div class="metric"><div class="label">Burn Bundles Rx</div><div class="value" x-text="p2pMetrics.burn_bundles_received ?? 0"></div></div> + <div class="metric"><div class="label">Burn Bundle Envelopes Rx</div><div class="value" x-text="p2pMetrics.burn_bundle_envelopes_received ?? 0"></div></div> <div class="metric"><div class="label">Control Rx</div><div class="value" x-text="p2pMetrics.control_envelopes_received ?? 0"></div></div> </div> <div class="metric-context"> @@ -905,7 +900,6 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="block-meta"> <span x-text="burnCountLabel(block)"></span> <span x-text="transferCountLabel(block)"></span> - <span x-text="commitCountLabel(block)"></span> <span x-text="mineCountLabel(block)"></span> </div> <div class="block-miner" x-text="blockFinalizerLabel(block)"></div> @@ -963,8 +957,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="detail-kv"><div class="key">Mode</div><div x-text="selectedBlock.finalizer_mode === 'recovery' ? 'Recovery' : `Rank ${selectedBlock.finalizer_rank ?? 0}`"></div></div> <div class="detail-kv"><div class="key">Reward</div><div>IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></div></div> - <div class="detail-kv"><div class="key">Reveal Lists</div><div x-text="blockRevealListRatio(selectedBlock)"></div></div> - <div class="detail-kv"><div class="key">Fee Penalty</div><div class="fee-penalty-value" :class="{ penalty: blockRevealFeePenaltyAmount(selectedBlock) > 0 }">IUNA <span x-text="amountLabel(blockRevealFeePenaltyAmount(selectedBlock))"></span></div></div> + <div class="detail-kv"><div class="key">Burn Bundles</div><div x-text="blockBurnBundleRatio(selectedBlock)"></div></div> <div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div> <div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div> <div class="detail-kv"><div class="key">Total Burned</div><div>IUNA <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div> @@ -984,39 +977,35 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })"> <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> + <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> - <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> - <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </template> </div> <div class="muted" x-show="selectedBlock.transactions.length === 0">No envelope transactions</div> </div> - <template x-for="bundle in selectedBlock.reveal_bundles || selectedBlock.revealBundles || []" :key="bundle.hash"> + <template x-for="bundle in selectedBlock.burn_bundles || selectedBlock.burnBundles || []" :key="bundle.hash"> <details class="tx-section"> - <summary class="tx-section-title"><span x-text="`Reveal bundle ${bundle.slot}`"></span><span class="tx-section-meta"><span x-text="shortAddressLabel(bundle.member)"></span> · <span x-text="bundle.byte_size || bundle.byteSize || 0"></span>B</span></summary> - <template x-for="tx in bundle.reveals" :key="tx.signature"> - <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })"> + <summary class="tx-section-title"><span x-text="`Burn bundle ${bundle.slot}`"></span><span class="tx-section-meta"><span x-text="shortAddressLabel(bundle.member)"></span> · <span x-text="bundle.byte_size || bundle.byteSize || 0"></span>B</span></summary> + <template x-for="tx in bundle.burns" :key="tx.signature"> + <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Burn bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Burn bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Burn bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })"> <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> + <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </template> - <div class="muted" x-show="bundle.reveals.length === 0">No reveals in bundle</div> + <div class="muted" x-show="bundle.burns.length === 0">No burns in bundle</div> </details> </template> - <div class="muted" x-show="selectedBlock.transactions.length === 0 && !(selectedBlock.reveal_bundles || selectedBlock.revealBundles || []).length">No transactions</div> + <div class="muted" x-show="selectedBlock.transactions.length === 0 && !(selectedBlock.burn_bundles || selectedBlock.burnBundles || []).length">No transactions</div> </div> </div> </template> @@ -1035,16 +1024,13 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </div> <span class="pill" :class="tx.kind" x-text="tx.kind"></span> </div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> + <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> - <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> - <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </template> <template x-if="mempoolPage.loading"> @@ -1387,13 +1373,10 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="tx-modal-summary"> <div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(selectedTransaction?.tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="addressLabel(txFrom(selectedTransaction?.tx || {}))"></code></div> + <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="addressLabel(txFrom(selectedTransaction?.tx || {}))"></code></div> <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(txTo(selectedTransaction?.tx || {}))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(selectedTransaction?.tx) || selectedTransaction?.tx?.commitment"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="selectedTransaction?.tx?.commitment || selectedTransaction?.tx?.signature || '-'"></code></div> - <div class="tx-field" x-show="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"><span class="tx-label">Encrypted Bytes</span><span class="tx-value number" x-text="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"></span></div> - <div class="tx-field" x-show="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"></span></div> <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Difficulty</span><span class="tx-value number" x-text="txDifficultyBits(selectedTransaction?.tx) ?? '-'"></span></div> <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number" x-text="txProofBits(selectedTransaction?.tx) ?? '-'"></span></div> <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="txProofHash(selectedTransaction?.tx) || '-'"></code></div> diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -253,8 +253,6 @@ pub(super) fn network_health_at( banned_peers, pending_transactions: local.pending_transactions, pending_plain_transactions: mempool.plain_transactions, - pending_blinded_transactions: mempool.blinded_transactions, - pending_blinded_reveals: mempool.blinded_reveals, network_time_offset_ms, bad_clock_peers, last_error, diff --git a/src/adapters/http/request_auth.rs b/src/adapters/http/request_auth.rs @@ -251,13 +251,11 @@ pub(super) async fn change_auth_password( pub(super) async fn restore_node_wallet_from_store( state: &HttpState, wallet: Wallet, - password: Option<&str>, + _password: Option<&str>, ) -> Result<()> { - let owned_blinded_transactions = - wallet_store::load_owned_blinded_transactions(&state.wallet_path, password)?; let mut node = state.node.lock().await; node.replace_wallet(wallet); - node.restore_owned_blinded_transactions(owned_blinded_transactions) + Ok(()) } async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> { diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs @@ -8,7 +8,7 @@ use crate::{ ui_data_store::SqliteUiDataStore, }, app::{SharedNode, SharedPeerBook, StratumStatus}, - domain::{BurnLeaderRank, OutPoint, RevealedBlindedTransaction, TxOutput}, + domain::{BurnLeaderRank, OutPoint, TxOutput}, }; #[derive(Clone)] @@ -47,14 +47,12 @@ pub(super) struct AuthBackoff { pub(super) struct UiChainCache { pub(super) tip_hash: Option<String>, pub(super) outputs: BTreeMap<OutPoint, TxOutput>, - pub(super) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, pub(super) burn_leader_ranks_by_hash: BTreeMap<String, Vec<BurnLeaderRank>>, } #[derive(Clone, Debug, Default)] pub(super) struct UiChainView { pub(super) outputs: BTreeMap<OutPoint, TxOutput>, - pub(super) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, pub(super) burn_leader_ranks_by_hash: BTreeMap<String, Vec<BurnLeaderRank>>, } diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -1,2363 +0,0 @@ -use std::{collections::BTreeMap, sync::Arc}; - -use axum::{ - Json, Router, - body::{Body, to_bytes}, - extract::{Query, State}, - http::{HeaderMap, Method, Request, StatusCode, header}, - middleware, - routing::{get, post}, -}; -use tokio::sync::Mutex; -use tower::ServiceExt; - -use crate::{ - adapters::{ - chain_store::SqliteChainStore, config_store, config_store::UiConfig, p2p::GossipNetwork, - ui_data_store::SqliteUiDataStore, wallet_store, - }, - app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, - domain::{ - Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, - GenesisBurn, LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, - OutPoint, RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput, - Wallet, - }, -}; - -use super::{ - AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, WalletTransactionFilters, - WalletTransactionsQuery, api_auth_change_password_form, api_auth_login_form, - api_auth_setup_form, api_auth_status, auth_client_key, dev_seed_verify_bypass_allowed, - hash_password, hex_encode, pbkdf2_sha256, persist_burn_settings_config, - persist_pow_mining_config, require_auth_middleware, required_fee_per_byte_burn, - same_origin_request, validate_password, validate_transfer_form, verify_password, - wallet_transaction_rows, wallet_utxo_rows, -}; - -#[test] -fn dev_seed_verify_bypass_requires_env_flag() { - assert!(dev_seed_verify_bypass_allowed(true)); - assert!(!dev_seed_verify_bypass_allowed(false)); -} - -#[test] -fn mempool_ui_items_can_represent_blinded_transactions_and_reveals() { - let commitment = "a".repeat(64); - let owner = "c".repeat(64); - let input_outpoint = OutPoint { - txid: "d".repeat(64), - index: 0, - }; - let blinded = BlindedTransaction { - commitment: commitment.clone(), - inputs: vec![TxInput { - outpoint: input_outpoint.clone(), - owner: owner.clone(), - signature: "e".repeat(128), - }], - fee: 7, - encrypted_size: 123, - expires_at_height: 42, - nonce: "00".repeat(12), - ciphertext: "11".repeat(123), - payload_hash: "b".repeat(64), - }; - let reveal = BlindedReveal { - commitment: commitment.clone(), - key: "22".repeat(32), - }; - let outputs = BTreeMap::from([( - input_outpoint.clone(), - TxOutput { - address: owner.clone(), - amount: 99, - }, - )]); - - let blinded_row = super::ui_blinded_transaction(&blinded, &outputs); - let reveal_row = super::ui_blinded_reveal(&reveal); - let revealed_row = super::ui_pending_revealed_transaction( - &crate::domain::RevealedBlindedTransaction { - height: 2, - commitment: reveal.commitment.clone(), - included_by: owner.clone(), - transaction: Transaction::Burn { - inputs: blinded.inputs.clone(), - change: Vec::new(), - amount: 12, - fee: blinded.fee, - signature: "f".repeat(128), - }, - }, - &outputs, - ); - - assert_eq!(blinded_row.kind, "blinded"); - assert_eq!(blinded_row.from, owner); - assert_eq!(blinded_row.inputs.len(), 1); - assert_eq!(blinded_row.inputs[0].amount, Some(99)); - assert_eq!(blinded_row.signature, commitment); - assert_eq!(blinded_row.encrypted_size, Some(123)); - assert_eq!(blinded_row.expires_at_height, Some(42)); - assert_eq!(reveal_row.kind, "reveal"); - assert_eq!(reveal_row.commitment, blinded_row.commitment); - assert_eq!(revealed_row.kind, "burn"); - assert!(revealed_row.revealed); - assert_eq!(revealed_row.commitment, Some(reveal.commitment)); - assert_eq!(revealed_row.amount, 12); - assert_eq!(revealed_row.fee, 7); - assert_eq!(revealed_row.inputs[0].amount, Some(99)); -} - -#[test] -fn block_detail_transactions_include_blinded_and_revealed_items() { - let alice = Wallet::from_seed("block-detail-blind-alice"); - let bob = Wallet::from_seed("block-detail-blind-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - let ledger = Ledger::new(allocations.clone(), 1); - let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); - let built = ledger - .build_blinded_transaction(&alice, transfer.clone(), 20) - .unwrap(); - let mut block = fake_block(7, Vec::new()); - block.blinded_transactions = vec![built.transaction.clone()]; - - let revealed = crate::domain::RevealedBlindedTransaction { - height: 7, - commitment: built.transaction.commitment.clone(), - included_by: "miner".to_string(), - transaction: transfer, - }; - let ui_block = super::ui_block(block, &BTreeMap::new(), &BTreeMap::new(), &[revealed]); - - assert_eq!(ui_block.transactions.len(), 2); - assert_eq!(ui_block.transactions[0].kind, "blinded"); - assert_eq!( - ui_block.transactions[0].commitment.as_deref(), - Some(built.transaction.commitment.as_str()) - ); - assert!(!ui_block.transactions[0].revealed); - assert_eq!(ui_block.transactions[1].kind, "transfer"); - assert!(ui_block.transactions[1].revealed); - assert_eq!(ui_block.revealed_transactions.len(), 1); - assert!(ui_block.revealed_transactions[0].revealed); - assert!(ui_block.total_bytes > 0); - assert!(ui_block.blinded_transaction_bytes > 0); - assert_eq!(ui_block.reveal_bundle_bytes, 0); - - let burn = ledger.build_burn(&alice, 5, 1).unwrap(); - let mine = Transaction::Mine { - recipient: bob.address().to_string(), - anchor: "a".repeat(64), - salt: 1, - nonce: 2, - difficulty_bits: 12, - proof_header: None, - signature: "b".repeat(64), - }; - let typed_block = super::ui_block( - fake_block( - 8, - vec![ - ledger.build_transfer(&alice, bob.address(), 7, 1).unwrap(), - burn, - mine, - ], - ), - &BTreeMap::new(), - &BTreeMap::new(), - &[], - ); - let typed_bytes = typed_block - .transaction_byte_breakdown - .iter() - .map(|row| (row.label, row.bytes)) - .collect::<BTreeMap<_, _>>(); - assert!(typed_bytes["transfer"] > 0); - assert!(typed_bytes["burn"] > 0); - assert!(typed_bytes["mine"] > 0); -} - -#[test] -fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { - let alice = Wallet::from_seed("block-detail-reveal-alice"); - let bob = Wallet::from_seed("block-detail-reveal-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - let ledger = Ledger::new(allocations.clone(), 1); - let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); - let built = ledger - .build_blinded_transaction(&alice, transfer.clone(), 20) - .unwrap(); - let mut commit_block = fake_block(7, Vec::new()); - commit_block.blinded_transactions = vec![built.transaction.clone()]; - let mut reveal_block = fake_block(8, Vec::new()); - reveal_block.transactions = vec![Transaction::Mine { - recipient: bob.address().to_string(), - anchor: "a".repeat(64), - salt: 1, - nonce: 2, - difficulty_bits: 12, - proof_header: None, - signature: "b".repeat(64), - }]; - reveal_block.reveal_bundle_section = RevealBundleSection { - signatures: vec![RevealBundleSignature { - slot: 0, - member: reveal_block.miner.clone(), - signature: "11".repeat(64), - }], - reveals: vec![MaskedBlindedReveal { - reveal: built.reveal.clone(), - bundle_mask: 1, - }], - }; - let snapshot = fake_snapshot( - allocations, - vec![commit_block.clone(), reveal_block.clone()], - ); - let mut burn_leader_ranks = BTreeMap::new(); - burn_leader_ranks.insert( - reveal_block.hash.clone(), - vec![ - BurnLeaderRank { - rank: 0, - ticket_id: "ticket-0".to_string(), - owner: alice.address().to_string(), - amount: 1, - eligible_from_height: 8, - eligible_until_height: 8, - }, - BurnLeaderRank { - rank: 1, - ticket_id: "ticket-1".to_string(), - owner: bob.address().to_string(), - amount: 1, - eligible_from_height: 8, - eligible_until_height: 8, - }, - BurnLeaderRank { - rank: 2, - ticket_id: "ticket-2".to_string(), - owner: "carol".to_string(), - amount: 1, - eligible_from_height: 8, - eligible_until_height: 8, - }, - ], - ); - - let blocks = super::ui_blocks( - vec![commit_block, reveal_block], - &snapshot, - &[], - &burn_leader_ranks, - ); - - assert_eq!(blocks[0].transactions.len(), 1); - assert_eq!(blocks[0].transactions[0].kind, "blinded"); - assert_eq!( - blocks[0].transactions[0].commitment.as_deref(), - Some(built.transaction.commitment.as_str()) - ); - assert_eq!(blocks[1].transactions.len(), 2); - assert_eq!(blocks[1].transactions[0].kind, "mine"); - assert_eq!(blocks[1].transactions[1].kind, "transfer"); - assert!(blocks[1].transactions[1].revealed); - assert_eq!(blocks[1].transactions[1].amount, transfer.amount()); - assert_eq!(blocks[1].transactions[1].to.as_deref(), Some(bob.address())); - assert_eq!(blocks[1].revealed_transactions.len(), 1); - assert_eq!(blocks[1].reveal_fee_penalty.reveal_lists_included, 1); - assert_eq!(blocks[1].reveal_fee_penalty.committee_size, 3); - assert_eq!(blocks[1].total_fees, transfer.fee() + MINE_FINALIZER_FEE); - assert_eq!( - blocks[1].reveal_fee_penalty.fee_penalty, - ((transfer.fee() + MINE_FINALIZER_FEE) as u128 * 2 / 3) as u64 - ); -} - -#[test] -fn block_detail_markup_uses_blinded_and_revealed_labels() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - - assert!(super::INDEX_HTML.contains("txPillLabel(tx)")); - assert!(super::INDEX_HTML.contains("commitCountLabel(block)")); - assert!(super::INDEX_HTML.contains(".pill.blinded")); - assert!(super::INDEX_HTML.contains(".pill.reveal, .pill.revealed")); - assert!(super::INDEX_HTML.contains(":class=\"mempoolItemClass(tx)\"")); - assert!(super::INDEX_HTML.contains(".mempool-item.before-last-block")); - assert!(super::INDEX_HTML.contains(":class=\"row[2]\"")); - assert!(super::INDEX_HTML.contains("New since last block")); - assert!(super::INDEX_HTML.contains("class=\"mempool-top\"")); - assert!(super::INDEX_HTML.contains("mempoolSeenTimeLabel(tx)")); - assert!(super::INDEX_HTML.contains("<details class=\"tx-section\">")); - assert!(super::INDEX_HTML.contains("<summary class=\"tx-section-title\">")); - assert!(super::INDEX_HTML.contains("class=\"tx-scroll-list\"")); - assert!(super::INDEX_HTML.contains(".tx-scroll-list")); - assert!(super::INDEX_HTML.contains("overscroll-behavior-y: contain")); - assert!(super::INDEX_HTML.contains("fee-penalty-value")); - assert!( - super::INDEX_HTML - .contains(":class=\"{ penalty: blockRevealFeePenaltyAmount(selectedBlock) > 0 }\"") - ); - assert!(super::INDEX_HTML.contains("Commitment")); - assert!(!super::INDEX_HTML.contains("<h3>Revealed</h3>")); - assert!(app_js.contains("tx?.revealed ? \"revealed\"")); - assert!(app_js.contains("transactions.some((tx) => tx?.revealed)")); - assert!(app_js.contains("blockCommitCount(block)")); - assert!(app_js.contains("blockTransactionByteBreakdown(block)")); - assert!(app_js.contains("[label, Number(row.bytes ?? 0), label]")); -} - -#[test] -fn password_policy_rejects_short_or_excessive_passwords() { - let short = validate_password("too-short").unwrap_err(); - assert!(short.to_string().contains("at least 12")); - - let long_password = "x".repeat(1025); - let long = validate_password(&long_password).unwrap_err(); - assert!(long.to_string().contains("too long")); - - validate_password("correct horse battery staple").unwrap(); -} - -#[test] -fn password_hash_round_trips_without_storing_plaintext() { - let password = "correct horse battery staple"; - let encoded = hash_password(password).unwrap(); - - assert!(!encoded.contains(password)); - assert!(verify_password(password, &encoded).unwrap()); - assert!(!verify_password("wrong horse battery staple", &encoded).unwrap()); -} - -#[test] -fn pbkdf2_sha256_matches_known_vectors() { - let one_iteration = pbkdf2_sha256(b"password", b"salt", 1); - assert_eq!( - hex_encode(one_iteration), - "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b" - ); - - let two_iterations = pbkdf2_sha256(b"password", b"salt", 2); - assert_eq!( - hex_encode(two_iterations), - "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43" - ); -} - -#[test] -fn same_origin_check_accepts_forwarded_host_and_rejects_cross_site_origin() { - let mut headers = HeaderMap::new(); - headers.insert(header::HOST, "127.0.0.1:18661".parse().unwrap()); - headers.insert("x-forwarded-host", "iuna.example".parse().unwrap()); - headers.insert(header::ORIGIN, "https://iuna.example".parse().unwrap()); - assert!(same_origin_request(&headers)); - - headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap()); - assert!(!same_origin_request(&headers)); -} - -#[test] -fn auth_client_key_trusts_forwarded_headers_only_from_private_or_local_peers() { - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-for", "198.51.100.99".parse().unwrap()); - let socket = Some("203.0.113.10:51234".parse().unwrap()); - - assert_eq!(auth_client_key(&headers, socket), "203.0.113.10"); - assert_eq!( - auth_client_key(&headers, Some("127.0.0.1:51234".parse().unwrap())), - "198.51.100.99" - ); - assert_eq!( - auth_client_key(&headers, Some("10.42.1.12:51234".parse().unwrap())), - "198.51.100.99" - ); - assert_eq!( - auth_client_key(&headers, Some("172.20.4.8:51234".parse().unwrap())), - "198.51.100.99" - ); - assert_eq!(auth_client_key(&headers, None), "198.51.100.99"); -} - -#[tokio::test] -async fn protected_endpoints_require_authentication_setup() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: None, - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let protected = http_request(app.clone(), Method::GET, "/api/protected", None, "").await; - assert_eq!(protected.status, StatusCode::UNAUTHORIZED); - assert!(protected.body.contains("authentication setup is required")); - - let status = http_request(app, Method::GET, "/api/auth/status", None, "").await; - assert_eq!(status.status, StatusCode::OK); - assert!(status.body.contains("\"configured\":false")); -} - -#[tokio::test] -async fn favicon_is_public_before_authentication_setup() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: None, - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let response = http_request(app, Method::GET, "/favicon.ico", None, "").await; - - assert_eq!(response.status, StatusCode::NO_CONTENT); -} - -#[tokio::test] -async fn protected_endpoints_require_valid_session_after_authentication_setup() { - let dir = tempfile::tempdir().unwrap(); - let password = "correct horse battery staple"; - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: Some(hash_password(password).unwrap()), - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let missing_cookie = http_request(app.clone(), Method::GET, "/api/protected", None, "").await; - assert_eq!(missing_cookie.status, StatusCode::UNAUTHORIZED); - assert!(missing_cookie.body.contains("authentication required")); - - let bad_cookie = http_request( - app.clone(), - Method::GET, - "/api/protected", - Some("iuna_session=bogus"), - "", - ) - .await; - assert_eq!(bad_cookie.status, StatusCode::UNAUTHORIZED); - - let login = http_request( - app.clone(), - Method::POST, - "/api/auth/login", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert_eq!(login.status, StatusCode::OK); - assert!(login.body.contains("\"ok\":true")); - let cookie = set_cookie_pair(&login.headers); - assert!(cookie.starts_with(AUTH_COOKIE_NAME)); - - let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; - assert_eq!(protected.status, StatusCode::OK); - assert_eq!(protected.body, "protected"); -} - -#[tokio::test] -async fn auth_posts_require_same_origin_headers() { - let dir = tempfile::tempdir().unwrap(); - let password = "correct horse battery staple"; - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: Some(hash_password(password).unwrap()), - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let response = app - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/auth/login") - .header(header::HOST, "127.0.0.1:18661") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("password=correct+horse+battery+staple")) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), - ) - .unwrap(); - assert!(body.contains("same-origin request required")); -} - -#[tokio::test] -async fn login_authentication_locks_out_after_repeated_failures() { - let dir = tempfile::tempdir().unwrap(); - let password = "correct horse battery staple"; - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: Some(hash_password(password).unwrap()), - ..UiConfig::default() - }, - ) - .await; - let client_a = "198.51.100.10"; - let client_b = "198.51.100.11"; - - for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS { - let error = super::login_auth_password(&state, "wrong horse battery staple", client_a) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("invalid password")); - } - - let locked = super::login_auth_password(&state, password, client_a) - .await - .unwrap_err(); - assert!(format!("{locked:#}").contains("too many failed login attempts")); - - let other_client_cookie = super::login_auth_password(&state, password, client_b) - .await - .unwrap(); - assert!(other_client_cookie.starts_with(AUTH_COOKIE_NAME)); - - state - .auth_backoff - .lock() - .await - .get_mut(client_a) - .unwrap() - .locked_until_ms = Some(crate::app::now_ms().saturating_sub(1)); - let cookie = super::login_auth_password(&state, password, client_a) - .await - .unwrap(); - assert!(cookie.starts_with(AUTH_COOKIE_NAME)); - assert!(!state.auth_backoff.lock().await.contains_key(client_a)); -} - -#[tokio::test] -async fn password_setup_creates_session_for_protected_endpoints() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state(config_path.clone(), UiConfig::default()).await; - let app = auth_test_app(state); - - let setup = http_request( - app.clone(), - Method::POST, - "/api/auth/setup", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert_eq!(setup.status, StatusCode::OK); - assert!(setup.body.contains("\"ok\":true")); - let cookie = set_cookie_pair(&setup.headers); - - let stored = config_store::load_or_create(&config_path).unwrap(); - assert!(stored.auth_password_hash.is_some()); - let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; - assert_eq!(protected.status, StatusCode::OK); - assert_eq!(protected.body, "protected"); -} - -#[tokio::test] -async fn password_change_reencrypts_wallet_and_replaces_login_password() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let wallet_path = config_path.with_file_name("wallet.json"); - let old_password = "correct horse battery staple"; - let new_password = "new correct battery staple"; - let state = auth_test_state( - config_path.clone(), - UiConfig { - auth_password_hash: Some(hash_password(old_password).unwrap()), - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - wallet_store::encrypt_existing_with_password(&wallet_path, old_password).unwrap(); - let app = auth_test_app(state); - - let login = http_request( - app.clone(), - Method::POST, - "/api/auth/login", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert_eq!(login.status, StatusCode::OK); - let cookie = set_cookie_pair(&login.headers); - - let change = http_request( - app.clone(), - Method::POST, - "/api/auth/change-password", - Some(&cookie), - "old_password=correct+horse+battery+staple&new_password=new+correct+battery+staple", - ) - .await; - assert_eq!(change.status, StatusCode::OK); - assert!(change.body.contains("\"ok\":true")); - - let stored = config_store::load_or_create(&config_path).unwrap(); - let stored_hash = stored.auth_password_hash.unwrap(); - assert!(!verify_password(old_password, &stored_hash).unwrap()); - assert!(verify_password(new_password, &stored_hash).unwrap()); - assert!(wallet_store::load_with_password(&wallet_path, old_password).is_err()); - assert!(wallet_store::load_with_password(&wallet_path, new_password).is_ok()); - - let old_login = http_request( - app.clone(), - Method::POST, - "/api/auth/login", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert!(old_login.body.contains("invalid password")); - - let new_login = http_request( - app, - Method::POST, - "/api/auth/login", - None, - "password=new+correct+battery+staple", - ) - .await; - assert_eq!(new_login.status, StatusCode::OK); -} - -#[tokio::test] -async fn peer_management_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - - super::add_peer(&state, " 127.0.0.1:9445 ".to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!(config.peers, vec!["127.0.0.1:9445"]); - assert_eq!(state.peers.lock().await.addresses(), vec!["127.0.0.1:9445"]); - - super::remove_peer(&state, "127.0.0.1:9445".to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.peers.is_empty()); - assert!(state.peers.lock().await.addresses().is_empty()); -} - -#[tokio::test] -async fn address_book_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - let alice = Wallet::from_seed("address-book-alice"); - let bob = Wallet::from_seed("address-book-bob"); - - super::upsert_address_book_entry( - &state, - format!(" {} ", alice.address().to_ascii_uppercase()), - " Alice ".to_string(), - None, - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!( - config.address_book.get(alice.address()), - Some(&"Alice".to_string()) - ); - - super::upsert_address_book_entry( - &state, - alice.address().to_string(), - "Alice Prime".to_string(), - Some(alice.address().to_string()), - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!( - config.address_book.get(alice.address()), - Some(&"Alice Prime".to_string()) - ); - - let error = super::upsert_address_book_entry( - &state, - alice.address().to_string(), - "Alice Duplicate".to_string(), - None, - ) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("address is already saved")); - - let carol = Wallet::from_seed("address-book-carol"); - super::upsert_address_book_entry( - &state, - carol.address().to_string(), - "Carol".to_string(), - None, - ) - .await - .unwrap(); - let error = super::upsert_address_book_entry( - &state, - carol.address().to_string(), - "Bob As Carol".to_string(), - Some(alice.address().to_string()), - ) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("address is already saved")); - - super::upsert_address_book_entry( - &state, - bob.address().to_string(), - "Bob".to_string(), - Some(alice.address().to_string()), - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.address_book.contains_key(alice.address())); - assert_eq!( - config.address_book.get(bob.address()), - Some(&"Bob".to_string()) - ); - assert_eq!( - config.address_book.get(carol.address()), - Some(&"Carol".to_string()) - ); - - let error = super::upsert_address_book_entry( - &state, - "iuna-address".to_string(), - "Not Alice".to_string(), - None, - ) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("invalid address book address")); - - super::remove_address_book_entry(&state, bob.address().to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.address_book.contains_key(bob.address())); - assert!(config.address_book.contains_key(carol.address())); -} - -#[tokio::test] -async fn p2p_announce_setting_persists_config_and_waits_for_public_node() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - - super::set_p2p_announce_addr(&state, " 203.0.113.10:9444 ".to_string()) - .await - .unwrap(); - - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!( - config.p2p_announce_addr.as_deref(), - Some("203.0.113.10:9444") - ); - match state.gossip.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(!peers.contains(&"203.0.113.10:9444".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - - super::set_p2p_accept_inbound(&state, true, Some(9555)) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.p2p_accept_inbound); - assert_eq!(config.p2p_bind_port, 9555); - assert!(!state.gossip.accepts_inbound().await); - match state.gossip.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(!peers.contains(&"203.0.113.10:9444".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - - super::set_p2p_accept_inbound(&state, false, None) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.p2p_accept_inbound); - assert!(!state.gossip.accepts_inbound().await); - - super::set_p2p_announce_addr(&state, " ".to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.p2p_announce_addr.is_none()); -} - -#[tokio::test] -async fn p2p_announce_setting_rejects_invalid_address() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - - let error = super::set_p2p_announce_addr(&state, "not-an-address".to_string()) - .await - .unwrap_err(); - - assert!(format!("{error:#}").contains("invalid P2P announce address")); -} - -#[tokio::test] -async fn setup_config_form_can_add_bootstrap_peer() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state(config_path.clone(), UiConfig::default()).await; - - super::apply_config_form( - &state, - super::ConfigForm { - setup_complete: true, - peer: " iuna.jhx.app:9444 ".to_string(), - }, - ) - .await - .unwrap(); - - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.setup_complete); - assert_eq!(config.peers, vec!["iuna.jhx.app:9444"]); - assert_eq!( - state.peers.lock().await.addresses(), - vec!["iuna.jhx.app:9444"] - ); -} - -#[tokio::test] -async fn setup_config_form_requires_peer_for_placeholder_chain() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state(config_path.clone(), UiConfig::default()).await; - - let error = super::apply_config_form( - &state, - super::ConfigForm { - setup_complete: true, - peer: " ".to_string(), - }, - ) - .await - .unwrap_err(); - - assert!(format!("{error:#}").contains("add a bootstrap peer")); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.setup_complete); -} - -#[tokio::test] -async fn peer_management_rejects_empty_and_inbound_removal() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - - assert!(super::add_peer(&state, " ".to_string()).await.is_err()); - state - .peers - .lock() - .await - .record_received("127.0.0.1:9555", 1); - - let result = super::remove_peer(&state, "127.0.0.1:9555".to_string()).await; - assert!(result.is_err()); - assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new()); -} - -#[tokio::test] -async fn network_health_summarizes_sync_and_peer_errors() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - let status = state.node.lock().await.status(); - let local = super::NetworkHealthLocalState { - height: status.chain.height, - pending_transactions: status.chain.pending_transactions, - }; - let mempool = super::MempoolCounts { - plain_transactions: 1, - blinded_transactions: 2, - blinded_reveals: 3, - }; - - let isolated = super::network_health(local, &[], mempool); - assert!(!isolated.ok); - assert_eq!(isolated.state, "isolated"); - assert_eq!(isolated.local_height, 0); - assert_eq!(isolated.best_known_height, 0); - assert_eq!(isolated.pending_plain_transactions, 1); - assert_eq!(isolated.pending_blinded_transactions, 2); - assert_eq!(isolated.pending_blinded_reveals, 3); - - let mut clock_peers = PeerBook::from_addresses(vec![ - "127.0.0.1:9450".to_string(), - "127.0.0.1:9451".to_string(), - ]); - clock_peers.record_status("127.0.0.1:9450", 0, "tip".to_string()); - clock_peers.record_status("127.0.0.1:9451", 0, "tip".to_string()); - clock_peers.record_clock_observation("127.0.0.1:9450", PeerDirection::Outbound, 10_500, 10_000); - clock_peers.record_clock_observation( - "127.0.0.1:9451", - PeerDirection::Outbound, - 11 * 60 * 1_000, - 10_000, - ); - let clock_health = super::network_health_at(local, &clock_peers.list(), mempool, 10_000); - assert_eq!(clock_health.network_time_offset_ms, Some(500)); - assert_eq!(clock_health.bad_clock_peers, 1); - - let syncing = super::network_health( - local, - &[PeerInfo { - address: "127.0.0.1:9445".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 1, - messages_received: 1, - last_known_height: Some(3), - last_known_tip_hash: Some("remote-tip".to_string()), - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: None, - last_contact_ms: Some(10_000), - last_success_ms: Some(10_000), - last_error_ms: None, - misbehavior_score: 0, - banned_until_ms: None, - ban_reason: None, - }], - mempool, - ); - assert!(!syncing.ok); - assert_eq!(syncing.state, "syncing"); - assert_eq!(syncing.best_known_height, 3); - assert_eq!(syncing.lag_blocks, 3); - - let peer_errors = super::network_health( - local, - &[PeerInfo { - address: "127.0.0.1:9446".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 0, - messages_received: 0, - last_known_height: None, - last_known_tip_hash: None, - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: Some("connection refused".to_string()), - last_contact_ms: Some(10_000), - last_success_ms: None, - last_error_ms: Some(10_000), - misbehavior_score: 1, - banned_until_ms: None, - ban_reason: Some("connection refused".to_string()), - }], - mempool, - ); - assert!(!peer_errors.ok); - assert_eq!(peer_errors.state, "peer errors"); - assert_eq!( - peer_errors.last_error.as_deref(), - Some("127.0.0.1:9446: connection refused") - ); - - let stale = super::network_health_at( - local, - &[PeerInfo { - address: "127.0.0.1:9447".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 1, - messages_received: 1, - last_known_height: Some(0), - last_known_tip_hash: Some("tip".to_string()), - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: None, - last_contact_ms: Some(1), - last_success_ms: Some(1), - last_error_ms: None, - misbehavior_score: 0, - banned_until_ms: None, - ban_reason: None, - }], - mempool, - PEER_STALE_AFTER_MS + 2, - ); - assert!(!stale.ok); - assert_eq!(stale.state, "stale"); - assert_eq!(stale.stale_peers, 1); - - let banned = super::network_health_at( - local, - &[PeerInfo { - address: "127.0.0.1:9448".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 0, - messages_received: 0, - last_known_height: None, - last_known_tip_hash: None, - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: Some("invalid block".to_string()), - last_contact_ms: Some(10), - last_success_ms: None, - last_error_ms: Some(10), - misbehavior_score: 3, - banned_until_ms: Some(1_000), - ban_reason: Some("invalid block".to_string()), - }], - mempool, - 20, - ); - assert!(!banned.ok); - assert_eq!(banned.state, "banned"); - assert_eq!(banned.banned_peers, 1); -} - -#[test] -fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() { - let alice = Wallet::from_seed("wallet-history-alice"); - let bob = Wallet::from_seed("wallet-history-bob"); - let carol = Wallet::from_seed("wallet-history-carol"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - allocations.insert(carol.address().to_string(), 100); - let ledger = crate::domain::Ledger::new(allocations.clone(), 1); - let old_received = ledger.build_transfer(&bob, alice.address(), 31, 0).unwrap(); - let pending_burn = ledger.build_burn(&alice, 2, 1).unwrap(); - let carol_transfer = ledger.build_transfer(&carol, bob.address(), 5, 0).unwrap(); - let carol_burn = ledger.build_burn(&carol, 1, 0).unwrap(); - let chain = vec![ - fake_block(30, vec![carol_transfer]), - fake_block(31, vec![old_received.clone()]), - fake_block(32, vec![carol_burn]), - ]; - - let snapshot = fake_snapshot(allocations, chain.clone()); - let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&pending_burn)); - let rows = wallet_transaction_rows( - alice.address(), - vec![pending_burn.clone()], - Vec::new(), - &chain, - &BTreeMap::new(), - &outputs, - WalletTransactionFilters::default(), - ); - - assert_eq!(rows.len(), 1); - assert_ne!(rows[0].signature, pending_burn.signature()); - assert_eq!(rows[0].signature, old_received.signature()); - assert_eq!(rows[0].inputs[0].amount, Some(100)); - assert_eq!(rows[0].status, "confirmed"); - assert_eq!(rows[0].block_height, Some(31)); - assert_eq!(rows[0].timestamp_ms, Some(31)); - assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); - assert_eq!(rows[0].direction, "received"); -} - -#[test] -fn wallet_transactions_show_owned_blinded_payloads_as_pending_blind() { - let alice = Wallet::from_seed("wallet-blind-pending-alice"); - let bob = Wallet::from_seed("wallet-blind-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - let ledger = Ledger::new(allocations.clone(), 1); - let pending_blind = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); - let snapshot = fake_snapshot(allocations, Vec::new()); - let outputs = super::known_output_index(&snapshot, &[]); - - let rows = wallet_transaction_rows( - alice.address(), - Vec::new(), - vec![pending_blind.clone()], - &[], - &BTreeMap::new(), - &outputs, - WalletTransactionFilters::default(), - ); - - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].kind, "transfer"); - assert_eq!(rows[0].status, "pending"); - assert_eq!(rows[0].timestamp_ms, None); - assert!(rows[0].blinded); - assert_eq!(rows[0].direction, "sent"); - assert_eq!(rows[0].to.as_deref(), Some(bob.address())); - assert_eq!(rows[0].amount, 12); - assert_eq!(rows[0].fee, 3); - assert_eq!(rows[0].signature, pending_blind.signature()); -} - -#[test] -fn wallet_transaction_query_defaults_to_tx_only() { - assert_eq!( - WalletTransactionFilters::from_query(WalletTransactionsQuery::default()), - WalletTransactionFilters::default() - ); - assert_eq!( - WalletTransactionFilters::from_query(WalletTransactionsQuery { - tx: Some(false), - mine: Some(true), - burn: Some(true), - offset: None, - limit: None, - }), - WalletTransactionFilters { - transfer: false, - mine: true, - burn: true, - } - ); -} - -#[test] -fn page_items_returns_bounded_slices_with_next_offset() { - let page = super::page_items( - vec![1, 2, 3, 4, 5], - super::PageQuery { - offset: Some(1), - limit: Some(2), - }, - ); - - assert_eq!(page.items, vec![2, 3]); - assert_eq!(page.offset, 1); - assert_eq!(page.limit, 2); - assert_eq!(page.total, 5); - assert!(page.has_more); - assert_eq!(page.next_offset, Some(3)); -} - -#[test] -fn page_items_clamps_limit_and_empty_tail() { - let page = super::page_items( - vec![1, 2], - super::PageQuery { - offset: Some(20), - limit: Some(0), - }, - ); - - assert!(page.items.is_empty()); - assert_eq!(page.offset, 2); - assert_eq!(page.limit, 1); - assert_eq!(page.total, 2); - assert!(!page.has_more); - assert_eq!(page.next_offset, None); -} - -#[test] -fn mine_transaction_views_include_protocol_finalizer_fee() { - let alice = Wallet::from_seed("wallet-mine-fee-alice"); - let ledger = Ledger::new(BTreeMap::new(), 1); - let mine = ledger.build_mine(alice.address()).unwrap(); - let chain = vec![fake_block(1, vec![mine.clone()])]; - let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); - let outputs = super::known_output_index(&snapshot, &[]); - - let rows = wallet_transaction_rows( - alice.address(), - Vec::new(), - Vec::new(), - &chain, - &BTreeMap::new(), - &outputs, - WalletTransactionFilters { - transfer: false, - mine: true, - burn: false, - }, - ); - let transaction = super::ui_transaction(&mine, &outputs); - - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].amount, mine.amount()); - assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); - assert_eq!(transaction.amount, mine.amount()); - assert_eq!(transaction.fee, MINE_FINALIZER_FEE); -} - -#[test] -fn wallet_transactions_include_public_mine_actions() { - let alice = Wallet::from_seed("wallet-revealed-mine-alice"); - let ledger = Ledger::new( - BTreeMap::from([(alice.address().to_string(), 2 * MICRO_IUNA)]), - 1, - ); - let mine = ledger.build_mine(alice.address()).unwrap(); - let mut mine_block = fake_block(8, vec![mine.clone()]); - mine_block.reward = mine.fee(); - let chain = vec![mine_block.clone()]; - let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); - let revealed_by_height = super::revealed_transactions_by_height(&snapshot); - let outputs = super::known_output_index(&snapshot, &[]); - - let rows = wallet_transaction_rows( - alice.address(), - Vec::new(), - Vec::new(), - &chain, - &revealed_by_height, - &outputs, - WalletTransactionFilters { - transfer: false, - mine: true, - burn: false, - }, - ); - - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].kind, "mine"); - assert_eq!(rows[0].status, "confirmed"); - assert_eq!(rows[0].block_height, Some(8)); - assert_eq!(rows[0].timestamp_ms, Some(8)); - assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); - assert_eq!(rows[0].direction, "received"); - assert_eq!(rows[0].amount, mine.amount()); - assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); - assert!(!rows[0].blinded); - assert_eq!(rows[0].signature, mine.signature()); -} - -#[test] -fn burn_wallet_transactions_require_burn_filter() { - let alice = Wallet::from_seed("wallet-burn-filter-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let ledger = Ledger::new(allocations.clone(), 1); - let burn = ledger.build_burn(&alice, 3, 1).unwrap(); - let snapshot = fake_snapshot(allocations, Vec::new()); - let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&burn)); - - let default_rows = wallet_transaction_rows( - alice.address(), - vec![burn.clone()], - Vec::new(), - &[], - &BTreeMap::new(), - &outputs, - WalletTransactionFilters::default(), - ); - let burn_rows = wallet_transaction_rows( - alice.address(), - vec![burn.clone()], - Vec::new(), - &[], - &BTreeMap::new(), - &outputs, - WalletTransactionFilters { - transfer: false, - mine: false, - burn: true, - }, - ); - - assert!(default_rows.is_empty()); - assert_eq!(burn_rows.len(), 1); - assert_eq!(burn_rows[0].kind, "burn"); - assert_eq!(burn_rows[0].direction, "burned"); - assert_eq!(burn_rows[0].amount, 3); - assert_eq!(burn_rows[0].fee, 1); -} - -#[test] -fn wallet_utxo_rows_include_pending_spent_outputs_as_disabled() { - let alice = Wallet::from_seed("wallet-utxo-pending-alice"); - let bob = Wallet::from_seed("wallet-utxo-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let mut ledger = Ledger::new(allocations, 1); - let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &pending else { - panic!("expected transfer"); - }; - let spent_outpoint = inputs[0].outpoint.clone(); - - ledger.submit_transaction(pending).unwrap(); - let rows = wallet_utxo_rows(&ledger, alice.address()); - - assert!(rows.iter().any(|row| row.outpoint == spent_outpoint)); - assert!( - rows.iter() - .any(|row| row.outpoint == spent_outpoint && !row.spendable) - ); -} - -#[test] -fn selectable_wallet_utxo_rows_include_only_spendable_outputs() { - let alice = Wallet::from_seed("wallet-utxo-selectable-alice"); - let bob = Wallet::from_seed("wallet-utxo-selectable-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let mut ledger = Ledger::new(allocations, 1); - let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &pending else { - panic!("expected transfer"); - }; - let spent_outpoint = inputs[0].outpoint.clone(); - - ledger.submit_transaction(pending).unwrap(); - let rows = super::selectable_wallet_utxo_rows(&ledger, alice.address()); - - assert!(!rows.iter().any(|row| row.outpoint == spent_outpoint)); - assert!(rows.iter().all(|row| row.spendable)); -} - -#[test] -fn wallet_utxo_rows_treat_owned_blinded_spends_as_pending() { - let alice = Wallet::from_seed("wallet-utxo-blind-pending-alice"); - let bob = Wallet::from_seed("wallet-utxo-blind-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let ledger = Ledger::new(allocations, 1); - let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); - let pending = node.transfer_with_fee(bob.address(), 3, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &pending else { - panic!("expected transfer"); - }; - let spent_outpoint = inputs[0].outpoint.clone(); - let wallet_view = node.wallet_view_ledger().unwrap(); - - let rows = wallet_utxo_rows(&wallet_view, alice.address()); - let selectable = super::selectable_wallet_utxo_rows(&wallet_view, alice.address()); - - assert!( - rows.iter() - .any(|row| row.outpoint == spent_outpoint && !row.spendable) - ); - assert!(!selectable.iter().any(|row| row.outpoint == spent_outpoint)); -} - -#[test] -fn wallet_utxo_rows_keep_local_anchor_spends_visible_as_pending() { - let alice = Wallet::from_seed("wallet-utxo-local-anchor-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut node = - NodeCore::from_ledger_with_burn_fee_and_enabled(alice.clone(), ledger, true, 1, 0); - - let plan = node.prepare_automatic_finalization(1); - assert!(plan.burned.is_some()); - let rows = wallet_utxo_rows(&node.wallet_view_ledger().unwrap(), alice.address()); - - assert!(!rows.is_empty()); - assert!(rows.iter().any(|row| !row.spendable)); -} - -fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block { - Block { - height, - prev_hash: format!("prev-{height}"), - timestamp_ms: height, - miner: "miner".to_string(), - finalizer_mode: crate::domain::FinalizerMode::Ticket, - finalizer_rank: 0, - reward: 100, - vdf_rounds: 0, - vdf_output: "vdf".to_string(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions, - hash: format!("hash-{height}"), - } -} - -fn fake_snapshot( - genesis_allocations: BTreeMap<String, Amount>, - blocks: Vec<Block>, -) -> ChainSnapshot { - ChainSnapshot { - genesis_allocations, - vdf_rounds: 1, - launch_profile: LaunchProfile::default(), - blocks, - } -} - -fn metric_row( - height: u64, - block_time_ms: Option<u64>, - vdf_rounds: u64, -) -> crate::adapters::ui_data_store::BlockMetricRow { - crate::adapters::ui_data_store::BlockMetricRow { - height, - block_hash: format!("hash-{height}"), - timestamp_ms: height, - block_time_ms, - mine_difficulty_bits: 12, - circulating_supply: 100, - known_wallet_addresses: 1, - transaction_count: 0, - transfer_count: 0, - burn_count: 0, - mine_count: 0, - burned_amount: 0, - total_burned_amount: 0, - fees_amount: 0, - reward_amount: 0, - vdf_rounds, - finalizer_rank: 0, - } -} - -#[test] -fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { - let response = super::metrics_response( - true, - vec![ - metric_row(0, None, 0), - metric_row(1, Some(1_764_000_000_000), 0), - metric_row(2, Some(600_000), 120), - metric_row(3, Some(610_000), 130), - ], - super::types::MetricsLeaderboards::default(), - ); - - let block_time = response - .charts - .iter() - .find(|chart| chart.id == "block-time") - .expect("block time chart should exist"); - assert_eq!( - block_time - .points - .iter() - .map(|point| (point.height, point.value)) - .collect::<Vec<_>>(), - vec![(2, 600.0), (3, 610.0)] - ); - - let vdf_rounds = response - .charts - .iter() - .find(|chart| chart.id == "vdf-rounds") - .expect("VDF rounds chart should exist"); - assert_eq!( - vdf_rounds - .points - .iter() - .map(|point| (point.height, point.value)) - .collect::<Vec<_>>(), - vec![(2, 120.0), (3, 130.0)] - ); - - let known_wallet_addresses = response - .charts - .iter() - .find(|chart| chart.id == "known-wallet-addresses") - .expect("known wallet addresses chart should exist"); - assert_eq!( - known_wallet_addresses - .points - .iter() - .map(|point| (point.height, point.value)) - .collect::<Vec<_>>(), - vec![(0, 1.0), (1, 1.0), (2, 1.0), (3, 1.0)] - ); -} - -#[test] -fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=109")); - assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); - assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); - assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); - assert!(super::INDEX_HTML.contains("setMetricsRange('all')")); - assert!(super::INDEX_HTML.contains("Known addresses")); - assert!(super::INDEX_HTML.contains("knownWalletAddresses")); - assert!(super::INDEX_HTML.contains("metric-chart-hover-point")); - assert!(!super::INDEX_HTML.contains("metric-chart-point-hit")); - assert!(!super::INDEX_HTML.contains("metric-chart-hover-point\" x-show")); - assert!(super::INDEX_HTML.contains("<template x-if=\"metricHover?.chartId === chart.id\">")); - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(app_js.contains("shellRefreshPromise: null")); - assert!(app_js.contains("metricsRequestSeq: 0")); - assert!(app_js.contains("await this.refreshMetrics(options);")); - assert!(app_js.contains("this.refreshShellState({ addressBookVersion, silent: true });")); - assert!(app_js.contains("async refreshShellState(options = {})")); - assert!(app_js.contains("this.refreshMetrics();")); - assert!(!app_js.contains("this.blockchainMetrics = { enabled: this.blockchainMetrics?.enabled ?? true, latest: this.blockchainMetrics?.latest ?? null, charts: [] };\n this.refresh({ force: true });")); - assert!(app_js.contains("async fetchMetricsResponse(range = this.metricsRange)")); - assert!(app_js.contains("prepareMetricsResponse(metrics)")); - assert!(app_js.contains("_linePoints: linePoints")); - assert!(app_js.contains("_gridPath: gridPath")); - assert!(!app_js.contains("label: this.metricPointLabel(chart, point)")); - assert!(app_js.contains("label: this.metricPointLabel(chart, marker)")); - assert!(app_js.contains("metricHoverPointStyle(chart)")); -} - -#[test] -fn version_update_check_normalizes_tags_before_comparing() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - let update_available = app_js - .split("updateAvailable()") - .nth(1) - .expect("updateAvailable should exist") - .split("versionPanelTitle()") - .next() - .expect("updateAvailable should precede versionPanelTitle"); - - assert!( - update_available - .contains("const current = this.normalizeVersion(this.status.app_version);") - ); - assert!( - update_available.contains("const latest = this.normalizeVersion(this.latestRelease?.tag);") - ); - assert!(update_available.contains("if (current === latest) return false;")); -} - -#[test] -fn blocks_endpoint_uses_cached_block_projection_without_cloning_full_ui_index() { - let api_rs = include_str!("api.rs"); - assert!(api_rs.contains("cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks)")); - assert!(!api_rs.contains("let mut outputs = view.outputs;\n add_pending_outputs(&mut outputs, &pending);\n Json(ui_blocks_from_indexes(\n blocks,\n &outputs,")); -} - -#[test] -fn mempool_and_wallet_transaction_endpoints_use_narrow_output_lookups() { - let api_rs = include_str!("api.rs"); - assert!(api_rs.contains("load_outputs_for_outpoints(&state, required_outputs)")); - assert!(api_rs.contains("load_outputs_for_outpoints(&state, pending_required_outputs)")); - assert!(api_rs.contains("load_outputs_for_outpoints(&state, confirmed_required_outputs)")); - assert!(!api_rs.contains("let mut outputs = view.outputs;")); -} - -#[test] -fn http_server_logs_slow_api_requests() { - let http_rs = include_str!("../http.rs"); - assert!(http_rs.contains("const SLOW_UI_REQUEST_LOG_MS: u128 = 250;")); - assert!(http_rs.contains("async fn log_slow_api_request")); - assert!(http_rs.contains("slow UI API request: {method} {path}")); -} - -#[test] -fn fee_estimate_polling_is_tab_scoped() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - let refresh = app_js - .split("async refreshFeeEstimates()") - .nth(1) - .expect("refreshFeeEstimates should exist") - .split("async refreshBurnFeeEstimate()") - .next() - .expect("refreshFeeEstimates body should precede burn fee estimate"); - - assert!(refresh.contains("if (this.tab === \"wallet\")")); - assert!(refresh.contains("await this.refreshTransferFeeEstimate();")); - assert!(refresh.contains("if (this.tab === \"mining\")")); - assert!(refresh.contains("this.refreshBurnFeeEstimate()")); - assert!(refresh.contains("this.refreshMineFeeEstimate()")); -} - -#[test] -fn transfer_utxo_selection_supports_shift_click_ranges() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!( - super::INDEX_HTML - .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\"") - ); - assert!(super::INDEX_HTML.contains(".send-utxo-option {")); - assert!(super::INDEX_HTML.contains("user-select: none;")); - assert!(super::INDEX_HTML.contains(".send-utxo-option input {")); - assert!(super::INDEX_HTML.contains("pointer-events: none;")); - assert!( - !super::INDEX_HTML - .contains("x-model=\"selectedTransferUtxos\" @click=\"toggleTransferUtxoSelection") - ); - assert!(!super::INDEX_HTML.contains("@change=\"toggleTransferUtxoSelection($event, utxo)\"")); - assert!( - super::INDEX_HTML.contains("@click.prevent=\"toggleTransferUtxoSelection($event, utxo)\"") - ); - assert!(super::INDEX_HTML.contains("class=\"amount-max-button\"")); - assert!(super::INDEX_HTML.contains("@click=\"setMaxTransferAmount\"")); - assert!(super::INDEX_HTML.contains(":disabled=\"transferMaxDisabled()\"")); - assert!( - !super::INDEX_HTML - .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\" @click=") - ); - assert!(app_js.contains("lastSelectedTransferUtxo: null")); - assert!(app_js.contains("toggleTransferUtxoSelection(event, utxo)")); - assert!(app_js.contains("const checked = !selected.has(outpoint);")); - assert!(app_js.contains("event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0")); - assert!(app_js.contains("const [from, to] = [anchorIndex, currentIndex].sort")); - assert!(app_js.contains("transferMaxDisabled()")); - assert!(app_js.contains("async setMaxTransferAmount()")); - assert!(app_js.contains("async maxTransferAmountForSelectedUtxos(selectedTotal)")); - assert!(app_js.contains("let low = 1;")); - assert!(app_js.contains("let high = total;")); - assert!(app_js.contains("while (low <= high)")); - assert!(app_js.contains("const required = amount + this.microiunaAmount(estimate.fee);")); - assert!(app_js.contains("selectedTransferUtxoShortfall()")); - assert!(app_js.contains("microiunaAmount(value)")); - assert!(app_js.contains("Math.round(Number(value) || 0)")); - assert!(app_js.contains("trim().replace(\",\", \".\")")); -} - -#[test] -fn reveal_mempool_items_show_unknown_fee_label() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(app_js.contains("txFeeLabel(tx)")); - assert!(app_js.contains("!tx?.revealed && tx?.kind === \"reveal\"")); - assert!(app_js.contains("unknown until reveal")); - assert!( - app_js.contains("!tx?.revealed && (tx?.kind === \"blinded\" || tx?.kind === \"reveal\")") - ); - assert!(app_js.contains("mempoolFirstSeenHeights")); - assert!(app_js.contains("mempoolFirstSeenAt")); - assert!( - app_js.contains("this.trackMempoolFirstSeenHeights({ append: options.replace !== true })") - ); - assert!(app_js.contains("return rightSeenAt - leftSeenAt")); - assert!(app_js.contains("syncMempoolBlockMarker")); - assert!(app_js.contains("this.status.chain?.height ?? this.lastBlockMempoolHeight")); - assert!(app_js.contains("mempoolItemClass")); - assert!(app_js.contains("walletTxTimeLabel(tx)")); - assert!(app_js.contains("timestampMs ?? tx?.timestamp_ms")); - assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\"")); - assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\"")); - assert!(super::INDEX_HTML.contains("<span class=\"tx-label\">Time</span>")); - assert!(super::INDEX_HTML.contains("x-text=\"walletTxTimeLabel(tx)\"")); -} - -#[test] -fn wallet_screen_includes_address_book_alias_controls() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("<h3>Address Book</h3>")); - assert!(super::INDEX_HTML.contains("saveAddressBookEntry")); - assert!(super::INDEX_HTML.contains("addressBookEntries()")); - assert!(super::INDEX_HTML.contains("openAddressBookModal()")); - assert!(super::INDEX_HTML.contains("addressBookModalOpen")); - assert!(super::INDEX_HTML.contains("openAddressBookPicker()")); - assert!(super::INDEX_HTML.contains("addressBookPickerOpen")); - assert!(super::INDEX_HTML.contains("selectTransferContact(entry.address)")); - assert!(super::INDEX_HTML.contains("editAddressBookEntry(entry)")); - assert!( - super::INDEX_HTML - .contains("x-show=\"!addressBookModalOpen\" @click=\"openAddressBookModal()\"") - ); - assert!( - super::INDEX_HTML.contains("class=\"address-book-form\" x-show=\"addressBookModalOpen\"") - ); - assert!( - super::INDEX_HTML.contains("removeAddressBookEntry({ address: addressBookEditingAddress") - ); - assert!(super::INDEX_HTML.contains("aria-label=\"Choose contact\"")); - assert!(super::INDEX_HTML.contains("aria-label=\"Delete contact\"")); - assert!(super::INDEX_HTML.contains("shortAddressLabel(tx.from)")); - assert!(super::INDEX_HTML.contains("addressLabel(input.owner)")); - assert!(app_js.contains("addressBook: {}")); - assert!(app_js.contains("addressBookVersion: 0")); - assert!(app_js.contains("addressBookPickerOpen: false")); - assert!(app_js.contains("this.config.address_book || this.config.addressBook")); - assert!(app_js.contains("options.addressBookVersion >= this.addressBookVersion")); - assert!(app_js.contains("async saveAddressBookEntry()")); - assert!(app_js.contains("Address is already saved")); - assert!(app_js.contains("validAddressBookAddress(address)")); - assert!(app_js.contains("openAddressBookPicker()")); - assert!(app_js.contains("this.addressBookPickerOpen = true;")); - assert!(!app_js.contains("No contacts saved yet")); - assert!(app_js.contains("await this.submitForm(\"/api/address-book\"")); - assert!(app_js.contains("\"/api/address-book\"")); - assert!(app_js.contains("addressLabel(address)")); - assert!(app_js.contains("shortAddressLabel(address)")); -} - -#[test] -fn initial_setup_includes_node_mode_choices() { - assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\"")); - assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')")); - assert!(super::INDEX_HTML.contains("selectSetupNodeMode('non-listening')")); - assert!(super::INDEX_HTML.contains("selectSetupNodeMode('listening')")); - assert!(super::INDEX_HTML.contains("setupNodeMode === 'listening'")); - assert!(super::INDEX_HTML.contains("x-model.number=\"p2pBindPort\"")); - assert!(super::INDEX_HTML.contains("Change later in Settings")); -} - -#[test] -fn setup_completion_refreshes_chain_data() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!( - app_js.contains("await this.refresh({ force: true });\n this.setupFeedback = null;") - ); - assert!( - !app_js.contains( - "await this.refreshConfig();\n await this.resetPagedDataset(\"peer\");" - ) - ); -} - -#[test] -fn setup_completion_applies_selected_node_mode() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(app_js.contains("setupNodeMode: \"wallet\"")); - assert!(app_js.contains("async applySetupNodeMode()")); - assert!(app_js.contains("await this.applySetupNodeMode();")); - assert!(app_js.contains("\"/api/settings/p2p-inbound\"")); - assert!(app_js.contains("bind_port: this.p2pBindPortValue()")); - assert!(app_js.contains("this.setUiMode(mode === \"wallet\" ? \"basic\" : \"advanced\")")); -} - -#[test] -fn p2p_bind_port_changes_show_global_restart_notice() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("Bind port")); - assert!(super::INDEX_HTML.contains("persistent-banner")); - assert!(super::INDEX_HTML.contains("p2pRestartRequired()")); - assert!(app_js.contains("p2pBindPort: 9444")); - assert!(app_js.contains("p2pConfiguredBindAddr()")); - assert!(app_js.contains("p2pRestartMessage()")); - assert!(app_js.contains("Restart iuna to close the public P2P listener.")); - assert!(app_js.contains("0.0.0.0:${port}")); -} - -#[test] -fn settings_includes_dangerous_chain_reset_flow() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("Danger Zone")); - assert!(super::INDEX_HTML.contains("Delete local chain")); - assert!(super::INDEX_HTML.contains("Type <strong>RESET</strong> to confirm.")); - assert!(super::INDEX_HTML.contains("class=\"danger\"")); - assert!(super::INDEX_HTML.contains("chainResetModalOpen")); - assert!(app_js.contains("chainResetModalOpen: false")); - assert!(app_js.contains("async resetLocalChain()")); - assert!(app_js.contains("\"/api/settings/chain-reset\"")); - assert!(app_js.contains("confirm: this.chainResetConfirm")); - assert!(app_js.contains( - "this.showFlash(\"Local chain deleted. Sync requested from peers.\", \"success\")" - )); - assert!(!app_js.contains("this.showSettingsFeedback(\"Local chain deleted.")); - assert!(!app_js.contains("this.showSettingsFeedback(error.message, \"error\");\n } finally {\n this.chainResetBusy = false;")); -} - -#[tokio::test] -async fn chain_reset_deletes_local_chain_and_returns_to_placeholder() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - let wallet = Wallet::from_seed("http-chain-reset-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - state.chain_store.save(&ledger.snapshot()).unwrap(); - state - .ui_data_store - .project_snapshot(&ledger.snapshot(), true) - .unwrap(); - { - let mut node = state.node.lock().await; - *node = NodeCore::from_ledger(wallet.clone(), ledger, 1); - } - { - let mut cache = state.ui_cache.lock().await; - cache.tip_hash = Some("stale-tip".to_string()); - } - - let error = super::reset_local_chain(&state, "nope").await.unwrap_err(); - assert!(error.to_string().contains("type RESET")); - assert!(state.chain_store.load().unwrap().is_some()); - assert!(state.node.lock().await.has_real_chain()); - - super::reset_local_chain(&state, "RESET").await.unwrap(); - - let node = state.node.lock().await; - assert!(node.ledger().is_setup_placeholder()); - assert_eq!(node.status().wallet_address, wallet.address()); - drop(node); - assert!(state.chain_store.load().unwrap().is_none()); - assert!(state.ui_data_store.load_metrics().unwrap().is_empty()); - assert!(state.ui_cache.lock().await.tip_hash.is_none()); -} - -#[test] -fn polling_refreshes_paged_datasets_without_visible_loaders() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(app_js.contains("setInterval(() => this.refresh({ silent: true }), 5000)")); - assert!( - app_js.contains( - "return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true;" - ) - ); - assert!(app_js.contains( - "async refreshNow(options = {}) {\n if (!this.canUseProtectedApi()) return;" - )); - assert!(app_js.contains("refreshPromise: null")); - assert!(app_js.contains("networkHealthPromise: null")); - assert!(app_js.contains("if (this.refreshPromise)")); - assert!(app_js.contains("return this.refreshPromise;")); - assert!(app_js.contains("options.force === true")); - assert!(app_js.contains("this.setTab(this.tabFromHash());")); - assert!(app_js.contains("const shouldLoadBlocks = tab === \"chain\" || tab === \"mining\";")); - assert!( - app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\" && this.developmentMode();") - ); - assert!(app_js.contains("const shouldLoadMetrics = tab === \"metrics\";")); - assert!( - app_js - .contains("if (tab === \"wallet\") pagedDatasets.push(\"walletTx\", \"walletUtxo\");") - ); - assert!(app_js.contains("if (tab === \"chain\") pagedDatasets.push(\"mempool\");")); - assert!(app_js.contains("if (tab === \"p2p\") pagedDatasets.push(\"peer\");")); - let merge_blocks_index = app_js - .find("if (blocks) this.mergeFreshBlocks(blocks, { animateHead: true });") - .expect("fresh blocks should be merged during refresh"); - let paged_dataset_index = app_js - .find("this.refreshPagedDataset(kind, { silent: options.silent === true })") - .expect("paged datasets should refresh during refresh"); - assert!(merge_blocks_index < paged_dataset_index); - assert!(app_js.contains("this.refreshNetworkHealth({ silent: options.silent === true });")); - assert!(app_js.contains("async refreshNetworkHealth(options = {})")); - assert!(app_js.contains("if (this.networkHealthPromise) return this.networkHealthPromise;")); - assert!(app_js.contains("cache: \"no-store\"")); - assert!(app_js.contains("async fetchWithTimeout(path, options = {})")); - assert!(app_js.contains("controller.abort()")); - assert!(app_js.contains("async refreshPagedDataset(kind, options = {}) {\n if (!this.canUseProtectedApi()) return;")); - assert!( - app_js - .contains("async loadNextPage(kind) {\n if (!this.canUseProtectedApi()) return;") - ); - assert!( - app_js.contains("async loadOlderBlocks() {\n if (!this.canUseProtectedApi()) return;") - ); - assert!(app_js.contains("this.stopPolling();\n await this.refreshAuth();")); - assert!(app_js.contains("backgroundLoading")); - assert!(app_js.contains("options.silent === true ? \"backgroundLoading\" : \"loading\"")); -} - -#[test] -fn wallet_pending_blinded_transactions_are_labeled() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(app_js.contains("Pending blind")); - assert!(app_js.contains("tx.blinded")); -} - -#[test] -fn mine_screen_shows_fixed_pow_reward_without_burn_slider() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(app_js.contains("powMineReward()")); - assert!(super::INDEX_HTML.contains("Search for PoW actions that mint a fixed IUNA reward.")); - assert!(super::INDEX_HTML.contains("amountLabel(powMineReward())")); - assert!(super::INDEX_HTML.contains("aria-label=\"Local mining status\"")); - assert!(super::INDEX_HTML.contains("PoB State")); - assert!(super::INDEX_HTML.contains("PoW State")); - assert!(super::INDEX_HTML.contains("Workers")); - assert!(super::INDEX_HTML.contains("Selected Finalizer")); - assert!(app_js.contains("pobStatusLabel()")); - assert!(app_js.contains("powStatusShortLabel()")); - assert!(app_js.contains("setPowMiningWorkers(workers)")); - assert!(app_js.contains("localMiningMempoolLabel()")); - assert!(app_js.contains("miningEventLog()")); - assert!(app_js.contains("miningEventLimit: 1000")); - assert!(app_js.contains("slice(0, this.miningEventLimit)")); - assert!(super::INDEX_HTML.contains("aria-label=\"Mining event log\"")); - assert!(super::INDEX_HTML.contains("miningEventLog().length === 0")); - assert!(super::INDEX_HTML.contains("mining-event-empty")); - assert!(app_js.contains("Resource budget:")); - assert!(app_js.contains("isPowMineSuccessStatus")); - assert!(app_js.contains("You mined a PoW action")); - assert!(app_js.contains("Waiting for a finalizer to include it in a block.")); - assert!(app_js.contains("Observed block")); - assert!(app_js.contains("Finalized by")); - assert!(app_js.contains("You finalized block")); - assert!(app_js.contains("if (!locallyFinalized)")); - assert!(app_js.contains("last?.title === title")); - assert!(app_js.contains("this.miningEventState.pob = enabled ? \"on\" : \"off\";")); - assert!(app_js.contains("Automatic burn prepared at height")); - assert!(app_js.contains("Eligible for the next block opportunity.")); - assert!(app_js.contains("!Number.isFinite(timestampMs)")); - assert!(!super::INDEX_HTML.contains("Needs burns")); -} - -#[test] -fn block_detail_finalizer_opens_burn_leader_ranks_modal() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("openBurnLeaderRanksModal(selectedBlock)")); - assert!(super::INDEX_HTML.contains("id=\"burn-ranks-title\"")); - assert!(super::INDEX_HTML.contains("burnLeaderRanks(selectedBurnLeaderBlock)")); - assert!(app_js.contains("selectedBurnLeaderBlock")); - assert!(app_js.contains("burnLeaderRankLabel(rank)")); - assert!(app_js.contains("block?.burn_leader_ranks")); - assert!(super::INDEX_HTML.contains("rank.ticket_id ?? rank.ticketId")); -} - -#[test] -fn block_loading_skeleton_matches_block_card_layout() { - let app_js = include_str!("../../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("block-skeleton-group")); - assert!(super::INDEX_HTML.contains("block-card block-card-skeleton skeleton-card")); - assert!(super::INDEX_HTML.contains("skeleton-block-height")); - assert!(super::INDEX_HTML.contains("skeleton-block-meta")); - assert!(super::INDEX_HTML.contains("skeleton-block-miner")); - assert!(app_js.contains("const hadBlocks = this.blocks.length > 0;")); - assert!(app_js.contains("const previousScrollWidth = hadBlocks ? rail?.scrollWidth ?? 0 : 0;")); - assert!(app_js.contains("&& hadBlocks")); - assert!(app_js.contains("this.$nextTick(() => this.resetBlockRailPosition());")); - assert!(app_js.contains("resetBlockRailPosition()")); -} - -#[tokio::test] -async fn startup_prewarm_populates_chain_view_cache_before_first_request() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - let expected_tip = { - let node = state.node.lock().await; - node.chain_snapshot() - .blocks - .last() - .map(|block| block.hash.clone()) - }; - let sentinel = OutPoint { - txid: "persisted-ui-index-sentinel".to_string(), - index: 7, - }; - let connection = rusqlite::Connection::open(state.ui_data_store.path()).unwrap(); - connection - .execute( - r#" -INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms) -VALUES (1, 1, ?1, 0) -"#, - rusqlite::params![expected_tip.as_ref().unwrap()], - ) - .unwrap(); - connection - .execute( - "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)", - rusqlite::params![expected_tip.as_ref().unwrap()], - ) - .unwrap(); - connection - .execute( - r#" -INSERT INTO ui_output_index (txid, output_index, address, amount) -VALUES (?1, ?2, ?3, ?4) -"#, - rusqlite::params![&sentinel.txid, sentinel.index, "cached-address", 123_u64], - ) - .unwrap(); - assert!(state.ui_cache.lock().await.tip_hash.is_none()); - - super::prewarm_chain_view_cache(state.clone()) - .await - .unwrap(); - - let cache = state.ui_cache.lock().await; - assert_eq!(cache.tip_hash, expected_tip); - assert!( - cache - .tip_hash - .as_ref() - .is_some_and(|tip_hash| cache.burn_leader_ranks_by_hash.contains_key(tip_hash)) - ); - assert_eq!( - cache.outputs.get(&sentinel), - Some(&TxOutput { - address: "cached-address".to_string(), - amount: 123, - }) - ); -} - -#[tokio::test] -async fn ui_data_endpoint_catches_up_stale_projection_before_reading() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - let wallet = Wallet::from_seed("http-ui-data-catch-up-wallet"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - let expected_tip = ledger.status().tip_hash; - { - let mut node = state.node.lock().await; - *node = NodeCore::from_ledger(wallet.clone(), ledger, 0); - } - - assert!(state.chain_store.load().unwrap().is_none()); - assert!(!state.ui_data_store.is_projected_to(&expected_tip).unwrap()); - - let Json(page) = - super::api_wallet_utxos(State(state.clone()), Query(super::PageQuery::default())).await; - - assert!( - page.items - .iter() - .any(|utxo| utxo.address == wallet.address()) - ); - let persisted_tip = state - .chain_store - .load() - .unwrap() - .and_then(|snapshot| snapshot.blocks.last().map(|block| block.hash.clone())); - assert_eq!(persisted_tip.as_deref(), Some(expected_tip.as_str())); - assert!(state.ui_data_store.is_projected_to(&expected_tip).unwrap()); -} - -async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState { - config_store::save(&config_path, &config).unwrap(); - let wallet_path = config_path.with_file_name("wallet.json"); - let (wallet, _) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap(); - let ledger = Ledger::new(BTreeMap::new(), 1); - let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); - let peers = Arc::new(Mutex::new(PeerBook::default())); - let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone()); - let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3")) - .expect("test chain store should open"); - let ui_data_store = SqliteUiDataStore::open(config_path.with_file_name("ui_data.sqlite3")) - .expect("test UI data store should open"); - HttpState { - node, - peers, - gossip, - ui_config: Arc::new(Mutex::new( - config_store::load_or_create(&config_path).unwrap(), - )), - config_path, - chain_store, - ui_data_store, - wallet_path, - stratum: StratumStatus { - enabled: false, - listen_addr: None, - }, - auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), - auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), - ui_cache: Arc::new(Mutex::new(super::UiChainCache::default())), - ui_data_refresh: Arc::new(Mutex::new(())), - } -} - -fn auth_test_app(state: HttpState) -> Router { - Router::new() - .route("/api/auth/status", get(api_auth_status)) - .route("/api/auth/setup", post(api_auth_setup_form)) - .route("/api/auth/login", post(api_auth_login_form)) - .route( - "/api/auth/change-password", - post(api_auth_change_password_form), - ) - .route("/favicon.ico", get(super::favicon)) - .route("/api/protected", get(protected_auth_test_endpoint)) - .layer(middleware::from_fn_with_state( - state.clone(), - require_auth_middleware, - )) - .with_state(state) -} - -async fn protected_auth_test_endpoint() -> &'static str { - "protected" -} - -struct TestHttpResponse { - status: StatusCode, - headers: HeaderMap, - body: String, -} - -async fn http_request( - app: Router, - method: Method, - path: &str, - cookie: Option<&str>, - body: &str, -) -> TestHttpResponse { - let mut builder = Request::builder() - .method(method.clone()) - .uri(path) - .header(header::ACCEPT, "application/json") - .header(header::HOST, "127.0.0.1:18661") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); - if matches!(method, Method::POST | Method::DELETE) { - builder = builder.header(header::ORIGIN, "http://127.0.0.1:18661"); - } - if let Some(cookie) = cookie { - builder = builder.header(header::COOKIE, cookie); - } - let response = app - .oneshot(builder.body(Body::from(body.to_string())).unwrap()) - .await - .unwrap(); - let status = response.status(); - let headers = response.headers().clone(); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), - ) - .unwrap(); - TestHttpResponse { - status, - headers, - body, - } -} - -fn set_cookie_pair(headers: &HeaderMap) -> String { - let header = headers - .get(header::SET_COOKIE) - .and_then(|value| value.to_str().ok()) - .expect("response should include Set-Cookie header"); - header.split(';').next().unwrap().to_string() -} - -#[tokio::test] -async fn burn_settings_config_persistence_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let ui_config = Arc::new(Mutex::new(UiConfig { - setup_complete: true, - ..UiConfig::default() - })); - let initial_config = ui_config.lock().await.clone(); - config_store::save(&config_path, &initial_config).expect("initial config should save"); - - persist_burn_settings_config( - &ui_config, - &config_path, - true, - 50 * MICRO_IUNA, - 3 * MICRO_IUNA, - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - - assert!(config.mining_enabled); - assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); - assert_eq!(config.burn_fee, 3 * MICRO_IUNA); -} - -#[tokio::test] -async fn enabled_burn_settings_reject_zero_amount() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - - let error = super::set_burn_settings(&state, true, 0, MICRO_IUNA) - .await - .unwrap_err(); - - assert!(format!("{error:#}").contains("greater than zero")); -} - -#[tokio::test] -async fn metrics_setting_persists_config_and_clears_rows_when_disabled() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - let wallet = Wallet::from_seed("metrics-setting-wallet"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = Ledger::new_with_genesis_burns( - genesis, - vec![crate::domain::GenesisBurn::new(wallet.address(), 1)], - 1, - ) - .unwrap(); - *state.node.lock().await = NodeCore::from_ledger(wallet, ledger, 0); - - super::set_keep_track_of_metrics(&state, true) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.keep_track_of_metrics); - assert!(!state.ui_data_store.load_metrics().unwrap().is_empty()); - - super::set_keep_track_of_metrics(&state, false) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.keep_track_of_metrics); - assert!(state.ui_data_store.load_metrics().unwrap().is_empty()); -} - -#[tokio::test] -async fn pow_mining_config_persistence_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let ui_config = Arc::new(Mutex::new(UiConfig { - setup_complete: true, - ..UiConfig::default() - })); - let initial_config = ui_config.lock().await.clone(); - config_store::save(&config_path, &initial_config).expect("initial config should save"); - - persist_pow_mining_config(&ui_config, &config_path, true, 4) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - - assert!(config.pow_mining_enabled); - assert_eq!(config.pow_mining_workers, 4); -} - -#[test] -fn transfer_form_requires_recipient_amount_and_fee() { - let error = validate_transfer_form(TransferForm { - to: " ".to_string(), - amount: 1, - fee_per_byte: Some(1), - utxos: String::new(), - }) - .unwrap_err(); - assert!(error.to_string().contains("recipient is required")); - - let error = validate_transfer_form(TransferForm { - to: "abc".to_string(), - amount: 0, - fee_per_byte: Some(1), - utxos: String::new(), - }) - .unwrap_err(); - assert!( - error - .to_string() - .contains("amount must be greater than zero") - ); - - let error = validate_transfer_form(TransferForm { - to: "abc".to_string(), - amount: 1, - fee_per_byte: None, - utxos: String::new(), - }) - .unwrap_err(); - assert!(error.to_string().contains("fee per byte is required")); -} - -#[test] -fn burn_and_mine_forms_require_fee_per_byte() { - let burn = required_fee_per_byte_burn(&super::BurnSettingsForm { - enabled: Some(true), - amount: 1, - fee_per_byte: None, - }) - .unwrap_err(); - assert!(burn.to_string().contains("fee per byte is required")); -} - -#[test] -fn transfer_form_trims_recipient() { - let (to, amount, fee, utxos) = validate_transfer_form(TransferForm { - to: " abc ".to_string(), - amount: 2, - fee_per_byte: Some(3), - utxos: String::new(), - }) - .unwrap(); - - assert_eq!(to, "abc"); - assert_eq!(amount, 2); - assert_eq!(fee, 3); - assert!(utxos.is_empty()); -} - -#[test] -fn transfer_form_parses_selected_utxos() { - let (_, _, _, utxos) = validate_transfer_form(TransferForm { - to: "abc".to_string(), - amount: 2, - fee_per_byte: Some(3), - utxos: "tx-one:0\ntx:with:colons:7,\n".to_string(), - }) - .unwrap(); - - assert_eq!( - utxos, - vec![ - OutPoint { - txid: "tx-one".to_string(), - index: 0 - }, - OutPoint { - txid: "tx:with:colons".to_string(), - index: 7 - } - ] - ); -} diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -38,8 +38,6 @@ pub(super) struct NetworkHealthResponse { pub(super) banned_peers: usize, pub(super) pending_transactions: usize, pub(super) pending_plain_transactions: usize, - pub(super) pending_blinded_transactions: usize, - pub(super) pending_blinded_reveals: usize, pub(super) network_time_offset_ms: Option<i64>, pub(super) bad_clock_peers: usize, pub(super) last_error: Option<String>, @@ -48,15 +46,11 @@ pub(super) struct NetworkHealthResponse { #[derive(Clone, Copy, Debug, Default)] pub(super) struct MempoolCounts { pub(super) plain_transactions: usize, - pub(super) blinded_transactions: usize, - pub(super) blinded_reveals: usize, } impl MempoolCounts { pub(super) fn total(&self) -> usize { self.plain_transactions - .saturating_add(self.blinded_transactions) - .saturating_add(self.blinded_reveals) } } @@ -324,7 +318,6 @@ pub(super) struct WalletTransactionRow { pub(super) timestamp_ms: Option<u64>, pub(super) block_finalizer: Option<String>, pub(super) direction: &'static str, - pub(super) blinded: bool, pub(super) difficulty_bits: Option<u32>, pub(super) proof_bits: Option<u32>, pub(super) proof_hash: Option<String>, @@ -345,7 +338,6 @@ pub(super) struct WalletTransactionContext { pub(super) block_height: Option<u64>, pub(super) timestamp_ms: Option<u64>, pub(super) block_finalizer: Option<String>, - pub(super) blinded: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -361,24 +353,21 @@ pub(super) struct UiBlock { pub(super) total_bytes: usize, pub(super) transaction_bytes: usize, pub(super) transaction_byte_breakdown: Vec<UiByteBreakdown>, - pub(super) blinded_transaction_bytes: usize, - pub(super) reveal_bundle_bytes: usize, - pub(super) reveal_fee_penalty: UiRevealFeePenalty, + pub(super) burn_bundle_bytes: usize, + pub(super) burn_bundle_quorum: UiBurnBundleQuorum, pub(super) vdf_rounds: u64, pub(super) vdf_output: String, pub(super) leader_proof: Option<crate::domain::LeaderProof>, pub(super) burn_leader_ranks: Vec<BurnLeaderRank>, pub(super) transactions: Vec<UiTransaction>, - pub(super) revealed_transactions: Vec<UiTransaction>, - pub(super) reveal_bundles: Vec<UiRevealBundle>, + pub(super) burn_bundles: Vec<UiBurnBundle>, pub(super) hash: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] -pub(super) struct UiRevealFeePenalty { - pub(super) reveal_lists_included: usize, +pub(super) struct UiBurnBundleQuorum { + pub(super) burn_bundles_included: usize, pub(super) committee_size: usize, - pub(super) fee_penalty: Amount, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -389,12 +378,12 @@ pub(super) struct UiByteBreakdown { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct UiRevealBundle { +pub(super) struct UiBurnBundle { pub(super) slot: u8, pub(super) member: String, pub(super) hash: String, pub(super) byte_size: usize, - pub(super) reveals: Vec<UiTransaction>, + pub(super) burns: Vec<UiTransaction>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -411,10 +400,6 @@ pub(super) struct UiTransaction { pub(super) difficulty_bits: Option<u32>, pub(super) proof_bits: Option<u32>, pub(super) proof_hash: Option<String>, - pub(super) commitment: Option<String>, - pub(super) encrypted_size: Option<u32>, - pub(super) expires_at_height: Option<u64>, - pub(super) revealed: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -1,9 +1,7 @@ use std::collections::BTreeMap; use crate::domain::{ - BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, MINE_REWARD, OutPoint, - RevealedBlindedTransaction, Transaction, TxInput, TxOutput, - reveal_committee_slot_count_for_height, + Block, BurnLeaderRank, ChainSnapshot, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, }; use crate::adapters::ui_index::build_ui_chain_index; @@ -11,7 +9,7 @@ use crate::adapters::ui_index::build_ui_chain_index; use super::{ HttpState, UiChainView, types::{ - UiBlock, UiByteBreakdown, UiRevealBundle, UiRevealFeePenalty, UiTransaction, UiTxInput, + UiBlock, UiBurnBundle, UiBurnBundleQuorum, UiByteBreakdown, UiTransaction, UiTxInput, WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, }, }; @@ -19,9 +17,7 @@ use super::{ pub(super) fn wallet_transaction_rows( wallet: &str, pending: Vec<Transaction>, - owned_blinded: Vec<Transaction>, chain: &[Block], - revealed_by_height: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, outputs: &BTreeMap<OutPoint, TxOutput>, filters: WalletTransactionFilters, ) -> Vec<WalletTransactionRow> { @@ -31,7 +27,6 @@ pub(super) fn wallet_transaction_rows( block_height: None, timestamp_ms: None, block_finalizer: None, - blinded: false, }; for (index, tx) in pending.iter().enumerate() { @@ -43,19 +38,6 @@ pub(super) fn wallet_transaction_rows( } } - let pending_blind_context = WalletTransactionContext { - blinded: true, - ..pending_context - }; - for (index, tx) in owned_blinded.iter().enumerate() { - if !filters.allows(tx) { - continue; - } - if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_blind_context) { - rows.push((u128::MAX - 10_000 - index as u128, row)); - } - } - for block in chain { for (index, tx) in block.transactions.iter().rev().enumerate() { if !filters.allows(tx) { @@ -70,34 +52,11 @@ pub(super) fn wallet_transaction_rows( block_height: Some(block.height), timestamp_ms: Some(block.timestamp_ms), block_finalizer: Some(block.miner.clone()), - blinded: false, }, ) { rows.push((block.height as u128 * 10_000 + index as u128, row)); } } - if let Some(revealed_transactions) = revealed_by_height.get(&block.height) { - for (index, revealed) in revealed_transactions.iter().rev().enumerate() { - let tx = &revealed.transaction; - if !filters.allows(tx) { - continue; - } - if let Some(row) = wallet_transaction_row( - wallet, - tx, - outputs, - &WalletTransactionContext { - status: "confirmed", - block_height: Some(block.height), - timestamp_ms: Some(block.timestamp_ms), - block_finalizer: Some(block.miner.clone()), - blinded: false, - }, - ) { - rows.push((block.height as u128 * 10_000 + 5_000 + index as u128, row)); - } - } - } } rows.sort_by(|left, right| right.0.cmp(&left.0)); @@ -135,7 +94,6 @@ pub(super) fn wallet_transaction_row( } else { "sent" }, - blinded: context.blinded, difficulty_bits: None, proof_bits: None, proof_hash: None, @@ -161,7 +119,6 @@ pub(super) fn wallet_transaction_row( timestamp_ms: context.timestamp_ms, block_finalizer: context.block_finalizer.clone(), direction: "burned", - blinded: context.blinded, difficulty_bits: None, proof_bits: None, proof_hash: None, @@ -189,7 +146,6 @@ pub(super) fn wallet_transaction_row( timestamp_ms: context.timestamp_ms, block_finalizer: context.block_finalizer.clone(), direction: "received", - blinded: context.blinded, difficulty_bits: Some(*difficulty_bits), proof_bits: Some(proof_bits(signature)), proof_hash: Some(signature.clone()), @@ -198,37 +154,14 @@ pub(super) fn wallet_transaction_row( } } -#[cfg(test)] -pub(super) fn revealed_transactions_by_height( - snapshot: &ChainSnapshot, -) -> BTreeMap<u64, Vec<RevealedBlindedTransaction>> { - crate::adapters::ui_index::revealed_transactions_by_height(snapshot) -} - -#[cfg(test)] -pub(super) fn ui_blocks( - blocks: Vec<Block>, - snapshot: &ChainSnapshot, - pending: &[Transaction], - burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, -) -> Vec<UiBlock> { - let outputs = known_output_index(snapshot, pending); - let revealed = revealed_transactions_by_height(snapshot); - ui_blocks_from_indexes(blocks, &outputs, &revealed, burn_leader_ranks) -} - pub(super) fn ui_blocks_from_indexes( blocks: Vec<Block>, outputs: &BTreeMap<OutPoint, TxOutput>, - revealed: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, ) -> Vec<UiBlock> { blocks .into_iter() - .map(|block| { - let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default(); - ui_block(block, outputs, burn_leader_ranks, &revealed_transactions) - }) + .map(|block| ui_block(block, outputs, burn_leader_ranks)) .collect() } @@ -236,97 +169,52 @@ pub(super) fn ui_block( block: Block, outputs: &BTreeMap<OutPoint, TxOutput>, burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, - revealed_transactions: &[RevealedBlindedTransaction], ) -> UiBlock { let ranks = burn_leader_ranks .get(&block.hash) .cloned() .unwrap_or_default(); - let reveal_lists_included = block.included_reveal_bundle_count(); - let committee_size = if ranks.is_empty() { - reveal_lists_included - } else { - reveal_committee_slot_count_for_height( - block.height, - ranks.len(), - ranks.iter().map(|rank| rank.owner.as_str()), - ) - }; + let burn_bundles_included = block.included_burn_bundle_count(); + let committee_size = ranks.len(); let public_fees = block .transactions .iter() .fold(0_u64, |total, tx| total.saturating_add(tx.fee())); - let revealed_fees = revealed_transactions.iter().fold(0_u64, |total, revealed| { - total.saturating_add(revealed.transaction.fee()) - }); - let total_fees = public_fees.saturating_add(revealed_fees); - let reveal_fee_penalty = reveal_fee_penalty( - total_fees, - !revealed_transactions.is_empty(), - reveal_lists_included, - committee_size, - ); + let total_fees = public_fees; let transaction_bytes = block .transactions .iter() .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) .sum::<usize>(); let transaction_byte_breakdown = transaction_byte_breakdown(&block.transactions); - let blinded_transaction_bytes = block - .blinded_transactions - .iter() - .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) - .sum::<usize>(); - let mut transactions = block + let transactions = block .transactions .iter() .map(|tx| ui_transaction(tx, outputs)) .collect::<Vec<_>>(); - transactions.extend( - block - .blinded_transactions - .iter() - .map(|transaction| ui_blinded_transaction(transaction, outputs)), - ); - transactions.extend( - revealed_transactions - .iter() - .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)), - ); - let revealed_by_commitment = revealed_transactions - .iter() - .map(|revealed| (revealed.commitment.clone(), revealed.transaction.clone())) - .collect::<BTreeMap<_, _>>(); - let reveal_bundles: Vec<UiRevealBundle> = block - .reveal_bundle_section + let burn_bundles: Vec<UiBurnBundle> = block + .burn_bundle_section .expand(block.height, &block.prev_hash) .into_iter() - .map(|bundle| UiRevealBundle { + .map(|bundle| UiBurnBundle { slot: bundle.slot, member: bundle.member.clone(), hash: bundle.bundle_hash(), byte_size: bundle.serialized_size_bytes().unwrap_or_default(), - reveals: bundle - .reveals + burns: bundle + .burns .iter() - .map(|reveal| { - revealed_by_commitment - .get(&reveal.commitment) - .map(|tx| ui_revealed_transaction(tx, outputs)) - .unwrap_or_else(|| ui_blinded_reveal(reveal)) - }) + .map(|burn| ui_transaction(burn, outputs)) .collect(), }) .collect(); - let reveal_bundle_bytes = reveal_bundles + let burn_bundle_bytes = burn_bundles .iter() - .map(|bundle: &UiRevealBundle| bundle.byte_size) + .map(|bundle: &UiBurnBundle| bundle.byte_size) .sum::<usize>(); - let total_bytes = block.serialized_size_bytes().unwrap_or_else(|_| { - transaction_bytes - .saturating_add(blinded_transaction_bytes) - .saturating_add(reveal_bundle_bytes) - }); + let total_bytes = block + .serialized_size_bytes() + .unwrap_or_else(|_| transaction_bytes.saturating_add(burn_bundle_bytes)); UiBlock { height: block.height, prev_hash: block.prev_hash, @@ -339,52 +227,21 @@ pub(super) fn ui_block( total_bytes, transaction_bytes, transaction_byte_breakdown, - blinded_transaction_bytes, - reveal_bundle_bytes, - reveal_fee_penalty, + burn_bundle_bytes, + burn_bundle_quorum: UiBurnBundleQuorum { + burn_bundles_included, + committee_size, + }, vdf_rounds: block.vdf_rounds, vdf_output: block.vdf_output, leader_proof: block.leader_proof, burn_leader_ranks: ranks, transactions, - revealed_transactions: revealed_transactions - .iter() - .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)) - .collect(), - reveal_bundles, + burn_bundles, hash: block.hash, } } -fn reveal_fee_penalty( - total_fees: u64, - has_reveals: bool, - reveal_lists_included: usize, - committee_size: usize, -) -> UiRevealFeePenalty { - let fee_penalty = - if !has_reveals || committee_size == 0 || reveal_lists_included >= committee_size { - 0 - } else { - let missing = committee_size.saturating_sub(reveal_lists_included); - ((total_fees as u128 * missing as u128) / committee_size as u128) as u64 - }; - UiRevealFeePenalty { - reveal_lists_included, - committee_size, - fee_penalty, - } -} - -fn ui_revealed_transaction( - transaction: &Transaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> UiTransaction { - let mut row = ui_transaction(transaction, outputs_by_outpoint); - row.revealed = true; - row -} - fn transaction_byte_breakdown(transactions: &[Transaction]) -> Vec<UiByteBreakdown> { let mut transfer_bytes = 0_usize; let mut burn_bytes = 0_usize; @@ -407,15 +264,6 @@ fn transaction_byte_breakdown(transactions: &[Transaction]) -> Vec<UiByteBreakdo .collect() } -pub(super) fn ui_pending_revealed_transaction( - revealed: &RevealedBlindedTransaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> UiTransaction { - let mut row = ui_revealed_transaction(&revealed.transaction, outputs_by_outpoint); - row.commitment = Some(revealed.commitment.clone()); - row -} - pub(super) fn ui_transaction( transaction: &Transaction, outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, @@ -439,10 +287,6 @@ pub(super) fn ui_transaction( difficulty_bits: None, proof_bits: None, proof_hash: None, - commitment: None, - encrypted_size: None, - expires_at_height: None, - revealed: false, }, Transaction::Burn { inputs, @@ -463,10 +307,6 @@ pub(super) fn ui_transaction( difficulty_bits: None, proof_bits: None, proof_hash: None, - commitment: None, - encrypted_size: None, - expires_at_height: None, - revealed: false, }, Transaction::Mine { recipient, @@ -489,63 +329,10 @@ pub(super) fn ui_transaction( difficulty_bits: Some(*difficulty_bits), proof_bits: Some(proof_bits(signature)), proof_hash: Some(signature.clone()), - commitment: None, - encrypted_size: None, - expires_at_height: None, - revealed: false, }, } } -pub(super) fn ui_blinded_transaction( - transaction: &BlindedTransaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> UiTransaction { - UiTransaction { - kind: "blinded", - from: transaction - .inputs - .first() - .map(|input| input.owner.clone()) - .unwrap_or_else(|| "encrypted".to_string()), - to: None, - amount: 0, - fee: transaction.fee, - inputs: ui_inputs(&transaction.inputs, outputs_by_outpoint), - outputs: Vec::new(), - change: Vec::new(), - signature: transaction.commitment.clone(), - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - commitment: Some(transaction.commitment.clone()), - encrypted_size: Some(transaction.encrypted_size), - expires_at_height: Some(transaction.expires_at_height), - revealed: false, - } -} - -pub(super) fn ui_blinded_reveal(reveal: &BlindedReveal) -> UiTransaction { - UiTransaction { - kind: "reveal", - from: "encrypted".to_string(), - to: None, - amount: 0, - fee: 0, - inputs: Vec::new(), - outputs: Vec::new(), - change: Vec::new(), - signature: reveal.commitment.clone(), - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - commitment: Some(reveal.commitment.clone()), - encrypted_size: None, - expires_at_height: None, - revealed: false, - } -} - fn ui_inputs( inputs: &[TxInput], outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, @@ -590,16 +377,6 @@ fn hex_nibble(byte: u8) -> Option<u8> { } } -#[cfg(test)] -pub(super) fn known_output_index( - snapshot: &ChainSnapshot, - pending: &[Transaction], -) -> BTreeMap<OutPoint, TxOutput> { - let mut outputs = build_ui_chain_index(snapshot).outputs; - add_pending_outputs(&mut outputs, pending); - outputs -} - pub(super) async fn cached_chain_view( state: &HttpState, snapshot: &ChainSnapshot, @@ -625,11 +402,9 @@ pub(super) async fn cached_chain_view( cache.tip_hash = computed_tip_hash; cache.outputs = view.outputs.clone(); - cache.revealed_by_height = view.revealed_by_height.clone(); cache.burn_leader_ranks_by_hash = view.burn_leader_ranks_by_hash.clone(); Ok(UiChainView { outputs: view.outputs, - revealed_by_height: view.revealed_by_height, burn_leader_ranks_by_hash: view.burn_leader_ranks_by_hash, }) } @@ -640,20 +415,13 @@ pub(super) async fn cached_ui_blocks_for_tip( blocks: Vec<Block>, ) -> Option<Vec<UiBlock>> { let cache = state.ui_cache.lock().await; - (cache.tip_hash.as_deref() == tip_hash).then(|| { - ui_blocks_from_indexes( - blocks, - &cache.outputs, - &cache.revealed_by_height, - &cache.burn_leader_ranks_by_hash, - ) - }) + (cache.tip_hash.as_deref() == tip_hash) + .then(|| ui_blocks_from_indexes(blocks, &cache.outputs, &cache.burn_leader_ranks_by_hash)) } fn ui_chain_view_from_cache(cache: &super::UiChainCache) -> UiChainView { UiChainView { outputs: cache.outputs.clone(), - revealed_by_height: cache.revealed_by_height.clone(), burn_leader_ranks_by_hash: cache.burn_leader_ranks_by_hash.clone(), } } @@ -664,7 +432,6 @@ fn build_chain_view(snapshot: &ChainSnapshot) -> (Option<String>, UiChainView) { index.tip_hash.clone(), UiChainView { outputs: index.outputs, - revealed_by_height: index.revealed_by_height, burn_leader_ranks_by_hash: index.burn_leader_ranks_by_hash, }, ) @@ -701,3 +468,73 @@ fn index_transaction_outputs( ); } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::domain::{ + Block, BurnBundleSection, FinalizerMode, MaskedBurn, OutPoint, Transaction, TxInput, + TxOutput, + }; + + use super::ui_block; + + fn burn(signature: &str) -> Transaction { + Transaction::Burn { + inputs: vec![TxInput { + outpoint: OutPoint { + txid: format!("{signature:0<64}"), + index: 0, + }, + owner: "owner".to_string(), + signature: signature.to_string(), + }], + change: vec![TxOutput { + address: "owner".to_string(), + amount: 1, + }], + amount: 1, + fee: 1, + signature: signature.to_string(), + } + } + + #[test] + fn ui_block_exposes_burn_bundles() { + let burn = burn("burn-a"); + let block = Block { + height: 1, + prev_hash: "parent".to_string(), + timestamp_ms: 1, + miner: "finalizer".to_string(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 1, + vdf_rounds: 1, + vdf_output: "vdf".to_string(), + leader_proof: None, + burn_bundle_section: BurnBundleSection { + signatures: vec![crate::domain::BurnBundleSignature { + slot: 1, + member: "member-1".to_string(), + signature: "sig-1".to_string(), + }], + burns: vec![MaskedBurn { + burn: burn.clone(), + bundle_mask: 1 << 1, + }], + }, + transactions: vec![burn], + hash: "hash".to_string(), + }; + + let ui = ui_block(block, &BTreeMap::new(), &BTreeMap::new()); + + assert_eq!(ui.burn_bundle_quorum.burn_bundles_included, 1); + assert_eq!(ui.burn_bundles.len(), 1); + assert_eq!(ui.burn_bundles[0].slot, 1); + assert_eq!(ui.burn_bundles[0].burns.len(), 1); + assert!(ui.burn_bundle_bytes > 0); + } +} diff --git a/src/adapters/http/wallet_persistence.rs b/src/adapters/http/wallet_persistence.rs @@ -1,52 +0,0 @@ -use std::time::Duration; - -use tokio::time::sleep; - -use crate::adapters::wallet_store; - -use super::{HttpState, now_ms}; - -pub(super) async fn run_owned_blinded_outbox_persistence(state: HttpState) { - loop { - sleep(Duration::from_millis(500)).await; - let (version, entries) = { - let node = state.node.lock().await; - let version = node.owned_blinded_outbox_version(); - if version == 0 { - continue; - } - (version, node.owned_blinded_transactions()) - }; - let password = wallet_persistence_password(&state).await; - match wallet_store::replace_owned_blinded_transactions( - &state.wallet_path, - password.as_deref(), - entries, - ) { - Ok(()) => { - state - .node - .lock() - .await - .mark_owned_blinded_outbox_persisted(version); - } - Err(error) if format!("{error:#}").contains("wallet is encrypted") => {} - Err(error) => eprintln!("failed to persist owned blinded transactions: {error:#}"), - } - } -} - -async fn wallet_persistence_password(state: &HttpState) -> Option<String> { - let metadata = wallet_store::metadata(&state.wallet_path).ok().flatten()?; - if !metadata.encrypted { - return None; - } - let now = now_ms(); - state - .auth_sessions - .lock() - .await - .values() - .find(|session| session.expires_at > now) - .map(|session| session.wallet_password.clone()) -} diff --git a/src/adapters/p2p/fetch.rs b/src/adapters/p2p/fetch.rs @@ -203,81 +203,3 @@ pub(super) async fn verify_block_vdf(block: Block) -> Result<Block> { Ok(block) } - -#[cfg(test)] -mod tests { - use crate::{ - app::GossipEnvelope, - domain::{Block, FinalizerMode, RevealBundleSection, Wallet}, - }; - - use super::super::test_support::{allocations, node}; - use super::{join_snapshot_response, validate_blocks_extension}; - - #[test] - fn join_snapshot_response_ignores_status_noise_before_snapshot() { - assert!( - join_snapshot_response( - "127.0.0.1:9544", - GossipEnvelope::PeerStatus { - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - } - ) - .unwrap() - .is_none() - ); - - let alice = Wallet::from_seed("join-noise-alice"); - let snapshot = node( - "alice", - alice.clone(), - allocations(std::slice::from_ref(&alice), 1_000), - ) - .chain_snapshot(); - let parsed = join_snapshot_response( - "127.0.0.1:9544", - GossipEnvelope::ChainSnapshot(snapshot.clone()), - ) - .unwrap(); - - assert_eq!(parsed, Some(snapshot)); - } - - #[tokio::test] - async fn block_batch_prechecks_before_vdf_verification() { - let alice = Wallet::from_seed("batch-precheck-alice"); - let test_node = node( - "alice", - alice.clone(), - allocations(std::slice::from_ref(&alice), 1_000), - ); - let tip = test_node.chain_snapshot().blocks.last().unwrap().clone(); - let ledger = test_node.clone_ledger(); - let invalid_height_block = Block { - height: ledger.height() + 2, - prev_hash: tip.hash, - timestamp_ms: tip.timestamp_ms + 1, - miner: alice.address().to_string(), - finalizer_mode: FinalizerMode::Ticket, - finalizer_rank: 0, - reward: 0, - vdf_rounds: ledger.vdf_rounds(), - vdf_output: "not-a-vdf-solution".to_string(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions: Vec::new(), - hash: "invalid-hash".to_string(), - }; - - let error = validate_blocks_extension(ledger, vec![invalid_height_block], u64::MAX) - .await - .unwrap_err(); - let message = format!("{error:#}"); - - assert!(message.contains("expected block height")); - assert!(!message.contains("VDF output is invalid")); - } -} diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs @@ -108,53 +108,25 @@ pub(super) fn record_received_envelope_kind( GossipEnvelope::Inventory { .. } => { P2pMetricsCounters::inc(&metrics.inventory_envelopes_received); } - GossipEnvelope::BlindedTransaction(_) => { + GossipEnvelope::Transaction(_) => { P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_transaction_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_transactions_received); + P2pMetricsCounters::inc(&metrics.transaction_envelopes_received); + P2pMetricsCounters::inc(&metrics.transactions_received); } - GossipEnvelope::BlindedTransactions { transactions } => { + GossipEnvelope::Transactions { transactions } => { P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_transaction_envelopes_received); - P2pMetricsCounters::add( - &metrics.blinded_transactions_received, - transactions.len() as u64, - ); - } - GossipEnvelope::MineAction(_) => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - } - GossipEnvelope::MineActions { .. } => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - } - GossipEnvelope::BlindedReveal(_) => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveals_received); + P2pMetricsCounters::inc(&metrics.transaction_envelopes_received); + P2pMetricsCounters::add(&metrics.transactions_received, transactions.len() as u64); } - GossipEnvelope::BlindedReveals { reveals } => { + GossipEnvelope::BurnBundle(_) => { P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::add(&metrics.blinded_reveals_received, reveals.len() as u64); + P2pMetricsCounters::inc(&metrics.burn_bundle_envelopes_received); + P2pMetricsCounters::inc(&metrics.burn_bundles_received); } - GossipEnvelope::RevealBundle(bundle) => { + GossipEnvelope::BurnBundles { bundles } => { P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::add( - &metrics.blinded_reveals_received, - bundle.reveals.len() as u64, - ); - } - GossipEnvelope::RevealBundles { bundles } => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::add( - &metrics.blinded_reveals_received, - bundles - .iter() - .map(|bundle| bundle.reveals.len() as u64) - .sum::<u64>(), - ); + P2pMetricsCounters::inc(&metrics.burn_bundle_envelopes_received); + P2pMetricsCounters::add(&metrics.burn_bundles_received, bundles.len() as u64); } GossipEnvelope::Block(_) | GossipEnvelope::Blocks { .. } @@ -193,33 +165,15 @@ pub(super) fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> GossipEnvelope::Inventory { blocks } => { ensure_len("block inventory", blocks.len(), MAX_INVENTORY_ITEMS)?; } - GossipEnvelope::BlindedTransactions { transactions } => { + GossipEnvelope::Transactions { transactions } => { ensure_len( - "blinded transaction batch", + "transaction batch", transactions.len(), TRANSACTION_BATCH_LIMIT, )?; } - GossipEnvelope::MineActions { transactions } => { - ensure_len( - "mine action batch", - transactions.len(), - TRANSACTION_BATCH_LIMIT, - )?; - } - GossipEnvelope::BlindedReveals { reveals } => { - ensure_len( - "blinded reveal batch", - reveals.len(), - TRANSACTION_BATCH_LIMIT, - )?; - } - GossipEnvelope::RevealBundles { bundles } => { - ensure_len( - "reveal bundle batch", - bundles.len(), - TRANSACTION_BATCH_LIMIT, - )?; + GossipEnvelope::BurnBundles { bundles } => { + ensure_len("burn bundle batch", bundles.len(), TRANSACTION_BATCH_LIMIT)?; } GossipEnvelope::Blocks { blocks } => { ensure_len("block batch", blocks.len(), MAX_BLOCK_BATCH)?; @@ -233,10 +187,8 @@ pub(super) fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> GossipEnvelope::Hello(_) | GossipEnvelope::ChainSnapshotRequest | GossipEnvelope::PeerStatus { .. } - | GossipEnvelope::BlindedTransaction(_) - | GossipEnvelope::MineAction(_) - | GossipEnvelope::BlindedReveal(_) - | GossipEnvelope::RevealBundle(_) + | GossipEnvelope::Transaction(_) + | GossipEnvelope::BurnBundle(_) | GossipEnvelope::Block(_) | GossipEnvelope::PeerAnnouncement { .. } | GossipEnvelope::PeerVerificationChallenge { .. } @@ -254,161 +206,79 @@ fn ensure_len(label: &str, len: usize, max: usize) -> Result<()> { #[cfg(test)] mod tests { - use tokio::io::AsyncWriteExt; - use crate::{ - adapters::p2p::{ - MAX_GOSSIP_LINE_BYTES, MAX_INVENTORY_ITEMS, MAX_OBJECT_REQUESTS, - metrics::P2pMetricsCounters, - }, - app::{BlockInventory, GossipEnvelope}, - domain::{BlindedReveal, BlindedTransaction}, - }; - - use super::{ - LimitedLineReader, parse_envelope, record_received_envelope_kind, validate_envelope_limits, + adapters::p2p::metrics::P2pMetricsCounters, + app::GossipEnvelope, + domain::{BurnBundle, OutPoint, Transaction, TxInput, TxOutput}, }; - #[test] - fn oversized_inventory_is_rejected_before_processing() { - let envelope = GossipEnvelope::Inventory { - blocks: vec![ - BlockInventory { - height: 1, - hash: "hash".to_string() - }; - MAX_INVENTORY_ITEMS + 1 - ], - }; - - let error = validate_envelope_limits(&envelope).unwrap_err(); - - assert!(error.to_string().contains("block inventory")); - } - - #[test] - fn parser_applies_envelope_limits() { - let line = serde_json::to_string(&GossipEnvelope::BlockRequest { - hashes: vec!["hash".to_string(); MAX_OBJECT_REQUESTS + 1], - }) - .unwrap(); - - let error = parse_envelope(&line).unwrap_err(); - - assert!(error.to_string().contains("block request")); - } - - #[test] - fn parser_rejects_empty_envelope_without_json_eof() { - let error = parse_envelope("").unwrap_err(); - - assert!(error.to_string().contains("empty p2p envelope")); - assert!(!format!("{error:#}").contains("EOF while parsing")); + use super::{parse_envelope, record_received_envelope_kind}; + + fn burn(signature: &str) -> Transaction { + Transaction::Burn { + inputs: vec![TxInput { + outpoint: OutPoint { + txid: format!("{signature:0<64}"), + index: 0, + }, + owner: "owner".to_string(), + signature: signature.to_string(), + }], + change: vec![TxOutput { + address: "owner".to_string(), + amount: 1, + }], + amount: 1, + fee: 1, + signature: signature.to_string(), + } } - #[test] - fn parser_accepts_legacy_peer_status_without_mempool_fields() { - let envelope = - parse_envelope(r#"{"type":"peer_status","height":7,"tip_hash":"tip"}"#).unwrap(); - - assert_eq!( - envelope, - GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 0, - } - ); + fn burn_bundle(slot: u8, signature: &str) -> BurnBundle { + BurnBundle { + height: 1, + prev_hash: "parent".to_string(), + slot, + member: format!("member-{slot}"), + burns: vec![burn(signature)], + signature: format!("bundle-{signature}"), + } } #[test] - fn received_envelope_metrics_are_categorized() { + fn metrics_count_transaction_and_burn_bundle_batches() { let metrics = P2pMetricsCounters::default(); - let blinded_tx = BlindedTransaction { - commitment: "commitment".to_string(), - inputs: Vec::new(), - fee: 3, - encrypted_size: 128, - expires_at_height: 20, - nonce: "nonce".to_string(), - ciphertext: "ciphertext".to_string(), - payload_hash: "payload-hash".to_string(), - }; - let blinded_reveal = BlindedReveal { - commitment: "commitment".to_string(), - key: "key".to_string(), - }; record_received_envelope_kind( &metrics, - &GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 1_000, + &GossipEnvelope::Transactions { + transactions: vec![burn("a"), burn("b")], }, ); - record_received_envelope_kind(&metrics, &GossipEnvelope::Inventory { blocks: Vec::new() }); - record_received_envelope_kind(&metrics, &GossipEnvelope::Blocks { blocks: Vec::new() }); record_received_envelope_kind( &metrics, - &GossipEnvelope::BlindedTransactions { - transactions: vec![blinded_tx.clone(), blinded_tx], + &GossipEnvelope::BurnBundles { + bundles: vec![burn_bundle(1, "c"), burn_bundle(2, "d")], }, ); - record_received_envelope_kind(&metrics, &GossipEnvelope::BlindedReveal(blinded_reveal)); - record_received_envelope_kind(&metrics, &GossipEnvelope::ChainSnapshotRequest); + record_received_envelope_kind(&metrics, &GossipEnvelope::Transaction(burn("e"))); + record_received_envelope_kind(&metrics, &GossipEnvelope::BurnBundle(burn_bundle(1, "f"))); let snapshot = metrics.snapshot(); - assert_eq!(snapshot.peer_status_envelopes_received, 1); - assert_eq!(snapshot.inventory_envelopes_received, 1); - assert_eq!(snapshot.data_envelopes_received, 3); - assert_eq!(snapshot.blinded_transaction_envelopes_received, 1); - assert_eq!(snapshot.blinded_transactions_received, 2); - assert_eq!(snapshot.blinded_reveal_envelopes_received, 1); - assert_eq!(snapshot.blinded_reveals_received, 1); - assert_eq!(snapshot.control_envelopes_received, 1); - } - - #[tokio::test] - async fn limited_line_reader_keeps_partial_line_after_cancelled_read() { - let (mut writer, reader) = tokio::io::duplex(1024); - let mut reader = LimitedLineReader::new(reader); - let line = serde_json::to_string(&GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }) - .unwrap(); - let split_at = line.len() / 2; - - writer - .write_all(&line.as_bytes()[..split_at]) - .await - .unwrap(); - let cancelled = - tokio::time::timeout(std::time::Duration::from_millis(25), reader.read_line()).await; - - assert!(cancelled.is_err()); - - writer - .write_all(&line.as_bytes()[split_at..]) - .await - .unwrap(); - writer.write_all(b"\n").await.unwrap(); - - assert_eq!( - reader.read_line().await.unwrap().as_deref(), - Some(line.as_str()) - ); + assert_eq!(snapshot.data_envelopes_received, 4); + assert_eq!(snapshot.transaction_envelopes_received, 2); + assert_eq!(snapshot.transactions_received, 3); + assert_eq!(snapshot.burn_bundle_envelopes_received, 2); + assert_eq!(snapshot.burn_bundles_received, 3); } - #[tokio::test] - async fn limited_line_reader_rejects_oversized_partial_frame_without_newline() { - let bytes = vec![b'a'; MAX_GOSSIP_LINE_BYTES + 1]; - let mut reader = LimitedLineReader::new(bytes.as_slice()); - - let error = reader.read_line().await.unwrap_err(); + #[test] + fn parser_accepts_burn_bundle_envelopes() { + let envelope = GossipEnvelope::BurnBundles { + bundles: vec![burn_bundle(1, "a")], + }; + let line = serde_json::to_string(&envelope).unwrap(); - assert!(error.to_string().contains("p2p message exceeds")); + assert_eq!(parse_envelope(&line).unwrap(), envelope); } } diff --git a/src/adapters/p2p/metrics.rs b/src/adapters/p2p/metrics.rs @@ -21,10 +21,10 @@ pub(super) struct P2pMetricsCounters { pub(super) peer_status_envelopes_received: AtomicU64, pub(super) inventory_envelopes_received: AtomicU64, pub(super) data_envelopes_received: AtomicU64, - pub(super) blinded_transaction_envelopes_received: AtomicU64, - pub(super) blinded_transactions_received: AtomicU64, - pub(super) blinded_reveal_envelopes_received: AtomicU64, - pub(super) blinded_reveals_received: AtomicU64, + pub(super) transaction_envelopes_received: AtomicU64, + pub(super) transactions_received: AtomicU64, + pub(super) burn_bundle_envelopes_received: AtomicU64, + pub(super) burn_bundles_received: AtomicU64, pub(super) control_envelopes_received: AtomicU64, pub(super) bytes_received: AtomicU64, pub(super) parse_errors: AtomicU64, @@ -54,10 +54,10 @@ pub struct P2pMetrics { pub peer_status_envelopes_received: u64, pub inventory_envelopes_received: u64, pub data_envelopes_received: u64, - pub blinded_transaction_envelopes_received: u64, - pub blinded_transactions_received: u64, - pub blinded_reveal_envelopes_received: u64, - pub blinded_reveals_received: u64, + pub transaction_envelopes_received: u64, + pub transactions_received: u64, + pub burn_bundle_envelopes_received: u64, + pub burn_bundles_received: u64, pub control_envelopes_received: u64, pub bytes_received: u64, pub parse_errors: u64, @@ -104,16 +104,14 @@ impl P2pMetricsCounters { .load(Ordering::Relaxed), inventory_envelopes_received: self.inventory_envelopes_received.load(Ordering::Relaxed), data_envelopes_received: self.data_envelopes_received.load(Ordering::Relaxed), - blinded_transaction_envelopes_received: self - .blinded_transaction_envelopes_received + transaction_envelopes_received: self + .transaction_envelopes_received .load(Ordering::Relaxed), - blinded_transactions_received: self - .blinded_transactions_received + transactions_received: self.transactions_received.load(Ordering::Relaxed), + burn_bundle_envelopes_received: self + .burn_bundle_envelopes_received .load(Ordering::Relaxed), - blinded_reveal_envelopes_received: self - .blinded_reveal_envelopes_received - .load(Ordering::Relaxed), - blinded_reveals_received: self.blinded_reveals_received.load(Ordering::Relaxed), + burn_bundles_received: self.burn_bundles_received.load(Ordering::Relaxed), control_envelopes_received: self.control_envelopes_received.load(Ordering::Relaxed), bytes_received: self.bytes_received.load(Ordering::Relaxed), parse_errors: self.parse_errors.load(Ordering::Relaxed), diff --git a/src/adapters/p2p/network.rs b/src/adapters/p2p/network.rs @@ -20,25 +20,6 @@ use super::{ }; impl GossipNetwork { - #[cfg(test)] - pub(crate) fn new_for_tests(node: SharedNode, peers: SharedPeerBook) -> Self { - Self { - inner: Arc::new(GossipNetworkInner { - node, - peers, - listen_addr: "127.0.0.1:0".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(None), - node_id: new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new( - BTreeMap::<String, mpsc::Sender<OutboundBatch>>::new(), - ), - inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), - metrics: P2pMetricsCounters::default(), - }), - } - } - pub async fn start( node: SharedNode, peers: SharedPeerBook, diff --git a/src/adapters/p2p/peer_addr.rs b/src/adapters/p2p/peer_addr.rs @@ -154,96 +154,3 @@ pub(super) fn inbound_error_counts_as_misbehavior(message: &str) -> bool { !message.contains("block timestamp is too far in the future") && !message.contains("block timestamp is before finalizer rank") } - -#[cfg(test)] -mod tests { - use std::net::SocketAddr; - - use crate::{ - app::GossipEnvelope, - domain::{Block, FinalizerMode, RevealBundleSection}, - }; - - use super::{is_self_peer_address_for, peer_needs_snapshot, reachable_advertised_addr}; - - #[test] - fn unspecified_announced_ip_uses_remote_ip_with_announced_port() { - let advertised: SocketAddr = "0.0.0.0:9445".parse().unwrap(); - let remote: SocketAddr = "203.0.113.10:52144".parse().unwrap(); - - assert_eq!( - reachable_advertised_addr(advertised, remote).to_string(), - "203.0.113.10:9445" - ); - } - - #[test] - fn explicit_announced_ip_is_kept() { - let advertised: SocketAddr = "127.0.0.1:9445".parse().unwrap(); - let remote: SocketAddr = "127.0.0.1:52144".parse().unwrap(); - - assert_eq!( - reachable_advertised_addr(advertised, remote).to_string(), - "127.0.0.1:9445" - ); - } - - #[test] - fn loopback_peer_on_unspecified_listen_port_is_self() { - let listen_addr: SocketAddr = "0.0.0.0:9545".parse().unwrap(); - - assert!(is_self_peer_address_for( - "127.0.0.1:9545", - listen_addr, - Some(listen_addr) - )); - assert!(is_self_peer_address_for( - "0.0.0.0:9545", - listen_addr, - Some(listen_addr) - )); - assert!(!is_self_peer_address_for( - "127.0.0.1:9546", - listen_addr, - Some(listen_addr) - )); - assert!(!is_self_peer_address_for( - "203.0.113.10:9545", - listen_addr, - Some(listen_addr) - )); - } - - #[test] - fn peer_needs_snapshot_when_block_gossip_skips_a_height() { - let block = Block { - height: 10, - prev_hash: "prev".to_string(), - timestamp_ms: 1, - miner: "miner".to_string(), - finalizer_mode: FinalizerMode::Ticket, - finalizer_rank: 0, - reward: 100, - vdf_rounds: 1, - vdf_output: "vdf".to_string(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions: Vec::new(), - hash: "hash".to_string(), - }; - - assert!(peer_needs_snapshot( - 8, - &[GossipEnvelope::Block(block.clone())] - )); - assert!(!peer_needs_snapshot(9, &[GossipEnvelope::Block(block)])); - assert!(!peer_needs_snapshot( - 8, - &[GossipEnvelope::PeerAnnouncement { - address: "127.0.0.1:9444".to_string(), - node_id: Some("peer-node".to_string()), - }] - )); - } -} diff --git a/src/adapters/p2p/process.rs b/src/adapters/p2p/process.rs @@ -5,7 +5,7 @@ use tokio::net::tcp::OwnedWriteHalf; use crate::{ app::{GossipEnvelope, debug_logging_enabled}, - domain::{BlindedReveal, BlindedTransaction, RevealBundle, Transaction}, + domain::{BurnBundle, Transaction}, }; use super::{ @@ -88,29 +88,17 @@ pub(super) async fn process_envelope( GossipEnvelope::PeerList { peers } => { apply_peer_list(network, remote_addr, peers).await?; } - GossipEnvelope::BlindedTransaction(tx) => { - process_blinded_transactions(network, remote_addr, known_peer, vec![tx]).await; + GossipEnvelope::Transaction(tx) => { + process_transactions(network, remote_addr, known_peer, vec![tx]).await; } - GossipEnvelope::BlindedTransactions { transactions } => { - process_blinded_transactions(network, remote_addr, known_peer, transactions).await; + GossipEnvelope::Transactions { transactions } => { + process_transactions(network, remote_addr, known_peer, transactions).await; } - GossipEnvelope::MineAction(tx) => { - process_mine_actions(network, remote_addr, known_peer, vec![tx]).await; + GossipEnvelope::BurnBundle(bundle) => { + process_burn_bundles(network, remote_addr, known_peer, vec![bundle]).await; } - GossipEnvelope::MineActions { transactions } => { - process_mine_actions(network, remote_addr, known_peer, transactions).await; - } - GossipEnvelope::BlindedReveal(reveal) => { - process_blinded_reveals(network, remote_addr, known_peer, vec![reveal]).await; - } - GossipEnvelope::BlindedReveals { reveals } => { - process_blinded_reveals(network, remote_addr, known_peer, reveals).await; - } - GossipEnvelope::RevealBundle(bundle) => { - process_reveal_bundles(network, remote_addr, known_peer, vec![bundle]).await; - } - GossipEnvelope::RevealBundles { bundles } => { - process_reveal_bundles(network, remote_addr, known_peer, bundles).await; + GossipEnvelope::BurnBundles { bundles } => { + process_burn_bundles(network, remote_addr, known_peer, bundles).await; } GossipEnvelope::Block(block) => { let adjusted_time_ms = super::network_adjusted_time_ms(network).await; @@ -185,35 +173,7 @@ pub(super) async fn process_envelope( Ok(()) } -async fn process_blinded_transactions( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &Option<String>, - transactions: Vec<BlindedTransaction>, -) { - let first_error = { - let mut node = network.inner.node.lock().await; - let mut first_error = None; - for tx in transactions { - if let Err(error) = node.receive_blinded_transaction(tx) { - first_error.get_or_insert(error); - } - } - first_error - }; - record_inbound_result( - network, - known_peer, - remote_addr, - first_error - .map(|error| Err(anyhow!(format!("{error:#}")))) - .unwrap_or(Ok(())), - ) - .await; - network.forward_outbox().await; -} - -async fn process_mine_actions( +async fn process_transactions( network: &GossipNetwork, remote_addr: SocketAddr, known_peer: &Option<String>, @@ -223,35 +183,7 @@ async fn process_mine_actions( let mut node = network.inner.node.lock().await; let mut first_error = None; for tx in transactions { - if let Err(error) = node.receive_mine_action(tx) { - first_error.get_or_insert(error); - } - } - first_error - }; - record_inbound_result( - network, - known_peer, - remote_addr, - first_error - .map(|error| Err(anyhow!(format!("{error:#}")))) - .unwrap_or(Ok(())), - ) - .await; - network.forward_outbox().await; -} - -async fn process_blinded_reveals( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &Option<String>, - reveals: Vec<BlindedReveal>, -) { - let first_error = { - let mut node = network.inner.node.lock().await; - let mut first_error = None; - for reveal in reveals { - if let Err(error) = node.receive_blinded_reveal(reveal) { + if let Err(error) = node.receive_gossiped_transaction(tx) { first_error.get_or_insert(error); } } @@ -269,17 +201,17 @@ async fn process_blinded_reveals( network.forward_outbox().await; } -async fn process_reveal_bundles( +async fn process_burn_bundles( network: &GossipNetwork, remote_addr: SocketAddr, known_peer: &Option<String>, - bundles: Vec<RevealBundle>, + bundles: Vec<BurnBundle>, ) { let first_error = { let mut node = network.inner.node.lock().await; let mut first_error = None; for bundle in bundles { - if let Err(error) = node.receive_reveal_bundle(bundle) { + if let Err(error) = node.receive_burn_bundle(bundle) { first_error.get_or_insert(error); } } diff --git a/src/adapters/p2p/sync.rs b/src/adapters/p2p/sync.rs @@ -211,300 +211,3 @@ pub(super) async fn envelopes_for_peer( }) .collect() } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use crate::{ - app::{GossipEnvelope, PeerBook}, - domain::Wallet, - }; - - use super::super::{ - PeerStatus, - test_support::{allocations, gossip_network, node, queue_plaintext_burn}, - }; - use super::{apply_peer_list, catchup_payload_for_peer, envelopes_for_peer}; - - #[tokio::test] - async fn peer_payload_repairs_lagging_peer_without_networking() { - let alice = Wallet::from_seed("p2p-alice"); - let bob = Wallet::from_seed("p2p-bob"); - let allocations = allocations(&[alice.clone(), bob.clone()], 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node( - "alice", - alice.clone(), - allocations, - ))); - let block = { - let mut node = node.lock().await; - queue_plaintext_burn(&mut node, &alice, 1); - node.drain_outbox(); - let block = node.mine_one_at(1).unwrap(); - node.drain_outbox(); - block - }; - - let payload = envelopes_for_peer( - Some(&node), - Some(PeerStatus::new(0, "genesis".to_string())), - &[GossipEnvelope::Block(block)], - ) - .await; - - assert!(matches!(payload[0], GossipEnvelope::Blocks { .. })); - match &payload[0] { - GossipEnvelope::Blocks { blocks } => { - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].height, 1); - } - _ => unreachable!(), - } - } - - #[tokio::test] - async fn session_catchup_payload_pushes_missing_blocks_to_lagging_peer() { - let alice = Wallet::from_seed("catchup-alice"); - let bob = Wallet::from_seed("catchup-bob"); - let allocations = allocations(&[alice.clone(), bob], 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node( - "alice", - alice.clone(), - allocations, - ))); - { - let mut node = node.lock().await; - for height in 1..=3 { - queue_plaintext_burn(&mut node, &alice, 1); - node.drain_outbox(); - node.mine_one_at(height).unwrap(); - node.drain_outbox(); - } - } - - let payload = - catchup_payload_for_peer(&node, &PeerStatus::new(1, "old-tip".to_string())).await; - - assert_eq!(payload.len(), 1); - match &payload[0] { - GossipEnvelope::Blocks { blocks } => { - assert_eq!( - blocks.iter().map(|block| block.height).collect::<Vec<_>>(), - vec![2, 3] - ); - } - other => panic!("expected missing block payload, got {other:?}"), - } - } - - #[tokio::test] - async fn session_catchup_payload_pushes_blinded_mempool_to_synced_peer() { - let alice = Wallet::from_seed("catchup-mempool-alice"); - let bob = Wallet::from_seed("catchup-mempool-bob"); - let allocations = allocations(&[alice.clone(), bob], 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node( - "alice", - alice.clone(), - allocations, - ))); - let expected_commitment = { - let mut node = node.lock().await; - let tx = node.ledger().build_burn(&alice, 1, 0).unwrap(); - let built = node - .ledger() - .build_blinded_transaction(&alice, tx, 20) - .unwrap(); - let commitment = built.transaction.commitment.clone(); - node.receive_blinded_transaction(built.transaction).unwrap(); - node.drain_outbox(); - commitment - }; - let peer_status = { - let node = node.lock().await; - let status = node.ledger().status(); - PeerStatus::new(status.height, status.tip_hash) - }; - - let payload = catchup_payload_for_peer(&node, &peer_status).await; - - assert_eq!(payload.len(), 1); - match &payload[0] { - GossipEnvelope::BlindedTransactions { transactions } => { - assert_eq!(transactions.len(), 1); - assert_eq!(transactions[0].commitment, expected_commitment); - } - other => panic!("expected blinded mempool payload, got {other:?}"), - } - } - - #[tokio::test] - async fn peer_list_adds_discovered_peers() { - let alice = Wallet::from_seed("px-recv-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = gossip_network( - node, - Arc::clone(&peers), - "127.0.0.1:9544".parse().unwrap(), - None, - ); - apply_peer_list( - &network, - "127.0.0.1:9545".parse().unwrap(), - vec!["127.0.0.1:9544".to_string(), "127.0.0.1:9546".to_string()], - ) - .await - .unwrap(); - - let addresses = peers - .lock() - .await - .list() - .into_iter() - .map(|peer| peer.address) - .collect::<Vec<_>>(); - assert!(!addresses.contains(&"127.0.0.1:9544".to_string())); - assert!(addresses.contains(&"127.0.0.1:9546".to_string())); - assert_eq!( - peers.lock().await.direction_for_tests("127.0.0.1:9546"), - Some(crate::app::PeerDirection::Discovered) - ); - } - - #[tokio::test] - async fn peer_list_ignores_invalid_peer_addresses() { - let alice = Wallet::from_seed("px-list-invalid-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = gossip_network( - node, - Arc::clone(&peers), - "127.0.0.1:9544".parse().unwrap(), - None, - ); - apply_peer_list( - &network, - "127.0.0.1:9545".parse().unwrap(), - vec![ - "iuna.jhx.app:9444".to_string(), - "127.0.0.1:9546".to_string(), - ], - ) - .await - .unwrap(); - - let addresses = peers - .lock() - .await - .list() - .into_iter() - .map(|peer| peer.address) - .collect::<Vec<_>>(); - assert!(!addresses.contains(&"iuna.jhx.app:9444".to_string())); - assert!(addresses.contains(&"127.0.0.1:9546".to_string())); - } - - #[tokio::test] - async fn peer_list_ignores_announced_self_address() { - let alice = Wallet::from_seed("px-list-announced-self-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = gossip_network( - node, - Arc::clone(&peers), - "0.0.0.0:9444".parse().unwrap(), - Some("8.8.8.8:9444".parse().unwrap()), - ); - - apply_peer_list( - &network, - "8.8.4.4:9444".parse().unwrap(), - vec!["8.8.8.8:9444".to_string(), "8.8.4.4:9445".to_string()], - ) - .await - .unwrap(); - - let addresses = peers - .lock() - .await - .list() - .into_iter() - .map(|peer| peer.address) - .collect::<Vec<_>>(); - assert!(!addresses.contains(&"8.8.8.8:9444".to_string())); - assert!(addresses.contains(&"8.8.4.4:9445".to_string())); - network.set_accept_inbound(false).await.unwrap(); - } - - #[tokio::test] - async fn peer_list_ignores_private_ephemeral_addresses() { - let alice = Wallet::from_seed("px-private-ephemeral-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = gossip_network( - node, - Arc::clone(&peers), - "0.0.0.0:9444".parse().unwrap(), - None, - ); - - apply_peer_list( - &network, - "142.132.164.59:9444".parse().unwrap(), - vec![ - "10.42.1.1:10091".to_string(), - "142.132.164.59:9444".to_string(), - ], - ) - .await - .unwrap(); - - let addresses = peers - .lock() - .await - .list() - .into_iter() - .map(|peer| peer.address) - .collect::<Vec<_>>(); - assert!(!addresses.contains(&"10.42.1.1:10091".to_string())); - assert!(addresses.contains(&"142.132.164.59:9444".to_string())); - } - - #[tokio::test] - async fn peer_list_ignores_loopback_alias_for_unspecified_self() { - let alice = Wallet::from_seed("px-self-alias-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = gossip_network( - node, - Arc::clone(&peers), - "0.0.0.0:9545".parse().unwrap(), - None, - ); - - apply_peer_list( - &network, - "127.0.0.1:9544".parse().unwrap(), - vec!["127.0.0.1:9545".to_string(), "127.0.0.1:9546".to_string()], - ) - .await - .unwrap(); - - let addresses = peers - .lock() - .await - .list() - .into_iter() - .map(|peer| peer.address) - .collect::<Vec<_>>(); - assert!(!addresses.contains(&"127.0.0.1:9545".to_string())); - assert!(addresses.contains(&"127.0.0.1:9546".to_string())); - assert_eq!(network.metrics().self_peer_skips, 1); - } -} diff --git a/src/adapters/p2p/test_support.rs b/src/adapters/p2p/test_support.rs @@ -26,7 +26,7 @@ pub(super) fn queue_plaintext_burn( wallet: &Wallet, amount: Amount, ) -> Transaction { - let tx = node.ledger().build_burn(wallet, amount, 0).unwrap(); + let tx = node.ledger().build_burn(wallet, amount, 1).unwrap(); node.receive_transaction(tx.clone()).unwrap(); tx } diff --git a/src/adapters/stratum.rs b/src/adapters/stratum.rs @@ -315,149 +315,3 @@ fn hex_value(byte: u8) -> Result<u8> { fn stratum_difficulty_for_bits(bits: u32) -> f64 { 2_f64.powi(bits as i32 - 16).max(0.000001) } - -#[cfg(test)] -mod tests { - use std::{collections::BTreeMap, net::SocketAddr, sync::Arc}; - - use serde_json::json; - use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, - sync::Mutex, - }; - - use crate::{ - adapters::p2p::GossipNetwork, - app::{NodeCore, PeerBook}, - domain::{Ledger, StratumMineShare, Wallet}, - }; - - use super::StratumServer; - - #[tokio::test] - async fn stratum_session_accepts_valid_iuna_share() { - let wallet = Wallet::from_seed("stratum-session-wallet"); - let ledger = Ledger::new(BTreeMap::new(), 1); - let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet.clone(), ledger, 0))); - let peers = Arc::new(Mutex::new(PeerBook::default())); - let gossip = GossipNetwork::new_for_tests(Arc::clone(&node), peers); - let server = match StratumServer::start( - Arc::clone(&node), - gossip, - "127.0.0.1:0".parse::<SocketAddr>().unwrap(), - ) - .await - { - Ok(server) => server, - Err(error) if format!("{error:#}").contains("Operation not permitted") => return, - Err(error) => panic!("{error:#}"), - }; - - let stream = tokio::net::TcpStream::connect(server.listen_addr()) - .await - .unwrap(); - let (read, mut write) = stream.into_split(); - let mut lines = BufReader::new(read).lines(); - send( - &mut write, - json!({"id": 1, "method": "mining.subscribe", "params": []}), - ) - .await; - assert_eq!(read_id(&mut lines, 1).await["error"], json!(null)); - - send( - &mut write, - json!({"id": 2, "method": "mining.authorize", "params": [wallet.address(), "x"]}), - ) - .await; - assert_eq!(read_id(&mut lines, 2).await["result"], json!(true)); - let notify = read_method(&mut lines, "mining.notify").await; - let job_id = notify["params"][0].as_str().unwrap().to_string(); - - let template = node - .lock() - .await - .external_mine_job(wallet.address(), 1) - .unwrap() - .template; - let mut nonce = None; - for candidate in 0_u32..50_000 { - let result = node.lock().await.ledger().build_stratum_mine( - template.clone(), - StratumMineShare { - extranonce2: [0, 0, 0, 0], - header_nonce: candidate.to_le_bytes(), - }, - ); - if result.is_ok() { - nonce = Some(candidate.to_le_bytes()); - break; - } - } - let nonce = nonce.expect("expected valid share"); - - send( - &mut write, - json!({ - "id": 3, - "method": "mining.submit", - "params": [wallet.address(), job_id, "00000000", template.ntime_hex, hex(nonce)] - }), - ) - .await; - assert_eq!(read_id(&mut lines, 3).await["result"], json!(true)); - let node = node.lock().await; - assert_eq!(node.ledger().pending().len(), 1); - assert!(node.ledger().pending_blinded_transactions().is_empty()); - } - - #[test] - fn worker_suffix_is_not_part_of_recipient_address() { - assert_eq!(super::recipient_from_worker("abc.bitaxe"), "abc"); - assert_eq!(super::recipient_from_worker("abc"), "abc"); - } - - async fn send(write: &mut tokio::net::tcp::OwnedWriteHalf, value: serde_json::Value) { - write - .write_all(serde_json::to_string(&value).unwrap().as_bytes()) - .await - .unwrap(); - write.write_all(b"\n").await.unwrap(); - } - - async fn read_id( - lines: &mut tokio::io::Lines<BufReader<tokio::net::tcp::OwnedReadHalf>>, - id: i64, - ) -> serde_json::Value { - loop { - let line = lines.next_line().await.unwrap().unwrap(); - let value: serde_json::Value = serde_json::from_str(&line).unwrap(); - if value.get("id").and_then(serde_json::Value::as_i64) == Some(id) { - return value; - } - } - } - - async fn read_method( - lines: &mut tokio::io::Lines<BufReader<tokio::net::tcp::OwnedReadHalf>>, - method: &str, - ) -> serde_json::Value { - loop { - let line = lines.next_line().await.unwrap().unwrap(); - let value: serde_json::Value = serde_json::from_str(&line).unwrap(); - if value.get("method").and_then(serde_json::Value::as_str) == Some(method) { - return value; - } - } - } - - fn hex(bytes: impl AsRef<[u8]>) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::new(); - for byte in bytes.as_ref() { - output.push(HEX[(byte >> 4) as usize] as char); - output.push(HEX[(byte & 0x0f) as usize] as char); - } - output - } -} diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -13,11 +13,8 @@ use serde::Serialize; use crate::{ adapters::ui_index::{UiChainIndex, build_ui_chain_index}, domain::{ - Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, - BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, BurnLeaderRank, - ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, - RevealedBlindedTransaction, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee, - hex_hash, reveal_committee_slot_count_for_height, revealed_blinded_transactions, + Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_REWARD, OutPoint, Transaction, + TxInput, TxOutput, hex_hash, }, }; @@ -76,7 +73,6 @@ CREATE TABLE IF NOT EXISTS ui_wallet_transactions ( block_height INTEGER NOT NULL, timestamp_ms INTEGER NOT NULL, block_finalizer TEXT NOT NULL, - blinded INTEGER NOT NULL, transaction_json BLOB NOT NULL, PRIMARY KEY (address, signature) ); @@ -87,16 +83,6 @@ ON ui_wallet_transactions(address, kind, sort_key DESC); CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_sort ON ui_wallet_transactions(address, sort_key DESC); -CREATE TABLE IF NOT EXISTS ui_revealed_transactions ( - height INTEGER NOT NULL, - commitment TEXT PRIMARY KEY, - included_by TEXT NOT NULL, - transaction_json BLOB NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_ui_revealed_transactions_height -ON ui_revealed_transactions(height); - CREATE TABLE IF NOT EXISTS ui_burn_leader_ranks ( block_hash TEXT NOT NULL, rank INTEGER NOT NULL, @@ -113,7 +99,7 @@ CREATE TABLE IF NOT EXISTS ui_burn_leader_rank_blocks ( ); "#; -const UI_CACHE_SCHEMA_VERSION: u32 = 1; +const UI_CACHE_SCHEMA_VERSION: u32 = 2; #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -151,7 +137,6 @@ pub struct WalletTransactionProjection { pub block_height: u64, pub timestamp_ms: u64, pub block_finalizer: String, - pub blinded: bool, pub transaction: Transaction, } @@ -211,7 +196,6 @@ impl SqliteUiDataStore { Ok(Some(UiChainIndex { tip_hash: Some(stored_tip_hash), outputs: load_ui_output_index(connection)?, - revealed_by_height: load_ui_revealed_transactions(connection)?, burn_leader_ranks_by_hash: load_ui_burn_leader_ranks(connection)?, })) }) @@ -662,31 +646,6 @@ VALUES (?1, ?2, ?3, ?4) ) })?; } - for (height, revealed_transactions) in &index.revealed_by_height { - for revealed in revealed_transactions { - let transaction_json = serde_json::to_vec(&revealed.transaction) - .context("failed to serialize UI revealed transaction")?; - transaction - .execute( - r#" -INSERT INTO ui_revealed_transactions (height, commitment, included_by, transaction_json) -VALUES (?1, ?2, ?3, ?4) -"#, - params![ - height, - revealed.commitment, - revealed.included_by, - transaction_json - ], - ) - .with_context(|| { - format!( - "failed to persist UI revealed transaction {}", - revealed.commitment - ) - })?; - } - } for (block_hash, ranks) in &index.burn_leader_ranks_by_hash { transaction .execute( @@ -763,9 +722,9 @@ fn replace_ui_wallet_transactions( .execute( r#" INSERT INTO ui_wallet_transactions ( - address, sort_key, kind, signature, block_height, timestamp_ms, block_finalizer, blinded, + address, sort_key, kind, signature, block_height, timestamp_ms, block_finalizer, transaction_json -) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) "#, params![ address, @@ -775,7 +734,6 @@ INSERT INTO ui_wallet_transactions ( row.block_height, row.timestamp_ms, row.block_finalizer, - row.blinded, transaction_json, ], ) @@ -804,9 +762,6 @@ fn clear_ui_chain_index_in_transaction(transaction: &rusqlite::Transaction<'_>) .execute("DELETE FROM ui_wallet_transactions", []) .context("failed to clear old UI wallet transaction index")?; transaction - .execute("DELETE FROM ui_revealed_transactions", []) - .context("failed to clear old UI revealed transaction index")?; - transaction .execute("DELETE FROM ui_burn_leader_ranks", []) .context("failed to clear old UI burn leader rank index")?; transaction @@ -932,7 +887,7 @@ fn load_wallet_transactions( let mut statement = connection .prepare( r#" -SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, blinded, transaction_json +SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json FROM ui_wallet_transactions WHERE address = ?1 ORDER BY sort_key DESC @@ -966,7 +921,7 @@ LIMIT ?2 OFFSET ?3 let query_sql = format!( r#" -SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, blinded, transaction_json +SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json FROM ui_wallet_transactions WHERE address = ? AND kind IN ({placeholders}) ORDER BY sort_key DESC @@ -1002,7 +957,7 @@ fn wallet_transaction_kinds_cover_all(kinds: &[&str]) -> bool { fn wallet_transaction_projection_from_row( row: &rusqlite::Row<'_>, ) -> rusqlite::Result<WalletTransactionProjection> { - let transaction_json = row.get::<_, Vec<u8>>(6)?; + let transaction_json = row.get::<_, Vec<u8>>(5)?; let transaction = serde_json::from_slice::<Transaction>(&transaction_json).map_err(|error| { rusqlite::Error::FromSqlConversionFailure( @@ -1017,7 +972,6 @@ fn wallet_transaction_projection_from_row( block_height: row.get(2)?, timestamp_ms: row.get(3)?, block_finalizer: row.get(4)?, - blinded: row.get::<_, u64>(5)? != 0, transaction, }) } @@ -1032,45 +986,6 @@ fn read_wallet_transaction_rows( .context("failed to read UI wallet transaction rows") } -fn load_ui_revealed_transactions( - connection: &Connection, -) -> Result<BTreeMap<u64, Vec<RevealedBlindedTransaction>>> { - let mut statement = connection - .prepare( - r#" -SELECT height, commitment, included_by, transaction_json -FROM ui_revealed_transactions -ORDER BY height, commitment -"#, - ) - .context("failed to prepare UI revealed transaction query")?; - let rows = statement - .query_map([], |row| { - let transaction_json = row.get::<_, Vec<u8>>(3)?; - let transaction = - serde_json::from_slice::<Transaction>(&transaction_json).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure( - transaction_json.len(), - rusqlite::types::Type::Blob, - Box::new(error), - ) - })?; - Ok(RevealedBlindedTransaction { - height: row.get(0)?, - commitment: row.get(1)?, - included_by: row.get(2)?, - transaction, - }) - }) - .context("failed to load UI revealed transactions")?; - let mut by_height = BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(); - for revealed in rows { - let revealed = revealed.context("failed to read UI revealed transaction row")?; - by_height.entry(revealed.height).or_default().push(revealed); - } - Ok(by_height) -} - fn load_ui_burn_leader_ranks( connection: &Connection, ) -> Result<BTreeMap<String, Vec<BurnLeaderRank>>> { @@ -1122,16 +1037,6 @@ ORDER BY block_hash, rank fn wallet_transactions_from_snapshot( snapshot: &ChainSnapshot, ) -> Vec<(String, WalletTransactionProjection)> { - let revealed_by_height = revealed_blinded_transactions(snapshot) - .unwrap_or_default() - .into_iter() - .fold( - BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(), - |mut by_height, revealed| { - by_height.entry(revealed.height).or_default().push(revealed); - by_height - }, - ); let mut rows = Vec::new(); for block in &snapshot.blocks { for (index, transaction) in block.transactions.iter().rev().enumerate() { @@ -1140,20 +1045,8 @@ fn wallet_transactions_from_snapshot( transaction, block, block.height as u128 * 10_000 + index as u128, - false, ); } - if let Some(revealed) = revealed_by_height.get(&block.height) { - for (index, revealed) in revealed.iter().rev().enumerate() { - push_wallet_transaction_projection( - &mut rows, - &revealed.transaction, - block, - block.height as u128 * 10_000 + 5_000 + index as u128, - false, - ); - } - } } rows } @@ -1163,7 +1056,6 @@ fn push_wallet_transaction_projection( transaction: &Transaction, block: &Block, sort_key: u128, - blinded: bool, ) { let kind = transaction_kind(transaction).to_string(); let projection = WalletTransactionProjection { @@ -1172,7 +1064,6 @@ fn push_wallet_transaction_projection( block_height: block.height, timestamp_ms: block.timestamp_ms, block_finalizer: block.miner.clone(), - blinded, transaction: transaction.clone(), }; for address in wallet_transaction_addresses(transaction) { @@ -1219,13 +1110,6 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> blocks: vec![genesis], }) .context("failed to rebuild genesis ledger for metrics")?; - let revealed = revealed_blinded_transactions(snapshot)?.into_iter().fold( - BTreeMap::<u64, Vec<crate::domain::RevealedBlindedTransaction>>::new(), - |mut by_height, revealed| { - by_height.entry(revealed.height).or_default().push(revealed); - by_height - }, - ); let mut known_wallet_addresses = snapshot .genesis_allocations .keys() @@ -1234,39 +1118,18 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> let mut total_burned_amount = 0_u64; let mut rows = Vec::with_capacity(snapshot.blocks.len()); let mut previous_timestamp_ms = None; - let mut active_blinded = BTreeMap::<String, BlindedTransaction>::new(); let mut metric_utxos = metric_genesis_utxos(snapshot); - let mut metric_locked_blinded_inputs = BTreeMap::<String, Amount>::new(); - let reveal_bundle_slots_by_height = ledger - .burn_leader_ranks_for_blocks(snapshot.blocks.iter().map(|block| block.height)) - .map(|ranks_by_height| { - ranks_by_height - .into_iter() - .map(|(height, ranks)| { - ( - height, - reveal_committee_slot_count_for_height( - height, - ranks.len(), - ranks.iter().map(|rank| rank.owner.as_str()), - ), - ) - }) - .collect::<BTreeMap<_, _>>() - }) - .unwrap_or_default(); for block in &snapshot.blocks { - let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default(); let mut transfer_count = 0_u64; let mut burn_count = 0_u64; let mut mine_count = 0_u64; let mut burned_amount = 0_u64; - let mut burned_fee_amount = 0_u64; + let burned_fee_amount = 0_u64; let mut fees_amount = 0_u64; known_wallet_addresses.insert(block.miner.clone()); - for signature in &block.reveal_bundle_section.signatures { + for signature in &block.burn_bundle_section.signatures { known_wallet_addresses.insert(signature.member.clone()); } for transaction in &block.transactions { @@ -1288,103 +1151,6 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> } } } - for revealed in &revealed_transactions { - let transaction = &revealed.transaction; - known_wallet_addresses.insert(revealed.included_by.clone()); - collect_transaction_addresses(transaction, &mut known_wallet_addresses); - metric_index_transaction_outputs(&mut metric_utxos, transaction); - metric_index_blinded_fee_outputs( - &mut metric_utxos, - &revealed.commitment, - &revealed.included_by, - block, - transaction.fee(), - ); - fees_amount = fees_amount - .checked_add(transaction.fee()) - .context("block metric fees overflow")?; - let committer_fee = blinded_fee_share(transaction.fee(), BLINDED_COMMITTER_FEE_BPS); - let included_reveal_bundle_count = block.included_reveal_bundle_count(); - let available_reveal_bundle_slots = reveal_bundle_slots_by_height - .get(&block.height) - .copied() - .unwrap_or(REVEAL_COMMITTEE_SIZE); - let reveal_finalizer_fee = blinded_reveal_finalizer_fee( - transaction.fee(), - included_reveal_bundle_count, - available_reveal_bundle_slots, - ); - let reveal_bundle_signer_fees = - blinded_fee_share(transaction.fee(), BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS) - .saturating_mul(included_reveal_bundle_count as u64); - let distributed_fee = committer_fee - .saturating_add(reveal_finalizer_fee) - .saturating_add(reveal_bundle_signer_fees); - burned_fee_amount = burned_fee_amount - .checked_add(transaction.fee().saturating_sub(distributed_fee)) - .context("block metric burned fees overflow")?; - match transaction { - Transaction::Transfer { .. } => transfer_count += 1, - Transaction::Burn { amount, .. } => { - burn_count += 1; - burned_amount = burned_amount - .checked_add(*amount) - .context("block metric burns overflow")?; - } - Transaction::Mine { .. } => { - mine_count += 1; - } - } - } - let revealed_commitments = block - .all_blinded_reveals() - .into_iter() - .map(|reveal| reveal.commitment.clone()) - .collect::<std::collections::BTreeSet<_>>(); - let mut expired_blinded_fee_values = Vec::new(); - active_blinded.retain(|commitment, transaction| { - if revealed_commitments.contains(commitment) { - metric_locked_blinded_inputs.remove(commitment); - return false; - } - if block.height >= transaction.expires_at_height { - if !transaction.inputs.is_empty() { - expired_blinded_fee_values.push(transaction.fee); - metric_index_expired_blinded_change( - &mut metric_utxos, - commitment, - transaction, - metric_locked_blinded_inputs - .remove(commitment) - .unwrap_or_default(), - ); - } - return false; - } - true - }); - let expired_blinded_fees = - expired_blinded_fee_values - .into_iter() - .try_fold(0_u64, |total, fee| { - total - .checked_add(fee) - .context("block metric expiry fees overflow") - })?; - fees_amount = fees_amount - .checked_add(expired_blinded_fees) - .context("block metric expiry fees overflow")?; - burned_fee_amount = burned_fee_amount - .checked_add(expired_blinded_fees) - .context("block metric expired burned fees overflow")?; - for transaction in &block.blinded_transactions { - for input in &transaction.inputs { - known_wallet_addresses.insert(input.owner.clone()); - } - let locked_total = metric_spend_blinded_inputs(transaction, &mut metric_utxos)?; - metric_locked_blinded_inputs.insert(transaction.commitment.clone(), locked_total); - active_blinded.insert(transaction.commitment.clone(), transaction.clone()); - } total_burned_amount = total_burned_amount .checked_add(burned_amount) .and_then(|amount| amount.checked_add(burned_fee_amount)) @@ -1396,9 +1162,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .with_context(|| format!("failed to replay block {} for metrics", block.height))?; } metric_index_block_reward(&mut metric_utxos, block); - let circulating_supply = ledger_circulating_supply(&running_ledger)? - .checked_add(metric_locked_supply(&metric_locked_blinded_inputs)?) - .context("circulating supply metric overflows")?; + let circulating_supply = ledger_circulating_supply(&running_ledger)?; let block_time_ms = previous_timestamp_ms.map(|previous| block.timestamp_ms.saturating_sub(previous)); previous_timestamp_ms = Some(block.timestamp_ms); @@ -1410,7 +1174,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> mine_difficulty_bits: ledger.mine_difficulty_bits_at_height(block.height), circulating_supply, known_wallet_addresses: known_wallet_addresses.len() as u64, - transaction_count: (block.transactions.len() + revealed_transactions.len()) as u64, + transaction_count: block.transactions.len() as u64, transfer_count, burn_count, mine_count, @@ -1437,14 +1201,6 @@ fn ledger_circulating_supply(ledger: &Ledger) -> Result<Amount> { }) } -fn metric_locked_supply(locked: &BTreeMap<String, Amount>) -> Result<Amount> { - locked.values().try_fold(0_u64, |total, amount| { - total - .checked_add(*amount) - .context("circulating supply metric overflows") - }) -} - fn metric_genesis_utxos(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> { snapshot .genesis_allocations @@ -1462,10 +1218,6 @@ fn metric_genesis_utxos(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput .collect() } -fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { - ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount -} - fn metric_apply_public_transaction( transaction: &Transaction, utxos: &mut BTreeMap<OutPoint, TxOutput>, @@ -1486,13 +1238,6 @@ fn metric_spend_transaction_inputs( metric_spend_inputs(inputs, utxos) } -fn metric_spend_blinded_inputs( - transaction: &BlindedTransaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<Amount> { - metric_spend_inputs(&transaction.inputs, utxos) -} - fn metric_spend_inputs( inputs: &[TxInput], utxos: &mut BTreeMap<OutPoint, TxOutput>, @@ -1533,62 +1278,6 @@ fn metric_index_transaction_outputs( } } -fn metric_index_blinded_fee_outputs( - utxos: &mut BTreeMap<OutPoint, TxOutput>, - commitment: &str, - included_by: &str, - block: &Block, - fee: Amount, -) { - if fee == 0 { - return; - } - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - if committer_fee > 0 { - utxos.insert( - metric_blinded_committer_fee_outpoint(commitment), - TxOutput { - address: included_by.to_string(), - amount: committer_fee, - }, - ); - } - let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - if reveal_bundle_signer_fee > 0 { - for signature in &block.reveal_bundle_section.signatures { - utxos.insert( - metric_blinded_reveal_bundle_signer_fee_outpoint(commitment, signature.slot), - TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - }, - ); - } - } -} - -fn metric_index_expired_blinded_change( - utxos: &mut BTreeMap<OutPoint, TxOutput>, - commitment: &str, - transaction: &BlindedTransaction, - locked_total: Amount, -) { - let Some(first_input) = transaction.inputs.first() else { - return; - }; - let change = locked_total.saturating_sub(transaction.fee); - if change == 0 { - return; - } - utxos.insert( - metric_blinded_expiry_change_outpoint(commitment), - TxOutput { - address: first_input.owner.clone(), - amount: change, - }, - ); -} - fn metric_index_block_reward(utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block) { if block.reward == 0 { return; @@ -1616,27 +1305,6 @@ fn metric_reward_outpoint(block_hash: &str) -> OutPoint { } } -fn metric_blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 1, - } -} - -fn metric_blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 3 - u32::from(slot), - } -} - -fn metric_blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: 0, - } -} - fn collect_transaction_addresses(transaction: &Transaction, addresses: &mut BTreeSet<String>) { match transaction { Transaction::Transfer { @@ -1673,6 +1341,3 @@ fn unix_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } - -#[cfg(test)] -mod tests; diff --git a/src/adapters/ui_data_store/tests.rs b/src/adapters/ui_data_store/tests.rs @@ -1,499 +0,0 @@ -use std::collections::BTreeMap; - -use rusqlite::Connection; -use tempfile::tempdir; - -use crate::{ - adapters::ui_index::build_ui_chain_index, - domain::{BLOCK_REWARD, GenesisBurn, Ledger, OutPoint, Wallet, run_vdf}, -}; - -use super::{BlockMetricRow, SqliteUiDataStore, replace_metrics}; - -#[test] -fn sqlite_ui_data_store_does_not_create_chain_snapshot_table() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - - store - .with_connection(|connection| { - let count = connection.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'chain_snapshots'", - [], - |row| row.get::<_, u64>(0), - )?; - assert_eq!(count, 0); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn sqlite_ui_data_store_persists_ui_chain_index_for_latest_tip() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("ui-index-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - let snapshot = ledger.snapshot(); - let expected = build_ui_chain_index(&snapshot); - let tip_hash = expected.tip_hash.as_deref().unwrap(); - - store.project_snapshot(&snapshot, false).unwrap(); - - let loaded = store.load_ui_chain_index(tip_hash).unwrap().unwrap(); - assert_eq!(loaded, expected); - assert!( - store - .load_ui_chain_index("different-tip") - .unwrap() - .is_none() - ); -} - -#[test] -fn sqlite_ui_data_store_clear_all_removes_metrics_and_ui_indexes() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("clear-ui-data-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - let expected = build_ui_chain_index(&ledger.snapshot()); - let tip_hash = expected.tip_hash.as_deref().unwrap(); - - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - assert!(!store.load_metrics().unwrap().is_empty()); - assert!(store.load_ui_chain_index(tip_hash).unwrap().is_some()); - - store.clear_all().unwrap(); - - assert!(store.load_metrics().unwrap().is_empty()); - assert!(store.load_ui_chain_index(tip_hash).unwrap().is_none()); -} - -#[test] -fn sqlite_ui_data_store_saves_and_clears_block_metrics() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("metrics-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - let burn = ledger.build_burn(&wallet, 2, 1).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - - assert_eq!(metrics.last().unwrap().height, 1); - assert_eq!(metrics.last().unwrap().burn_count, 1); - assert_eq!(metrics.last().unwrap().burned_amount, 2); - assert_eq!(metrics.last().unwrap().fees_amount, 1); - assert_eq!( - metrics.last().unwrap().circulating_supply, - ledger.status().balances.values().copied().sum::<u64>() - ); - assert_eq!(metrics.last().unwrap().known_wallet_addresses, 1); - - store.clear_metrics().unwrap(); - assert!(store.load_metrics().unwrap().is_empty()); -} - -#[test] -fn sqlite_ui_data_store_projects_wallet_utxos() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let alice = Wallet::from_seed("ui-utxo-alice"); - let bob = Wallet::from_seed("ui-utxo-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) - .unwrap(); - let burn = ledger.build_burn(&alice, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); - ledger.submit_transaction(transfer).unwrap(); - let block = ledger.mine_next_block(&alice, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - - store.project_snapshot(&ledger.snapshot(), false).unwrap(); - - let bob_utxos = store.load_wallet_utxos(bob.address()).unwrap(); - assert_eq!(bob_utxos.len(), 1); - assert_eq!(bob_utxos[0].1.amount, 10); -} - -#[test] -fn sqlite_ui_data_store_loads_only_requested_outputs() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let alice = Wallet::from_seed("ui-output-lookup-alice"); - let bob = Wallet::from_seed("ui-output-lookup-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) - .unwrap(); - let burn = ledger.build_burn(&alice, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); - let output = OutPoint { - txid: transfer.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(transfer).unwrap(); - let block = ledger.mine_next_block(&alice, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - store.project_snapshot(&ledger.snapshot(), false).unwrap(); - - let requested = [ - output.clone(), - OutPoint { - txid: "missing".to_string(), - index: 99, - }, - ] - .into_iter() - .collect(); - let outputs = store.load_outputs(&requested).unwrap(); - - assert_eq!(outputs.len(), 1); - assert_eq!(outputs.get(&output).unwrap().amount, 10); - assert_eq!(outputs.get(&output).unwrap().address, bob.address()); -} - -#[test] -fn sqlite_ui_data_store_projects_wallet_transactions() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let alice = Wallet::from_seed("ui-wallet-tx-alice"); - let bob = Wallet::from_seed("ui-wallet-tx-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) - .unwrap(); - let burn = ledger.build_burn(&alice, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); - let signature = transfer.signature().to_string(); - ledger.submit_transaction(transfer).unwrap(); - let block = ledger.mine_next_block(&alice, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - - store.project_snapshot(&ledger.snapshot(), false).unwrap(); - - let (rows, total) = store - .load_wallet_transactions(bob.address(), &["transfer"], 0, 25) - .unwrap(); - assert_eq!(total, 1); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].transaction.signature(), signature); - assert_eq!(rows[0].kind, "transfer"); -} - -#[test] -fn sqlite_ui_data_store_loads_recent_metrics_in_height_order() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("recent-metrics-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - for timestamp_ms in [1_000, 2_000, 3_000] { - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&wallet, timestamp_ms).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - } - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - - let metrics = store.load_recent_metrics(2).unwrap(); - - assert_eq!( - metrics.iter().map(|row| row.height).collect::<Vec<_>>(), - vec![2, 3] - ); -} - -#[test] -fn sqlite_ui_data_store_migrates_known_wallet_address_metrics_column() { - let dir = tempdir().unwrap(); - let path = dir.path().join("ui_data.sqlite3"); - let connection = Connection::open(&path).unwrap(); - connection - .execute_batch( - r#" -CREATE TABLE block_metrics ( - height INTEGER PRIMARY KEY, - block_hash TEXT NOT NULL, - timestamp_ms INTEGER NOT NULL, - block_time_ms INTEGER, - mine_difficulty_bits INTEGER NOT NULL, - circulating_supply INTEGER NOT NULL, - transaction_count INTEGER NOT NULL, - transfer_count INTEGER NOT NULL, - burn_count INTEGER NOT NULL, - mine_count INTEGER NOT NULL, - burned_amount INTEGER NOT NULL, - total_burned_amount INTEGER NOT NULL, - fees_amount INTEGER NOT NULL, - reward_amount INTEGER NOT NULL, - vdf_rounds INTEGER NOT NULL, - finalizer_rank INTEGER NOT NULL -); -"#, - ) - .unwrap(); - drop(connection); - - let store = SqliteUiDataStore::open(&path).unwrap(); - - store - .with_connection(|connection| { - let count = connection - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('block_metrics') WHERE name = 'known_wallet_addresses'", - [], - |row| row.get::<_, u64>(0), - ) - .unwrap(); - assert_eq!(count, 1); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn sqlite_ui_data_store_metrics_supply_matches_wallet_balances_after_block_rewards() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let alice = Wallet::from_seed("metrics-supply-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) - .unwrap(); - - for timestamp_ms in [1_000, 2_000, 3_000] { - let burn = ledger.build_burn(&alice, 1, 1).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&alice, timestamp_ms).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - } - - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>(); - - assert_eq!(metrics.last().unwrap().height, 3); - assert_eq!( - metrics.last().unwrap().circulating_supply, - supply_from_balances - ); -} - -#[test] -fn sqlite_ui_data_store_metrics_count_known_wallet_addresses_seen_on_chain() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let alice = Wallet::from_seed("metrics-address-alice"); - let bob = Wallet::from_seed("metrics-address-bob"); - let carol = Wallet::from_seed("metrics-address-carol"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) - .unwrap(); - - let burn = ledger.build_burn(&alice, 1, 1).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); - ledger.submit_transaction(transfer).unwrap(); - let mine = ledger.build_mine(carol.address()).unwrap(); - ledger.submit_transaction(mine).unwrap(); - let block = ledger.mine_next_block(&alice, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - - assert_eq!(metrics[0].known_wallet_addresses, 1); - assert_eq!(metrics.last().unwrap().known_wallet_addresses, 3); -} - -#[test] -fn sqlite_ui_data_store_metrics_include_revealed_blinded_burns() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let alice = Wallet::from_seed("metrics-blinded-alice"); - let bob = Wallet::from_seed("metrics-blinded-bob"); - let carol = Wallet::from_seed("metrics-blinded-carol"); - let wallets = [alice.clone(), bob.clone()]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 10_000_000); - genesis.insert(bob.address().to_string(), 10_000_000); - genesis.insert(carol.address().to_string(), 10_000_000); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1_000_000), - GenesisBurn::new(bob.address(), 1_000_000), - ], - 1, - ) - .unwrap(); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(wallet, 1).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - let supply_after_commit = ledger.status().balances.values().copied().sum::<u64>(); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == member.owner) - .unwrap(); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - assert!(!bundles.is_empty()); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles) - .unwrap(); - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - let block = prepared.finish(wallet, vdf_output); - assert_eq!(block.all_blinded_reveals().len(), 1); - ledger.apply_locally_mined_block(block).unwrap(); - - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - let commit = metrics - .iter() - .find(|metric| metric.height == 1) - .expect("commit block metrics should exist"); - let last = metrics.last().unwrap(); - let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>(); - - assert_eq!(commit.circulating_supply, supply_after_commit + 10_000_000); - assert_eq!(last.burn_count, 2); - assert_eq!(last.burned_amount, 4); - assert_eq!(last.fees_amount, 7); - assert_eq!(last.circulating_supply, supply_from_balances); - assert_eq!(last.known_wallet_addresses, 3); -} - -#[test] -fn sqlite_ui_data_store_roundtrips_vdf_round_metrics_above_legacy_u32_limit() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let vdf_rounds = u64::from(u32::MAX) + 42; - - store - .with_connection_mut(|connection| { - let transaction = connection.transaction().unwrap(); - replace_metrics( - &transaction, - &[BlockMetricRow { - height: 1, - block_hash: "hash".to_string(), - timestamp_ms: 1_000, - block_time_ms: Some(415_000), - mine_difficulty_bits: 12, - circulating_supply: 100, - known_wallet_addresses: 1, - transaction_count: 0, - transfer_count: 0, - burn_count: 0, - mine_count: 0, - burned_amount: 0, - total_burned_amount: 0, - fees_amount: 0, - reward_amount: 0, - vdf_rounds, - finalizer_rank: 0, - }], - )?; - transaction.commit().unwrap(); - Ok(()) - }) - .unwrap(); - - let metrics = store.load_metrics().unwrap(); - assert_eq!(metrics.len(), 1); - assert_eq!(metrics[0].vdf_rounds, vdf_rounds); -} - -#[test] -fn sqlite_ui_data_store_metrics_include_genesis_reward_when_burn_consumes_allocation() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("metrics-genesis-reward"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - - assert_eq!(metrics.len(), 1); - assert_eq!(metrics[0].height, 0); - assert_eq!(metrics[0].burned_amount, 1); - assert_eq!(metrics[0].reward_amount, BLOCK_REWARD); - assert_eq!(metrics[0].circulating_supply, BLOCK_REWARD); -} - -#[test] -fn sqlite_ui_data_store_disabled_metrics_projection_deletes_old_metrics() { - let dir = tempdir().unwrap(); - let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("metrics-cleanup"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - store.project_snapshot(&ledger.snapshot(), true).unwrap(); - assert!(!store.load_metrics().unwrap().is_empty()); - - store.project_snapshot(&ledger.snapshot(), false).unwrap(); - assert!(store.load_metrics().unwrap().is_empty()); -} diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs @@ -1,18 +1,13 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use crate::domain::{ - Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, - BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, - Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, - TxOutput, blinded_reveal_finalizer_fee, hex_hash, reveal_committee_slot_count_for_height, - revealed_blinded_transactions, + Block, BurnLeaderRank, ChainSnapshot, Ledger, OutPoint, Transaction, TxOutput, hex_hash, }; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct UiChainIndex { pub(crate) tip_hash: Option<String>, pub(crate) outputs: BTreeMap<OutPoint, TxOutput>, - pub(crate) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, pub(crate) burn_leader_ranks_by_hash: BTreeMap<String, Vec<BurnLeaderRank>>, } @@ -20,26 +15,10 @@ pub(crate) fn build_ui_chain_index(snapshot: &ChainSnapshot) -> UiChainIndex { UiChainIndex { tip_hash: snapshot.blocks.last().map(|block| block.hash.clone()), outputs: known_chain_output_index(snapshot), - revealed_by_height: revealed_transactions_by_height(snapshot), burn_leader_ranks_by_hash: burn_leader_ranks_for_blocks(snapshot, &snapshot.blocks), } } -pub(crate) fn revealed_transactions_by_height( - snapshot: &ChainSnapshot, -) -> BTreeMap<u64, Vec<RevealedBlindedTransaction>> { - revealed_blinded_transactions(snapshot) - .unwrap_or_default() - .into_iter() - .fold( - BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(), - |mut by_height, revealed| { - by_height.entry(revealed.height).or_default().push(revealed); - by_height - }, - ) -} - pub(crate) fn burn_leader_ranks_for_blocks( snapshot: &ChainSnapshot, blocks: &[Block], @@ -80,26 +59,16 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu }, ); } - let revealed = revealed_blinded_transactions(snapshot).unwrap_or_default(); - let blocks_by_height = snapshot - .blocks - .iter() - .map(|block| (block.height, block)) - .collect::<BTreeMap<_, _>>(); - let reveal_bundle_slots_by_height = reveal_bundle_slots_by_height(snapshot); - let blinded_by_commitment = snapshot - .blocks - .iter() - .flat_map(|block| block.blinded_transactions.iter()) - .map(|transaction| (transaction.commitment.clone(), transaction.clone())) - .collect::<BTreeMap<_, _>>(); for block in &snapshot.blocks { for transaction in &block.transactions { index_transaction_outputs(&mut outputs, transaction); } if block.reward > 0 { outputs.insert( - reward_outpoint(&block.hash), + OutPoint { + txid: block.hash.clone(), + index: u32::MAX, + }, TxOutput { address: block.miner.clone(), amount: block.reward, @@ -107,190 +76,28 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu ); } } - for revealed in revealed { - index_transaction_outputs(&mut outputs, &revealed.transaction); - let fee = revealed.transaction.fee(); - if matches!(revealed.transaction, Transaction::Mine { .. }) { - if let Some(commit) = blinded_by_commitment.get(&revealed.commitment) { - index_blinded_collateral_change(&mut outputs, commit, fee); - } - } - if fee > 0 { - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - if committer_fee > 0 { - outputs.insert( - blinded_committer_fee_outpoint(&revealed.commitment), - TxOutput { - address: revealed.included_by, - amount: committer_fee, - }, - ); - } - if let Some(block) = blocks_by_height.get(&revealed.height) { - let reveal_finalizer_fee = blinded_reveal_finalizer_fee( - fee, - block.included_reveal_bundle_count(), - reveal_bundle_slots_by_height - .get(&revealed.height) - .copied() - .unwrap_or(REVEAL_COMMITTEE_SIZE), - ); - if reveal_finalizer_fee > 0 { - outputs.insert( - blinded_executor_fee_outpoint(&revealed.commitment), - TxOutput { - address: block.miner.clone(), - amount: reveal_finalizer_fee, - }, - ); - } - let reveal_bundle_signer_fee = - blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - if reveal_bundle_signer_fee > 0 { - for signature in &block.reveal_bundle_section.signatures { - outputs.insert( - blinded_reveal_bundle_signer_fee_outpoint( - &revealed.commitment, - signature.slot, - ), - TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - }, - ); - } - } - } - } - } - index_expired_blinded_outputs(&mut outputs, snapshot); outputs } -fn reveal_bundle_slots_by_height(snapshot: &ChainSnapshot) -> BTreeMap<u64, usize> { - Ledger::from_persisted_snapshot(snapshot.clone()) - .ok() - .and_then(|ledger| { - ledger - .burn_leader_ranks_for_blocks(snapshot.blocks.iter().map(|block| block.height)) - .ok() - }) - .map(|ranks_by_height| { - ranks_by_height - .into_iter() - .map(|(height, ranks)| { - ( - height, - reveal_committee_slot_count_for_height( - height, - ranks.len(), - ranks.iter().map(|rank| rank.owner.as_str()), - ), - ) - }) - .collect() - }) - .unwrap_or_default() -} - -fn index_blinded_collateral_change( - outputs: &mut BTreeMap<OutPoint, TxOutput>, - transaction: &BlindedTransaction, - fee: Amount, -) { - let Some(first_input) = transaction.inputs.first() else { - return; - }; - let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { - total.saturating_add( - outputs - .get(&input.outpoint) - .map(|output| output.amount) - .unwrap_or_default(), - ) - }); - if fee >= locked_total { - return; - } - outputs.insert( - blinded_expiry_change_outpoint(&transaction.commitment), - TxOutput { - address: first_input.owner.clone(), - amount: locked_total - fee, - }, - ); -} - -fn index_expired_blinded_outputs( - outputs: &mut BTreeMap<OutPoint, TxOutput>, - snapshot: &ChainSnapshot, -) { - let mut active = BTreeMap::<String, (BlindedTransaction, Amount)>::new(); - for block in &snapshot.blocks { - let revealed = block - .all_blinded_reveals() - .into_iter() - .map(|reveal| reveal.commitment.clone()) - .collect::<BTreeSet<_>>(); - active.retain(|commitment, (transaction, locked_total)| { - if revealed.contains(commitment) { - return false; - } - if block.height >= transaction.expires_at_height { - if let Some(first_input) = transaction.inputs.first() { - if transaction.fee <= *locked_total { - let change = *locked_total - transaction.fee; - if change > 0 { - outputs.insert( - blinded_expiry_change_outpoint(commitment), - TxOutput { - address: first_input.owner.clone(), - amount: change, - }, - ); - } - } - } - return false; - } - true - }); - for transaction in &block.blinded_transactions { - let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { - total.saturating_add( - outputs - .get(&input.outpoint) - .map(|output| output.amount) - .unwrap_or_default(), - ) - }); - active.insert( - transaction.commitment.clone(), - (transaction.clone(), locked_total), - ); - } - } -} - fn index_transaction_outputs( outputs: &mut BTreeMap<OutPoint, TxOutput>, transaction: &Transaction, ) { - let created_outputs = match transaction { + let tx_outputs = match transaction { Transaction::Transfer { outputs, .. } => outputs.clone(), Transaction::Burn { change, .. } => change.clone(), Transaction::Mine { recipient, .. } => vec![TxOutput { address: recipient.clone(), - amount: MINE_REWARD, + amount: crate::domain::MINE_REWARD, }], }; - for (index, output) in created_outputs.iter().enumerate() { + for (index, output) in tx_outputs.into_iter().enumerate() { outputs.insert( OutPoint { txid: transaction.signature().to_string(), index: index as u32, }, - output.clone(), + output, ); } } @@ -302,41 +109,33 @@ fn genesis_allocation_outpoint(address: &str) -> OutPoint { } } -fn reward_outpoint(block_hash: &str) -> OutPoint { - OutPoint { - txid: block_hash.to_string(), - index: u32::MAX, - } -} +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; -fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 1, - } -} + use crate::domain::{ChainSnapshot, Ledger, MICRO_IUNA, Wallet}; -fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 2, - } -} + use super::{build_ui_chain_index, genesis_allocation_outpoint}; -fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 3 - u32::from(slot), - } -} + #[test] + fn ui_chain_index_keeps_genesis_allocation_outputs() { + let wallet = Wallet::from_seed("ui-genesis-output-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), MICRO_IUNA); + let ledger = Ledger::new(allocations.clone(), 1); + let snapshot = ChainSnapshot { + genesis_allocations: allocations, + vdf_rounds: ledger.vdf_rounds(), + launch_profile: ledger.launch_profile().clone(), + blocks: ledger.chain().to_vec(), + }; -fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: 0, - } -} + let index = build_ui_chain_index(&snapshot); + let outpoint = genesis_allocation_outpoint(wallet.address()); -fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { - ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount + assert_eq!( + index.outputs.get(&outpoint).map(|output| output.amount), + Some(MICRO_IUNA) + ); + } } diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs @@ -14,7 +14,7 @@ use pbkdf2::pbkdf2_hmac; use serde::{Deserialize, Serialize}; use sha2::Sha256; -use crate::domain::{OwnedBlindedTransaction, Wallet}; +use crate::domain::Wallet; const WALLET_FILE_VERSION: u32 = 3; const PLAINTEXT_WALLET_FILE_VERSION: u32 = 2; @@ -30,8 +30,6 @@ struct WalletFile { #[serde(default, skip_serializing_if = "Option::is_none")] seed: Option<String>, address: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - owned_blinded_transactions: Vec<OwnedBlindedTransaction>, #[serde(default, skip_serializing_if = "Option::is_none")] encryption: Option<EncryptedWalletSeed>, } @@ -39,8 +37,6 @@ struct WalletFile { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct WalletData { seed: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - owned_blinded_transactions: Vec<OwnedBlindedTransaction>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -144,36 +140,6 @@ pub fn load_with_password(path: &Path, password: &str) -> Result<Wallet> { load_encrypted_or_plaintext(path, Some(password)) } -pub fn load_owned_blinded_transactions( - path: &Path, - password: Option<&str>, -) -> Result<Vec<OwnedBlindedTransaction>> { - let stored = read_wallet_file(path)?; - Ok(wallet_data(&stored, password)?.owned_blinded_transactions) -} - -pub fn replace_owned_blinded_transactions( - path: &Path, - password: Option<&str>, - owned_blinded_transactions: Vec<OwnedBlindedTransaction>, -) -> Result<()> { - if !path.exists() { - return Ok(()); - } - let stored = read_wallet_file(path)?; - let mut data = wallet_data(&stored, password)?; - data.owned_blinded_transactions = owned_blinded_transactions; - let mut file = open_wallet_file(path, WalletFileMode::Replace)?; - if stored.encryption.is_some() { - let password = password - .context("wallet is encrypted; unlock it before persisting blinded transactions")?; - write_encrypted_wallet_data_file(&mut file, data, &stored.address, password) - } else { - write_wallet_data_file(&mut file, data, &stored.address) - } - .with_context(|| format!("failed to update wallet file {}", path.display())) -} - pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> { if !path.exists() { return Ok(()); @@ -195,16 +161,8 @@ pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> ); } let mut file = open_wallet_file(path, WalletFileMode::Replace)?; - write_encrypted_wallet_data_file( - &mut file, - WalletData { - seed, - owned_blinded_transactions: data.owned_blinded_transactions, - }, - wallet.address(), - password, - ) - .with_context(|| format!("failed to encrypt wallet file {}", path.display())) + write_encrypted_wallet_data_file(&mut file, WalletData { seed }, wallet.address(), password) + .with_context(|| format!("failed to encrypt wallet file {}", path.display())) } pub fn reencrypt_with_password( @@ -227,10 +185,7 @@ pub fn reencrypt_with_password( let mut file = open_wallet_file(path, WalletFileMode::Replace)?; write_encrypted_wallet_data_file( &mut file, - WalletData { - seed, - owned_blinded_transactions: data.owned_blinded_transactions, - }, + WalletData { seed }, wallet.address(), new_password, ) @@ -327,14 +282,7 @@ fn write_wallet_encrypted( } fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> { - write_wallet_data_file( - file, - WalletData { - seed, - owned_blinded_transactions: Vec::new(), - }, - address, - ) + write_wallet_data_file(file, WalletData { seed }, address) } fn write_wallet_data_file(file: &mut File, data: WalletData, address: &str) -> Result<()> { @@ -342,7 +290,6 @@ fn write_wallet_data_file(file: &mut File, data: WalletData, address: &str) -> R version: PLAINTEXT_WALLET_FILE_VERSION, seed: Some(data.seed), address: address.to_string(), - owned_blinded_transactions: data.owned_blinded_transactions, encryption: None, }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; @@ -357,15 +304,7 @@ fn write_encrypted_wallet_file( address: &str, password: &str, ) -> Result<()> { - write_encrypted_wallet_data_file( - file, - WalletData { - seed, - owned_blinded_transactions: Vec::new(), - }, - address, - password, - ) + write_encrypted_wallet_data_file(file, WalletData { seed }, address, password) } fn write_encrypted_wallet_data_file( @@ -379,7 +318,6 @@ fn write_encrypted_wallet_data_file( version: WALLET_FILE_VERSION, seed: None, address: address.to_string(), - owned_blinded_transactions: Vec::new(), encryption: Some(encryption), }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; @@ -397,10 +335,7 @@ fn wallet_data(stored: &WalletFile, password: Option<&str>) -> Result<WalletData .seed .clone() .context("wallet file does not contain a seed")?; - Ok(WalletData { - seed, - owned_blinded_transactions: stored.owned_blinded_transactions.clone(), - }) + Ok(WalletData { seed }) } fn encrypt_wallet_data( @@ -469,7 +404,6 @@ fn decrypt_wallet_data( Ok(data) => Ok(data), Err(_) => Ok(WalletData { seed: String::from_utf8(plaintext).context("wallet seed is not valid utf-8")?, - owned_blinded_transactions: Vec::new(), }), } } @@ -579,302 +513,3 @@ fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> { .open(path) .with_context(|| format!("failed to create wallet file {}", path.display())) } - -#[cfg(test)] -mod tests { - use std::fs; - - use bip39::{Language, Mnemonic}; - use tempfile::tempdir; - - use crate::domain::{ - BlindedReveal, BlindedTransaction, OwnedBlindedTransaction, Transaction, TxInput, TxOutput, - }; - - use super::{ - encrypt_existing_with_password, load_or_create, load_owned_blinded_transactions, - load_with_password, metadata, replace_owned_blinded_transactions, - replace_with_generated_seed_phrase, replace_with_generated_seed_phrase_encrypted, - replace_with_imported_seed_phrase, setup_seed_phrase, setup_seed_phrase_with_password, - }; - - #[test] - fn creates_and_reuses_wallet_file() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - - let first = load_or_create(&path).unwrap(); - let second = load_or_create(&path).unwrap(); - - assert_eq!(first.address(), second.address()); - let stored = fs::read_to_string(path).unwrap(); - assert!(stored.contains(first.address())); - assert!(!stored.contains("dev-wallet")); - assert!( - setup_seed_phrase(&dir.path().join("wallet.json")) - .unwrap() - .is_some() - ); - } - - #[test] - fn generated_wallet_uses_recovery_phrase() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - - let (_wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap(); - let words = seed_phrase.split_whitespace().collect::<Vec<_>>(); - - assert_eq!(words.len(), 24); - assert!(Mnemonic::parse_in_normalized(Language::English, &seed_phrase).is_ok()); - assert_eq!( - setup_seed_phrase(&path).unwrap().as_deref(), - Some(seed_phrase.as_str()) - ); - } - - #[test] - fn generated_verified_phrase_imports_to_same_wallet() { - let dir = tempdir().unwrap(); - let generated_path = dir.path().join("generated-wallet.json"); - let imported_path = dir.path().join("imported-wallet.json"); - - let (generated_wallet, seed_phrase) = - replace_with_generated_seed_phrase(&generated_path).unwrap(); - assert_recovery_words_verify(&seed_phrase, &[0, 6, 13, 23]); - - let generated_loaded = load_or_create(&generated_path).unwrap(); - assert_eq!(generated_wallet.address(), generated_loaded.address()); - - let imported_wallet = - replace_with_imported_seed_phrase(&imported_path, &seed_phrase).unwrap(); - assert_recovery_words_verify( - setup_seed_phrase(&imported_path) - .unwrap() - .as_deref() - .unwrap(), - &[0, 6, 13, 23], - ); - - let imported_loaded = load_or_create(&imported_path).unwrap(); - assert_eq!(generated_wallet.address(), imported_wallet.address()); - assert_eq!(generated_wallet.address(), imported_loaded.address()); - assert_eq!( - setup_seed_phrase(&generated_path).unwrap(), - setup_seed_phrase(&imported_path).unwrap() - ); - } - - #[test] - fn encrypted_generated_wallet_hides_seed_and_requires_password() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - - let (wallet, seed_phrase) = - replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple") - .unwrap(); - let stored = fs::read_to_string(&path).unwrap(); - - assert!(stored.contains("\"version\": 3")); - assert!(stored.contains("\"encryption\"")); - assert!(!stored.contains(&seed_phrase)); - assert_eq!(metadata(&path).unwrap().unwrap().address, wallet.address()); - assert!(metadata(&path).unwrap().unwrap().encrypted); - assert!( - load_or_create(&path) - .unwrap_err() - .to_string() - .contains("encrypted") - ); - assert!(load_with_password(&path, "wrong password").is_err()); - - let loaded = load_with_password(&path, "correct horse battery staple").unwrap(); - assert_eq!(loaded.address(), wallet.address()); - assert_eq!( - setup_seed_phrase_with_password(&path, Some("correct horse battery staple")) - .unwrap() - .as_deref(), - Some(seed_phrase.as_str()) - ); - assert!(setup_seed_phrase(&path).unwrap().is_none()); - } - - #[test] - fn plaintext_wallet_can_be_encrypted_in_place() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - - let (wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap(); - encrypt_existing_with_password(&path, "correct horse battery staple").unwrap(); - let stored = fs::read_to_string(&path).unwrap(); - - assert!(stored.contains("\"version\": 3")); - assert!(!stored.contains(&seed_phrase)); - assert_eq!( - load_with_password(&path, "correct horse battery staple") - .unwrap() - .address(), - wallet.address() - ); - } - - #[test] - fn plaintext_wallet_persists_owned_blinded_transactions() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - replace_with_generated_seed_phrase(&path).unwrap(); - let owned = sample_owned_blinded_transaction(); - - replace_owned_blinded_transactions(&path, None, vec![owned.clone()]).unwrap(); - - assert_eq!( - load_owned_blinded_transactions(&path, None).unwrap(), - vec![owned] - ); - } - - #[test] - fn encrypted_wallet_persists_owned_blinded_transactions_without_plaintext() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple") - .unwrap(); - let owned = sample_owned_blinded_transaction(); - - replace_owned_blinded_transactions( - &path, - Some("correct horse battery staple"), - vec![owned.clone()], - ) - .unwrap(); - - let stored = fs::read_to_string(&path).unwrap(); - assert!(!stored.contains(&owned.payload.signature().to_string())); - assert!(!stored.contains(&owned.reveal.key)); - assert_eq!( - load_owned_blinded_transactions(&path, Some("correct horse battery staple")).unwrap(), - vec![owned] - ); - assert!(load_owned_blinded_transactions(&path, Some("wrong password")).is_err()); - } - - #[test] - fn imports_normalized_seed_phrase() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - - let wallet = replace_with_imported_seed_phrase( - &path, - " ABANDON abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art ", - ) - .unwrap(); - let loaded = load_or_create(&path).unwrap(); - - assert_eq!(wallet.address(), loaded.address()); - assert_eq!( - setup_seed_phrase(&path).unwrap().as_deref(), - Some( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" - ) - ); - } - - #[test] - fn rejects_invalid_imported_seed_phrase() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - - let error = replace_with_imported_seed_phrase(&path, "too few words").unwrap_err(); - - assert!(error.to_string().contains("24 words")); - } - - #[test] - fn rejects_imported_seed_phrase_with_invalid_checksum() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - let invalid_checksum = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"; - - let error = replace_with_imported_seed_phrase(&path, invalid_checksum).unwrap_err(); - - assert!(error.to_string().contains("BIP-39")); - } - - fn assert_recovery_words_verify(seed_phrase: &str, indexes: &[usize]) { - let words = seed_phrase.split_whitespace().collect::<Vec<_>>(); - assert_eq!(words.len(), 24); - for index in indexes { - let answer = words[*index].to_ascii_uppercase(); - assert_eq!( - answer.trim().to_ascii_lowercase(), - words[*index], - "word {} should verify case-insensitively", - index + 1 - ); - } - } - - fn sample_owned_blinded_transaction() -> OwnedBlindedTransaction { - let payload = Transaction::Transfer { - inputs: vec![TxInput { - outpoint: crate::domain::OutPoint { - txid: "a".repeat(64), - index: 0, - }, - owner: "mv_sample_owner".to_string(), - signature: "b".repeat(64), - }], - outputs: vec![TxOutput { - address: "mv_sample_recipient".to_string(), - amount: 1, - }], - fee: 1, - signature: "c".repeat(64), - }; - OwnedBlindedTransaction { - transaction: BlindedTransaction { - commitment: "d".repeat(64), - inputs: Vec::new(), - fee: 1, - encrypted_size: 42, - expires_at_height: 10, - nonce: "e".repeat(24), - ciphertext: "f".repeat(84), - payload_hash: "1".repeat(64), - }, - payload, - reveal: BlindedReveal { - commitment: "d".repeat(64), - key: "2".repeat(64), - }, - } - } - - #[test] - fn migrates_v1_wallet_file_to_current_address() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - fs::write(&path, r#"{"version":1,"seed":"alice","address":"old"}"#).unwrap(); - - let wallet = load_or_create(&path).unwrap(); - let stored = fs::read_to_string(path).unwrap(); - - assert!(stored.contains("\"version\": 2")); - assert!(stored.contains(wallet.address())); - } - - #[test] - fn rejects_seed_address_mismatch() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - fs::write( - &path, - r#"{"version":2,"seed":"alice","address":"mv_wrong"}"#, - ) - .unwrap(); - - let error = load_or_create(&path).unwrap_err(); - - assert!(error.to_string().contains("seed derives")); - } -} diff --git a/src/app.rs b/src/app.rs @@ -11,8 +11,7 @@ use anyhow::Result; use tokio::sync::Mutex; use crate::domain::{ - Amount, BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, Ledger, - MINE_ACTIONS_PER_ANCHOR_LIMIT, PreparedBlock, RevealBundle, Transaction, run_vdf, + Amount, BurnBundle, Ledger, MINE_ACTIONS_PER_ANCHOR_LIMIT, PreparedBlock, Transaction, run_vdf, }; mod automatic_mining; @@ -21,7 +20,6 @@ mod helpers; mod in_memory_network; mod ledger_view; mod node_lifecycle; -mod owned_blinded; mod peer_book; mod receive; mod status; @@ -51,9 +49,9 @@ pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000; const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000; const AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK: u64 = 100_000; const AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS: u64 = 60_000; -const REVEAL_BUNDLE_COLLECTION_MS: u64 = 30_000; +const BURN_BUNDLE_COLLECTION_MS: u64 = 30_000; const AUTO_BLOCK_ANCHOR_BURN_AMOUNT: Amount = 1; -const AUTO_BLOCK_ANCHOR_BURN_FEE: Amount = 0; +const AUTO_BLOCK_ANCHOR_BURN_FEE: Amount = 1; static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); pub fn set_debug_logging(enabled: bool) { @@ -114,13 +112,9 @@ pub struct NodeCore { last_auto_pow_mine_anchor: Option<String>, last_auto_pow_mine_status: Option<String>, auto_pow_mine_cursor: Option<AutoPowMineCursor>, - owned_blinded_transactions: BTreeMap<String, BlindedTransaction>, - owned_blinded_reveals: BTreeMap<String, BlindedReveal>, - owned_blinded_payloads: BTreeMap<String, Transaction>, - owned_blinded_outbox_version: u64, - reveal_bundles: BTreeMap<(u64, u8), RevealBundle>, - equivocated_reveal_bundle_slots: BTreeSet<(u64, u8)>, - reveal_bundle_collection_started: Option<(u64, u64)>, + burn_bundles: BTreeMap<(u64, u8), BurnBundle>, + equivocated_burn_bundle_slots: BTreeSet<(u64, u8)>, + burn_bundle_collection_started: Option<(u64, u64)>, local_block_anchor_burn: Option<(u64, Transaction)>, outbox: Vec<GossipEnvelope>, } @@ -131,512 +125,3 @@ pub fn now_ms() -> u64 { .expect("system time is before unix epoch") .as_millis() as u64 } - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use crate::domain::{ - GenesisBurn, Ledger, MICRO_IUNA, OutPoint, Transaction, VDF_TARGET_BLOCK_MS, Wallet, - run_vdf, - }; - - use super::{ - InMemoryNetwork, NodeCore, REVEAL_BUNDLE_COLLECTION_MS, - helpers::transaction_input_outpoints, - }; - - fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { - wallets - .iter() - .find(|wallet| wallet.address() == address) - .unwrap_or_else(|| panic!("missing wallet for address {address}")) - } - - fn queue_auto_pow_mine_action(node: &mut NodeCore) -> Transaction { - node.set_pow_mining_enabled(true); - (0..10_000) - .find_map(|timestamp| node.prepare_automatic_mining(timestamp).pow_mined) - .expect("test node should find a PoW mine action") - } - - fn assert_block_has_mine_action(block: &crate::domain::Block) { - assert!( - block - .transactions - .iter() - .any(|transaction| matches!(transaction, Transaction::Mine { .. })), - "block {} should include a mine action", - block.height - ); - } - - #[test] - fn automatic_finalization_includes_reveals_with_two_nodes_and_one_burner() { - let finalizer = Wallet::from_seed("single-burner-reveal-finalizer"); - let wallet = Wallet::from_seed("single-burner-reveal-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut network = InMemoryNetwork::default(); - network.insert( - "finalizer", - NodeCore::from_ledger_with_burn_fee_and_enabled( - finalizer.clone(), - ledger.clone(), - true, - MICRO_IUNA / 10, - 1, - ), - ); - network.insert("wallet", NodeCore::from_ledger(wallet.clone(), ledger, 0)); - - let blinded = network - .node_mut("wallet") - .unwrap() - .blinded_burn_with_fee(MICRO_IUNA / 10, 1, 4) - .unwrap(); - network.deliver_until_idle().unwrap(); - - let commit_plan = network - .node_mut("finalizer") - .unwrap() - .prepare_automatic_finalization(1); - let commit_work = commit_plan - .work - .expect("finalizer should prepare commit block"); - let commit_vdf = run_vdf(commit_work.vdf_seed(), commit_work.vdf_rounds()); - let commit_block = network - .node_mut("finalizer") - .unwrap() - .complete_prepared_block_at(commit_work, commit_vdf, 1) - .unwrap(); - let wallet_commitment = blinded.commitment.clone(); - assert!( - commit_block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == wallet_commitment), - "first block should commit the wallet's blinded burn" - ); - network.deliver_until_idle().unwrap(); - assert!( - network - .node("finalizer") - .unwrap() - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == wallet_commitment), - "finalizer should have received the reveal before building the next block" - ); - assert!( - network - .node("wallet") - .unwrap() - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == wallet_commitment), - "wallet node should also keep the reveal in its mempool" - ); - - let reveal_plan = network - .node_mut("finalizer") - .unwrap() - .prepare_automatic_finalization(2); - assert!(reveal_plan.work.is_none()); - assert!( - reveal_plan - .skipped_reason - .as_deref() - .unwrap_or_default() - .contains("collecting blinded reveals") - ); - let reveal_plan = network - .node_mut("finalizer") - .unwrap() - .prepare_automatic_finalization(REVEAL_BUNDLE_COLLECTION_MS + 2); - let reveal_work = reveal_plan - .work - .expect("finalizer should prepare reveal block"); - let reveal_vdf = run_vdf(reveal_work.vdf_seed(), reveal_work.vdf_rounds()); - let reveal_block = network - .node_mut("finalizer") - .unwrap() - .complete_prepared_block_at(reveal_work, reveal_vdf, 2) - .unwrap(); - - assert!( - reveal_block - .all_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == wallet_commitment), - "automatic finalization should include the pending reveal without requiring an extra mempool poll" - ); - } - - #[test] - fn genesis_transfer_arriving_during_vdf_with_peer_mines_does_not_stall_following_blocks() { - let miner = Wallet::from_seed("during-vdf-miner"); - let (_finalizer, finalizer_node, block2_work) = (0..1_000) - .find_map(|seed_index| { - let finalizer = Wallet::from_seed(&format!("during-vdf-finalizer-{seed_index}")); - let mut allocations = BTreeMap::new(); - allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let split = ledger - .build_transfer(&finalizer, finalizer.address(), MICRO_IUNA / 10, 0) - .ok()?; - let split_change = OutPoint { - txid: split.signature().to_string(), - index: 1, - }; - ledger.submit_transaction(split).ok()?; - let burn = ledger - .build_burn_with_inputs(&finalizer, 1, 0, &[split_change]) - .ok()?; - ledger.submit_transaction(burn).ok()?; - let block1 = ledger.mine_next_block(&finalizer, 1).ok()?; - assert!(block1.blinded_transactions.is_empty()); - assert!( - !block1 - .transactions - .iter() - .any(|transaction| matches!(transaction, Transaction::Mine { .. })), - "node B joins after block 1, so block 1 should not include B's mine action" - ); - ledger.apply_block(block1).ok()?; - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - finalizer.clone(), - ledger, - true, - 0, - 100, - ); - let block2_plan = node.prepare_automatic_finalization(2); - let block2_work = block2_plan.work?; - let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; - let anchor_inputs = transaction_input_outpoints(&anchor_burn); - let anchor_total = node - .ledger() - .utxos_for_address(finalizer.address()) - .iter() - .filter(|(outpoint, _)| anchor_inputs.contains(outpoint)) - .map(|(_, output)| output.amount) - .sum::<u64>(); - if anchor_total >= 200_000 { - return None; - } - Some((finalizer, node, block2_work)) - }) - .expect("test should find a seed where block 2 anchor uses the small reward UTXO"); - let mut network = InMemoryNetwork::default(); - network.insert("finalizer", finalizer_node); - - let miner_ledger = - Ledger::from_snapshot(network.node("finalizer").unwrap().chain_snapshot()).unwrap(); - network.insert( - "miner", - NodeCore::from_ledger(miner.clone(), miner_ledger, 0), - ); - - queue_auto_pow_mine_action(network.node_mut("miner").unwrap()); - network.deliver_until_idle().unwrap(); - network - .node_mut("finalizer") - .unwrap() - .transfer_with_fee_rate(miner.address(), MICRO_IUNA / 10, 100, &[]) - .unwrap(); - let blinded = network - .node("finalizer") - .unwrap() - .ledger() - .pending_blinded_transactions() - .last() - .cloned() - .expect("A should queue the A -> B blinded transfer while block 2 VDF is running"); - network.deliver_until_idle().unwrap(); - assert!( - network - .node("finalizer") - .unwrap() - .ledger() - .pending_blinded_transactions() - .iter() - .any(|tx| tx.commitment == blinded.commitment), - "the finalizer should receive the blinded tx while block 2 VDF is running" - ); - - let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); - let block2 = network - .node_mut("finalizer") - .unwrap() - .complete_prepared_block_at(block2_work, block2_vdf, 2) - .unwrap(); - assert!( - block2.blinded_transactions.is_empty(), - "block 2 work was prepared before the blinded tx arrived" - ); - assert!( - !block2 - .transactions - .iter() - .any(|transaction| matches!(transaction, Transaction::Mine { .. })), - "block 2 work was prepared before B's mine action arrived" - ); - network.deliver_until_idle().unwrap(); - - let block3_outcome = network - .node_mut("finalizer") - .unwrap() - .automatic_mine_once(3); - assert!( - block3_outcome.block.is_some(), - "finalizer should keep producing after the during-VDF blinded tx: {:?}", - block3_outcome.skipped_reason - ); - let block3 = block3_outcome.block.unwrap(); - assert!( - block3 - .transactions - .first() - .is_some_and(Transaction::is_burn), - "the mandatory anchor burn must be selected before during-VDF mempool items" - ); - let committed_blinded = block3 - .blinded_transactions - .iter() - .any(|tx| tx.commitment == blinded.commitment); - assert_block_has_mine_action(&block3); - network.deliver_until_idle().unwrap(); - - queue_auto_pow_mine_action(network.node_mut("miner").unwrap()); - network.deliver_until_idle().unwrap(); - let block4_started_at = block3.timestamp_ms.saturating_add(1); - let mut block4_outcome = network - .node_mut("finalizer") - .unwrap() - .automatic_mine_once(block4_started_at); - if block4_outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("collecting blinded reveals")) - { - block4_outcome = network.node_mut("finalizer").unwrap().automatic_mine_once( - block4_started_at.saturating_add(REVEAL_BUNDLE_COLLECTION_MS + 1), - ); - } - let block4 = block4_outcome - .block - .expect("finalizer should keep producing the next block"); - if committed_blinded { - assert!( - block4 - .all_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == blinded.commitment), - "the committed during-VDF blinded tx should reveal in a later block" - ); - } else { - assert!( - network - .node("finalizer") - .unwrap() - .ledger() - .pending_blinded_transactions() - .is_empty(), - "a conflicting during-VDF blinded tx should be pruned after the anchor burn spends its input" - ); - assert!( - network - .node("finalizer") - .unwrap() - .owned_blinded_transactions() - .is_empty(), - "owned blinded state should not keep rebroadcasting a pruned tx" - ); - assert!( - block4.all_blinded_reveals().is_empty(), - "a pruned blinded tx was never committed, so there should be no reveal" - ); - } - assert_block_has_mine_action(&block4); - } - - #[test] - fn own_blinded_transaction_arriving_during_vdf_does_not_starve_next_anchor_burn() { - let recipient = Wallet::from_seed("during-vdf-own-recipient"); - let (mut node, block2_work, transfer_outpoint, transfer_amount) = (0..1_000) - .find_map(|seed_index| { - let finalizer = - Wallet::from_seed(&format!("during-vdf-own-finalizer-{seed_index}")); - let mut allocations = BTreeMap::new(); - allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - finalizer.clone(), - ledger, - true, - 0, - 1_000, - ); - node.set_pow_mining_enabled(true); - let block1 = node.automatic_mine_once(1).block?; - assert!(block1.blinded_transactions.is_empty()); - - let block2_plan = node.prepare_automatic_finalization(2); - let block2_work = block2_plan.work?; - let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; - let anchor_inputs = transaction_input_outpoints(&anchor_burn); - let utxos = node.ledger().utxos_for_address(finalizer.address()); - let anchor_total = utxos - .iter() - .filter(|(outpoint, _)| anchor_inputs.contains(outpoint)) - .map(|(_, output)| output.amount) - .sum::<u64>(); - let (transfer_outpoint, transfer_output) = - utxos.into_iter().find(|(outpoint, output)| { - !anchor_inputs.contains(outpoint) && output.amount > MICRO_IUNA - })?; - if anchor_total >= 2_000_000 { - return None; - } - Some(( - node, - block2_work, - transfer_outpoint, - transfer_output.amount.min(MICRO_IUNA) / 10, - )) - }) - .expect("test should find a seed with live-like small-anchor/large-change UTXOs"); - - node.transfer_with_fee_spending( - recipient.address(), - transfer_amount, - 1, - &[transfer_outpoint], - ) - .expect("wallet tx created while block 2 VDF is running"); - let blinded = node - .ledger() - .pending_blinded_transactions() - .last() - .cloned() - .expect("wallet tx should be queued as a blinded transaction"); - - let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); - let block2 = node - .complete_prepared_block_at(block2_work, block2_vdf, 2) - .unwrap(); - assert!( - block2.blinded_transactions.is_empty(), - "block 2 work was prepared before the blinded tx arrived" - ); - assert!( - node.ledger() - .pending_blinded_transactions() - .iter() - .any(|tx| tx.commitment == blinded.commitment), - "the during-VDF blinded tx should remain pending for block 3" - ); - - let block3_outcome = node.automatic_mine_once(3); - assert!( - block3_outcome.block.is_some(), - "pending own blinded tx must not starve the next anchor burn: {:?}", - block3_outcome.skipped_reason - ); - let block3 = block3_outcome.block.unwrap(); - assert!( - block3.blinded_transactions.is_empty() - || block3 - .blinded_transactions - .iter() - .any(|tx| tx.commitment == blinded.commitment), - "block 3 may include the during-VDF tx, but must not stall when the anchor burn has priority" - ); - } - - #[test] - fn locally_produced_blocks_import_on_independent_peer_ledger() { - let alice = Wallet::from_seed("producer-parity-alice"); - let bob = Wallet::from_seed("producer-parity-bob"); - let carol = Wallet::from_seed("producer-parity-carol"); - let wallets = [alice.clone(), bob.clone(), carol.clone()]; - let mut allocations = BTreeMap::new(); - for wallet in &wallets { - allocations.insert(wallet.address().to_string(), 20 * MICRO_IUNA); - } - let genesis_burns = wallets - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(); - let mut producer_ledger = - Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap(); - let mut peer_ledger = producer_ledger.clone(); - - for step in 0..8 { - assert_eq!( - producer_ledger.status().tip_hash, - peer_ledger.status().tip_hash - ); - let leader = producer_ledger - .expected_leader_for_next_block() - .expect("test chain should have an eligible leader"); - let leader_wallet = wallet_for_address(&wallets, &leader).clone(); - let timestamp_ms = (step + 1) as u64 * VDF_TARGET_BLOCK_MS; - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - leader_wallet.clone(), - producer_ledger.clone(), - true, - MICRO_IUNA / 10, - 1, - ); - let plan = node.prepare_automatic_finalization(timestamp_ms); - assert!(plan.burned.is_some()); - - match step % 3 { - 0 => { - let _ = node.blinded_burn_with_fee(1, 0, node.chain_height() + 4); - } - 1 => { - let recipient = wallets[(step + 1) % wallets.len()].address(); - let _ = - node.blinded_transfer_with_fee(recipient, 1, 0, node.chain_height() + 4); - } - _ => {} - } - - let work = node - .prepare_next_block_with_local_anchor(timestamp_ms) - .unwrap(); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let block = node - .complete_prepared_block_at(work, vdf_output, timestamp_ms) - .unwrap(); - peer_ledger.apply_block_at(block, u64::MAX).unwrap(); - producer_ledger = node.clone_ledger(); - assert_eq!( - producer_ledger.status().tip_hash, - peer_ledger.status().tip_hash - ); - } - } -} diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -3,10 +3,10 @@ use anyhow::{Context, Result}; use super::helpers::{allowed_recovery_vdf_rank_count, recovery_vdf_sample_percent}; use super::{ AUTO_BLOCK_ANCHOR_BURN_AMOUNT, AUTO_BLOCK_ANCHOR_BURN_FEE, - AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS, AutoMineOutcome, AutoMinePlan, BuiltBlindedTransaction, - Ledger, NodeCore, PreparedBlock, REVEAL_BUNDLE_COLLECTION_MS, Transaction, run_vdf, + AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS, AutoMineOutcome, AutoMinePlan, + BURN_BUNDLE_COLLECTION_MS, Ledger, NodeCore, PreparedBlock, Transaction, run_vdf, }; -use crate::domain::Amount; +use crate::domain::{Amount, FinalizerMode}; mod pow; @@ -94,17 +94,17 @@ impl NodeCore { let will_run_recovery_vdf = self.should_prepare_recovery_vdf(timestamp_ms); let will_run_ticket_vdf = !will_run_recovery_vdf && wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); - if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms( + if let Some(wait_ms) = self.burn_bundle_collection_wait_ms( timestamp_ms, will_run_ticket_vdf || will_run_recovery_vdf, ) { plan.skipped_reason = Some(format!( - "collecting blinded reveals for next block ({:.1}s remaining)", + "collecting burns for next block ({:.1}s remaining)", wait_ms as f64 / 1000.0 )); return plan; } - if let Err(error) = self.publish_reveal_bundle_for_next_block() { + if let Err(error) = self.publish_burn_bundle_for_next_block() { plan.skipped_reason = Some(format!("{error:#}")); return plan; } @@ -184,18 +184,18 @@ impl NodeCore { let will_run_recovery_vdf = self.should_prepare_recovery_vdf(timestamp_ms); let will_run_ticket_vdf = !will_run_recovery_vdf && wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); - if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms( + if let Some(wait_ms) = self.burn_bundle_collection_wait_ms( timestamp_ms, will_run_ticket_vdf || will_run_recovery_vdf, ) { plan.skipped_reason = Some(format!( - "collecting blinded reveals for next block ({:.1}s remaining)", + "collecting burns for next block ({:.1}s remaining)", wait_ms as f64 / 1000.0 )); self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } - if let Err(error) = self.publish_reveal_bundle_for_next_block() { + if let Err(error) = self.publish_burn_bundle_for_next_block() { plan.skipped_reason = Some(format!("{error:#}")); self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; @@ -280,8 +280,8 @@ impl NodeCore { self.last_auto_burn_height = Some(current_height); return Ok(anchor_burn); }; - let burn = tx.payload.clone(); - self.submit_owned_blinded_transaction(tx)?; + let burn = tx.clone(); + self.submit_public_transaction(tx)?; self.last_auto_burn_height = Some(current_height); Ok(Some(burn)) } @@ -343,13 +343,13 @@ impl NodeCore { ledger: &Ledger, fee_per_byte: Amount, balance: Amount, - ) -> Option<BuiltBlindedTransaction> { + ) -> Option<Transaction> { let target = self.burn_per_block.min(balance); if target == 0 { return None; } let exact_at_fee_rate = - self.build_blinded_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte); + self.build_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte); if let Ok((built, estimate)) = exact_at_fee_rate { if target .checked_add(estimate.fee) @@ -361,7 +361,7 @@ impl NodeCore { if self.burn_per_block <= balance { let affordable_fee = balance.saturating_sub(target); if let Ok(built) = - self.build_blinded_burn_with_fee_on_ledger(ledger, target, affordable_fee) + ledger.build_burn(self.wallet.unlocked().ok()?, target, affordable_fee) { return Some(built); } @@ -372,7 +372,7 @@ impl NodeCore { let mut best = None; while low <= high { let amount = low + (high - low) / 2; - match self.build_blinded_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) { + match self.build_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) { Ok((built, estimate)) => { let fits = amount .checked_add(estimate.fee) @@ -428,39 +428,56 @@ impl NodeCore { < self.recovery_vdf_top_rank_percent } - fn reveal_bundle_collection_wait_ms( + fn burn_bundle_collection_wait_ms( &mut self, timestamp_ms: u64, will_run_vdf: bool, ) -> Option<u64> { let next_height = self.ledger.height().saturating_add(1); - let needs_reveal_attestations = self - .ledger - .reveal_bundle_attestations_required_for_next_block(); - let has_pending_reveals = !self.ledger.pending_blinded_reveals().is_empty(); - let needs_reveal_collection = has_pending_reveals || needs_reveal_attestations; + let (attestation_ledger, _) = self.ledger_with_local_block_anchor(); + let explicit_signatures_required = if will_run_vdf { + let finalizer_mode = if self.should_prepare_recovery_vdf(timestamp_ms) { + FinalizerMode::Recovery + } else { + FinalizerMode::Ticket + }; + let finalizer_rank = if matches!(finalizer_mode, FinalizerMode::Ticket) { + attestation_ledger + .finalizer_rank_for_next_block(self.wallet.address()) + .unwrap_or(0) + } else { + 0 + }; + attestation_ledger.explicit_burn_bundle_signatures_required_for_next_block( + finalizer_mode, + finalizer_rank, + self.wallet.address(), + ) + } else { + 0 + }; let wallet_is_committee_member = self .ledger - .reveal_committee_for_next_block() + .burn_committee_for_next_block() .iter() .any(|member| member.owner == self.wallet.address()); - if !needs_reveal_collection || (!wallet_is_committee_member && !will_run_vdf) { - if !needs_reveal_collection { - self.reveal_bundle_collection_started = None; + if explicit_signatures_required == 0 || (!wallet_is_committee_member && !will_run_vdf) { + if explicit_signatures_required == 0 { + self.burn_bundle_collection_started = None; } return None; } - let started_at = match self.reveal_bundle_collection_started { + let started_at = match self.burn_bundle_collection_started { Some((height, started_at)) if height == next_height => started_at, _ => { - self.reveal_bundle_collection_started = Some((next_height, timestamp_ms)); + self.burn_bundle_collection_started = Some((next_height, timestamp_ms)); timestamp_ms } }; let elapsed = timestamp_ms.saturating_sub(started_at); - (elapsed < REVEAL_BUNDLE_COLLECTION_MS) - .then(|| REVEAL_BUNDLE_COLLECTION_MS.saturating_sub(elapsed)) + (elapsed < BURN_BUNDLE_COLLECTION_MS) + .then(|| BURN_BUNDLE_COLLECTION_MS.saturating_sub(elapsed)) } pub(super) fn prepare_next_block_with_local_anchor( @@ -468,34 +485,34 @@ impl NodeCore { timestamp_ms: u64, ) -> Result<PreparedBlock> { let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); - ledger.prepare_next_block_with_required_burn_and_reveal_bundles( + ledger.prepare_next_block_with_required_burn_and_burn_bundles( self.wallet.address(), timestamp_ms, - self.usable_reveal_bundles(), + self.usable_burn_bundles(), required_burn_signature.as_deref(), ) } fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); - ledger.prepare_recovery_block_with_required_burn_and_reveal_bundles( + ledger.prepare_recovery_block_with_required_burn_and_burn_bundles( self.wallet.address(), timestamp_ms, - self.usable_reveal_bundles(), + self.usable_burn_bundles(), required_burn_signature.as_deref(), ) } - fn ledger_with_local_block_anchor(&self) -> (Ledger, Option<String>) { + pub(super) fn ledger_with_local_block_anchor(&self) -> (Ledger, Option<String>) { let mut ledger = self.ledger.clone(); let Some((height, burn)) = &self.local_block_anchor_burn else { return (ledger, None); }; - if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { - ledger.drop_pending_blinded_conflicting_with_transaction(burn); - if ledger.submit_transaction(burn.clone()).is_ok() { - return (ledger, Some(burn.signature().to_string())); - } + if *height == ledger.height() + && !ledger.has_transaction(burn.signature()) + && ledger.submit_transaction(burn.clone()).is_ok() + { + return (ledger, Some(burn.signature().to_string())); } (ledger, None) } @@ -510,19 +527,208 @@ impl NodeCore { } } - pub(super) fn clear_stale_reveal_bundle_collection(&mut self) { + pub(super) fn clear_stale_burn_bundle_collection(&mut self) { let current_next_height = self.ledger.height().saturating_add(1); if self - .reveal_bundle_collection_started + .burn_bundle_collection_started .is_some_and(|(height, _)| height != current_next_height) { - self.reveal_bundle_collection_started = None; - } - if self.ledger.pending_blinded_reveals().is_empty() { - self.reveal_bundle_collection_started = None; + self.burn_bundle_collection_started = None; } } } #[cfg(test)] -mod tests; +mod tests { + use std::collections::BTreeMap; + + use crate::{ + app::InMemoryNetwork, + domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet, run_vdf}, + }; + + use super::NodeCore; + + fn funded_ledger(wallets: &[Wallet]) -> Ledger { + let allocations = wallets + .iter() + .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA)) + .collect::<BTreeMap<_, _>>(); + let genesis_burns = wallets + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect::<Vec<_>>(); + Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap() + } + + fn selected_finalizer(ledger: &Ledger, wallets: &[Wallet]) -> Wallet { + let leader = ledger + .expected_leader_for_next_block() + .expect("test ledger should have a next finalizer"); + wallets + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap_or_else(|| panic!("missing wallet for selected finalizer {leader}")) + .clone() + } + + fn non_finalizer_wallet<'a>(wallets: &'a [Wallet], finalizer: &Wallet) -> &'a Wallet { + wallets + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .expect("test fixture should include a non-finalizer wallet") + } + + #[test] + fn local_anchor_burn_is_included_when_publishing_bundle() { + let wallet = Wallet::from_seed("local-anchor-bundle-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + assert!( + ledger + .finalizer_rank_for_next_block(wallet.address()) + .is_some() + ); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 0, 1); + + node.prepare_automatic_burn(1).unwrap(); + + assert_eq!(node.burn_bundle_collection_wait_ms(1, true), None); + + node.publish_burn_bundle_for_next_block().unwrap(); + + assert!( + node.usable_burn_bundles() + .iter() + .any(|bundle| bundle.member == wallet.address() && !bundle.burns.is_empty()) + ); + } + + #[test] + fn burn_bundle_gossip_reaches_peer_with_matching_mempool() { + let alice = Wallet::from_seed("bundle-gossip-alice"); + let bob = Wallet::from_seed("bundle-gossip-bob"); + let wallets = [alice.clone(), bob.clone()]; + let ledger = funded_ledger(&wallets); + let finalizer = selected_finalizer(&ledger, &wallets); + let burner = non_finalizer_wallet(&wallets, &finalizer); + let burn = ledger.build_burn(burner, 1, 1).unwrap(); + let mut sender = NodeCore::from_ledger(finalizer.clone(), ledger.clone(), 0); + let mut receiver = NodeCore::from_ledger(finalizer.clone(), ledger, 0); + sender.receive_transaction(burn.clone()).unwrap(); + receiver.receive_transaction(burn).unwrap(); + + sender.publish_burn_bundle_for_next_block().unwrap(); + let mut network = InMemoryNetwork::default(); + network.insert("sender", sender); + network.insert("receiver", receiver); + + network.deliver_until_idle().unwrap(); + + assert!( + network + .node("receiver") + .unwrap() + .usable_burn_bundles() + .iter() + .any(|bundle| bundle.member == finalizer.address() && !bundle.burns.is_empty()) + ); + } + + #[test] + fn block_with_burn_bundle_imports_on_independent_peer_ledger() { + let alice = Wallet::from_seed("bundle-import-alice"); + let bob = Wallet::from_seed("bundle-import-bob"); + let wallets = [alice.clone(), bob.clone()]; + let ledger = funded_ledger(&wallets); + let finalizer = selected_finalizer(&ledger, &wallets); + let burner = non_finalizer_wallet(&wallets, &finalizer); + let burn = ledger.build_burn(burner, 1, 1).unwrap(); + let mut producer = NodeCore::from_ledger_with_burn_fee_and_enabled( + finalizer.clone(), + ledger.clone(), + true, + 0, + 1, + ); + let mut peer = NodeCore::from_ledger(finalizer.clone(), ledger, 0); + producer.receive_transaction(burn.clone()).unwrap(); + peer.receive_transaction(burn.clone()).unwrap(); + producer.prepare_automatic_burn(1).unwrap(); + producer.publish_burn_bundle_for_next_block().unwrap(); + + let work = producer.prepare_next_block_with_local_anchor(1).unwrap(); + let block = producer + .complete_prepared_block_at( + work.clone(), + run_vdf(work.vdf_seed(), work.vdf_rounds()), + 1, + ) + .unwrap(); + assert!(!block.burn_bundle_section.burns.is_empty()); + + peer.receive(super::super::GossipEnvelope::Block(block)) + .unwrap(); + + assert_eq!(producer.chain_tip_hash(), peer.chain_tip_hash()); + assert!( + !peer + .pending_transactions() + .iter() + .any(|transaction| transaction.signature() == burn.signature()) + ); + } + + #[test] + fn burn_bundle_contents_change_prepared_vdf_seed() { + let alice = Wallet::from_seed("bundle-seed-alice"); + let bob = Wallet::from_seed("bundle-seed-bob"); + let wallets = [alice.clone(), bob.clone()]; + let ledger = funded_ledger(&wallets); + let finalizer = selected_finalizer(&ledger, &wallets); + let burner = non_finalizer_wallet(&wallets, &finalizer); + let burn = ledger.build_burn(burner, 1, 1).unwrap(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(finalizer, ledger, true, 0, 1); + node.receive_transaction(burn).unwrap(); + node.prepare_automatic_burn(1).unwrap(); + + let (ledger_with_anchor, required_burn_signature) = node.ledger_with_local_block_anchor(); + let without_bundle = ledger_with_anchor + .prepare_next_block_with_required_burn_and_burn_bundles( + node.wallet_address(), + 1, + Vec::new(), + required_burn_signature.as_deref(), + ) + .unwrap(); + + node.publish_burn_bundle_for_next_block().unwrap(); + let with_bundle = node.prepare_next_block_with_local_anchor(1).unwrap(); + + assert_ne!(without_bundle.vdf_seed(), with_bundle.vdf_seed()); + assert_ne!( + without_bundle + .finish_at( + node.wallet.unlocked().unwrap(), + "precheck-vdf-output".to_string(), + 1, + ) + .burn_bundle_hashes(), + with_bundle + .finish_at( + node.wallet.unlocked().unwrap(), + "precheck-vdf-output".to_string(), + 1, + ) + .burn_bundle_hashes() + ); + } +} diff --git a/src/app/automatic_mining/tests.rs b/src/app/automatic_mining/tests.rs @@ -1,618 +0,0 @@ -use std::collections::BTreeMap; - -use crate::{ - adapters::config_store::{DEFAULT_POW_MINING_WORKERS, MAX_POW_MINING_WORKERS}, - app::{GossipEnvelope, NodeConfig, NodeCore}, - domain::{ - FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, - MINE_FINALIZER_FEE, RECOVERY_BLOCK_DELAY_MS, Transaction, VDF_TARGET_BLOCK_MS, Wallet, - run_vdf, - }, -}; - -#[test] -fn same_height_verified_import_does_not_reset_auto_burn_guard() { - let alice = Wallet::from_seed("same-height-import-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - let mut node = NodeCore::new(NodeConfig { - wallet: alice, - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: 1, - burn_fee: 1, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - let first = node.prepare_automatic_mining(1); - assert!(first.burned.is_some()); - assert_eq!(node.last_auto_burn_height, Some(0)); - - let same_height_ledger = node.clone_ledger(); - assert!(!node.import_verified_ledger(same_height_ledger).unwrap()); - assert_eq!(node.last_auto_burn_height, Some(0)); - - let second = node.prepare_automatic_mining(2); - assert!(second.burned.is_none()); -} - -#[test] -fn automatic_pow_mining_searches_bounded_nonce_batches_per_tip() { - let wallet = Wallet::from_seed("automatic-pow-mining-wallet"); - let ledger = Ledger::new_with_genesis_burns( - BTreeMap::from([(wallet.address().to_string(), 1)]), - vec![GenesisBurn::new(wallet.address(), 1)], - 10, - ) - .unwrap(); - let mut node = NodeCore::from_ledger(wallet.clone(), ledger, 0); - - let disabled = node.prepare_automatic_mining(1); - assert!(disabled.pow_mined.is_none()); - assert_eq!( - disabled.skipped_reason.as_deref(), - Some("automatic mining is off") - ); - - node.set_pow_mining_enabled(true); - let first = node.prepare_automatic_mining(2); - assert!(node.ledger().pending_blinded_transactions().len() <= 1); - let first = std::iter::once(first) - .chain((3..10_000).map(|timestamp| node.prepare_automatic_mining(timestamp))) - .find(|plan| plan.pow_mined.is_some()) - .expect("bounded PoW search should eventually find a proof"); - let first_mine = first.pow_mined.as_ref().expect("PoW should be queued"); - let Transaction::Mine { - anchor, - recipient, - difficulty_bits, - .. - } = first_mine - else { - panic!("expected mine transaction"); - }; - assert_eq!(anchor, &node.chain().last().unwrap().hash); - assert_eq!(recipient, wallet.address()); - assert_eq!( - *difficulty_bits, - node.ledger().current_mine_difficulty_bits() - ); - let first_pending = node.ledger().pending().len(); - assert!(first_pending >= 1); - assert!(node.ledger().pending_blinded_transactions().is_empty()); - assert!(node.drain_outbox().iter().any(|envelope| { - matches!(envelope, GossipEnvelope::MineAction(tx) if tx.signature() == first_mine.signature()) - })); - assert!( - node.status() - .mining - .last_auto_pow_mine_status - .as_deref() - .unwrap_or_default() - .contains("queued") - ); - - let second = (10_000..20_000) - .map(|timestamp| node.prepare_automatic_mining(timestamp)) - .find(|plan| plan.pow_mined.is_some()) - .expect("automatic PoW should allow a second proof for the same tip"); - let second_mine = second.pow_mined.as_ref().expect("PoW should be queued"); - assert_ne!(second_mine.signature(), first_mine.signature()); - assert_eq!(node.ledger().pending().len(), first_pending + 1); - - for timestamp in 20_000..20_010 { - assert!(node.prepare_automatic_mining(timestamp).pow_mined.is_none()); - } - assert_eq!(node.ledger().pending().len(), first_pending + 1); - assert_eq!( - node.status().mining.last_auto_pow_mine_status.as_deref(), - Some("waiting for next chain tip after queued mine actions") - ); - assert!(node.ledger().pending_blinded_transactions().is_empty()); -} - -#[test] -fn automatic_pow_mining_waits_after_queueing_anchor_limit_for_tip() { - let wallet = Wallet::from_seed("automatic-pow-independent-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - node.set_pow_mining_enabled(true); - let first_mined = (1..10_000) - .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) - .expect("PoW should eventually queue a mine action"); - let anchor = match first_mined { - Transaction::Mine { ref anchor, .. } => anchor.clone(), - _ => panic!("expected mine action"), - }; - assert_eq!(node.ledger().pending_mine_count_for_anchor(&anchor), 1); - - (1..10_000) - .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) - .expect("PoW should allow a second mine action for the same tip"); - assert_eq!( - node.ledger().pending_mine_count_for_anchor(&anchor), - MINE_ACTIONS_PER_ANCHOR_LIMIT - ); - assert!(node.prepare_automatic_pow_mining().unwrap().is_none()); - assert!(node.auto_pow_mine_cursor.is_none()); -} - -#[test] -fn disabling_automatic_pow_mining_clears_local_work() { - let wallet = Wallet::from_seed("automatic-pow-disable-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - node.set_pow_mining_enabled(true); - assert!(node.pow_mining_enabled()); - node.prepare_automatic_pow_mining().unwrap(); - assert!(node.auto_pow_mine_cursor.is_some()); - assert!(node.status().mining.last_auto_pow_mine_status.is_some()); - - node.set_pow_mining_enabled(false); - - assert!(!node.pow_mining_enabled()); - assert!(node.auto_pow_mine_cursor.is_none()); - assert!(node.status().mining.last_auto_pow_mine_status.is_none()); -} - -#[test] -fn automatic_pow_mining_workers_are_clamped_and_reported() { - let wallet = Wallet::from_seed("automatic-pow-workers-wallet"); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: BTreeMap::new(), - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 99, - recovery_vdf_top_rank_percent: 100, - }); - - assert_eq!(node.pow_mining_workers(), MAX_POW_MINING_WORKERS); - assert_eq!( - node.status().mining.max_pow_mining_workers, - MAX_POW_MINING_WORKERS - ); - - node.set_pow_mining_workers(0); - - assert_eq!(node.pow_mining_workers(), DEFAULT_POW_MINING_WORKERS); - assert_eq!( - node.status().mining.pow_mining_workers, - DEFAULT_POW_MINING_WORKERS - ); -} - -#[test] -fn automatic_pow_mining_skips_unspendable_owned_blinded_payloads() { - let alice = Wallet::from_seed("automatic-pow-stale-owned-blind-alice"); - let bob = Wallet::from_seed("automatic-pow-stale-owned-blind-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], - 10, - ) - .unwrap(); - let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); - - let blinded = node - .blinded_burn_with_fee(MICRO_IUNA / 10, 7, node.chain_height() + 4) - .unwrap(); - let mut finalizer_ledger = node.ledger().clone(); - let leader_burn = finalizer_ledger.build_burn(&bob, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(leader_burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(&bob, 1).unwrap(); - assert!( - commit_block - .blinded_transactions - .iter() - .any(|tx| tx.commitment == blinded.commitment) - ); - finalizer_ledger.apply_block(commit_block).unwrap(); - assert!(node.import_verified_ledger(finalizer_ledger).unwrap()); - assert!( - node.ledger() - .has_unrevealed_blinded_transaction(&blinded.commitment) - ); - - node.set_pow_mining_enabled(true); - - assert!(node.prepare_automatic_pow_mining().is_ok()); -} - -#[test] -fn automatic_pow_mining_skips_stale_local_anchor_reservation() { - let wallet = Wallet::from_seed("automatic-pow-stale-anchor-wallet"); - let mut stale_allocations = BTreeMap::new(); - stale_allocations.insert(wallet.address().to_string(), MICRO_IUNA); - let stale_ledger = Ledger::new(stale_allocations, 10); - let stale_anchor = stale_ledger.build_burn(&wallet, 1, 0).unwrap(); - let live_ledger = Ledger::new(BTreeMap::new(), 10); - let mut node = NodeCore::from_ledger(wallet.clone(), live_ledger, 0); - node.local_block_anchor_burn = Some((node.chain_height(), stale_anchor)); - node.set_pow_mining_enabled(true); - - assert!(node.prepare_automatic_pow_mining().is_ok()); -} - -#[test] -fn automatic_finalization_does_not_tick_pow_mining() { - let wallet = Wallet::from_seed("automatic-pow-separated-finalizer-wallet"); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: BTreeMap::new(), - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - node.set_pow_mining_enabled(true); - let _ = node.prepare_automatic_finalization(1); - - assert!(node.auto_pow_mine_cursor.is_none()); -} - -#[test] -fn automatic_finalization_prepares_recovery_after_ticket_timeout() { - let alice = Wallet::from_seed("automatic-recovery-alice"); - let bob = Wallet::from_seed("automatic-recovery-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - let mut node = NodeCore::from_ledger(bob, ledger, 1); - - let early = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS - 1); - assert!(early.work.is_none()); - assert!( - early - .skipped_reason - .as_deref() - .unwrap_or_default() - .contains("waiting for selected finalizer") - ); - - let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); - let work = recovery.work.expect("recovery work should be prepared"); - let block = work.finish( - node.wallet.unlocked().unwrap(), - "preverified-vdf".to_string(), - ); - - assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); - assert!(block.leader_proof.is_none()); -} - -#[test] -fn automatic_finalization_prepares_recovery_even_when_ticket_ranks_remain() { - let alice = Wallet::from_seed("automatic-recovery-with-ranks-alice"); - let bob = Wallet::from_seed("automatic-recovery-with-ranks-bob"); - let carol = Wallet::from_seed("automatic-recovery-with-ranks-carol"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 10, - ) - .unwrap(); - assert!(ledger.finalizer_rank_count_for_next_block() > 0); - assert_eq!(ledger.finalizer_rank_for_next_block(carol.address()), None); - let mut node = NodeCore::from_ledger(carol, ledger, 1); - - let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); - let work = recovery.work.expect("recovery work should be prepared"); - let block = work.finish( - node.wallet.unlocked().unwrap(), - "preverified-vdf".to_string(), - ); - - assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); - assert!(block.leader_proof.is_none()); -} - -#[test] -fn automatic_finalization_respects_zero_recovery_vdf_threshold() { - let alice = Wallet::from_seed("automatic-recovery-zero-alice"); - let bob = Wallet::from_seed("automatic-recovery-zero-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - let mut node = NodeCore::from_ledger(bob, ledger, 1); - node.set_recovery_vdf_top_rank_percent(0); - - let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); - - assert!(recovery.work.is_none()); -} - -#[test] -fn selected_finalizer_runs_vdf_with_zero_recovery_vdf_threshold() { - let alice = Wallet::from_seed("automatic-selected-zero-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(alice.address()) - ); - let mut node = NodeCore::from_ledger(alice.clone(), ledger, 1); - node.set_recovery_vdf_top_rank_percent(0); - - let plan = node.prepare_automatic_finalization(1); - - assert!(plan.work.is_some()); - assert!(plan.skipped_reason.is_none()); -} - -#[test] -fn automatic_non_leader_burn_is_queued_as_blinded() { - let alice = Wallet::from_seed("auto-blinded-burn-alice"); - let bob = Wallet::from_seed("auto-blinded-burn-bob"); - let carol = Wallet::from_seed("auto-blinded-burn-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - assert_eq!(ledger.finalizer_rank_for_next_block(carol.address()), None); - let mut node = - NodeCore::from_ledger_with_burn_fee_and_enabled(carol, ledger, true, MICRO_IUNA / 10, 1); - - let plan = node.prepare_automatic_finalization(1); - let outbox = node.drain_outbox(); - - assert!(plan.burned.is_some()); - assert!(node.ledger().pending().is_empty()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - assert!( - outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) - ); -} - -#[test] -fn automatic_fallback_finalizer_prepares_anchor_and_blinded_burn() { - let alice = Wallet::from_seed("auto-fallback-burn-alice"); - let bob = Wallet::from_seed("auto-fallback-burn-bob"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let fallback = finalizers - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) - .unwrap() - .clone(); - let mut node = - NodeCore::from_ledger_with_burn_fee_and_enabled(fallback, ledger, true, MICRO_IUNA / 10, 1); - - let plan = node.prepare_automatic_finalization(1); - let outbox = node.drain_outbox(); - - assert!(plan.burned.is_some()); - assert!( - plan.skipped_reason.is_none(), - "fallback should not be skipped: {:?}", - plan.skipped_reason - ); - assert!(node.ledger().pending().is_empty()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - let (_, anchor_burn) = node - .local_block_anchor_burn - .as_ref() - .expect("fallback anchor burn should be held locally"); - let anchor_signature = anchor_burn.signature().to_string(); - assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); - assert!( - outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) - ); - let work = plan.work.expect("fallback work should be prepared"); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let block = node - .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) - .unwrap(); - - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.finalizer_mode, FinalizerMode::Ticket); - assert!(block.transactions.iter().any(|transaction| { - transaction.is_burn() && transaction.amount() == super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT - })); - assert_eq!( - block - .transactions - .first() - .map(|transaction| transaction.signature()), - Some(anchor_signature.as_str()) - ); - assert!(!block.blinded_transactions.is_empty()); - assert_eq!(node.ledger().height(), 1); -} - -#[test] -fn automatic_ranked_finalizer_prefers_recovery_after_timeout() { - let alice = Wallet::from_seed("auto-ranked-recovery-alice"); - let bob = Wallet::from_seed("auto-ranked-recovery-bob"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 10, - ) - .unwrap(); - let fallback = finalizers - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) - .unwrap() - .clone(); - let mut node = NodeCore::from_ledger(fallback, ledger, 1); - - let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); - let work = recovery.work.expect("recovery work should be prepared"); - let block = work.finish( - node.wallet.unlocked().unwrap(), - "preverified-vdf".to_string(), - ); - - assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); - assert_eq!(block.finalizer_rank, 0); - assert_eq!(block.vdf_rounds, 10); - assert!(block.leader_proof.is_none()); -} - -#[test] -fn automatic_leader_prepares_anchor_and_blinded_burn() { - let alice = Wallet::from_seed("auto-plaintext-burn-alice"); - let bob = Wallet::from_seed("auto-plaintext-burn-bob"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() - .clone(); - for wallet in &finalizers { - let split = ledger - .build_transfer(wallet, wallet.address(), MICRO_IUNA, 0) - .unwrap(); - ledger.submit_transaction(split).unwrap(); - } - let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); - ledger.submit_transaction(anchor).unwrap(); - let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); - ledger.apply_block(split_block).unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() - .clone(); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - leader_wallet, - ledger, - true, - MICRO_IUNA / 10, - 1, - ); - - let plan = node.prepare_automatic_finalization(1); - let outbox = node.drain_outbox(); - - assert!(plan.burned.is_some()); - assert!(node.ledger().pending().is_empty()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - let (_, anchor_burn) = node - .local_block_anchor_burn - .as_ref() - .expect("leader anchor burn should be held locally"); - assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); - assert!( - outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) - ); - assert!(node.prepare_automatic_finalization(1).work.is_some()); -} - -#[test] -fn automatic_pow_mining_uses_protocol_finalizer_fee() { - let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet"); - let ledger = Ledger::new_with_genesis_burns( - BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]), - vec![GenesisBurn::new(wallet.address(), 1)], - 10, - ) - .unwrap(); - let mut node = NodeCore::from_ledger(wallet, ledger, 0); - - node.set_pow_mining_enabled(true); - let plan = (1..10_000) - .map(|timestamp| node.prepare_automatic_mining(timestamp)) - .find(|plan| plan.pow_mined.is_some()) - .expect("bounded PoW search should eventually find a proof"); - let mine = plan.pow_mined.expect("PoW should be queued"); - - assert_eq!(mine.fee(), MINE_FINALIZER_FEE); - assert_eq!(mine.amount(), crate::domain::MINE_REWARD); - assert_eq!( - node.status().mining.automatic_pow_mine_fee, - MINE_FINALIZER_FEE - ); -} diff --git a/src/app/gossip.rs b/src/app/gossip.rs @@ -1,4 +1,4 @@ -use crate::domain::{Block, ChainSnapshot, Transaction}; +use crate::domain::{Block, ChainSnapshot}; use super::{ BLOCK_REQUEST_LIMIT, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, ProtocolHello, @@ -8,38 +8,18 @@ use super::{ impl NodeCore { pub fn mempool_gossip(&mut self) -> Vec<GossipEnvelope> { let mut gossip = Vec::new(); - let mine_actions = self - .ledger - .pending() - .iter() - .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) - .cloned() - .collect::<Vec<_>>(); - gossip.extend(mine_actions.chunks(TRANSACTION_BATCH_LIMIT).map(|chunk| { - GossipEnvelope::MineActions { - transactions: chunk.to_vec(), - } - })); gossip.extend( self.ledger - .pending_blinded_transactions() + .pending() .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::BlindedTransactions { + .map(|chunk| GossipEnvelope::Transactions { transactions: chunk.to_vec(), }), ); gossip.extend( - self.ledger - .pending_blinded_reveals() - .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::BlindedReveals { - reveals: chunk.to_vec(), - }), - ); - gossip.extend( - self.usable_reveal_bundles() + self.usable_burn_bundles() .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::RevealBundles { + .map(|chunk| GossipEnvelope::BurnBundles { bundles: chunk.to_vec(), }), ); @@ -123,26 +103,30 @@ mod tests { }; #[test] - fn mempool_gossip_includes_blinded_transactions() { - let alice = Wallet::from_seed("blinded-gossip-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new(genesis, 1); - let blinded = ledger.build_blinded_burn(&alice, MICRO_IUNA, 7, 3).unwrap(); - let mut sender = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); - let mut receiver = NodeCore::from_ledger(alice, ledger, 0); - - sender - .receive_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - for envelope in sender.mempool_gossip() { - receiver.receive(envelope).unwrap(); - } - - assert_eq!( - receiver.ledger().pending_blinded_transactions(), - std::slice::from_ref(&blinded.transaction) - ); + fn mempool_gossip_rebroadcasts_public_burns() { + let wallet = Wallet::from_seed("mempool-gossip-public-burn"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut node = NodeCore::from_ledger(wallet, ledger, 0); + let burn = node.burn_with_fee(MICRO_IUNA / 10, 1).unwrap(); + node.drain_outbox(); + + let gossip = node.mempool_gossip(); + + assert!(gossip.iter().any(|envelope| { + match envelope { + GossipEnvelope::Transactions { transactions } => transactions + .iter() + .any(|tx| tx.signature() == burn.signature()), + _ => false, + } + })); } #[test] @@ -159,7 +143,6 @@ mod tests { } assert_eq!(receiver.ledger().pending(), std::slice::from_ref(&mine)); - assert!(receiver.ledger().pending_blinded_transactions().is_empty()); } #[test] @@ -167,8 +150,8 @@ mod tests { let alice = Wallet::from_seed("missing-inv-alice"); let bob = Wallet::from_seed("missing-inv-bob"); let allocations = allocations(&[alice.clone(), bob], 1_000); - let mut local = node("local", alice.clone(), allocations.clone()); - let mut remote = node("remote", alice.clone(), allocations); + let mut local = node(alice.clone(), allocations.clone()); + let mut remote = node(alice.clone(), allocations); queue_plaintext_burn(&mut local, &alice, 1); let block = local.mine_one_at(1).unwrap(); let inventory = [BlockInventory { @@ -189,8 +172,8 @@ mod tests { let alice = Wallet::from_seed("gap-inv-alice"); let bob = Wallet::from_seed("gap-inv-bob"); let allocations = allocations(&[alice.clone(), bob.clone()], 1_000); - let mut local = node("local", alice.clone(), allocations.clone()); - let remote = node("remote", bob, allocations); + let mut local = node(alice.clone(), allocations.clone()); + let remote = node(bob, allocations); let mut latest = None; for height in 1..=3 { @@ -214,7 +197,7 @@ mod tests { } } - fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { + fn node(wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { let ledger = Ledger::new_with_genesis_burns( allocations, vec![GenesisBurn::new(wallet.address(), 1)], @@ -225,7 +208,7 @@ mod tests { } fn queue_plaintext_burn(node: &mut NodeCore, wallet: &Wallet, amount: Amount) -> Transaction { - let tx = node.ledger().build_burn(wallet, amount, 0).unwrap(); + let tx = node.ledger().build_burn(wallet, amount, 1).unwrap(); node.receive_transaction(tx.clone()).unwrap(); tx } diff --git a/src/app/helpers.rs b/src/app/helpers.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; use sha2::{Digest, Sha256}; -use crate::domain::{Amount, BuiltBlindedTransaction, MINE_REWARD, OutPoint, Transaction}; +use crate::domain::{Amount, MINE_REWARD, OutPoint, Transaction}; use super::FeeEstimate; @@ -16,25 +16,24 @@ pub(super) fn auto_pow_salt(wallet_address: &str, anchor: &str) -> u64 { pub(super) fn converge_fee_by_byte( fee_per_byte: Amount, - mut build: impl FnMut(Amount) -> Result<BuiltBlindedTransaction>, -) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { - let mut fee = if fee_per_byte == 0 { 0 } else { 1 }; + mut build: impl FnMut(Amount) -> Result<Transaction>, +) -> Result<(Transaction, FeeEstimate)> { + let mut fee = 1; let mut best = None; for _ in 0..64 { let built = build(fee)?; - let bytes = built.transaction.fee_rate_size_bytes(); + let bytes = built.economic_size_bytes(); let required_fee = fee_per_byte .checked_mul(bytes as Amount) - .context("fee per byte times blinded transaction bytes overflows")?; + .context("fee per byte times transaction bytes overflows")? + .max(1); if fee == required_fee { return Ok((built, FeeEstimate { bytes, fee })); } if fee > required_fee && best .as_ref() - .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| { - fee < estimate.fee - }) + .is_none_or(|(_, estimate): &(Transaction, FeeEstimate)| fee < estimate.fee) { best = Some((built, FeeEstimate { bytes, fee })); } @@ -42,14 +41,15 @@ pub(super) fn converge_fee_by_byte( } let built = build(fee)?; - let bytes = built.transaction.fee_rate_size_bytes(); + let bytes = built.economic_size_bytes(); let required_fee = fee_per_byte .checked_mul(bytes as Amount) - .context("fee per byte times blinded transaction bytes overflows")?; + .context("fee per byte times transaction bytes overflows")? + .max(1); if fee >= required_fee { if best .as_ref() - .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| fee < estimate.fee) + .is_none_or(|(_, estimate): &(Transaction, FeeEstimate)| fee < estimate.fee) { best = Some((built, FeeEstimate { bytes, fee })); } @@ -58,10 +58,11 @@ pub(super) fn converge_fee_by_byte( } } let built = build(required_fee)?; - let bytes = built.transaction.fee_rate_size_bytes(); + let bytes = built.economic_size_bytes(); let final_required_fee = fee_per_byte .checked_mul(bytes as Amount) - .context("fee per byte times blinded transaction bytes overflows")?; + .context("fee per byte times transaction bytes overflows")? + .max(1); if required_fee < final_required_fee { bail!("fee per byte did not converge"); } @@ -129,61 +130,3 @@ pub(super) fn recovery_vdf_sample_percent(address: &str, tip_hash: &str) -> u8 { let digest = Sha256::digest(format!("iuna-recovery-vdf-sample:{tip_hash}:{address}")); digest[0] % 100 } - -#[cfg(test)] -mod tests { - use anyhow::bail; - - use crate::domain::{BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, Transaction}; - - use super::converge_fee_by_byte; - - fn built_with_fee(fee: u64) -> BuiltBlindedTransaction { - BuiltBlindedTransaction { - payload: Transaction::Burn { - inputs: Vec::new(), - change: Vec::new(), - amount: 1, - fee, - signature: "payload".to_string(), - }, - transaction: BlindedTransaction { - commitment: "0".repeat(64), - inputs: Vec::new(), - fee, - encrypted_size: 1, - expires_at_height: 1, - nonce: "0".repeat(24), - ciphertext: "00".to_string(), - payload_hash: "1".repeat(64), - }, - reveal: BlindedReveal { - commitment: "0".repeat(64), - key: "2".repeat(64), - }, - } - } - - #[test] - fn fee_convergence_does_not_probe_zero_when_fee_rate_is_positive() { - let (built, estimate) = converge_fee_by_byte(1, |fee| { - if fee == 0 { - bail!("zero fee rejected after activation"); - } - Ok(built_with_fee(fee)) - }) - .expect("positive fee-rate convergence should skip invalid zero fee"); - - assert!(estimate.fee > 0); - assert_eq!(built.transaction.fee, estimate.fee); - } - - #[test] - fn fee_convergence_allows_zero_when_fee_rate_is_zero() { - let (built, estimate) = - converge_fee_by_byte(0, |fee| Ok(built_with_fee(fee))).expect("zero fee rate works"); - - assert_eq!(estimate.fee, 0); - assert_eq!(built.transaction.fee, 0); - } -} diff --git a/src/app/in_memory_network.rs b/src/app/in_memory_network.rs @@ -89,12 +89,7 @@ impl InMemoryNetwork { fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Result<()> { let transaction_like = matches!( envelope, - GossipEnvelope::BlindedTransaction(_) - | GossipEnvelope::BlindedTransactions { .. } - | GossipEnvelope::MineAction(_) - | GossipEnvelope::MineActions { .. } - | GossipEnvelope::BlindedReveal(_) - | GossipEnvelope::BlindedReveals { .. } + GossipEnvelope::Transaction(_) | GossipEnvelope::Transactions { .. } ); match node.receive(envelope) { Ok(()) => Ok(()), @@ -102,576 +97,3 @@ fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Err(error) => Err(error), } } - -#[cfg(test)] -mod tests { - use std::collections::{BTreeMap, BTreeSet}; - - use crate::{ - app::{GossipEnvelope, InMemoryNetwork, NodeCore, REVEAL_BUNDLE_COLLECTION_MS}, - domain::{ - Block, GenesisBurn, Ledger, MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MICRO_IUNA, - RECOVERY_BLOCK_DELAY_MS, VDF_TARGET_BLOCK_MS, Wallet, - }, - }; - - #[derive(Clone, Debug)] - struct ChaosRng { - state: u64, - } - - impl ChaosRng { - fn new(seed: u64) -> Self { - Self { - state: seed ^ 0x517c_c1b7_2722_0a95, - } - } - - fn next_u64(&mut self) -> u64 { - self.state = self - .state - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - self.state - } - - fn index(&mut self, len: usize) -> usize { - assert!(len > 0); - (self.next_u64() as usize) % len - } - } - - #[test] - fn sparse_network_stays_bounded_under_generated_actions() { - const NODES: usize = 10; - const ROUNDS: usize = 36; - const SETTLE_BLOCKS: u64 = MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS + 10; - const QUIET_BLOCKS: usize = SETTLE_BLOCKS as usize + 4; - - let mut rng = ChaosRng::new(0x1aba_0100); - let wallets = (0..NODES) - .map(|index| Wallet::from_seed(&format!("sparse-network-chaos-{index}"))) - .collect::<Vec<_>>(); - let allocations = wallets - .iter() - .map(|wallet| (wallet.address().to_string(), 75 * MICRO_IUNA)) - .collect::<BTreeMap<_, _>>(); - let genesis_burns = wallets - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect::<Vec<_>>(); - let ledger = Ledger::new_with_genesis_burns(allocations, genesis_burns, 1) - .expect("chaos genesis is valid"); - let node_ids = (0..NODES) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let peers = sparse_chaos_peers(NODES, &mut rng); - let mut offline_until = vec![0_usize; NODES]; - let mut pending_since = BTreeMap::new(); - let mut network = InMemoryNetwork::default(); - - for (index, wallet) in wallets.iter().enumerate() { - let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA / 10, - 0, - ); - node.set_recovery_vdf_top_rank_percent(100); - network.insert(node_ids[index].clone(), node); - } - - for round in 0..ROUNDS { - let online = online_chaos_nodes(&offline_until, round); - for index in online.iter().copied().collect::<Vec<_>>() { - match rng.index(32) { - 0 => { - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - let expiry_height = network - .node(&node_ids[index]) - .expect("actor node exists") - .chain_height() - + 16; - let _ = network - .node_mut(&node_ids[index]) - .expect("actor node exists") - .blinded_transfer_with_fee(recipient, 1, 0, expiry_height); - } - 1 => { - attempt_bounded_mine_action( - &mut network, - &node_ids[index], - wallets[index].address(), - &mut rng, - ); - } - 2 if online.len() > 1 => { - offline_until[index] = round + 1 + rng.index(4); - } - _ => {} - } - } - - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - mine_one_sparse_chaos_block( - &mut network, - &node_ids, - &wallets, - &online, - chaos_timestamp(round), - ); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); - } - - for round in ROUNDS..ROUNDS + QUIET_BLOCKS { - let online = online_chaos_nodes(&offline_until, round); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - mine_one_sparse_chaos_block( - &mut network, - &node_ids, - &wallets, - &online, - chaos_timestamp(round), - ); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); - } - - let all_online = (0..NODES).collect::<BTreeSet<_>>(); - for round in ROUNDS + QUIET_BLOCKS..ROUNDS + QUIET_BLOCKS + SETTLE_BLOCKS as usize * 4 { - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &all_online, - chaos_timestamp(round), - &mut rng, - ); - if !sparse_chaos_has_pending(&network, &node_ids) { - break; - } - mine_one_sparse_chaos_block( - &mut network, - &node_ids, - &wallets, - &all_online, - chaos_timestamp(round), - ); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &all_online, - chaos_timestamp(round), - &mut rng, - ); - observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); - } - assert_sparse_chaos_converged(&network, &node_ids); - assert_sparse_chaos_mempools_empty(&network, &node_ids); - } - - fn sparse_chaos_peers(nodes: usize, rng: &mut ChaosRng) -> Vec<Vec<usize>> { - let mut peers = vec![BTreeSet::new(); nodes]; - for index in 0..nodes { - let next = (index + 1) % nodes; - peers[index].insert(next); - peers[next].insert(index); - } - for index in 0..nodes { - let target_degree = 1 + rng.index(4); - while peers[index].len() < target_degree { - let peer = rng.index(nodes); - if peer != index { - peers[index].insert(peer); - peers[peer].insert(index); - } - } - } - peers - .into_iter() - .map(|set| set.into_iter().take(4).collect()) - .collect() - } - - fn online_chaos_nodes(offline_until: &[usize], round: usize) -> BTreeSet<usize> { - offline_until - .iter() - .enumerate() - .filter_map(|(index, until)| (*until <= round).then_some(index)) - .collect() - } - - fn chaos_timestamp(round: usize) -> u64 { - (round as u64 + 1) * (RECOVERY_BLOCK_DELAY_MS + VDF_TARGET_BLOCK_MS) - } - - fn attempt_bounded_mine_action( - network: &mut InMemoryNetwork, - node_id: &str, - recipient: &str, - rng: &mut ChaosRng, - ) { - let outcome = network - .node(node_id) - .expect("mine actor exists") - .ledger() - .search_mine(recipient, rng.next_u64(), 0, 4) - .expect("bounded mine search is valid"); - let Some(transaction) = outcome.transaction else { - return; - }; - let _ = network - .node_mut(node_id) - .expect("mine actor exists") - .receive_mine_action(transaction); - } - - fn mine_one_sparse_chaos_block( - network: &mut InMemoryNetwork, - node_ids: &[String], - wallets: &[Wallet], - online: &BTreeSet<usize>, - timestamp_ms: u64, - ) { - let Some(reference_index) = highest_online_node(network, node_ids, online) else { - return; - }; - let reference = network - .node(&node_ids[reference_index]) - .expect("reference node exists"); - let leader = reference.ledger().expected_leader_for_next_block(); - let mut candidates = Vec::new(); - if let Some(index) = leader - .as_deref() - .and_then(|leader| { - wallets - .iter() - .position(|wallet| wallet.address() == leader) - .filter(|index| online.contains(index)) - }) - .filter(|index| { - network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height() - }) - { - candidates.push(index); - } - candidates.push(reference_index); - candidates.extend(online.iter().copied().filter(|index| { - network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height() - })); - let mut seen = BTreeSet::new(); - for producer_index in candidates { - if !seen.insert(producer_index) { - continue; - } - let mut producer = network - .node(&node_ids[producer_index]) - .expect("producer node exists") - .clone(); - let mut publish_timestamp_ms = timestamp_ms; - let mut plan = producer.prepare_automatic_finalization(timestamp_ms); - if plan.work.is_none() - && plan - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("collecting blinded reveals")) - { - publish_timestamp_ms = timestamp_ms.saturating_add(REVEAL_BUNDLE_COLLECTION_MS + 1); - plan = producer.prepare_automatic_finalization(publish_timestamp_ms); - } - let Some(work) = plan.work else { - continue; - }; - let block = work.finish_at( - &wallets[producer_index], - "preverified-chaos-vdf".to_string(), - publish_timestamp_ms, - ); - network - .node_mut(&node_ids[producer_index]) - .expect("producer node exists") - .receive_preverified_block_at(block, publish_timestamp_ms) - .expect("mock-VDF block applies locally"); - return; - } - } - - fn highest_online_node( - network: &InMemoryNetwork, - node_ids: &[String], - online: &BTreeSet<usize>, - ) -> Option<usize> { - online.iter().copied().max_by_key(|index| { - network - .node(&node_ids[*index]) - .expect("online node exists") - .chain_height() - }) - } - - fn deliver_sparse_chaos_until_idle( - network: &mut InMemoryNetwork, - node_ids: &[String], - peers: &[Vec<usize>], - online: &BTreeSet<usize>, - timestamp_ms: u64, - rng: &mut ChaosRng, - ) { - for _ in 0..512 { - let mut progressed = - sync_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms); - progressed |= - deliver_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms, rng); - if !progressed { - return; - } - } - panic!("sparse chaos network did not become idle"); - } - - fn sync_sparse_chaos_once( - network: &mut InMemoryNetwork, - node_ids: &[String], - peers: &[Vec<usize>], - online: &BTreeSet<usize>, - timestamp_ms: u64, - ) -> bool { - let mut syncs = Vec::new(); - for from in online { - let from_height = network.node(&node_ids[*from]).unwrap().chain_height(); - for to in &peers[*from] { - if !online.contains(to) { - continue; - } - let to_height = network.node(&node_ids[*to]).unwrap().chain_height(); - if from_height > to_height { - let blocks = network - .node(&node_ids[*from]) - .unwrap() - .blocks_from(to_height + 1, 16); - syncs.push((*to, blocks)); - } - } - } - - let mut progressed = false; - for (to, blocks) in syncs { - for block in blocks { - let before = network.node(&node_ids[to]).unwrap().chain_height(); - receive_sparse_chaos_block(network, &node_ids[to], block, timestamp_ms); - progressed |= network.node(&node_ids[to]).unwrap().chain_height() > before; - } - } - progressed - } - - fn deliver_sparse_chaos_once( - network: &mut InMemoryNetwork, - node_ids: &[String], - peers: &[Vec<usize>], - online: &BTreeSet<usize>, - timestamp_ms: u64, - rng: &mut ChaosRng, - ) -> bool { - let mut outbound = Vec::new(); - for from in online { - let node = network - .node_mut(&node_ids[*from]) - .expect("online node exists"); - outbound.extend( - node.drain_outbox() - .into_iter() - .map(|envelope| (*from, envelope)), - ); - } - if outbound.is_empty() { - return false; - } - - while !outbound.is_empty() { - let index = rng.index(outbound.len()); - let (from, envelope) = outbound.swap_remove(index); - for to in &peers[from] { - if online.contains(to) { - receive_sparse_chaos_envelope( - network, - &node_ids[*to], - envelope.clone(), - timestamp_ms, - ); - } - } - } - true - } - - fn receive_sparse_chaos_envelope( - network: &mut InMemoryNetwork, - node_id: &str, - envelope: GossipEnvelope, - timestamp_ms: u64, - ) { - match envelope { - GossipEnvelope::Block(block) => { - receive_sparse_chaos_block(network, node_id, block, timestamp_ms); - } - other => { - if let Err(error) = network - .node_mut(node_id) - .expect("node exists") - .receive(other) - { - let message = error.to_string(); - assert!( - message.contains("mine transaction anchor is not on this chain") - || message.contains("conflicts with an existing pending transaction") - || message.contains("blinded transaction expired") - || message.contains( - "blinded reveal does not reference an active blinded transaction", - ), - "unexpected sparse chaos delivery error: {message}" - ); - } - } - } - } - - fn receive_sparse_chaos_block( - network: &mut InMemoryNetwork, - node_id: &str, - block: Block, - timestamp_ms: u64, - ) { - if let Err(error) = network - .node_mut(node_id) - .expect("node exists") - .receive_preverified_block_at(block, timestamp_ms) - { - let message = error.to_string(); - assert!( - message.contains("expected block height") - || message.contains("same-height fork") - || message.contains("block is already known"), - "unexpected sparse chaos block error: {message}" - ); - } - } - - fn observe_bounded_pending( - network: &InMemoryNetwork, - node_ids: &[String], - pending_since: &mut BTreeMap<String, u64>, - settle_blocks: u64, - ) { - let mut current = BTreeSet::new(); - for node_id in node_ids { - let node = network.node(node_id).expect("node exists"); - for id in pending_item_ids(node) { - current.insert(id); - } - } - let max_height = node_ids - .iter() - .map(|id| network.node(id).unwrap().chain_height()) - .max() - .unwrap_or_default(); - pending_since.retain(|id, _| current.contains(id)); - for id in current { - pending_since.entry(id).or_insert(max_height); - } - for (id, first_height) in pending_since { - assert!( - max_height.saturating_sub(*first_height) <= settle_blocks, - "pending item {id} stayed in mempool for more than {settle_blocks} blocks" - ); - } - } - - fn pending_item_ids(node: &NodeCore) -> Vec<String> { - let mut ids = Vec::new(); - ids.extend( - node.pending_transactions() - .into_iter() - .map(|tx| format!("tx:{}", tx.signature())), - ); - ids.extend( - node.pending_blinded_transactions() - .into_iter() - .map(|tx| format!("commit:{}@{}", tx.commitment, tx.expires_at_height)), - ); - ids.extend( - node.pending_blinded_reveals() - .into_iter() - .map(|reveal| format!("reveal:{}", reveal.commitment)), - ); - ids - } - - fn assert_sparse_chaos_converged(network: &InMemoryNetwork, node_ids: &[String]) { - let first = network.node(&node_ids[0]).expect("first node exists"); - let height = first.chain_height(); - let tip = first.ledger().status().tip_hash.clone(); - for node_id in node_ids.iter().skip(1) { - let node = network.node(node_id).expect("node exists"); - assert_eq!(node.chain_height(), height, "{node_id} height diverged"); - assert_eq!( - node.ledger().status().tip_hash, - tip, - "{node_id} tip diverged" - ); - } - } - - fn sparse_chaos_has_pending(network: &InMemoryNetwork, node_ids: &[String]) -> bool { - node_ids.iter().any(|node_id| { - let node = network.node(node_id).expect("node exists"); - !node.pending_transactions().is_empty() - || !node.pending_blinded_transactions().is_empty() - || !node.pending_blinded_reveals().is_empty() - }) - } - - fn assert_sparse_chaos_mempools_empty(network: &InMemoryNetwork, node_ids: &[String]) { - for node_id in node_ids { - let node = network.node(node_id).expect("node exists"); - let pending = pending_item_ids(node); - assert!( - pending.is_empty(), - "{node_id} at height {} still has pending mempool items: {pending:?}", - node.chain_height() - ); - } - } -} diff --git a/src/app/ledger_view.rs b/src/app/ledger_view.rs @@ -2,9 +2,7 @@ use anyhow::Result; use std::collections::BTreeSet; -use crate::domain::{ - BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, Ledger, OutPoint, Transaction, -}; +use crate::domain::{Block, BurnLeaderRank, Ledger, OutPoint, Transaction}; use super::{NodeCore, helpers::transaction_input_outpoints}; @@ -20,7 +18,6 @@ impl NodeCore { pub fn wallet_view_ledger(&self) -> Result<Ledger> { let mut ledger = self.ledger.clone(); self.queue_local_block_anchor(&mut ledger)?; - self.queue_owned_blinded_payloads(&mut ledger)?; Ok(ledger) } @@ -30,17 +27,6 @@ impl NodeCore { .pending() .iter() .flat_map(transaction_input_outpoints) - .chain( - self.ledger - .pending_blinded_transactions() - .iter() - .flat_map(|transaction| { - transaction - .inputs - .iter() - .map(|input| input.outpoint.clone()) - }), - ) .collect::<BTreeSet<_>>(); if let Some((height, burn)) = &self.local_block_anchor_burn { if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) { @@ -81,18 +67,4 @@ impl NodeCore { pub fn pending_transactions(&self) -> Vec<Transaction> { self.ledger.pending().to_vec() } - - pub fn pending_blinded_transactions(&self) -> Vec<BlindedTransaction> { - self.ledger.pending_blinded_transactions().to_vec() - } - - pub fn pending_blinded_reveals(&self) -> Vec<BlindedReveal> { - self.ledger.pending_blinded_reveals().to_vec() - } - - pub fn pending_revealed_blinded_transactions( - &self, - ) -> Vec<crate::domain::RevealedBlindedTransaction> { - self.ledger.pending_revealed_blinded_transactions() - } } diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::{ adapters::config_store::{DEFAULT_POW_MINING_WORKERS, clamp_pow_mining_workers}, - domain::{Amount, DEFAULT_FEE_PER_BYTE, Ledger, RevealBundle, Wallet}, + domain::{Amount, BurnBundle, DEFAULT_FEE_PER_BYTE, Ledger, Wallet}, }; use super::{GossipEnvelope, NodeConfig, NodeCore, NodeWallet}; @@ -102,13 +102,9 @@ impl NodeCore { last_auto_pow_mine_anchor: None, last_auto_pow_mine_status: None, auto_pow_mine_cursor: None, - owned_blinded_transactions: BTreeMap::new(), - owned_blinded_reveals: BTreeMap::new(), - owned_blinded_payloads: BTreeMap::new(), - owned_blinded_outbox_version: 0, - reveal_bundles: BTreeMap::<(u64, u8), RevealBundle>::new(), - equivocated_reveal_bundle_slots: BTreeSet::new(), - reveal_bundle_collection_started: None, + burn_bundles: BTreeMap::<(u64, u8), BurnBundle>::new(), + equivocated_burn_bundle_slots: BTreeSet::new(), + burn_bundle_collection_started: None, local_block_anchor_burn: None, outbox: Vec::<GossipEnvelope>::new(), } @@ -125,26 +121,18 @@ impl NodeCore { pub fn replace_wallet(&mut self, wallet: Wallet) { self.wallet = NodeWallet::Unlocked(wallet); self.reset_automatic_mining_progress(); - self.owned_blinded_transactions.clear(); - self.owned_blinded_reveals.clear(); - self.owned_blinded_payloads.clear(); - self.bump_owned_blinded_outbox_version(); - self.reveal_bundles.clear(); - self.equivocated_reveal_bundle_slots.clear(); - self.reveal_bundle_collection_started = None; + self.burn_bundles.clear(); + self.equivocated_burn_bundle_slots.clear(); + self.burn_bundle_collection_started = None; self.local_block_anchor_burn = None; } pub fn reset_chain_to_setup_placeholder(&mut self) { self.ledger = Ledger::new(BTreeMap::new(), 1); self.reset_automatic_mining_progress(); - self.owned_blinded_transactions.clear(); - self.owned_blinded_reveals.clear(); - self.owned_blinded_payloads.clear(); - self.bump_owned_blinded_outbox_version(); - self.reveal_bundles.clear(); - self.equivocated_reveal_bundle_slots.clear(); - self.reveal_bundle_collection_started = None; + self.burn_bundles.clear(); + self.equivocated_burn_bundle_slots.clear(); + self.burn_bundle_collection_started = None; self.local_block_anchor_burn = None; self.outbox.clear(); } @@ -156,6 +144,6 @@ impl NodeCore { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; - self.reveal_bundle_collection_started = None; + self.burn_bundle_collection_started = None; } } diff --git a/src/app/owned_blinded.rs b/src/app/owned_blinded.rs @@ -1,461 +0,0 @@ -use anyhow::Result; - -use crate::domain::{ - BlindedTransaction, Block, BuiltBlindedTransaction, Ledger, - MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, OwnedBlindedTransaction, Transaction, -}; - -use super::{GossipEnvelope, NodeCore}; - -impl NodeCore { - pub fn owned_blinded_payloads(&self) -> Vec<Transaction> { - self.owned_blinded_payloads.values().cloned().collect() - } - - pub fn owned_blinded_outbox_version(&self) -> u64 { - self.owned_blinded_outbox_version - } - - pub fn owned_blinded_transactions(&self) -> Vec<OwnedBlindedTransaction> { - self.owned_blinded_transactions - .iter() - .filter_map(|(commitment, transaction)| { - let payload = self.owned_blinded_payloads.get(commitment)?; - let reveal = self.owned_blinded_reveals.get(commitment)?; - Some(OwnedBlindedTransaction { - transaction: transaction.clone(), - payload: payload.clone(), - reveal: reveal.clone(), - }) - }) - .collect() - } - - pub fn mark_owned_blinded_outbox_persisted(&mut self, version: u64) { - if self.owned_blinded_outbox_version == version { - self.owned_blinded_outbox_version = 0; - } - } - - pub fn restore_owned_blinded_transactions( - &mut self, - transactions: Vec<OwnedBlindedTransaction>, - ) -> Result<()> { - self.owned_blinded_transactions.clear(); - self.owned_blinded_reveals.clear(); - self.owned_blinded_payloads.clear(); - for owned in transactions { - let commitment = owned.transaction.commitment.clone(); - if owned.reveal.commitment != commitment { - continue; - } - if self.ledger.has_blinded_reveal(&commitment) - || !self.ledger.has_unrevealed_blinded_transaction(&commitment) - && owned.transaction.expires_at_height <= self.ledger.height().saturating_add(1) - { - continue; - } - if !self.ledger.has_blinded_transaction(&commitment) { - match self - .ledger - .submit_blinded_transaction(owned.transaction.clone()) - { - Ok(true) => self.outbox.push(GossipEnvelope::BlindedTransaction( - owned.transaction.clone(), - )), - Ok(false) => {} - Err(_) => continue, - } - } - if !self.ledger.has_unrevealed_blinded_transaction(&commitment) { - continue; - } - self.owned_blinded_transactions - .insert(commitment.clone(), owned.transaction); - self.owned_blinded_payloads - .insert(commitment.clone(), owned.payload); - self.owned_blinded_reveals - .insert(commitment.clone(), owned.reveal); - } - self.publish_owned_reveals_for_active_commits()?; - self.bump_owned_blinded_outbox_version(); - Ok(()) - } - - pub(super) fn submit_owned_blinded_transaction( - &mut self, - built: BuiltBlindedTransaction, - ) -> Result<BlindedTransaction> { - let transaction = built.transaction; - self.owned_blinded_transactions - .insert(transaction.commitment.clone(), transaction.clone()); - self.owned_blinded_payloads - .insert(transaction.commitment.clone(), built.payload); - self.owned_blinded_reveals - .insert(transaction.commitment.clone(), built.reveal); - self.bump_owned_blinded_outbox_version(); - if self - .ledger - .submit_blinded_transaction(transaction.clone())? - { - self.outbox - .push(GossipEnvelope::BlindedTransaction(transaction.clone())); - } - Ok(transaction) - } - - pub(super) fn submit_transaction_as_owned_blinded( - &mut self, - tx: Transaction, - ) -> Result<Transaction> { - let built = self.ledger.build_blinded_transaction( - self.wallet.unlocked()?, - tx.clone(), - self.default_blinded_transaction_expiry_height(), - )?; - self.submit_owned_blinded_transaction(built)?; - Ok(tx) - } - - pub(super) fn default_blinded_transaction_expiry_height(&self) -> u64 { - self.ledger - .height() - .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS) - } - - pub(super) fn publish_owned_reveals_for_block(&mut self, block: &Block) -> Result<()> { - for transaction in &block.blinded_transactions { - let Some(reveal) = self.owned_blinded_reveals.get(&transaction.commitment) else { - continue; - }; - if self.ledger.submit_blinded_reveal(reveal.clone())? { - self.outbox - .push(GossipEnvelope::BlindedReveal(reveal.clone())); - } - } - Ok(()) - } - - pub(super) fn publish_owned_reveals_for_active_commits(&mut self) -> Result<()> { - let reveals = self - .owned_blinded_reveals - .iter() - .filter(|(commitment, _)| self.ledger.has_active_blinded_transaction(commitment)) - .map(|(_, reveal)| reveal.clone()) - .collect::<Vec<_>>(); - for reveal in reveals { - if self.ledger.submit_blinded_reveal(reveal.clone())? { - self.outbox.push(GossipEnvelope::BlindedReveal(reveal)); - } - } - Ok(()) - } - - pub(super) fn prune_owned_blinded_payloads_for_block(&mut self, block: &Block) { - let before = self.owned_blinded_transactions.len() - + self.owned_blinded_reveals.len() - + self.owned_blinded_payloads.len(); - for reveal in block.all_blinded_reveals() { - self.owned_blinded_transactions.remove(&reveal.commitment); - self.owned_blinded_reveals.remove(&reveal.commitment); - self.owned_blinded_payloads.remove(&reveal.commitment); - } - let unrevealed = self - .owned_blinded_transactions - .keys() - .filter(|commitment| self.ledger.has_unrevealed_blinded_transaction(commitment)) - .cloned() - .collect::<Vec<_>>(); - self.owned_blinded_transactions - .retain(|commitment, _| unrevealed.contains(commitment)); - self.owned_blinded_reveals - .retain(|commitment, _| unrevealed.contains(commitment)); - self.owned_blinded_payloads - .retain(|commitment, _| unrevealed.contains(commitment)); - let after = self.owned_blinded_transactions.len() - + self.owned_blinded_reveals.len() - + self.owned_blinded_payloads.len(); - if before != after { - self.bump_owned_blinded_outbox_version(); - } - } - - pub(super) fn queue_owned_blinded_payloads(&self, ledger: &mut Ledger) -> Result<()> { - for (commitment, payload) in &self.owned_blinded_payloads { - if self.ledger.has_unrevealed_blinded_transaction(commitment) - && !ledger.has_transaction(payload.signature()) - { - let _ = ledger.submit_transaction(payload.clone()); - } - } - Ok(()) - } - - pub(super) fn bump_owned_blinded_outbox_version(&mut self) { - self.owned_blinded_outbox_version = self.owned_blinded_outbox_version.saturating_add(1); - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use crate::{ - app::{GossipEnvelope, NodeCore}, - domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet}, - }; - - #[test] - fn owned_blinded_transaction_reveals_after_commit_block_import() { - let alice = Wallet::from_seed("owned-blinded-reveal-alice"); - let bob = Wallet::from_seed("owned-blinded-reveal-bob"); - let carol = Wallet::from_seed("owned-blinded-reveal-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); - let mut finalizer_ledger = ledger; - - let blinded = wallet_node - .blinded_burn_with_fee(3, 7, wallet_node.chain_height() + 4) - .unwrap(); - wallet_node.drain_outbox(); - finalizer_ledger - .submit_blinded_transaction(blinded.clone()) - .unwrap(); - let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); - let finalizer = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); - - wallet_node - .receive(GossipEnvelope::Block(commit_block)) - .unwrap(); - let outbox = wallet_node.drain_outbox(); - - assert!( - wallet_node - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == blinded.commitment) - ); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment - ))); - } - - #[test] - fn status_wallet_balance_includes_owned_blinded_change_before_and_after_commit() { - let alice = Wallet::from_seed("owned-blinded-balance-alice"); - let bob = Wallet::from_seed("owned-blinded-balance-bob"); - let carol = Wallet::from_seed("owned-blinded-balance-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let starting_balance = 2 * MICRO_IUNA; - let burn_amount = MICRO_IUNA / 10; - let fee = MICRO_IUNA / 10; - let expected_balance = starting_balance - burn_amount - fee; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), starting_balance); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); - let mut finalizer_ledger = ledger; - - let blinded = wallet_node - .blinded_burn_with_fee(burn_amount, fee, wallet_node.chain_height() + 4) - .unwrap(); - - assert_eq!(wallet_node.status().wallet_balance, expected_balance); - - finalizer_ledger - .submit_blinded_transaction(blinded.clone()) - .unwrap(); - let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); - let finalizer = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); - - wallet_node - .receive(GossipEnvelope::Block(commit_block)) - .unwrap(); - - assert_eq!(wallet_node.ledger().balance_of(carol.address()), 0); - assert_eq!(wallet_node.status().wallet_balance, expected_balance); - } - - #[test] - fn owned_blinded_transaction_restore_requeues_pending_commit() { - let alice = Wallet::from_seed("owned-blinded-restore-pending-alice"); - let bob = Wallet::from_seed("owned-blinded-restore-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new(allocations, 1); - let mut node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); - - let blinded = node - .blinded_transfer_with_fee(bob.address(), MICRO_IUNA, 7, node.chain_height() + 4) - .unwrap(); - let owned = node.owned_blinded_transactions(); - let mut restarted = NodeCore::from_ledger(alice, ledger, 0); - - restarted.restore_owned_blinded_transactions(owned).unwrap(); - - assert_eq!( - restarted.ledger().pending_blinded_transactions(), - std::slice::from_ref(&blinded) - ); - let outbox = restarted.drain_outbox(); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedTransaction(transaction) - if transaction.commitment == blinded.commitment - ))); - assert!(!outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment - ))); - } - - #[test] - fn owned_blinded_transaction_restore_skips_stale_commit_with_spent_input() { - let alice = Wallet::from_seed("owned-blinded-restore-stale-alice"); - let bob = Wallet::from_seed("owned-blinded-restore-stale-bob"); - let finalizers = [bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); - - wallet_node - .blinded_burn_with_fee(MICRO_IUNA / 10, 7, wallet_node.chain_height() + 4) - .unwrap(); - let owned = wallet_node.owned_blinded_transactions(); - let stale_conflict = ledger.build_burn(&alice, MICRO_IUNA / 10, 7).unwrap(); - let mut advanced_ledger = ledger; - advanced_ledger.submit_transaction(stale_conflict).unwrap(); - let leader = advanced_ledger.expected_leader_for_next_block().unwrap(); - assert_eq!(leader, bob.address()); - let anchor_burn = advanced_ledger.build_burn(&bob, 1, 0).unwrap(); - advanced_ledger.submit_transaction(anchor_burn).unwrap(); - let block = advanced_ledger.mine_next_block(&bob, 1).unwrap(); - advanced_ledger.apply_block(block).unwrap(); - let mut restarted = NodeCore::from_ledger(alice, advanced_ledger, 0); - - restarted.restore_owned_blinded_transactions(owned).unwrap(); - - assert!(restarted.owned_blinded_transactions().is_empty()); - assert!(restarted.ledger().pending_blinded_transactions().is_empty()); - assert!(restarted.drain_outbox().is_empty()); - } - - #[test] - fn owned_blinded_transaction_restore_publishes_reveal_after_commit() { - let alice = Wallet::from_seed("owned-blinded-restore-reveal-alice"); - let bob = Wallet::from_seed("owned-blinded-restore-reveal-bob"); - let carol = Wallet::from_seed("owned-blinded-restore-reveal-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); - let mut finalizer_ledger = ledger; - - let blinded = wallet_node - .blinded_burn_with_fee(3, 7, wallet_node.chain_height() + 4) - .unwrap(); - let owned = wallet_node.owned_blinded_transactions(); - finalizer_ledger - .submit_blinded_transaction(blinded.clone()) - .unwrap(); - let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); - let finalizer = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); - finalizer_ledger.apply_block(commit_block.clone()).unwrap(); - let mut restarted_ledger = NodeCore::from_ledger( - carol, - Ledger::from_snapshot(finalizer_ledger.snapshot()).unwrap(), - 0, - ); - - restarted_ledger - .restore_owned_blinded_transactions(owned) - .unwrap(); - - assert!( - restarted_ledger - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == blinded.commitment) - ); - assert!( - restarted_ledger - .drain_outbox() - .iter() - .any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment - )) - ); - assert!( - commit_block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.commitment) - ); - } -} diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -1,13 +1,10 @@ -use anyhow::{Result, bail}; +use anyhow::Result; use crate::domain::{ - BlindedReveal, BlindedTransaction, Block, ChainSnapshot, Ledger, RevealBundle, Transaction, - TransactionSubmitOutcome, + Block, BurnBundle, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome, }; -use super::{ - GossipEnvelope, IMPORT_REBROADCAST_LIMIT, NodeCore, helpers::transaction_input_outpoints, -}; +use super::{GossipEnvelope, IMPORT_REBROADCAST_LIMIT, NodeCore}; impl NodeCore { pub fn receive_transaction(&mut self, tx: Transaction) -> Result<TransactionSubmitOutcome> { @@ -15,60 +12,18 @@ impl NodeCore { Ok(outcome) } - pub fn receive_mine_action(&mut self, tx: Transaction) -> Result<()> { - if !matches!(tx, Transaction::Mine { .. }) { - bail!("only mine actions may be gossiped as plaintext"); - } + pub fn receive_gossiped_transaction(&mut self, tx: Transaction) -> Result<()> { if self .ledger .submit_transaction_with_outcome(tx.clone())? .added() { - self.outbox.push(GossipEnvelope::MineAction(tx)); + self.outbox.push(GossipEnvelope::Transaction(tx)); } Ok(()) } - pub fn receive_blinded_transaction(&mut self, tx: BlindedTransaction) -> Result<()> { - if self.blinded_transaction_conflicts_with_local_anchor(&tx) { - return Ok(()); - } - if self.ledger.submit_blinded_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::BlindedTransaction(tx)); - } - Ok(()) - } - - fn blinded_transaction_conflicts_with_local_anchor(&self, tx: &BlindedTransaction) -> bool { - let Some((height, burn)) = &self.local_block_anchor_burn else { - return false; - }; - if *height != self.ledger.height() || self.ledger.has_transaction(burn.signature()) { - return false; - } - let anchor_inputs = transaction_input_outpoints(burn); - tx.inputs - .iter() - .any(|input| anchor_inputs.contains(&input.outpoint)) - } - - pub fn receive_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<()> { - self.receive_blinded_reveal_without_bundle_publish(reveal)?; - Ok(()) - } - - fn receive_blinded_reveal_without_bundle_publish( - &mut self, - reveal: BlindedReveal, - ) -> Result<bool> { - if self.ledger.submit_blinded_reveal(reveal.clone())? { - self.outbox.push(GossipEnvelope::BlindedReveal(reveal)); - return Ok(true); - } - Ok(false) - } - - pub fn receive_reveal_bundle(&mut self, bundle: RevealBundle) -> Result<()> { + pub fn receive_burn_bundle(&mut self, bundle: BurnBundle) -> Result<()> { let next_height = self.ledger.height().saturating_add(1); if bundle.height <= self.ledger.height() { return Ok(()); @@ -77,20 +32,23 @@ impl NodeCore { return Ok(()); } let key = (bundle.height, bundle.slot); + for burn in &bundle.burns { + self.receive_gossiped_transaction(burn.clone())?; + } self.ledger - .validate_next_block_reveal_bundles(vec![bundle.clone()])?; - if self.equivocated_reveal_bundle_slots.contains(&key) { + .validate_next_block_burn_bundles(vec![bundle.clone()])?; + if self.equivocated_burn_bundle_slots.contains(&key) { return Ok(()); } - if let Some(existing) = self.reveal_bundles.get(&key) { + if let Some(existing) = self.burn_bundles.get(&key) { if existing.canonical() != bundle.canonical() { - self.reveal_bundles.remove(&key); - self.equivocated_reveal_bundle_slots.insert(key); + self.burn_bundles.remove(&key); + self.equivocated_burn_bundle_slots.insert(key); } return Ok(()); } - self.reveal_bundles.insert(key, bundle.clone()); - self.outbox.push(GossipEnvelope::RevealBundle(bundle)); + self.burn_bundles.insert(key, bundle.clone()); + self.outbox.push(GossipEnvelope::BurnBundle(bundle)); Ok(()) } @@ -102,31 +60,17 @@ impl NodeCore { | GossipEnvelope::BlockRangeRequest { .. } | GossipEnvelope::BlockRequest { .. } | GossipEnvelope::Inventory { .. } => Ok(()), - GossipEnvelope::BlindedTransaction(tx) => self.receive_blinded_transaction(tx), - GossipEnvelope::BlindedTransactions { transactions } => { + GossipEnvelope::Transaction(tx) => self.receive_gossiped_transaction(tx), + GossipEnvelope::Transactions { transactions } => { for tx in transactions { - self.receive_blinded_transaction(tx)?; + self.receive_gossiped_transaction(tx)?; } Ok(()) } - GossipEnvelope::MineAction(tx) => self.receive_mine_action(tx), - GossipEnvelope::MineActions { transactions } => { - for tx in transactions { - self.receive_mine_action(tx)?; - } - Ok(()) - } - GossipEnvelope::BlindedReveal(reveal) => self.receive_blinded_reveal(reveal), - GossipEnvelope::BlindedReveals { reveals } => { - for reveal in reveals { - self.receive_blinded_reveal_without_bundle_publish(reveal)?; - } - Ok(()) - } - GossipEnvelope::RevealBundle(bundle) => self.receive_reveal_bundle(bundle), - GossipEnvelope::RevealBundles { bundles } => { + GossipEnvelope::BurnBundle(bundle) => self.receive_burn_bundle(bundle), + GossipEnvelope::BurnBundles { bundles } => { for bundle in bundles { - self.receive_reveal_bundle(bundle)?; + self.receive_burn_bundle(bundle)?; } Ok(()) } @@ -135,10 +79,8 @@ impl NodeCore { self.ledger.apply_block(block.clone())?; if self.ledger.height() > previous_height { self.clear_stale_local_block_anchor(); - self.clear_stale_reveal_bundle_collection(); - self.prune_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); - self.publish_owned_reveals_for_block(&block)?; + self.clear_stale_burn_bundle_collection(); + self.prune_burn_bundles(); self.outbox.push(GossipEnvelope::Block(block)); } Ok(()) @@ -150,10 +92,8 @@ impl NodeCore { self.ledger.apply_block(block.clone())?; if self.ledger.height() > previous_height { self.clear_stale_local_block_anchor(); - self.clear_stale_reveal_bundle_collection(); - self.prune_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); - self.publish_owned_reveals_for_block(&block)?; + self.clear_stale_burn_bundle_collection(); + self.prune_burn_bundles(); imported.push(block); } } @@ -176,10 +116,8 @@ impl NodeCore { .apply_preverified_block_at(block.clone(), now_ms)?; if self.ledger.height() > previous_height { self.clear_stale_local_block_anchor(); - self.clear_stale_reveal_bundle_collection(); - self.prune_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); - self.publish_owned_reveals_for_block(&block)?; + self.clear_stale_burn_bundle_collection(); + self.prune_burn_bundles(); self.outbox.push(GossipEnvelope::Block(block)); } Ok(()) @@ -200,8 +138,8 @@ impl NodeCore { if imported { self.reset_automatic_mining_progress(); self.clear_stale_local_block_anchor(); - self.clear_stale_reveal_bundle_collection(); - self.prune_reveal_bundles(); + self.clear_stale_burn_bundle_collection(); + self.prune_burn_bundles(); self.enqueue_imported_blocks(previous_height)?; } Ok(()) @@ -221,8 +159,8 @@ impl NodeCore { self.ledger = ledger; self.reset_automatic_mining_progress(); self.clear_stale_local_block_anchor(); - self.clear_stale_reveal_bundle_collection(); - self.prune_reveal_bundles(); + self.clear_stale_burn_bundle_collection(); + self.prune_burn_bundles(); self.enqueue_imported_blocks(previous_height)?; Ok(true) } @@ -238,11 +176,9 @@ impl NodeCore { let blocks = self .ledger .blocks_from(previous_height + 1, IMPORT_REBROADCAST_LIMIT); - for block in &blocks { - self.prune_reveal_bundles(); - self.clear_stale_reveal_bundle_collection(); - self.prune_owned_blinded_payloads_for_block(block); - self.publish_owned_reveals_for_block(block)?; + for _ in &blocks { + self.prune_burn_bundles(); + self.clear_stale_burn_bundle_collection(); } if !blocks.is_empty() { self.outbox.push(GossipEnvelope::Blocks { blocks }); @@ -256,271 +192,53 @@ mod tests { use std::collections::BTreeMap; use crate::{ - app::{ - GossipEnvelope, NodeCore, REVEAL_BUNDLE_COLLECTION_MS, - helpers::transaction_input_outpoints, - }, + app::NodeCore, domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet}, }; - fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { - wallets + fn funded_ledger(wallets: &[Wallet]) -> Ledger { + let allocations = wallets .iter() - .find(|wallet| wallet.address() == address) - .unwrap_or_else(|| panic!("missing wallet for address {address}")) - } - - #[test] - fn receiving_blinded_reveal_batch_waits_before_signing_committee_bundle() { - let alice = Wallet::from_seed("immediate-bundle-alice"); - let bob = Wallet::from_seed("immediate-bundle-bob"); - let carol = Wallet::from_seed("immediate-bundle-carol"); - let dave = Wallet::from_seed("immediate-bundle-dave"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(dave.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let first = ledger - .build_blinded_burn(&carol, 3, 100, ledger.height() + 4) - .unwrap(); - let second = ledger - .build_blinded_burn(&dave, 4, 100, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(first.transaction.clone()) - .unwrap(); - ledger - .submit_blinded_transaction(second.transaction.clone()) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let burn = ledger.build_burn(leader_wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let commit_block = ledger.mine_next_block(leader_wallet, 1).unwrap(); - ledger.apply_locally_mined_block(commit_block).unwrap(); - - let committee = ledger.reveal_committee_for_next_block(); - let committee_wallet = committee + .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA)) + .collect::<BTreeMap<_, _>>(); + let genesis_burns = wallets .iter() - .filter_map(|member| { - finalizers - .iter() - .find(|wallet| wallet.address() == member.owner) - }) - .next() - .expect("test finalizer should be in reveal committee"); - let mut committee_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - committee_wallet.clone(), - ledger, - true, - 0, - 0, - ); - - committee_node - .receive(GossipEnvelope::BlindedReveals { - reveals: vec![first.reveal.clone()], - }) - .unwrap(); - let outbox = committee_node.drain_outbox(); - - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == first.reveal.commitment - ))); - assert!( - !outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::RevealBundle(_))) - ); - - let early = committee_node.prepare_automatic_finalization(2); - assert!(early.work.is_none()); - assert!( - early - .skipped_reason - .as_deref() - .unwrap_or_default() - .contains("collecting blinded reveals") - ); - assert!( - !committee_node - .drain_outbox() - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::RevealBundle(_))) - ); - - committee_node - .receive(GossipEnvelope::BlindedReveals { - reveals: vec![second.reveal.clone()], - }) - .unwrap(); - let outbox = committee_node.drain_outbox(); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == second.reveal.commitment - ))); - assert!( - !outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::RevealBundle(_))) - ); - - let ready = committee_node.prepare_automatic_finalization(REVEAL_BUNDLE_COLLECTION_MS + 3); - let _ = ready; - let outbox = committee_node.drain_outbox(); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::RevealBundle(bundle) - if bundle.member == committee_wallet.address() - && bundle.reveals.len() == 2 - && bundle.reveals.iter().any(|reveal| reveal.commitment == first.reveal.commitment) - && bundle.reveals.iter().any(|reveal| reveal.commitment == second.reveal.commitment) - ))); - } - - #[test] - fn invalid_conflicting_reveal_bundle_does_not_poison_stored_slot() { - const TEST_SIGNATURE_BYTES: usize = 64; - - let alice = Wallet::from_seed("invalid-conflict-bundle-alice"); - let bob = Wallet::from_seed("invalid-conflict-bundle-bob"); - let carol = Wallet::from_seed("invalid-conflict-bundle-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let blinded = ledger - .build_blinded_burn(&carol, 3, 100, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let burn = ledger.build_burn(leader_wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let commit_block = ledger.mine_next_block(leader_wallet, 1).unwrap(); - ledger.apply_locally_mined_block(commit_block).unwrap(); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - - let committee_member = ledger.reveal_committee_for_next_block()[0].clone(); - let committee_wallet = wallet_for_address(&finalizers, &committee_member.owner); - let valid_bundle = ledger - .build_reveal_bundle(committee_wallet) - .unwrap() - .unwrap(); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - committee_wallet.clone(), - ledger, - true, - 0, - 0, - ); - - node.receive_reveal_bundle(valid_bundle.clone()).unwrap(); - node.drain_outbox(); - - let mut invalid_conflict = valid_bundle.clone(); - invalid_conflict.signature = "00".repeat(TEST_SIGNATURE_BYTES); - let error = node.receive_reveal_bundle(invalid_conflict).unwrap_err(); - - assert!(format!("{error:#}").contains("reveal bundle signature is invalid")); - let key = (valid_bundle.height, valid_bundle.slot); - assert_eq!(node.reveal_bundles.get(&key), Some(&valid_bundle)); - assert!(!node.equivocated_reveal_bundle_slots.contains(&key)); - assert_eq!(node.usable_reveal_bundles(), vec![valid_bundle]); - assert!(node.drain_outbox().is_empty()); + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect::<Vec<_>>(); + Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap() } #[test] - fn inbound_blinded_transaction_conflicting_with_local_anchor_is_not_queued() { - let alice = Wallet::from_seed("local-anchor-inbound-alice"); - let bob = Wallet::from_seed("local-anchor-inbound-bob"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers + fn burn_bundle_imports_new_signed_burns_to_mempool() { + let alice = Wallet::from_seed("bundle-import-new-burn-alice"); + let bob = Wallet::from_seed("bundle-import-new-burn-bob"); + let wallets = [alice.clone(), bob.clone()]; + let ledger = funded_ledger(&wallets); + let finalizer = ledger.expected_leader_for_next_block().unwrap(); + let signer = wallets .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() + .find(|wallet| wallet.address() == finalizer) + .expect("test ledger should include selected finalizer") .clone(); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - leader_wallet.clone(), - ledger, - true, - MICRO_IUNA / 10, - 1, - ); - - let plan = node.prepare_automatic_finalization(1); - assert!(plan.burned.is_some()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - let automatic_burn_commitment = node.ledger().pending_blinded_transactions()[0] - .commitment + let burner = wallets + .iter() + .find(|wallet| wallet.address() != signer.address()) + .expect("test ledger should include a non-finalizer") .clone(); - assert_eq!( - node.ledger().pending_blinded_transactions()[0].commitment, - automatic_burn_commitment - ); - node.drain_outbox(); - let (_, anchor_burn) = node - .local_block_anchor_burn - .clone() - .expect("leader burn should be held as a local block anchor"); - let anchor_inputs = transaction_input_outpoints(&anchor_burn) - .into_iter() - .collect::<Vec<_>>(); - let conflicting_payload = node - .ledger() - .build_transfer_with_inputs(&leader_wallet, bob.address(), 1, 0, &anchor_inputs) - .unwrap(); - let conflicting = node - .ledger() - .build_blinded_transaction(&leader_wallet, conflicting_payload, node.chain_height() + 4) - .unwrap(); + let burn = ledger.build_burn(&burner, 1, 1).unwrap(); + let mut signer_ledger = ledger.clone(); + signer_ledger.submit_transaction(burn.clone()).unwrap(); + let bundle = signer_ledger.build_burn_bundle(&signer).unwrap().unwrap(); + let mut receiver = NodeCore::from_ledger(signer, ledger, 0); - node.receive_blinded_transaction(conflicting.transaction) - .unwrap(); + receiver.receive_burn_bundle(bundle).unwrap(); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - assert_eq!( - node.ledger().pending_blinded_transactions()[0].commitment, - automatic_burn_commitment + assert!( + receiver + .ledger() + .pending() + .iter() + .any(|transaction| transaction.signature() == burn.signature()) ); - assert!(node.drain_outbox().is_empty()); - assert!(node.prepare_automatic_finalization(1).work.is_some()); } } diff --git a/src/app/status.rs b/src/app/status.rs @@ -1,5 +1,3 @@ -use std::collections::BTreeMap; - use anyhow::Result; use crate::{ @@ -72,24 +70,7 @@ impl NodeCore { .utxos_for_address(address) .into_iter() .map(|(outpoint, output)| (outpoint, output.amount)) - .collect::<BTreeMap<_, _>>(); - - for (commitment, payload) in &self.owned_blinded_payloads { - if !self.ledger.has_unrevealed_blinded_transaction(commitment) { - continue; - } - let output_total = transaction_output_total_for_address(payload, address); - if self.ledger.has_active_blinded_transaction(commitment) { - balance = balance.saturating_add(output_total); - } else { - let input_total = - transaction_input_total_from_outputs(payload, address, &confirmed_outputs); - balance = balance - .saturating_sub(input_total) - .saturating_add(output_total); - } - } - + .collect::<std::collections::BTreeMap<_, _>>(); if let Some((height, burn)) = &self.local_block_anchor_burn { if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) { let output_total = transaction_output_total_for_address(burn, address); diff --git a/src/app/types.rs b/src/app/types.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::domain::{ - Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, ChainStatus, PreparedBlock, - RevealBundle, StratumMineTemplate, Transaction, Wallet, + Amount, Block, BurnBundle, ChainSnapshot, ChainStatus, PreparedBlock, StratumMineTemplate, + Transaction, Wallet, }; #[derive(Clone, Debug)] @@ -50,21 +50,13 @@ pub enum GossipEnvelope { Inventory { blocks: Vec<BlockInventory>, }, - BlindedTransaction(BlindedTransaction), - BlindedTransactions { - transactions: Vec<BlindedTransaction>, - }, - MineAction(Transaction), - MineActions { + Transaction(Transaction), + Transactions { transactions: Vec<Transaction>, }, - BlindedReveal(BlindedReveal), - BlindedReveals { - reveals: Vec<BlindedReveal>, - }, - RevealBundle(RevealBundle), - RevealBundles { - bundles: Vec<RevealBundle>, + BurnBundle(BurnBundle), + BurnBundles { + bundles: Vec<BurnBundle>, }, Block(Block), Blocks { diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -1,9 +1,8 @@ use anyhow::{Context, Result, bail}; use crate::domain::{ - Amount, BlindedTransaction, Block, BuiltBlindedTransaction, DEFAULT_TRANSACTION_FEE, Ledger, - OutPoint, PreparedBlock, RevealBundle, StratumMineShare, StratumMineTemplate, Transaction, - Wallet, run_vdf, + Amount, Block, BurnBundle, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint, PreparedBlock, + StratumMineShare, StratumMineTemplate, Transaction, Wallet, run_vdf, }; use super::{ @@ -38,14 +37,14 @@ impl NodeWallet { impl NodeCore { pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { - self.burn_with_fee(amount, 0) + self.burn_with_fee(amount, DEFAULT_TRANSACTION_FEE) } pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { let tx = self .wallet_build_ledger()? .build_burn(self.wallet.unlocked()?, amount, fee)?; - self.submit_transaction_as_owned_blinded(tx) + self.submit_public_transaction(tx) } pub fn burn_with_fee_rate( @@ -53,27 +52,11 @@ impl NodeCore { amount: Amount, fee_per_byte: Amount, ) -> Result<(Transaction, FeeEstimate)> { - let (built, estimate) = self.build_blinded_burn_with_fee_rate(amount, fee_per_byte)?; - let tx = built.payload.clone(); - self.submit_owned_blinded_transaction(built)?; + let (tx, estimate) = self.build_burn_with_fee_rate(amount, fee_per_byte)?; + self.submit_public_transaction(tx.clone())?; Ok((tx, estimate)) } - pub fn blinded_burn_with_fee( - &mut self, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BlindedTransaction> { - let built = self.wallet_build_ledger()?.build_blinded_burn( - self.wallet.unlocked()?, - amount, - fee, - expires_at_height, - )?; - self.submit_owned_blinded_transaction(built) - } - pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> { self.build_burn_with_fee_rate(amount, fee_per_byte) .map(|(_, estimate)| estimate) @@ -92,7 +75,7 @@ impl NodeCore { let tx = self.wallet_build_ledger()? .build_transfer(self.wallet.unlocked()?, to, amount, fee)?; - self.submit_transaction_as_owned_blinded(tx) + self.submit_public_transaction(tx) } pub fn transfer_with_fee_spending( @@ -109,24 +92,7 @@ impl NodeCore { fee, outpoints, )?; - self.submit_transaction_as_owned_blinded(tx) - } - - pub fn blinded_transfer_with_fee( - &mut self, - to: impl Into<String>, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BlindedTransaction> { - let built = self.wallet_build_ledger()?.build_blinded_transfer( - self.wallet.unlocked()?, - to, - amount, - fee, - expires_at_height, - )?; - self.submit_owned_blinded_transaction(built) + self.submit_public_transaction(tx) } pub fn transfer_with_fee_rate( @@ -136,10 +102,9 @@ impl NodeCore { fee_per_byte: Amount, outpoints: &[OutPoint], ) -> Result<(Transaction, FeeEstimate)> { - let (built, estimate) = - self.build_blinded_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; - let tx = built.payload.clone(); - self.submit_owned_blinded_transaction(built)?; + let (tx, estimate) = + self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; + self.submit_public_transaction(tx.clone())?; Ok((tx, estimate)) } @@ -210,15 +175,15 @@ impl NodeCore { self.submit_public_mine_action(tx) } - pub(super) fn usable_reveal_bundles(&self) -> Vec<RevealBundle> { + pub(super) fn usable_burn_bundles(&self) -> Vec<BurnBundle> { let next_height = self.ledger.height().saturating_add(1); let mut bundles = self - .reveal_bundles + .burn_bundles .iter() .filter(|((height, slot), _)| { *height == next_height && !self - .equivocated_reveal_bundle_slots + .equivocated_burn_bundle_slots .contains(&(*height, *slot)) }) .map(|(_, bundle)| bundle.clone()) @@ -227,32 +192,31 @@ impl NodeCore { bundles } - pub(super) fn prune_reveal_bundles(&mut self) { + pub(super) fn prune_burn_bundles(&mut self) { let height = self.ledger.height(); - self.reveal_bundles + self.burn_bundles .retain(|(bundle_height, _), _| *bundle_height > height); - self.equivocated_reveal_bundle_slots + self.equivocated_burn_bundle_slots .retain(|(bundle_height, _)| *bundle_height > height); } - pub(super) fn publish_reveal_bundle_for_next_block(&mut self) -> Result<()> { + pub(super) fn publish_burn_bundle_for_next_block(&mut self) -> Result<()> { let wallet = match &self.wallet { NodeWallet::Unlocked(wallet) => wallet, NodeWallet::Locked { .. } => return Ok(()), }; - let Some(bundle) = self.ledger.build_reveal_bundle(wallet)? else { + let (ledger, _) = self.ledger_with_local_block_anchor(); + let Some(bundle) = ledger.build_burn_bundle(wallet)? else { return Ok(()); }; let key = (bundle.height, bundle.slot); - if self.equivocated_reveal_bundle_slots.contains(&key) - || self.reveal_bundles.contains_key(&key) + if self.equivocated_burn_bundle_slots.contains(&key) || self.burn_bundles.contains_key(&key) { return Ok(()); } - self.ledger - .validate_next_block_reveal_bundles(vec![bundle.clone()])?; - self.reveal_bundles.insert(key, bundle.clone()); - self.outbox.push(GossipEnvelope::RevealBundle(bundle)); + ledger.validate_next_block_burn_bundles(vec![bundle.clone()])?; + self.burn_bundles.insert(key, bundle.clone()); + self.outbox.push(GossipEnvelope::BurnBundle(bundle)); Ok(()) } @@ -265,53 +229,45 @@ impl NodeCore { .submit_transaction_with_outcome(tx.clone())? .added() { - self.outbox.push(GossipEnvelope::MineAction(tx.clone())); + self.outbox.push(GossipEnvelope::Transaction(tx.clone())); } Ok(tx) } - pub(super) fn build_burn_with_fee_rate( - &self, - amount: Amount, - fee_per_byte: Amount, - ) -> Result<(Transaction, FeeEstimate)> { - let (built, estimate) = self.build_blinded_burn_with_fee_rate(amount, fee_per_byte)?; - Ok((built.payload, estimate)) + pub(super) fn submit_public_transaction(&mut self, tx: Transaction) -> Result<Transaction> { + if matches!(tx, Transaction::Mine { .. }) { + return self.submit_public_mine_action(tx); + } + if self + .ledger + .submit_transaction_with_outcome(tx.clone())? + .added() + { + self.outbox.push(GossipEnvelope::Transaction(tx.clone())); + } + Ok(tx) } - pub(super) fn build_blinded_burn_with_fee_rate( + pub(super) fn build_burn_with_fee_rate( &self, amount: Amount, fee_per_byte: Amount, - ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { + ) -> Result<(Transaction, FeeEstimate)> { let ledger = self.wallet_build_ledger()?; - self.build_blinded_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte) + self.build_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte) } - pub(super) fn build_blinded_burn_with_fee_rate_on_ledger( + pub(super) fn build_burn_with_fee_rate_on_ledger( &self, ledger: &Ledger, amount: Amount, fee_per_byte: Amount, - ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { - let expires_at_height = self.default_blinded_transaction_expiry_height(); + ) -> Result<(Transaction, FeeEstimate)> { converge_fee_by_byte(fee_per_byte, |fee| { - let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; - ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) + ledger.build_burn(self.wallet.unlocked()?, amount, fee) }) } - pub(super) fn build_blinded_burn_with_fee_on_ledger( - &self, - ledger: &Ledger, - amount: Amount, - fee: Amount, - ) -> Result<BuiltBlindedTransaction> { - let expires_at_height = self.default_blinded_transaction_expiry_height(); - let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; - ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) - } - pub(super) fn build_transfer_with_fee_rate( &self, to: impl Into<String>, @@ -319,23 +275,10 @@ impl NodeCore { fee_per_byte: Amount, outpoints: &[OutPoint], ) -> Result<(Transaction, FeeEstimate)> { - let (built, estimate) = - self.build_blinded_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; - Ok((built.payload, estimate)) - } - - pub(super) fn build_blinded_transfer_with_fee_rate( - &self, - to: impl Into<String>, - amount: Amount, - fee_per_byte: Amount, - outpoints: &[OutPoint], - ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { let to = to.into(); let ledger = self.wallet_build_ledger()?; - let expires_at_height = self.default_blinded_transaction_expiry_height(); converge_fee_by_byte(fee_per_byte, |fee| { - let tx = if outpoints.is_empty() { + if outpoints.is_empty() { ledger.build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee) } else { ledger.build_transfer_with_inputs( @@ -345,8 +288,7 @@ impl NodeCore { fee, outpoints, ) - }?; - ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) + } }) } @@ -364,7 +306,6 @@ impl NodeCore { pub(super) fn wallet_build_ledger(&self) -> Result<Ledger> { let mut ledger = self.ledger.clone(); self.reserve_local_block_anchor_inputs(&mut ledger)?; - self.queue_owned_blinded_payloads(&mut ledger)?; Ok(ledger) } @@ -372,7 +313,6 @@ impl NodeCore { let mut ledger = self.ledger.clone(); self.reserve_local_block_anchor_inputs(&mut ledger)?; ledger.clear_pending_transactions(); - ledger.clear_pending_blinded_transactions(); Ok(ledger) } @@ -401,7 +341,7 @@ impl NodeCore { } pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> { - self.publish_reveal_bundle_for_next_block()?; + self.publish_burn_bundle_for_next_block()?; let work = self.prepare_next_block_with_local_anchor(timestamp_ms)?; let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); self.complete_prepared_block_at(work, vdf_output, timestamp_ms) @@ -424,10 +364,8 @@ impl NodeCore { let block = work.finish_at(self.wallet.unlocked()?, vdf_output, timestamp_ms); self.ledger.apply_locally_mined_block(block.clone())?; self.clear_stale_local_block_anchor(); - self.prune_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); + self.prune_burn_bundles(); self.outbox.push(GossipEnvelope::Block(block.clone())); - self.publish_owned_reveals_for_block(&block)?; Ok(block) } @@ -446,176 +384,3 @@ impl NodeCore { Ok(()) } } - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use crate::{ - app::{FeeEstimate, NodeCore}, - domain::{ - GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, Transaction, VDF_TARGET_BLOCK_MS, - Wallet, run_vdf, - }, - }; - - #[test] - fn fee_rate_transfer_and_burn_pay_at_least_bytes_times_rate() { - let transfer_sender = Wallet::from_seed("fee-rate-transfer-sender"); - let transfer_recipient = Wallet::from_seed("fee-rate-transfer-recipient"); - let mut transfer_genesis = BTreeMap::new(); - transfer_genesis.insert(transfer_sender.address().to_string(), 10 * MICRO_IUNA); - let transfer_ledger = Ledger::new_with_genesis_burns( - transfer_genesis, - vec![GenesisBurn::new(transfer_sender.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut transfer_node = NodeCore::from_ledger(transfer_sender, transfer_ledger, 0); - - let (transfer, transfer_estimate) = transfer_node - .transfer_with_fee_rate(transfer_recipient.address(), MICRO_IUNA, 2, &[]) - .unwrap(); - let transfer_blinded_bytes = - transfer_node.ledger().pending_blinded_transactions()[0].fee_rate_size_bytes(); - assert_eq!(transfer_estimate.bytes, transfer_blinded_bytes); - let minimum_transfer_fee = transfer_blinded_bytes as u64 * 2; - assert!(transfer.fee() >= minimum_transfer_fee); - assert!(transfer_node.ledger().pending().is_empty()); - assert_eq!( - transfer_node.ledger().pending_blinded_transactions().len(), - 1 - ); - - let burn_wallet = Wallet::from_seed("fee-rate-burn-wallet"); - let mut burn_genesis = BTreeMap::new(); - burn_genesis.insert(burn_wallet.address().to_string(), 10 * MICRO_IUNA); - let burn_ledger = Ledger::new_with_genesis_burns( - burn_genesis, - vec![GenesisBurn::new(burn_wallet.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut burn_node = NodeCore::from_ledger(burn_wallet, burn_ledger, 0); - let (burn, burn_estimate) = burn_node.burn_with_fee_rate(MICRO_IUNA, 3).unwrap(); - let burn_blinded_bytes = - burn_node.ledger().pending_blinded_transactions()[0].fee_rate_size_bytes(); - assert_eq!(burn_estimate.bytes, burn_blinded_bytes); - let minimum_burn_fee = burn_blinded_bytes as u64 * 3; - assert!(burn.fee() >= minimum_burn_fee); - assert!(burn_node.ledger().pending().is_empty()); - assert_eq!(burn_node.ledger().pending_blinded_transactions().len(), 1); - } - - #[test] - fn mine_fee_estimate_uses_template_without_searching_pow() { - let wallet = Wallet::from_seed("mine-fee-template-wallet"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - genesis, - vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let node = NodeCore::from_ledger(wallet, ledger, 0); - - let estimate = node.estimate_mine_fee(MINE_FINALIZER_FEE).unwrap(); - - assert_eq!( - estimate, - FeeEstimate { - bytes: Transaction::Mine { - recipient: node.wallet_address().to_string(), - anchor: node.ledger().tip_hash().to_string(), - salt: 0, - nonce: 0, - difficulty_bits: node.ledger().current_mine_difficulty_bits(), - proof_header: None, - signature: "0".repeat(64), - } - .economic_size_bytes(), - fee: MINE_FINALIZER_FEE, - } - ); - } - - #[test] - fn wallet_building_reserves_local_anchor_burn_inputs() { - let alice = Wallet::from_seed("local-anchor-reserve-alice"); - let finalizers = [alice.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() - .clone(); - let split = ledger - .build_transfer(&leader_wallet, leader_wallet.address(), MICRO_IUNA, 0) - .unwrap(); - ledger.submit_transaction(split).unwrap(); - let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); - ledger.submit_transaction(anchor).unwrap(); - let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); - ledger.apply_block(split_block).unwrap(); - let mut node = - NodeCore::from_ledger_with_burn_fee_and_enabled(leader_wallet, ledger, true, 0, 1); - - let plan = node.prepare_automatic_finalization(1); - assert!(plan.burned.is_some()); - assert!(node.status().wallet_balance < node.ledger().balance_of(node.wallet_address())); - let (_, anchor_burn) = node - .local_block_anchor_burn - .clone() - .expect("leader burn should be held as a local block anchor"); - let Transaction::Burn { inputs, .. } = anchor_burn else { - panic!("local block anchor must be a burn"); - }; - let anchor_inputs = inputs - .iter() - .map(|input| input.outpoint.clone()) - .collect::<Vec<_>>(); - - let blinded = node - .blinded_burn_with_fee(MICRO_IUNA / 20, 1, node.chain_height() + 4) - .unwrap(); - - assert!( - blinded - .inputs - .iter() - .all(|input| !anchor_inputs.contains(&input.outpoint)), - "blinded wallet transactions must not spend inputs reserved by the local anchor burn" - ); - - let work = node.prepare_next_block_with_local_anchor(2).unwrap(); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let mut peer_ledger = node.clone_ledger(); - let block = node - .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) - .unwrap(); - assert!(block.transactions.iter().any(Transaction::is_burn)); - assert!( - block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.commitment) - ); - peer_ledger.apply_block_at(block, u64::MAX).unwrap(); - assert_eq!( - node.ledger().status().tip_hash, - peer_ledger.status().tip_hash - ); - } -} diff --git a/src/domain.rs b/src/domain.rs @@ -1,13 +1,11 @@ -#[cfg(test)] -use std::collections::BTreeMap; use std::collections::BTreeSet; -mod blinded; +#[cfg(test)] +mod adversarial_tests; mod block; mod fork; mod genesis; mod hex; -mod history; mod ledger_apply; mod ledger_builders; mod ledger_chain; @@ -32,80 +30,42 @@ mod transaction; mod validation; mod vdf; mod wallet; -use blinded::{ActiveBlindedTransaction, blinded_fee_share}; -#[cfg(test)] -use blinded::{ - blinded_committer_fee_outpoint, blinded_executor_fee_outpoint, blinded_expiry_change_outpoint, - blinded_reveal_bundle_signer_fee_outpoint, blinded_transaction_commitment, - credit_blinded_fee_outputs, decrypt_blinded_payload, decrypt_blinded_transaction, -}; use block::LeaderProofPayload; pub use block::{ Block, BurnLeaderRank, ChainSnapshot, ChainStatus, FinalizerMode, LeaderProof, PreparedBlock, }; use fork::LeaderScore; -#[cfg(test)] -use genesis::balances_from_utxos; use genesis::genesis_allocation_outpoint; pub use hex::hex_hash; use hex::{decode_hex, decode_hex_array, hex_encode}; -pub use history::revealed_blinded_transactions; -#[cfg(test)] -use ledger_apply::{reveal_fee_bundle_count_for_height, reveal_fee_signatures_for_height}; -#[cfg(test)] -use ledger_lineage::newest_lineage_root; use ledger_lineage::{ LineageOwnerValues, UtxoLineageRoot, insert_output_with_lineage, - output_lineage_root_for_transaction, spend_blinded_inputs_with_lineage, - spend_inputs_with_lineage, + output_lineage_root_for_transaction, spend_inputs_with_lineage, }; -#[cfg(test)] -use ledger_ops::estimated_block_selection_size_bytes; -#[cfg(test)] -use ledger_ops::fee_reward; -#[cfg(test)] -use ledger_ops::reward_outpoint; -#[cfg(test)] -use ledger_ops::validate_block_fee_policy; use ledger_ops::{ apply_transaction, credit_reward_output, ensure_block_has_burn, ensure_block_has_burn_from, - ensure_outputs_do_not_overflow, ensure_single_input_owner_for_inputs, recovery_vdf_seed_for_child, validate_genesis_burn_transaction, vdf_seed_for_child, }; pub use ledger_state::Ledger; use ledger_state::unix_now_ms; -#[cfg(test)] -use mine_policy::MINE_RETARGET_WINDOW_BLOCKS; -#[cfg(test)] -use mine_policy::{ - MINE_MAX_ANCHOR_AGE_BLOCKS, MINE_MAX_RETARGET_STEP_BITS, MINE_MIN_DIFFICULTY_BITS, -}; use mining::{mine_payload, mine_signature}; pub use profile::{GenesisBurn, LaunchProfile}; pub use protocol::{ - Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, - BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BLINDED_REVEAL_FINALIZER_FEE_BPS, - BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BLOCK_REWARD, - DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, - MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_BLOCK_BYTES, MAX_PENDING_TRANSACTIONS, - MAX_REVEAL_BUNDLE_BYTES, MAX_VDF_ROUNDS, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, + Amount, BLOCK_REWARD, BURN_COMMITTEE_SIZE, BURN_LINEAGE_MATURITY_HEIGHTS, DEFAULT_FEE_PER_BYTE, + DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, + MAX_PENDING_TRANSACTIONS, MAX_VDF_ROUNDS, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD, RECOVERY_BLOCK_DELAY_MS, - REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, REVEAL_COMMITTEE_SIZE, - REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, REVEAL_LINEAGE_MATURITY_HEIGHTS, TransactionSubmitOutcome, - UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, VDF_TARGET_BLOCK_MS, + TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, }; use protocol::{ - BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLOCK_MEDIAN_TIME_PAST_WINDOW, - DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, FORK_FINALITY_DEPTH, HASH_BYTES, - MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MAX_BLOCK_TRANSACTIONS, MAX_ORPHAN_TRANSACTIONS, - MAX_PENDING_POOL_BYTES, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, + BLOCK_MEDIAN_TIME_PAST_WINDOW, DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, + FORK_FINALITY_DEPTH, HASH_BYTES, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MAX_BLOCK_TRANSACTIONS, + MAX_ORPHAN_TRANSACTIONS, MAX_PENDING_POOL_BYTES, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, }; -use reveal::RevealBundlePayload; -#[cfg(test)] -use reveal::reveal_bundle_hashes; +use reveal::BurnBundlePayload; pub use reveal::{ - MaskedBlindedReveal, RevealBundle, RevealBundleSection, RevealBundleSignature, - RevealCommitteeMember, default_reveal_bundle_hash, + BurnBundle, BurnBundleSection, BurnBundleSignature, BurnCommitteeMember, MaskedBurn, + default_burn_bundle_hash, }; use selection::BlockSelection; pub use stratum::{ @@ -114,67 +74,20 @@ pub use stratum::{ }; use stratum::{hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature}; use ticket::{BurnTicket, ticket_block_min_timestamp}; -#[cfg(test)] -use ticket::{ - MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT, consume_leader_ticket, ranked_tickets_for_height, - ticket_is_eligible_for_height, -}; -pub use transaction::{ - BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, MineSearchOutcome, OutPoint, - OwnedBlindedTransaction, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, -}; -#[cfg(test)] -use transaction::{ - UnsignedTxInput, UnsignedUtxoTransaction, signed_blinded_inputs, unsigned_inputs, -}; +pub use transaction::{MineSearchOutcome, OutPoint, Transaction, TxInput, TxOutput}; pub use validation::validate_address; use validation::{ canonical_transaction_size_bytes, validate_hash, validate_protocol_id, validate_signature, }; -#[cfg(test)] -use vdf::{ - MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS, - VDF_RETARGET_DEADBAND_PERCENT, clamped_vdf_retarget_observed_block_ms, retarget_vdf_rounds, - vdf_retarget_observed_block_ms, -}; pub use vdf::{VdfProgress, VdfProgressPhase, run_vdf, run_vdf_with_progress, verify_vdf}; pub use wallet::Wallet; -pub fn reveal_committee_slot_count(eligible_rank_count: usize) -> usize { - eligible_rank_count.min(REVEAL_COMMITTEE_SIZE) +pub fn burn_committee_slot_count(eligible_rank_count: usize) -> usize { + eligible_rank_count.min(BURN_COMMITTEE_SIZE) } -pub fn reveal_committee_slot_count_for_owners<'a>( +pub fn burn_committee_slot_count_for_owners<'a>( owners: impl IntoIterator<Item = &'a str>, ) -> usize { - reveal_committee_slot_count(owners.into_iter().collect::<BTreeSet<_>>().len()) + burn_committee_slot_count(owners.into_iter().collect::<BTreeSet<_>>().len()) } - -pub fn reveal_committee_slot_count_for_height<'a>( - height: u64, - eligible_rank_count: usize, - owners: impl IntoIterator<Item = &'a str>, -) -> usize { - if height < UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT { - return reveal_committee_slot_count(eligible_rank_count); - } - reveal_committee_slot_count_for_owners(owners) -} - -pub fn blinded_reveal_finalizer_fee( - fee: Amount, - included_bundle_count: usize, - available_bundle_slots: usize, -) -> Amount { - if included_bundle_count == 0 { - return 0; - } - let available_bundle_slots = available_bundle_slots.clamp(1, REVEAL_COMMITTEE_SIZE); - let included_bundle_count = included_bundle_count.min(available_bundle_slots); - let full_share = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); - ((full_share as u128 * included_bundle_count as u128) / available_bundle_slots as u128) - as Amount -} - -#[cfg(test)] -mod tests; diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -0,0 +1,1061 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use proptest::prelude::*; +use proptest::test_runner::Config; + +use super::ledger_ops::block_reward; +use super::reveal::{BurnBundlePayload, burn_bundle_slot_mask}; +use super::ticket::ticket_block_min_timestamp; +use super::{ + Amount, BURN_LINEAGE_MATURITY_HEIGHTS, Block, BurnBundle, BurnBundleSignature, + BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, + MaskedBurn, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, +}; + +const NOW_MS: u64 = 10_000_000_000; +const LEVELS: [u8; 6] = [1, 5, 10, 25, 33, 50]; +const COMBINED_LEVELS: [(u8, u8); 5] = [(10, 1), (10, 10), (25, 5), (25, 25), (50, 10)]; +const LINEAGE_RESOURCE_ROOTS: usize = 10; + +#[derive(Clone, Copy, Debug)] +enum AdversaryStrategy { + Honest, + CensorBurns, + MaximizeBurnWeight, + MaximizeCommitteeWeight, + WithholdBurnFromCommittee, + MissRank0, + ForceFallback, + AttemptRecovery, + AddressRotation, + CombinedStrategy, +} + +impl AdversaryStrategy { + fn from_index(index: usize) -> Self { + match index % 10 { + 0 => Self::Honest, + 1 => Self::CensorBurns, + 2 => Self::MaximizeBurnWeight, + 3 => Self::MaximizeCommitteeWeight, + 4 => Self::WithholdBurnFromCommittee, + 5 => Self::MissRank0, + 6 => Self::ForceFallback, + 7 => Self::AttemptRecovery, + 8 => Self::AddressRotation, + _ => Self::CombinedStrategy, + } + } +} + +#[derive(Clone, Debug)] +struct Actor { + wallet: Wallet, + burn_wallets: Vec<Wallet>, + lineage_wallets: Vec<Wallet>, + strategy: AdversaryStrategy, +} + +#[derive(Clone, Copy, Debug)] +struct ResourceLevel { + burn_percent: u8, + lineage_percent: u8, +} + +#[derive(Clone, Debug, Default)] +struct AdversarialMetrics { + attacker_finalization_share: f64, + attacker_committee_share: f64, + third_party_burn_censorship_rate: f64, + fallback_rate: f64, + recovery_rate: f64, + average_blocks_until_recovery: f64, + attacker_burn_cost: Amount, + attacker_net_reward: i128, +} + +struct Harness { + ledger: Ledger, + attacker: Actor, + honest: Vec<Wallet>, + wallets: BTreeMap<String, Wallet>, + seed: u64, + resource: ResourceLevel, +} + +impl Harness { + fn new(seed: u64, burn_percent: u8, lineage_percent: u8, strategy: AdversaryStrategy) -> Self { + let attacker_wallet = Wallet::from_seed(&format!("adv-{seed}-attacker-main")); + let attacker_burn_wallets = (0..4) + .map(|index| Wallet::from_seed(&format!("adv-{seed}-attacker-burn-{index}"))) + .collect::<Vec<_>>(); + let attacker_lineage_wallets = (0..4) + .map(|index| Wallet::from_seed(&format!("adv-{seed}-attacker-lineage-{index}"))) + .collect::<Vec<_>>(); + let honest = (0..8) + .map(|index| Wallet::from_seed(&format!("adv-{seed}-honest-{index}"))) + .collect::<Vec<_>>(); + let mut all_wallets = vec![attacker_wallet.clone()]; + all_wallets.extend(attacker_burn_wallets.clone()); + all_wallets.extend(attacker_lineage_wallets.clone()); + all_wallets.extend(honest.clone()); + + let mut allocations = BTreeMap::new(); + for wallet in &all_wallets { + allocations.insert(wallet.address().to_string(), 10_000 * MICRO_IUNA); + } + let attacker_burn = u64::from(burn_percent).max(1) * MICRO_IUNA; + let honest_burn_total = u64::from(100_u8.saturating_sub(burn_percent)).max(1) * MICRO_IUNA; + let honest_burn_each = (honest_burn_total / honest.len() as u64).max(1); + let mut genesis_burns = vec![GenesisBurn::new(attacker_wallet.address(), attacker_burn)]; + genesis_burns.extend( + honest + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), honest_burn_each)), + ); + let mut ledger = Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap(); + ledger.launch_profile.mine_difficulty_bits = 0; + + let wallets = all_wallets + .into_iter() + .map(|wallet| (wallet.address().to_string(), wallet)) + .collect::<BTreeMap<_, _>>(); + + Self { + ledger, + attacker: Actor { + wallet: attacker_wallet, + burn_wallets: attacker_burn_wallets, + lineage_wallets: attacker_lineage_wallets, + strategy, + }, + honest, + wallets, + seed, + resource: ResourceLevel { + burn_percent, + lineage_percent, + }, + } + } + + fn wallet(&self, address: &str) -> &Wallet { + self.wallets + .get(address) + .unwrap_or_else(|| panic!("seed {} missing wallet {address}", self.seed)) + } + + fn next_rank(&self, rank: usize) -> BurnLeaderRank { + self.ledger + .burn_leader_ranks_for_block(self.ledger.height() + 1) + .unwrap() + .get(rank) + .cloned() + .unwrap_or_else(|| { + panic!( + "seed {} has no rank {rank} at height {}", + self.seed, + self.ledger.height() + 1 + ) + }) + } + + fn submit_anchor_burn(&mut self, wallet: &Wallet) -> Transaction { + let burn = self + .ledger + .build_burn(wallet, 1, 1) + .unwrap_or_else(|error| { + panic!( + "seed {} failed to build anchor burn for {}: {error:#}", + self.seed, + wallet.address() + ) + }); + self.ledger.submit_transaction(burn.clone()).unwrap(); + burn + } + + fn submit_fee_burn(&mut self, wallet: &Wallet, amount: Amount, fee: Amount) -> Transaction { + let burn = self + .ledger + .build_burn(wallet, amount, fee) + .unwrap_or_else(|error| { + panic!( + "seed {} failed to build fee burn for {}: {error:#}", + self.seed, + wallet.address() + ) + }); + self.ledger.submit_transaction(burn.clone()).unwrap(); + burn + } + + fn committee_bundles(&self) -> Vec<BurnBundle> { + self.ledger + .burn_committee_for_next_block() + .into_iter() + .filter_map(|member| { + self.wallets + .get(&member.owner) + .and_then(|wallet| self.ledger.build_burn_bundle(wallet).unwrap()) + }) + .collect() + } + + fn prepared_ticket_block(&mut self, rank: usize, bundles: Vec<BurnBundle>) -> Block { + let leader = self.next_rank(rank); + let wallet = self.wallet(&leader.owner).clone(); + self.submit_anchor_burn(&wallet); + self.finish_ticket_block_from_pending(rank, bundles) + } + + fn finish_ticket_block_from_pending(&self, rank: usize, bundles: Vec<BurnBundle>) -> Block { + let leader = self.next_rank(rank); + let wallet = self.wallet(&leader.owner).clone(); + let timestamp = self + .ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS * 2 * rank as u64) + .saturating_add(1); + let prepared = self + .ledger + .prepare_next_block_with_burn_bundles(wallet.address(), timestamp, bundles) + .unwrap_or_else(|error| { + panic!( + "seed {} failed to prepare ticket block rank {rank}: {error:#}", + self.seed + ) + }); + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + prepared.finish(&wallet, vdf_output) + } + + fn mine_ticket_block(&mut self, rank: usize) -> Block { + let leader = self.next_rank(rank); + let wallet = self.wallet(&leader.owner).clone(); + self.submit_anchor_burn(&wallet); + let bundles = self.committee_bundles(); + let block = self.finish_ticket_block_from_pending(rank, bundles); + self.ledger + .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms)) + .unwrap_or_else(|error| { + panic!( + "seed {} validator rejected generated ticket block: {error:#}", + self.seed + ) + }); + block + } + + fn mine_recovery_block(&mut self, wallet: &Wallet) -> Block { + self.submit_anchor_burn(wallet); + let timestamp = self.ledger.recovery_block_min_timestamp(); + let prepared = self + .ledger + .prepare_recovery_block(wallet.address(), timestamp) + .unwrap(); + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + let block = prepared.finish(wallet, vdf_output); + self.ledger + .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms)) + .unwrap(); + block + } + + fn mature_lineages(&mut self, attacker_roots: usize, honest_roots: usize) { + let mut roots_remaining = attacker_roots + honest_roots; + let mut latest_root_height = self.ledger.height(); + while roots_remaining > 0 { + let anchor = self.ledger.tip_hash().to_string(); + for index in 0..2 { + if roots_remaining == 0 { + break; + } + let wallet = if roots_remaining > honest_roots { + self.attacker.lineage_wallets[index % self.attacker.lineage_wallets.len()] + .clone() + } else { + self.honest[index % self.honest.len()].clone() + }; + let mine = self + .ledger + .build_mine(wallet.address()) + .unwrap_or_else(|error| { + panic!( + "seed {} failed to build mine for anchor {anchor}: {error:#}", + self.seed + ) + }); + self.ledger.submit_transaction(mine).unwrap(); + roots_remaining -= 1; + } + self.mine_ticket_block(0); + latest_root_height = self.ledger.height(); + } + while self.ledger.height() + < latest_root_height + .saturating_add(BURN_LINEAGE_MATURITY_HEIGHTS) + .saturating_add(1) + { + self.mine_ticket_block(0); + } + } + + fn mature_resource_lineages(&mut self) { + let (attacker_roots, honest_roots) = lineage_resource_roots(self.resource.lineage_percent); + self.mature_lineages(attacker_roots, honest_roots); + } + + fn attacker_addresses(&self) -> BTreeSet<String> { + std::iter::once(self.attacker.wallet.address().to_string()) + .chain( + self.attacker + .burn_wallets + .iter() + .map(|wallet| wallet.address().to_string()), + ) + .chain( + self.attacker + .lineage_wallets + .iter() + .map(|wallet| wallet.address().to_string()), + ) + .collect() + } + + fn run_strategy(&mut self, blocks: usize) -> AdversarialMetrics { + self.mature_resource_lineages(); + let attacker_addresses = self.attacker_addresses(); + let mut metrics = AdversarialMetrics::default(); + let mut third_party_burns = 0usize; + let mut censored_third_party_burns = 0usize; + let mut finalizations = 0usize; + let mut attacker_finalizations = 0usize; + let mut committee_slots = 0usize; + let mut attacker_committee_slots = 0usize; + let mut fallback_blocks = 0usize; + let mut recovery_blocks = 0usize; + let mut blocks_until_recovery = Vec::new(); + + for step in 0..blocks { + let victim = self.honest[step % self.honest.len()].clone(); + let third_party_burn = if matches!( + self.attacker.strategy, + AdversaryStrategy::CensorBurns + | AdversaryStrategy::WithholdBurnFromCommittee + | AdversaryStrategy::CombinedStrategy + ) { + third_party_burns += 1; + if matches!( + self.attacker.strategy, + AdversaryStrategy::WithholdBurnFromCommittee + | AdversaryStrategy::CombinedStrategy + ) { + Some(self.ledger.build_burn(&victim, 1, 1).unwrap()) + } else { + Some(self.submit_fee_burn(&victim, 1, 1)) + } + } else { + None + }; + + let committee = self.ledger.burn_committee_for_next_block(); + committee_slots += committee.len(); + attacker_committee_slots += committee + .iter() + .filter(|member| attacker_addresses.contains(&member.owner)) + .count(); + + let rank = match self.attacker.strategy { + AdversaryStrategy::MissRank0 + | AdversaryStrategy::ForceFallback + | AdversaryStrategy::CombinedStrategy => { + usize::from(self.ledger.finalizer_rank_count_for_next_block() > 1) + } + _ => 0, + }; + let block = if matches!( + self.attacker.strategy, + AdversaryStrategy::AttemptRecovery | AdversaryStrategy::CombinedStrategy + ) && self + .ledger + .recovery_block_available_at(self.ledger.recovery_block_min_timestamp()) + && step % 7 == 6 + { + recovery_blocks += 1; + blocks_until_recovery.push((step + 1) as f64); + let wallet = self.attacker.wallet.clone(); + self.mine_recovery_block(&wallet) + } else { + if rank > 0 { + fallback_blocks += 1; + } + self.mine_ticket_block(rank) + }; + + finalizations += 1; + if attacker_addresses.contains(&block.miner) { + attacker_finalizations += 1; + metrics.attacker_burn_cost = metrics.attacker_burn_cost.saturating_add(1); + metrics.attacker_net_reward += i128::from(block.reward); + } + if let Some(burn) = third_party_burn { + censored_third_party_burns += usize::from( + !block + .transactions + .iter() + .any(|tx| tx.signature() == burn.signature()), + ); + } + } + + metrics.attacker_finalization_share = share(attacker_finalizations, finalizations); + metrics.attacker_committee_share = share(attacker_committee_slots, committee_slots); + metrics.third_party_burn_censorship_rate = + share(censored_third_party_burns, third_party_burns); + metrics.fallback_rate = share(fallback_blocks, blocks); + metrics.recovery_rate = share(recovery_blocks, blocks); + metrics.average_blocks_until_recovery = if blocks_until_recovery.is_empty() { + 0.0 + } else { + blocks_until_recovery.iter().sum::<f64>() / blocks_until_recovery.len() as f64 + }; + metrics.attacker_net_reward -= i128::from(metrics.attacker_burn_cost); + metrics + } +} + +fn share(numerator: usize, denominator: usize) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 + } +} + +fn lineage_resource_roots(lineage_percent: u8) -> (usize, usize) { + let attacker_roots = (usize::from(lineage_percent) * LINEAGE_RESOURCE_ROOTS).div_ceil(100); + let attacker_roots = attacker_roots.clamp(1, LINEAGE_RESOURCE_ROOTS); + (attacker_roots, LINEAGE_RESOURCE_ROOTS - attacker_roots) +} + +fn rehash(block: &mut Block) { + block.reward = block_reward(&block.transactions, 0).unwrap(); + block.hash = block.compute_hash(); +} + +fn assert_rejects(mut ledger: Ledger, block: Block, label: &str) { + assert!( + ledger.apply_block_at(block, NOW_MS).is_err(), + "{label} was accepted by the consensus validator" + ); +} + +fn harness_for_percent(seed: u64, burn_percent: u8) -> Harness { + Harness::new(seed, burn_percent, 10, AdversaryStrategy::Honest) +} + +fn mutate_signature(signature: &mut String) { + let replacement = if signature.starts_with('0') { "1" } else { "0" }; + signature.replace_range(0..1, replacement); +} + +fn committee_roots_are_unique(committee: &[BurnCommitteeMember]) -> bool { + let mut roots = BTreeSet::new(); + committee + .iter() + .filter(|member| member.slot > 0) + .all(|member| roots.insert(member.root.clone())) +} + +proptest! { + #![proptest_config(Config { cases: 32, .. Config::default() })] + + #[test] + fn required_attested_burn_must_be_included(seed in any::<u64>(), burn_idx in 0usize..LEVELS.len()) { + let mut harness = harness_for_percent(seed, LEVELS[burn_idx]); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let victim = harness + .honest + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .unwrap() + .clone(); + let victim_burn = harness.submit_fee_burn(&victim, 1, 1); + let bundle = finalizer.burn_bundle(BurnBundlePayload { + height: harness.ledger.height() + 1, + prev_hash: harness.ledger.tip_hash().to_string(), + slot: 0, + member: finalizer.address().to_string(), + burns: vec![victim_burn.clone()], + }); + let mut block = harness.finish_ticket_block_from_pending(0, vec![bundle]); + block.transactions.retain(|tx| tx.signature() != victim_burn.signature()); + rehash(&mut block); + + prop_assert!( + harness.ledger.apply_block_at(block, NOW_MS).is_err(), + "seed={} burn_percent={} strategy={:?} accepted a block missing attested burn {}", + seed, + LEVELS[burn_idx], + harness.attacker.strategy, + victim_burn.signature() + ); + } + + #[test] + fn timing_and_vdf_mutations_are_rejected(seed in any::<u64>(), rank in 1usize..3) { + let mut harness = harness_for_percent(seed, 25); + let leader = harness.next_rank(rank); + let wallet = harness.wallet(&leader.owner).clone(); + let parent = harness.ledger.tip().clone(); + let block = harness.prepared_ticket_block(rank, Vec::new()); + + let mut early = block.clone(); + early.timestamp_ms = ticket_block_min_timestamp(&parent, rank as u32).unwrap() - 1; + rehash(&mut early); + prop_assert!( + harness.ledger.clone().apply_block_at(early, NOW_MS).is_err(), + "seed={} rank={} accepted early fallback timestamp for {}", + seed, + rank, + wallet.address() + ); + + let mut future = block.clone(); + future.timestamp_ms = NOW_MS + super::MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS + 1; + rehash(&mut future); + prop_assert!( + harness.ledger.clone().apply_block_at(future, NOW_MS).is_err(), + "seed={} rank={} accepted future drift timestamp", + seed, + rank + ); + } + + #[test] + fn committee_selection_is_deterministic_and_sybil_resistant(seed in any::<u64>(), lineage_idx in 0usize..LEVELS.len()) { + let mut harness = Harness::new(seed, 10, LEVELS[lineage_idx], AdversaryStrategy::AddressRotation); + harness.mature_lineages(2, 4); + let committee = harness.ledger.burn_committee_for_next_block(); + let snapshot_committee = Ledger::from_snapshot_at(harness.ledger.snapshot(), NOW_MS) + .unwrap() + .burn_committee_for_next_block(); + + prop_assert_eq!(committee.clone(), snapshot_committee, "seed={} committee selection is not deterministic", seed); + prop_assert!(committee_roots_are_unique(&committee), "seed={} selected one lineage more than once: {:?}", seed, committee); + let non_finalizer_roots = committee.iter().filter(|member| member.slot > 0).count(); + prop_assert!(non_finalizer_roots < super::BURN_COMMITTEE_SIZE); + } + + #[test] + fn adversarial_state_machine_keeps_invalid_paths_out( + seed in any::<u64>(), + strategy_idx in 0usize..10, + level_idx in 0usize..LEVELS.len(), + blocks in 3usize..8, + ) { + let strategy = AdversaryStrategy::from_index(strategy_idx); + let level = LEVELS[level_idx]; + let mut harness = Harness::new(seed, level, level, strategy); + let metrics = harness.run_strategy(blocks); + prop_assert!(metrics.attacker_finalization_share.is_finite()); + prop_assert!(metrics.attacker_committee_share.is_finite()); + match strategy { + AdversaryStrategy::Honest + | AdversaryStrategy::MaximizeBurnWeight + | AdversaryStrategy::MaximizeCommitteeWeight + | AdversaryStrategy::AddressRotation => { + prop_assert_eq!(metrics.third_party_burn_censorship_rate, 0.0); + prop_assert_eq!(metrics.fallback_rate, 0.0); + prop_assert_eq!(metrics.recovery_rate, 0.0); + } + AdversaryStrategy::CensorBurns => { + prop_assert_eq!(metrics.third_party_burn_censorship_rate, 0.0); + } + AdversaryStrategy::WithholdBurnFromCommittee => { + prop_assert!(metrics.third_party_burn_censorship_rate > 0.0); + } + AdversaryStrategy::MissRank0 | AdversaryStrategy::ForceFallback => { + prop_assert!(metrics.fallback_rate > 0.0); + prop_assert_eq!(metrics.recovery_rate, 0.0); + } + AdversaryStrategy::AttemptRecovery => { + if blocks >= 7 { + prop_assert!(metrics.recovery_rate > 0.0); + } else { + prop_assert_eq!(metrics.recovery_rate, 0.0); + } + } + AdversaryStrategy::CombinedStrategy => { + prop_assert!(metrics.third_party_burn_censorship_rate > 0.0); + prop_assert!(metrics.fallback_rate > 0.0); + } + } + } +} + +#[test] +fn attested_burn_is_not_selected_again_as_normal_transaction() { + let mut harness = harness_for_percent(10, 25); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let victim = harness + .honest + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .unwrap() + .clone(); + let attested_burn = harness.submit_fee_burn(&victim, 1, 1); + let extra_burn = harness.submit_fee_burn(&victim, 1, 1); + let bundle = finalizer.burn_bundle(BurnBundlePayload { + height: harness.ledger.height() + 1, + prev_hash: harness.ledger.tip_hash().to_string(), + slot: 0, + member: finalizer.address().to_string(), + burns: vec![attested_burn.clone()], + }); + + let block = harness.finish_ticket_block_from_pending(0, vec![bundle]); + + assert_eq!( + block + .burn_bundle_section + .burns + .iter() + .filter(|masked| masked.burn.signature() == attested_burn.signature()) + .count(), + 1 + ); + assert_eq!( + block + .transactions + .iter() + .filter(|tx| tx.signature() == attested_burn.signature()) + .count(), + 1 + ); + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == extra_burn.signature()) + ); +} + +#[test] +fn required_burn_cannot_be_executed_twice_in_one_block() { + let mut harness = harness_for_percent(11, 25); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let victim = harness + .honest + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .unwrap() + .clone(); + let burn = harness.submit_fee_burn(&victim, 1, 1); + let mut block = harness.finish_ticket_block_from_pending(0, Vec::new()); + block.transactions.push(burn.clone()); + rehash(&mut block); + + assert_rejects(harness.ledger, block, "duplicate burn transaction"); +} + +#[test] +fn misused_or_extra_committee_bundle_is_rejected() { + let mut harness = harness_for_percent(12, 25); + let mut block = harness.prepared_ticket_block(0, Vec::new()); + block + .burn_bundle_section + .signatures + .push(BurnBundleSignature { + slot: 1, + member: harness.attacker.wallet.address().to_string(), + signature: "00".repeat(64), + }); + rehash(&mut block); + + assert_rejects(harness.ledger, block, "extra committee signature"); +} + +#[test] +fn finalizer_anchor_alone_does_not_require_committee_signatures() { + let mut harness = harness_for_percent(20, 25); + harness.mature_lineages(1, 4); + let block = harness.prepared_ticket_block(0, Vec::new()); + + harness + .ledger + .apply_block_at(block, NOW_MS.saturating_add(1)) + .unwrap(); +} + +#[test] +fn pending_third_party_burn_does_not_affect_block_validity() { + let mut harness = harness_for_percent(21, 25); + harness.mature_lineages(1, 4); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + let anchor = harness.submit_anchor_burn(&finalizer); + let victim = harness + .honest + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .unwrap() + .clone(); + let third_party = harness.submit_fee_burn(&victim, 1, 1); + let mut block = harness.finish_ticket_block_from_pending(0, Vec::new()); + block + .transactions + .retain(|tx| tx.signature() == anchor.signature()); + rehash(&mut block); + + assert!( + harness + .ledger + .pending() + .iter() + .any(|tx| tx.signature() == third_party.signature()) + ); + harness + .ledger + .apply_block_at(block, NOW_MS.saturating_add(1)) + .unwrap(); +} + +#[test] +fn included_third_party_burn_requires_committee_signatures() { + let mut harness = harness_for_percent(21, 25); + harness.mature_lineages(1, 4); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let victim = harness + .honest + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .unwrap() + .clone(); + harness.submit_fee_burn(&victim, 1, 1); + let block = harness.finish_ticket_block_from_pending(0, Vec::new()); + + assert_rejects( + harness.ledger, + block, + "included third-party burn without committee signatures", + ); +} + +#[test] +fn zero_fee_public_burn_is_rejected() { + let wallet = Wallet::from_seed("zero-fee-public-burn"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + + let error = ledger.build_burn(&wallet, 1, 0).unwrap_err(); + + assert!( + error + .to_string() + .contains("burn transaction fee must be greater than zero") + ); +} + +#[test] +fn invalid_committee_signature_is_rejected() { + let mut harness = harness_for_percent(13, 25); + harness.mature_lineages(1, 4); + let member = harness + .ledger + .burn_committee_for_next_block() + .into_iter() + .find(|member| member.slot > 0) + .expect("mature lineage should provide an additional committee member"); + let wallet = harness.wallet(&member.owner).clone(); + let burn = harness.submit_fee_burn(&harness.honest[0].clone(), 1, 1); + let mut bundle = harness.ledger.build_burn_bundle(&wallet).unwrap().unwrap(); + assert!( + bundle + .burns + .iter() + .any(|tx| tx.signature() == burn.signature()) + ); + mutate_signature(&mut bundle.signature); + + assert!( + harness + .ledger + .validate_next_block_burn_bundles(vec![bundle]) + .is_err() + ); +} + +#[test] +fn non_mature_lineage_cannot_join_committee() { + let mut harness = harness_for_percent(14, 25); + let attacker = harness.attacker.lineage_wallets[0].clone(); + let mine = harness.ledger.build_mine(attacker.address()).unwrap(); + harness.ledger.submit_transaction(mine).unwrap(); + harness.mine_ticket_block(0); + + let committee = harness.ledger.burn_committee_for_next_block(); + assert!( + committee + .iter() + .all(|member| member.owner != attacker.address()), + "non-mature lineage appeared in committee: {committee:?}" + ); +} + +#[test] +fn changing_attestation_set_after_vdf_changes_seed_and_invalidates_block() { + let mut harness = harness_for_percent(15, 25); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let victim = harness + .honest + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .unwrap() + .clone(); + let victim_burn = harness.submit_fee_burn(&victim, 1, 1); + let mut block = harness.finish_ticket_block_from_pending(0, Vec::new()); + let original_seed = block.vdf_seed(); + block.burn_bundle_section.burns.push(MaskedBurn { + burn: victim_burn, + bundle_mask: burn_bundle_slot_mask(0).unwrap_or(0), + }); + let changed_seed = block.vdf_seed(); + assert_ne!(original_seed, changed_seed); + rehash(&mut block); + + assert_rejects(harness.ledger, block, "post-VDF attestation mutation"); +} + +#[test] +fn fallback_block_must_match_its_rank_and_ticket() { + let mut harness = harness_for_percent(16, 25); + let mut block = harness.prepared_ticket_block(1, Vec::new()); + block.finalizer_rank = 0; + rehash(&mut block); + + assert_rejects(harness.ledger, block, "fallback block with wrong rank"); +} + +#[test] +fn consumed_ticket_cannot_be_reused() { + let mut harness = harness_for_percent(17, 25); + let block = harness.mine_ticket_block(0); + let used_ticket = block.leader_proof.as_ref().unwrap().ticket_id.clone(); + let next_ranks = harness + .ledger + .burn_leader_ranks_for_block(harness.ledger.height() + 1) + .unwrap(); + + assert!( + next_ranks.iter().all(|rank| rank.ticket_id != used_ticket), + "consumed ticket {used_ticket} was eligible again" + ); +} + +#[test] +fn recovery_cannot_bypass_ticket_rules_before_threshold_and_requires_own_burn() { + let mut harness = harness_for_percent(18, 25); + let attacker = harness.attacker.wallet.clone(); + harness.submit_anchor_burn(&attacker); + assert!( + harness + .ledger + .prepare_recovery_block(attacker.address(), VDF_TARGET_BLOCK_MS) + .is_err() + ); + harness.ledger.clear_pending_transactions(); + + let mut recovery = harness.mine_recovery_block(&attacker); + recovery + .transactions + .retain(|tx| !(tx.is_burn() && tx.sender() == attacker.address())); + rehash(&mut recovery); + let parent_snapshot = ChainSnapshot { + genesis_allocations: harness.ledger.genesis_allocations.clone(), + vdf_rounds: harness.ledger.initial_vdf_rounds, + launch_profile: harness.ledger.launch_profile.clone(), + blocks: harness.ledger.chain[..harness.ledger.chain.len() - 1].to_vec(), + }; + let parent_ledger = Ledger::from_snapshot_at(parent_snapshot, NOW_MS).unwrap(); + + assert_rejects(parent_ledger, recovery, "recovery without finalizer burn"); +} + +#[test] +fn recovery_and_fork_choice_cannot_cross_finality_depth() { + let mut local = harness_for_percent(19, 25); + for _ in 0..9 { + local.mine_ticket_block(0); + } + + let fork_base = ChainSnapshot { + genesis_allocations: local.ledger.genesis_allocations.clone(), + vdf_rounds: local.ledger.initial_vdf_rounds, + launch_profile: local.ledger.launch_profile.clone(), + blocks: local.ledger.chain[..2].to_vec(), + }; + let mut remote = Harness { + ledger: Ledger::from_snapshot_at(fork_base, NOW_MS).unwrap(), + attacker: local.attacker.clone(), + honest: local.honest.clone(), + wallets: local.wallets.clone(), + seed: local.seed, + resource: local.resource, + }; + remote.mine_ticket_block(1); + for _ in 0..12 { + remote.mine_ticket_block(0); + } + + let switched = local + .ledger + .extend_from_snapshot_at(remote.ledger.snapshot(), NOW_MS) + .unwrap(); + assert!(!switched, "fork deeper than finality depth was accepted"); +} + +#[test] +fn adversarial_scenarios_cover_resource_matrix() { + let strategies = [ + AdversaryStrategy::MaximizeBurnWeight, + AdversaryStrategy::MaximizeCommitteeWeight, + AdversaryStrategy::CensorBurns, + AdversaryStrategy::WithholdBurnFromCommittee, + AdversaryStrategy::ForceFallback, + AdversaryStrategy::AttemptRecovery, + AdversaryStrategy::AddressRotation, + AdversaryStrategy::CombinedStrategy, + ]; + + for (case, strategy) in strategies.into_iter().enumerate() { + let (burn, lineage) = COMBINED_LEVELS[case % COMBINED_LEVELS.len()]; + let mut harness = Harness::new(100 + case as u64, burn, lineage, strategy); + let blocks = if matches!( + strategy, + AdversaryStrategy::AttemptRecovery | AdversaryStrategy::CombinedStrategy + ) { + 7 + } else { + 4 + }; + let metrics = harness.run_strategy(blocks); + assert!( + metrics.attacker_finalization_share.is_finite() + && metrics.attacker_committee_share.is_finite(), + "strategy={strategy:?} burn={burn}% lineage={lineage}% metrics={metrics:?}", + ); + match strategy { + AdversaryStrategy::MaximizeBurnWeight + | AdversaryStrategy::MaximizeCommitteeWeight + | AdversaryStrategy::AddressRotation => { + assert_eq!( + metrics.third_party_burn_censorship_rate, 0.0, + "strategy={strategy:?} should not censor burns: {metrics:?}" + ); + assert_eq!( + metrics.fallback_rate, 0.0, + "strategy={strategy:?} should not force fallbacks: {metrics:?}" + ); + assert_eq!( + metrics.recovery_rate, 0.0, + "strategy={strategy:?} should not mine recovery blocks: {metrics:?}" + ); + } + AdversaryStrategy::CensorBurns => { + assert_eq!( + metrics.third_party_burn_censorship_rate, 0.0, + "visible burns should be included by consensus-valid ticket blocks: {metrics:?}" + ); + } + AdversaryStrategy::WithholdBurnFromCommittee => { + assert!( + metrics.third_party_burn_censorship_rate > 0.0, + "withheld burns should be absent from produced blocks: {metrics:?}" + ); + } + AdversaryStrategy::ForceFallback => { + assert!( + metrics.fallback_rate > 0.0, + "force-fallback strategy did not produce fallback blocks: {metrics:?}" + ); + assert_eq!( + metrics.recovery_rate, 0.0, + "force-fallback strategy should not mine recovery blocks: {metrics:?}" + ); + } + AdversaryStrategy::AttemptRecovery => { + assert!( + metrics.recovery_rate > 0.0, + "recovery strategy did not produce recovery blocks: {metrics:?}" + ); + } + AdversaryStrategy::CombinedStrategy => { + assert!( + metrics.third_party_burn_censorship_rate > 0.0, + "combined strategy did not withhold burns: {metrics:?}" + ); + assert!( + metrics.fallback_rate > 0.0, + "combined strategy did not produce fallback blocks: {metrics:?}" + ); + assert!( + metrics.recovery_rate > 0.0, + "combined strategy did not produce recovery blocks: {metrics:?}" + ); + } + AdversaryStrategy::Honest | AdversaryStrategy::MissRank0 => unreachable!(), + } + } + + for burn in LEVELS { + for lineage in LEVELS { + let mut harness = Harness::new( + 200 + u64::from(burn) * 10 + u64::from(lineage), + burn, + lineage, + AdversaryStrategy::Honest, + ); + let metrics = harness.run_strategy(3); + assert!( + metrics.attacker_finalization_share.is_finite() + && metrics.attacker_committee_share.is_finite(), + "burn={burn}% lineage={lineage}% configured={:?} metrics={metrics:?}", + ( + harness.resource.burn_percent, + harness.resource.lineage_percent + ), + ); + assert_eq!( + metrics.third_party_burn_censorship_rate, 0.0, + "honest resource matrix run censored third-party burns: {metrics:?}" + ); + assert_eq!( + metrics.fallback_rate, 0.0, + "honest resource matrix run produced fallback blocks: {metrics:?}" + ); + assert_eq!( + metrics.recovery_rate, 0.0, + "honest resource matrix run produced recovery blocks: {metrics:?}" + ); + } + } +} diff --git a/src/domain/blinded.rs b/src/domain/blinded.rs @@ -1,373 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::{Context, Result, anyhow, bail}; -use chacha20poly1305::{ - ChaCha20Poly1305, Nonce, - aead::{Aead, KeyInit}, -}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - -use super::transaction::{ - BlindedTransactionPayload, canonical_inputs, signed_blinded_inputs, unsigned_inputs, -}; -use super::{ - Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, BLINDED_KEY_BYTES, - BLINDED_NONCE_BYTES, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, - OutPoint, PUBLIC_KEY_BYTES, RevealBundleSignature, SIGNATURE_BYTES, Transaction, TxInput, - TxOutput, UtxoLineageRoot, blinded_reveal_finalizer_fee, decode_hex, decode_hex_array, - ensure_outputs_do_not_overflow, ensure_single_input_owner_for_inputs, hex_hash, -}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(super) struct ActiveBlindedTransaction { - pub(super) transaction: BlindedTransaction, - pub(super) locked_outputs: Vec<TxOutput>, - pub(super) locked_lineage_root: Option<UtxoLineageRoot>, - pub(super) included_height: u64, - pub(super) included_by: String, -} - -pub(super) fn decrypt_blinded_transaction( - transaction: &BlindedTransaction, - reveal: &BlindedReveal, -) -> Result<Transaction> { - if reveal.commitment != transaction.commitment { - bail!("blinded reveal commitment does not match transaction"); - } - let key = - decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?; - let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce) - .context("invalid blinded transaction nonce")?; - let ciphertext = - decode_hex(&transaction.ciphertext).context("invalid blinded transaction ciphertext")?; - let plaintext = decrypt_blinded_payload( - &key, - &nonce, - &signed_blinded_inputs(&unsigned_inputs(&transaction.inputs), ""), - transaction.fee, - transaction.expires_at_height, - &ciphertext, - )?; - if hex_hash(&plaintext) != transaction.payload_hash { - bail!("blinded transaction payload hash is invalid"); - } - let payload = serde_json::from_slice(&plaintext) - .context("failed to decode blinded transaction payload")?; - transaction_from_blinded_payload(payload, &transaction.inputs, transaction.fee) -} - -pub(super) fn blinded_payload_from_transaction( - transaction: &Transaction, -) -> Result<BlindedTransactionPayload> { - match transaction { - Transaction::Transfer { - outputs, signature, .. - } => Ok(BlindedTransactionPayload::Transfer { - outputs: outputs.clone(), - signature: signature.clone(), - }), - Transaction::Burn { - change, - amount, - signature, - .. - } => Ok(BlindedTransactionPayload::Burn { - change: change.clone(), - amount: *amount, - signature: signature.clone(), - }), - Transaction::Mine { .. } => bail!("mine actions are public and cannot be blinded"), - } -} - -pub(super) fn transaction_from_blinded_payload( - payload: BlindedTransactionPayload, - envelope_inputs: &[TxInput], - fee: Amount, -) -> Result<Transaction> { - match payload { - BlindedTransactionPayload::Transfer { outputs, signature } => { - let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature); - Ok(Transaction::Transfer { - inputs, - outputs, - fee, - signature, - }) - } - BlindedTransactionPayload::Burn { - change, - amount, - signature, - } => { - let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature); - Ok(Transaction::Burn { - inputs, - change, - amount, - fee, - signature, - }) - } - } -} - -pub(super) fn encrypt_blinded_payload( - key: &[u8; BLINDED_KEY_BYTES], - nonce: &[u8; BLINDED_NONCE_BYTES], - inputs: &[TxInput], - fee: Amount, - expires_at_height: u64, - plaintext: &[u8], -) -> Result<Vec<u8>> { - let cipher = ChaCha20Poly1305::new(key.into()); - cipher - .encrypt( - Nonce::from_slice(nonce), - chacha20poly1305::aead::Payload { - msg: plaintext, - aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(), - }, - ) - .map_err(|_| anyhow!("failed to encrypt blinded transaction payload")) -} - -pub(super) fn decrypt_blinded_payload( - key: &[u8; BLINDED_KEY_BYTES], - nonce: &[u8; BLINDED_NONCE_BYTES], - inputs: &[TxInput], - fee: Amount, - expires_at_height: u64, - ciphertext: &[u8], -) -> Result<Vec<u8>> { - let cipher = ChaCha20Poly1305::new(key.into()); - cipher - .decrypt( - Nonce::from_slice(nonce), - chacha20poly1305::aead::Payload { - msg: ciphertext, - aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(), - }, - ) - .map_err(|_| anyhow!("failed to decrypt blinded transaction payload")) -} - -fn blinded_payload_aad(inputs: &[TxInput], fee: Amount, expires_at_height: u64) -> String { - format!( - "iuna-blinded-payload-v3:{}:{fee}:{expires_at_height}", - canonical_inputs(&unsigned_inputs(inputs)) - ) -} - -pub(super) fn blinded_transaction_commitment(transaction: &BlindedTransaction) -> Result<String> { - let mut without_commitment = transaction.clone(); - without_commitment.commitment.clear(); - Ok(hex_hash(without_commitment.canonical())) -} - -pub(super) fn blinded_transaction_signing_payload(transaction: &BlindedTransaction) -> String { - format!( - "blinded-tx-inputs:{}:{}:{}:{}:{}:{}:{}", - canonical_inputs(&unsigned_inputs(&transaction.inputs)), - transaction.fee, - transaction.encrypted_size, - transaction.expires_at_height, - transaction.nonce, - transaction.ciphertext, - transaction.payload_hash - ) -} - -pub(super) fn verify_blinded_input_signatures(transaction: &BlindedTransaction) -> Result<()> { - if transaction.inputs.is_empty() { - return Ok(()); - } - ensure_single_input_owner_for_inputs(&transaction.inputs)?; - let signature = transaction.inputs[0].signature.clone(); - if !transaction - .inputs - .iter() - .all(|input| input.signature == signature) - { - bail!("blinded transaction input signature mismatch"); - } - let mut unsigned = transaction.clone(); - for input in &mut unsigned.inputs { - input.signature.clear(); - } - let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(&transaction.inputs[0].owner) - .context("invalid blinded transaction input owner")?; - let signature = decode_hex_array::<SIGNATURE_BYTES>(&signature) - .context("invalid blinded input signature")?; - let verifying_key = - VerifyingKey::from_bytes(&public_key).context("invalid blinded input public key")?; - let signature = Signature::from_bytes(&signature); - verifying_key - .verify( - blinded_transaction_signing_payload(&unsigned).as_bytes(), - &signature, - ) - .context("blinded transaction input signature is invalid") -} - -pub(super) fn credit_blinded_fee_outputs( - utxos: &mut BTreeMap<OutPoint, TxOutput>, - active: &ActiveBlindedTransaction, - reveal_executor: &str, - transaction: &Transaction, - reveal_bundle_signatures: &[RevealBundleSignature], - available_bundle_slots: usize, - aggregate_finalizer_fee: bool, -) -> Result<()> { - let fee = transaction.fee(); - if fee == 0 { - return Ok(()); - } - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - let reveal_finalizer_fee = - blinded_reveal_finalizer_fee(fee, reveal_bundle_signatures.len(), available_bundle_slots); - let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - let mut outputs = Vec::new(); - if committer_fee > 0 { - outputs.push(( - blinded_committer_fee_outpoint(&active.transaction.commitment), - TxOutput { - address: active.included_by.clone(), - amount: committer_fee, - }, - )); - } - if reveal_finalizer_fee > 0 && !aggregate_finalizer_fee { - outputs.push(( - blinded_executor_fee_outpoint(&active.transaction.commitment), - TxOutput { - address: reveal_executor.to_string(), - amount: reveal_finalizer_fee, - }, - )); - } - for signature in reveal_bundle_signatures { - if reveal_bundle_signer_fee > 0 { - outputs.push(( - blinded_reveal_bundle_signer_fee_outpoint( - &active.transaction.commitment, - signature.slot, - ), - TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - }, - )); - } - } - let tx_outputs = outputs - .iter() - .map(|(_, output)| output.clone()) - .collect::<Vec<_>>(); - ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; - for (outpoint, output) in outputs { - utxos.insert(outpoint, output); - } - Ok(()) -} - -pub(super) fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { - ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount -} - -pub(super) fn blinded_envelope_fee_for_transaction(transaction: &Transaction) -> Amount { - match transaction { - Transaction::Mine { .. } => 0, - Transaction::Transfer { .. } | Transaction::Burn { .. } => transaction.fee(), - } -} - -pub(super) fn blinded_locked_output_total(active: &ActiveBlindedTransaction) -> Result<Amount> { - active - .locked_outputs - .iter() - .try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("blinded transaction locked input total overflows") - }) -} - -pub(super) fn blinded_reveal_inputs_match( - active: &ActiveBlindedTransaction, - transaction: &Transaction, -) -> bool { - let visible = active - .transaction - .inputs - .iter() - .map(TxInput::without_signature) - .collect::<Vec<_>>(); - let revealed = transaction - .inputs() - .iter() - .map(TxInput::without_signature) - .collect::<Vec<_>>(); - visible == revealed -} - -pub(super) fn credit_expired_blinded_outputs( - utxos: &mut BTreeMap<OutPoint, TxOutput>, - active: &ActiveBlindedTransaction, -) -> Result<()> { - let Some(first_input) = active.transaction.inputs.first() else { - return Ok(()); - }; - let input_total = blinded_locked_output_total(active)?; - if active.transaction.fee > input_total { - bail!("blinded transaction fee exceeds locked inputs"); - } - let change = input_total - active.transaction.fee; - let mut outputs = Vec::new(); - if change > 0 { - outputs.push(( - blinded_expiry_change_outpoint(&active.transaction.commitment), - TxOutput { - address: first_input.owner.clone(), - amount: change, - }, - )); - } - let tx_outputs = outputs - .iter() - .map(|(_, output)| output.clone()) - .collect::<Vec<_>>(); - ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; - for (outpoint, output) in outputs { - utxos.insert(outpoint, output); - } - Ok(()) -} - -pub(super) fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 1, - } -} - -pub(super) fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 2, - } -} - -pub(super) fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 3 - u32::from(slot), - } -} - -pub(super) fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: 0, - } -} diff --git a/src/domain/block.rs b/src/domain/block.rs @@ -4,9 +4,8 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use super::{ - Amount, BlindedReveal, BlindedTransaction, BurnTicket, LaunchProfile, LeaderScore, - REVEAL_COMMITTEE_SIZE, RevealBundleSection, Transaction, Wallet, hex_hash, - recovery_vdf_seed_for_child, vdf_seed_for_child, + Amount, BURN_COMMITTEE_SIZE, BurnBundleSection, BurnTicket, LaunchProfile, LeaderScore, + Transaction, Wallet, hex_hash, recovery_vdf_seed_for_child, vdf_seed_for_child, }; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -24,9 +23,7 @@ pub struct Block { pub vdf_output: String, pub leader_proof: Option<LeaderProof>, #[serde(default)] - pub blinded_transactions: Vec<BlindedTransaction>, - #[serde(default)] - pub reveal_bundle_section: RevealBundleSection, + pub burn_bundle_section: BurnBundleSection, pub transactions: Vec<Transaction>, pub hash: String, } @@ -50,7 +47,7 @@ impl Block { } pub fn vdf_seed(&self) -> String { - let bundle_hashes = self.reveal_bundle_hashes(); + let bundle_hashes = self.burn_bundle_hashes(); match self.finalizer_mode { FinalizerMode::Ticket => { vdf_seed_for_child(&self.prev_hash, self.height, &bundle_hashes) @@ -71,13 +68,7 @@ impl Block { .map(Transaction::canonical) .collect::<Vec<_>>() .join("|"); - let blinded = self - .blinded_transactions - .iter() - .map(BlindedTransaction::canonical) - .collect::<Vec<_>>() - .join("|"); - let reveal_section = self.reveal_bundle_section.canonical(); + let burn_section = self.burn_bundle_section.canonical(); let leader_proof = self .leader_proof .as_ref() @@ -89,7 +80,7 @@ impl Block { }) .unwrap_or_default(); hex_hash(format!( - "block-content-v3:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", + "block-content-v4:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", self.height, self.prev_hash, self.timestamp_ms, @@ -99,7 +90,7 @@ impl Block { self.vdf_rounds, leader_proof, txs, - canonical_blinded_block_items(&blinded, &reveal_section) + canonical_burn_block_items(&burn_section) )) } @@ -121,17 +112,17 @@ impl Block { .context("failed to serialize block for size check") } - pub fn all_blinded_reveals(&self) -> Vec<&BlindedReveal> { - self.reveal_bundle_section.all_reveals() + pub fn required_burns(&self) -> Vec<&Transaction> { + self.burn_bundle_section.required_burns() } - pub fn reveal_bundle_hashes(&self) -> [String; REVEAL_COMMITTEE_SIZE] { - self.reveal_bundle_section - .reveal_bundle_hashes(self.height, &self.prev_hash, &self.miner) + pub fn burn_bundle_hashes(&self) -> [String; BURN_COMMITTEE_SIZE] { + self.burn_bundle_section + .burn_bundle_hashes(self.height, &self.prev_hash, &self.miner) } - pub fn included_reveal_bundle_count(&self) -> usize { - self.reveal_bundle_section.included_bundle_count() + pub fn included_burn_bundle_count(&self) -> usize { + self.burn_bundle_section.included_bundle_count() } } @@ -144,8 +135,8 @@ impl FinalizerMode { } } -fn canonical_blinded_block_items(blinded: &str, reveal_section: &str) -> String { - format!("blinded-v3:{blinded}:reveal-section:{reveal_section}") +fn canonical_burn_block_items(burn_section: &str) -> String { + format!("burn-section-v1:{burn_section}") } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -225,8 +216,7 @@ pub struct PreparedBlock { pub(super) vdf_rounds: u64, pub(super) vdf_seed: String, pub(super) leader_ticket: Option<BurnTicket>, - pub(super) blinded_transactions: Vec<BlindedTransaction>, - pub(super) reveal_bundle_section: RevealBundleSection, + pub(super) burn_bundle_section: BurnBundleSection, pub(super) transactions: Vec<Transaction>, } @@ -289,8 +279,7 @@ impl PreparedBlock { vdf_rounds: self.vdf_rounds, vdf_output, leader_proof, - blinded_transactions: self.blinded_transactions, - reveal_bundle_section: self.reveal_bundle_section, + burn_bundle_section: self.burn_bundle_section, transactions: self.transactions, hash: String::new(), }; @@ -321,8 +310,8 @@ pub struct ChainSnapshot { #[cfg(test)] mod tests { - use super::{Block, FinalizerMode, LeaderProofPayload, canonical_blinded_block_items}; - use crate::domain::{RevealBundleSection, Transaction}; + use super::{Block, FinalizerMode, LeaderProofPayload, canonical_burn_block_items}; + use crate::domain::{BurnBundleSection, Transaction}; #[test] fn primary_leader_proof_payload_omits_rank_from_canonical_form() { @@ -351,10 +340,10 @@ mod tests { } #[test] - fn blinded_block_items_keep_canonical_prefix() { + fn burn_block_items_keep_canonical_prefix() { assert_eq!( - canonical_blinded_block_items("blind", "section"), - "blinded-v3:blind:reveal-section:section" + canonical_burn_block_items("section"), + "burn-section-v1:section" ); } @@ -371,8 +360,7 @@ mod tests { vdf_rounds: 1, vdf_output: "out".to_string(), leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), + burn_bundle_section: BurnBundleSection::default(), transactions: vec![Transaction::genesis_burn("owner", 1)], hash: String::new(), }; diff --git a/src/domain/genesis.rs b/src/domain/genesis.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use anyhow::{Result, bail}; use super::{ - Amount, BLOCK_REWARD, Block, FinalizerMode, OutPoint, RevealBundleSection, Transaction, - TxOutput, apply_transaction, credit_reward_output, hex_hash, validate_genesis_burn_transaction, + Amount, BLOCK_REWARD, Block, BurnBundleSection, FinalizerMode, OutPoint, Transaction, TxOutput, + apply_transaction, credit_reward_output, hex_hash, validate_genesis_burn_transaction, }; pub(super) fn build_genesis_block( @@ -30,8 +30,7 @@ pub(super) fn build_genesis_block( vdf_rounds: 0, vdf_output, leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), + burn_bundle_section: BurnBundleSection::default(), transactions, hash: String::new(), }; @@ -117,8 +116,8 @@ pub(super) fn validate_genesis_block(block: &Block) -> Result<()> { if block.leader_proof.is_some() { bail!("genesis block must not carry a leader proof"); } - if !block.blinded_transactions.is_empty() || !block.reveal_bundle_section.is_empty() { - bail!("genesis block must not carry blinded transactions"); + if !block.burn_bundle_section.is_empty() { + bail!("genesis block must not carry burn bundle attestations"); } if block.compute_hash() != block.hash { bail!("genesis block hash is invalid"); diff --git a/src/domain/history.rs b/src/domain/history.rs @@ -1,60 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::{Context, Result, bail}; - -use super::blinded::{ - ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, decrypt_blinded_transaction, -}; -use super::{ChainSnapshot, RevealedBlindedTransaction, Transaction}; - -pub fn revealed_blinded_transactions( - snapshot: &ChainSnapshot, -) -> Result<Vec<RevealedBlindedTransaction>> { - let mut active = BTreeMap::<String, ActiveBlindedTransaction>::new(); - let mut revealed = Vec::new(); - for block in &snapshot.blocks { - for reveal in block.all_blinded_reveals() { - let active_transaction = active.get(&reveal.commitment).with_context(|| { - format!( - "block {} reveals unknown blinded transaction {}", - block.height, reveal.commitment - ) - })?; - let transaction = decrypt_blinded_transaction(&active_transaction.transaction, reveal)?; - if matches!(transaction, Transaction::Mine { .. }) { - bail!("block {} blinded reveal is a mine action", block.height); - } - if blinded_envelope_fee_for_transaction(&transaction) - != active_transaction.transaction.fee - { - bail!( - "block {} blinded reveal fee does not match envelope", - block.height - ); - } - revealed.push(RevealedBlindedTransaction { - height: block.height, - commitment: reveal.commitment.clone(), - included_by: active_transaction.included_by.clone(), - transaction, - }); - active.remove(&reveal.commitment); - } - active.retain(|_, active_transaction| { - block.height < active_transaction.transaction.expires_at_height - }); - for transaction in &block.blinded_transactions { - active.insert( - transaction.commitment.clone(), - ActiveBlindedTransaction { - transaction: transaction.clone(), - locked_outputs: Vec::new(), - locked_lineage_root: None, - included_height: block.height, - included_by: block.miner.clone(), - }, - ); - } - } - Ok(revealed) -} diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -2,27 +2,21 @@ use std::collections::BTreeSet; use anyhow::{Context, Result, bail}; -use super::blinded::{ - ActiveBlindedTransaction, blinded_expiry_change_outpoint, blinded_locked_output_total, - credit_blinded_fee_outputs, credit_expired_blinded_outputs, -}; use super::ledger_ops::{ block_reward, credit_reward_output, ensure_block_has_burn, ensure_outputs_do_not_overflow, - ensure_single_input_owner, ensure_valid_recovery_block, validate_block_blinded_items, - validate_block_fee_policy, verify_leader_proof, + ensure_single_input_owner, ensure_valid_recovery_block, validate_block_fee_policy, + verify_leader_proof, }; use super::mine_policy::ensure_mine_anchor_limit; use super::ticket::{ apply_finalizer_ticket_effects, ticket_block_min_timestamp, tickets_created_by_block, tickets_created_by_transactions, }; -use super::transaction::{blinded_transaction_inputs_available, transaction_inputs_available}; +use super::transaction::transaction_inputs_available; use super::{ - Amount, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, FinalizerMode, - Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MaskedBlindedReveal, - REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, RevealBundleSection, RevealBundleSignature, Transaction, - blinded_reveal_finalizer_fee, insert_output_with_lineage, output_lineage_root_for_transaction, - spend_blinded_inputs_with_lineage, spend_inputs_with_lineage, unix_now_ms, verify_vdf, + Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, BurnBundleSection, FinalizerMode, Ledger, + MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, Transaction, insert_output_with_lineage, + output_lineage_root_for_transaction, spend_inputs_with_lineage, unix_now_ms, verify_vdf, }; impl Ledger { @@ -76,14 +70,11 @@ impl Ledger { bail!("block VDF output is invalid"); } - let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len(); let mut utxos = self.utxos.clone(); let mut utxo_lineage = self.utxo_lineage.clone(); let mut lineage_values = self.lineage_values.clone(); let mut lineage_owners = self.lineage_owners.clone(); let mut signatures = BTreeSet::new(); - let mut revealed_transactions = Vec::new(); - let mut aggregated_reveal_finalizer_fees = 0_u64; for tx in &block.transactions { if !signatures.insert(tx.signature()) { bail!("duplicate transaction in block"); @@ -98,84 +89,7 @@ impl Ledger { &mut lineage_owners, )?; } - let mut revealed_commitments = BTreeSet::new(); - for masked in &block.reveal_bundle_section.reveals { - let reveal = &masked.reveal; - if !revealed_commitments.insert(reveal.commitment.clone()) { - bail!("duplicate blinded reveal in block"); - } - let active = self - .active_blinded - .get(&reveal.commitment) - .context("blinded reveal does not reference an active blinded transaction")? - .clone(); - let tx = self.decrypt_active_blinded(&active, reveal)?; - if block.height >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT && tx.fee() == 0 { - bail!( - "revealed blinded transaction must pay a fee from height {}", - BLOCK_ITEM_FEES_REQUIRED_HEIGHT - ); - } - apply_revealed_blinded_transaction_with_lineage( - &active, - &tx, - &mut utxos, - &mut utxo_lineage, - &mut lineage_values, - &mut lineage_owners, - )?; - let reveal_bundle_signatures = reveal_fee_signatures_for_height( - block.height, - &block.reveal_bundle_section, - masked, - ); - credit_blinded_fee_outputs( - &mut utxos, - &active, - &block.miner, - &tx, - &reveal_bundle_signatures, - reveal_bundle_slot_count, - true, - )?; - aggregated_reveal_finalizer_fees = aggregated_reveal_finalizer_fees - .checked_add(blinded_reveal_finalizer_fee( - tx.fee(), - reveal_fee_bundle_count_for_height( - block.height, - &block.reveal_bundle_section, - masked, - ), - reveal_bundle_slot_count, - )) - .context("aggregated reveal finalizer fees overflow")?; - revealed_transactions.push(tx); - } - for (commitment, active) in &self.active_blinded { - if !revealed_commitments.contains(commitment) - && block.height >= active.transaction.expires_at_height - { - credit_expired_blinded_outputs(&mut utxos, active)?; - if let Some((outpoint, output)) = utxos - .get_key_value(&blinded_expiry_change_outpoint(commitment)) - .map(|(outpoint, output)| (outpoint.clone(), output.clone())) - { - if let Some(root) = active.locked_lineage_root.clone() { - utxos.remove(&outpoint); - insert_output_with_lineage( - outpoint, - output, - Some(root), - &mut utxos, - &mut utxo_lineage, - &mut lineage_values, - &mut lineage_owners, - )?; - } - } - } - } - let expected_reward = block_reward(&block.transactions, aggregated_reveal_finalizer_fees)?; + let expected_reward = block_reward(&block.transactions, 0)?; if block.reward != expected_reward { bail!("block reward is invalid"); } @@ -184,42 +98,12 @@ impl Ledger { .iter() .map(|tx| tx.signature().to_string()) .collect::<BTreeSet<_>>(); - let included_blinded = block - .blinded_transactions - .iter() - .map(|transaction| transaction.commitment.clone()) - .collect::<BTreeSet<_>>(); - let revealed_blinded = block - .all_blinded_reveals() - .into_iter() - .map(|reveal| reveal.commitment.clone()) - .collect::<BTreeSet<_>>(); - let mut new_active_blinded = Vec::new(); - for transaction in &block.blinded_transactions { - let (locked_outputs, locked_lineage_root) = spend_blinded_inputs_with_lineage( - transaction, - &mut utxos, - &mut utxo_lineage, - &mut lineage_values, - &mut lineage_owners, - )?; - new_active_blinded.push(( - transaction.commitment.clone(), - ActiveBlindedTransaction { - transaction: transaction.clone(), - locked_outputs, - locked_lineage_root, - included_height: block.height, - included_by: block.miner.clone(), - }, - )); - } let mut tickets = self.tickets.clone(); apply_finalizer_ticket_effects(self.tip(), &block, &mut tickets)?; tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); tickets.extend(tickets_created_by_transactions( block.height, - &revealed_transactions, + &block.transactions, &self.launch_profile, )?); credit_reward_output(&mut utxos, &block)?; @@ -229,14 +113,6 @@ impl Ledger { self.lineage_owners = lineage_owners; self.tickets = tickets; self.chain.push(block); - let new_height = self.height(); - self.active_blinded.retain(|commitment, active| { - !revealed_blinded.contains(commitment) - && new_height < active.transaction.expires_at_height - }); - for (commitment, active) in new_active_blinded { - self.active_blinded.insert(commitment, active); - } let available = self.utxos.clone(); let pending = std::mem::take(&mut self.pending); self.pending = pending @@ -255,24 +131,6 @@ impl Ledger { && self.validate_transaction_terms(tx).is_ok() }) .collect(); - let pending_blinded = std::mem::take(&mut self.pending_blinded); - self.pending_blinded = pending_blinded - .into_iter() - .filter(|transaction| { - !included_blinded.contains(&transaction.commitment) - && new_height < transaction.expires_at_height - && blinded_transaction_inputs_available(transaction, &available) - && self.validate_blinded_transaction(transaction).is_ok() - }) - .collect(); - let pending_reveals = std::mem::take(&mut self.pending_reveals); - self.pending_reveals = pending_reveals - .into_iter() - .filter(|reveal| { - !revealed_blinded.contains(&reveal.commitment) - && self.pending_reveal_transaction(reveal).is_ok() - }) - .collect(); self.refresh_pending_pool_byte_counters()?; self.promote_orphan_transactions()?; self.vdf_rounds = self.next_vdf_rounds_after_tip(); @@ -307,8 +165,7 @@ impl Ledger { if block.compute_hash() != block.hash { bail!("block hash is invalid"); } - let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len(); - if block.reward != self.expected_reward_for_block(block, reveal_bundle_slot_count)? { + if block.reward != self.expected_reward_for_block(block)? { bail!("block reward is invalid"); } let expected_vdf_rounds = self.expected_vdf_rounds_for_block(block)?; @@ -338,10 +195,7 @@ impl Ledger { if block.transactions.len() > self.launch_profile.max_block_transactions { bail!("block has too many transactions"); } - let block_item_count = block.transactions.len() - + block.blinded_transactions.len() - + block.all_blinded_reveals().len(); - if block_item_count > self.launch_profile.max_block_transactions { + if block.transactions.len() > self.launch_profile.max_block_transactions { bail!("block has too many transaction items"); } if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes { @@ -350,14 +204,7 @@ impl Ledger { ensure_mine_anchor_limit(block.height, &block.transactions)?; ensure_block_has_burn(&block.transactions)?; validate_block_fee_policy(block)?; - self.validate_reveal_bundle_section_for_block( - block.height, - &block.prev_hash, - block.finalizer_mode, - block.finalizer_rank, - &block.reveal_bundle_section, - )?; - validate_block_blinded_items(block, self)?; + self.validate_burn_bundle_section_for_block(block)?; match block.finalizer_mode { FinalizerMode::Ticket => { let selected_ticket = self @@ -402,87 +249,16 @@ impl Ledger { pub(super) fn expected_reward_for_next_block( &self, transactions: &[Transaction], - reveal_bundle_section: &RevealBundleSection, - ) -> Result<Amount> { - let height = self.tip().height + 1; - let reveal_bundle_slot_count = self.reveal_committee_for_height(height).len(); - let aggregate = - self.aggregate_reveal_finalizer_fees(reveal_bundle_section, reveal_bundle_slot_count)?; - block_reward(transactions, aggregate) - } - - fn expected_reward_for_block( - &self, - block: &Block, - reveal_bundle_slot_count: usize, + _burn_bundle_section: &BurnBundleSection, ) -> Result<Amount> { - let aggregate = self.aggregate_reveal_finalizer_fees( - &block.reveal_bundle_section, - reveal_bundle_slot_count, - )?; - block_reward(&block.transactions, aggregate) + block_reward(transactions, 0) } - fn aggregate_reveal_finalizer_fees( - &self, - reveal_bundle_section: &RevealBundleSection, - reveal_bundle_slot_count: usize, - ) -> Result<Amount> { - reveal_bundle_section - .reveals - .iter() - .try_fold(0_u64, |total, masked| { - let active = self - .active_blinded - .get(&masked.reveal.commitment) - .context("blinded reveal does not reference an active blinded transaction")?; - total - .checked_add(blinded_reveal_finalizer_fee( - active.transaction.fee, - reveal_fee_bundle_count_for_height( - self.tip().height + 1, - reveal_bundle_section, - masked, - ), - reveal_bundle_slot_count, - )) - .context("aggregated reveal finalizer fees overflow") - }) + fn expected_reward_for_block(&self, block: &Block) -> Result<Amount> { + block_reward(&block.transactions, 0) } } -pub(super) fn reveal_fee_bundle_count_for_height( - height: u64, - section: &RevealBundleSection, - masked: &MaskedBlindedReveal, -) -> usize { - let explicit = reveal_fee_signatures_for_height(height, section, masked).len(); - if height >= super::REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT { - explicit.saturating_add(1) - } else { - explicit - } -} - -pub(super) fn reveal_fee_signatures_for_height( - height: u64, - section: &RevealBundleSection, - masked: &MaskedBlindedReveal, -) -> Vec<RevealBundleSignature> { - if height < REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT { - return section.signatures.clone(); - } - section - .signatures - .iter() - .filter(|signature| { - 1_u8.checked_shl(u32::from(signature.slot)) - .is_some_and(|slot_mask| masked.bundle_mask & slot_mask != 0) - }) - .cloned() - .collect() -} - fn apply_transaction_with_lineage( transaction: &Transaction, block_height: u64, @@ -552,52 +328,3 @@ fn apply_transaction_with_lineage( } Ok(()) } - -fn apply_revealed_blinded_transaction_with_lineage( - active: &ActiveBlindedTransaction, - transaction: &Transaction, - utxos: &mut std::collections::BTreeMap<super::OutPoint, super::TxOutput>, - utxo_lineage: &mut std::collections::BTreeMap<super::OutPoint, super::UtxoLineageRoot>, - lineage_values: &mut std::collections::BTreeMap<super::UtxoLineageRoot, Amount>, - lineage_owners: &mut super::LineageOwnerValues, -) -> Result<()> { - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - transaction.verify_signature()?; - ensure_single_input_owner(transaction)?; - let input_total = blinded_locked_output_total(active)?; - let outputs = transaction.outputs(); - let output_total = outputs.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("transaction outputs overflow") - })?; - let required = output_total - .checked_add(transaction.fee()) - .context("transaction outputs plus fee overflow")? - .checked_add(match transaction { - Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, - }) - .context("transaction outputs plus burn overflow")?; - if input_total != required { - bail!("blinded transaction inputs do not balance outputs, burn, and fee"); - } - ensure_outputs_do_not_overflow(utxos, &outputs)?; - for (index, output) in outputs.into_iter().enumerate() { - insert_output_with_lineage( - super::OutPoint { - txid: transaction.signature().to_string(), - index: index as u32, - }, - output, - active.locked_lineage_root.clone(), - utxos, - utxo_lineage, - lineage_values, - lineage_owners, - )?; - } - Ok(()) -} diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -1,24 +1,15 @@ -use anyhow::{Context, Result, anyhow, bail}; -use getrandom::getrandom; - -use super::blinded::{ - blinded_envelope_fee_for_transaction, blinded_payload_from_transaction, - blinded_transaction_commitment, blinded_transaction_signing_payload, encrypt_blinded_payload, -}; -use super::hex::{hex_encode, hex_hash}; +use super::hex::hex_encode; use super::mining::mine_signature; use super::stratum::{ hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature, stratum_mine_template, }; -use super::transaction::{ - UnsignedTxInput, UnsignedUtxoTransaction, signed_blinded_inputs, unsigned_inputs, -}; +use super::transaction::{UnsignedTxInput, UnsignedUtxoTransaction}; use super::validation::validate_address; use super::{ - Amount, BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BlindedReveal, BlindedTransaction, - BuiltBlindedTransaction, Ledger, MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MineSearchOutcome, - OutPoint, StratumMineShare, StratumMineTemplate, Transaction, TxOutput, Wallet, + Amount, Ledger, MineSearchOutcome, OutPoint, StratumMineShare, StratumMineTemplate, + Transaction, TxOutput, Wallet, }; +use anyhow::{Context, Result, bail}; impl Ledger { pub fn build_transfer( @@ -151,126 +142,6 @@ impl Ledger { Ok(transaction) } - pub fn build_blinded_burn( - &self, - wallet: &Wallet, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - let transaction = self.build_burn(wallet, amount, fee)?; - self.blind_transaction(wallet, transaction, fee, expires_at_height) - } - - pub fn build_blinded_transfer( - &self, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - let transaction = self.build_transfer(wallet, to, amount, fee)?; - self.blind_transaction(wallet, transaction, fee, expires_at_height) - } - - pub fn build_blinded_transaction( - &self, - wallet: &Wallet, - transaction: Transaction, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - let fee = blinded_envelope_fee_for_transaction(&transaction); - self.blind_transaction(wallet, transaction, fee, expires_at_height) - } - - fn blind_transaction( - &self, - wallet: &Wallet, - transaction: Transaction, - fee: Amount, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - if expires_at_height <= self.height() { - bail!("blinded transaction expiry must be in the future"); - } - if expires_at_height - > self - .height() - .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS) - { - bail!("blinded transaction expiry is too far in the future"); - } - if fee != blinded_envelope_fee_for_transaction(&transaction) { - bail!("blinded transaction fee must match plaintext transaction fee"); - } - let unsigned_inputs = if transaction.inputs().is_empty() && transaction.fee() > 0 { - self.select_inputs(wallet.address(), transaction.fee())?.0 - } else { - unsigned_inputs(transaction.inputs()) - }; - if unsigned_inputs - .iter() - .any(|input| input.owner != wallet.address()) - { - bail!("blinded transaction inputs must be owned by the signing wallet"); - } - let blinded_payload = blinded_payload_from_transaction(&transaction)?; - let plaintext = serde_json::to_vec(&blinded_payload) - .context("failed to serialize transaction for blinded payload")?; - let payload_hash = hex_hash(&plaintext); - let unsigned_commit_inputs = signed_blinded_inputs(&unsigned_inputs, ""); - let payload = transaction; - let mut key = [0_u8; BLINDED_KEY_BYTES]; - let mut nonce = [0_u8; BLINDED_NONCE_BYTES]; - getrandom(&mut key) - .map_err(|error| anyhow!("failed to generate blinded transaction key: {error}"))?; - getrandom(&mut nonce) - .map_err(|error| anyhow!("failed to generate blinded transaction nonce: {error}"))?; - let ciphertext = encrypt_blinded_payload( - &key, - &nonce, - &unsigned_commit_inputs, - fee, - expires_at_height, - &plaintext, - )?; - let encrypted_size = u32::try_from(ciphertext.len()) - .context("blinded transaction ciphertext is too large")?; - let transaction = BlindedTransaction { - commitment: String::new(), - inputs: unsigned_commit_inputs, - fee, - encrypted_size, - expires_at_height, - nonce: hex_encode(nonce), - ciphertext: hex_encode(&ciphertext), - payload_hash, - }; - let signature = wallet.sign_payload(&blinded_transaction_signing_payload(&transaction)); - let transaction = BlindedTransaction { - inputs: signed_blinded_inputs(&unsigned_inputs, &signature), - ..transaction - }; - let commitment = blinded_transaction_commitment(&transaction)?; - let transaction = BlindedTransaction { - commitment: commitment.clone(), - ..transaction - }; - self.validate_blinded_transaction(&transaction)?; - Ok(BuiltBlindedTransaction { - payload, - transaction, - reveal: BlindedReveal { - commitment, - key: hex_encode(key), - }, - }) - } - 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_chain.rs b/src/domain/ledger_chain.rs @@ -55,13 +55,8 @@ impl Ledger { tickets, pending: Vec::new(), orphans: Vec::new(), - pending_blinded: Vec::new(), - pending_reveals: Vec::new(), pending_bytes: 0, orphan_bytes: 0, - pending_blinded_bytes: 0, - pending_reveal_bytes: 0, - active_blinded: BTreeMap::new(), mine_reward: MINE_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, @@ -117,13 +112,8 @@ impl Ledger { tickets: Vec::new(), pending: Vec::new(), orphans: Vec::new(), - pending_blinded: Vec::new(), - pending_reveals: Vec::new(), pending_bytes: 0, orphan_bytes: 0, - pending_blinded_bytes: 0, - pending_reveal_bytes: 0, - active_blinded: BTreeMap::new(), mine_reward: MINE_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, @@ -268,16 +258,12 @@ impl Ledger { fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) { let mut carry_forward = self.pending.clone(); carry_forward.extend(self.orphans.clone()); - let mut carry_forward_blinded = self.pending_blinded.clone(); - let mut carry_forward_reveals = self.pending_reveals.clone(); for block in self .chain .iter() .skip(fork_point.first_diverging_height() as usize) { carry_forward.extend(block.transactions.clone()); - carry_forward_blinded.extend(block.blinded_transactions.clone()); - carry_forward_reveals.extend(block.all_blinded_reveals().into_iter().cloned()); } let mined_signatures = candidate @@ -286,34 +272,11 @@ impl Ledger { .flat_map(|block| block.transactions.iter()) .map(|tx| tx.signature().to_string()) .collect::<BTreeSet<_>>(); - let mined_blinded_commitments = candidate - .chain - .iter() - .flat_map(|block| block.blinded_transactions.iter()) - .map(|transaction| transaction.commitment.clone()) - .collect::<BTreeSet<_>>(); - let mined_reveal_commitments = candidate - .chain - .iter() - .flat_map(|block| block.all_blinded_reveals()) - .map(|reveal| reveal.commitment.clone()) - .collect::<BTreeSet<_>>(); - for transaction in carry_forward { if !mined_signatures.contains(transaction.signature()) { let _ = candidate.submit_transaction(transaction); } } - for transaction in carry_forward_blinded { - if !mined_blinded_commitments.contains(&transaction.commitment) { - let _ = candidate.submit_blinded_transaction(transaction); - } - } - for reveal in carry_forward_reveals { - if !mined_reveal_commitments.contains(&reveal.commitment) { - let _ = candidate.submit_blinded_reveal(reveal); - } - } *self = candidate; } diff --git a/src/domain/ledger_lineage.rs b/src/domain/ledger_lineage.rs @@ -45,47 +45,6 @@ pub(super) fn spend_inputs_with_lineage( Ok((total, inherited_root)) } -pub(super) fn spend_blinded_inputs_with_lineage( - transaction: &super::BlindedTransaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, - utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>, - lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>, - lineage_owners: &mut LineageOwnerValues, -) -> Result<(Vec<TxOutput>, Option<UtxoLineageRoot>)> { - let mut locked = Vec::new(); - let mut seen = std::collections::BTreeSet::new(); - let mut inherited_root = None; - for input in &transaction.inputs { - if !seen.insert(input.outpoint.clone()) { - bail!("duplicate input in blinded transaction"); - } - let output = utxos.remove(&input.outpoint).with_context(|| { - format!( - "blinded transaction spends missing output {}", - input.outpoint.id() - ) - })?; - if output.address != input.owner { - bail!("blinded transaction input owner does not match spent output"); - } - if let Some(root) = utxo_lineage.remove(&input.outpoint) { - subtract_lineage_value(lineage_values, &root, output.amount)?; - subtract_lineage_owner_value(lineage_owners, &root, &output.address, &input.outpoint)?; - inherited_root = newest_lineage_root(inherited_root, Some(root)); - } - locked.push(output); - } - let locked_total = locked.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("blinded transaction locked input total overflows") - })?; - if transaction.fee > locked_total { - bail!("blinded transaction fee exceeds locked inputs"); - } - Ok((locked, inherited_root)) -} - pub(super) fn insert_output_with_lineage( outpoint: OutPoint, output: TxOutput, diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs @@ -2,13 +2,9 @@ use anyhow::{Context, Result, bail}; use serde::Serialize; use super::ledger_ops::{ - apply_transaction, ensure_blinded_transaction_fits_empty_block, - ensure_transaction_fits_empty_block, spend_blinded_inputs, transaction_has_missing_inputs, -}; -use super::transaction::{ - BlindedReveal, BlindedTransaction, Transaction, blinded_transaction_inputs_spent_by, - transaction_inputs_spent_by, transaction_inputs_spent_by_inputs, + apply_transaction, ensure_transaction_fits_empty_block, transaction_has_missing_inputs, }; +use super::transaction::{Transaction, transaction_inputs_spent_by}; use super::{ Ledger, MAX_ORPHAN_TRANSACTIONS, MAX_PENDING_POOL_BYTES, MAX_PENDING_TRANSACTIONS, TransactionSubmitOutcome, spend_inputs_with_lineage, @@ -39,74 +35,6 @@ impl Ledger { Ok(()) } - pub fn submit_blinded_transaction(&mut self, transaction: BlindedTransaction) -> Result<bool> { - if self.has_blinded_transaction(&transaction.commitment) { - return Ok(false); - } - self.validate_blinded_transaction(&transaction)?; - ensure_blinded_transaction_fits_empty_block( - &transaction, - self.launch_profile.max_block_bytes, - )?; - if blinded_transaction_inputs_spent_by(&transaction, &self.pending_blinded) - || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.pending) - || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.orphans) - { - bail!("blinded transaction conflicts with pending inputs"); - } - let mut utxos = self.utxos_after_valid_pending_and_blinded()?; - spend_blinded_inputs(&transaction, &mut utxos)?; - if self.pending_blinded.len() >= MAX_PENDING_TRANSACTIONS { - bail!("blinded mempool is full"); - } - let candidate_bytes = ensure_pending_pool_bytes( - "blinded mempool", - self.pending_blinded_bytes, - &transaction, - MAX_PENDING_POOL_BYTES, - )?; - self.pending_blinded.push(transaction); - self.pending_blinded_bytes = self.pending_blinded_bytes.saturating_add(candidate_bytes); - Ok(true) - } - - pub fn submit_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<bool> { - if self.has_blinded_reveal(&reveal.commitment) { - return Ok(false); - } - self.validate_blinded_reveal_terms(&reveal)?; - self.pending_reveal_transaction(&reveal)?; - if self.pending_reveals.len() >= MAX_PENDING_TRANSACTIONS - && !self.drop_one_invalid_pending_blinded_reveal()? - { - bail!("blinded reveal pool is full"); - } - let candidate_bytes = ensure_pending_pool_bytes( - "blinded reveal pool", - self.pending_reveal_bytes, - &reveal, - MAX_PENDING_POOL_BYTES, - )?; - self.pending_reveals.push(reveal); - self.pending_reveal_bytes = self.pending_reveal_bytes.saturating_add(candidate_bytes); - Ok(true) - } - - fn drop_one_invalid_pending_blinded_reveal(&mut self) -> Result<bool> { - let Some(index) = self - .pending_reveals - .iter() - .position(|reveal| self.pending_reveal_transaction(reveal).is_err()) - else { - return Ok(false); - }; - let removed = self.pending_reveals.remove(index); - self.pending_reveal_bytes = self - .pending_reveal_bytes - .saturating_sub(serialized_len(&removed)?); - Ok(true) - } - pub fn submit_transaction_with_outcome( &mut self, transaction: Transaction, @@ -131,7 +59,7 @@ impl Ledger { bail!("mempool is full"); } - let mut utxos = self.utxos_after_valid_pending_and_blinded()?; + let mut utxos = self.utxos_after_valid_pending()?; if transaction_has_missing_inputs(&transaction, &utxos) { if self.orphans.len() >= MAX_ORPHAN_TRANSACTIONS { bail!("orphan transaction pool is full"); @@ -162,8 +90,6 @@ impl Ledger { pub(super) fn refresh_pending_pool_byte_counters(&mut self) -> Result<()> { self.pending_bytes = serialized_pool_len(&self.pending)?; self.orphan_bytes = serialized_pool_len(&self.orphans)?; - self.pending_blinded_bytes = serialized_pool_len(&self.pending_blinded)?; - self.pending_reveal_bytes = serialized_pool_len(&self.pending_reveals)?; Ok(()) } } diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -3,17 +3,16 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; -use super::blinded::verify_blinded_input_signatures; use super::hex::hex_hash; -use super::reveal::{RevealBundleSection, canonical_reveal_bundle_hashes}; -use super::selection::{TransactionKind, blinded_fee_rate_key, fee_rate_key}; +use super::reveal::{BurnBundleSection, canonical_burn_bundle_hashes}; +use super::selection::{TransactionKind, fee_rate_key}; use super::ticket::ticket_is_eligible_for_height; -use super::transaction::{BlindedTransaction, Transaction}; +use super::transaction::Transaction; use super::{ Amount, Block, BlockSelection, BurnTicket, FinalizerMode, LeaderProof, LeaderProofPayload, - Ledger, MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS, - REVEAL_COMMITTEE_SIZE, SIGNATURE_BYTES, TxInput, TxOutput, decode_hex_array, validate_address, - validate_hash, validate_protocol_id, validate_signature, + MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS, SIGNATURE_BYTES, TxInput, + TxOutput, decode_hex_array, validate_address, validate_hash, validate_protocol_id, + validate_signature, }; pub(super) fn validate_genesis_allocations( @@ -70,7 +69,7 @@ pub(super) fn validate_genesis_burn_transaction(transaction: &Transaction) -> Re pub(super) fn estimated_block_selection_size_bytes( selection: &BlockSelection, recovery: bool, - reveal_bundle_section: &RevealBundleSection, + burn_bundle_section: &BurnBundleSection, ) -> Result<usize> { let block = Block { height: u64::MAX, @@ -91,8 +90,7 @@ pub(super) fn estimated_block_selection_size_bytes( public_key: "f".repeat(64), signature: "f".repeat(128), }), - blinded_transactions: selection.blinded_transactions.clone(), - reveal_bundle_section: reveal_bundle_section.clone(), + burn_bundle_section: burn_bundle_section.clone(), transactions: selection.transactions.clone(), hash: "f".repeat(64), }; @@ -105,9 +103,8 @@ pub(super) fn ensure_transaction_fits_empty_block( ) -> Result<()> { let selection = BlockSelection { transactions: vec![transaction.clone()], - blinded_transactions: Vec::new(), }; - if estimated_block_selection_size_bytes(&selection, false, &RevealBundleSection::default())? + if estimated_block_selection_size_bytes(&selection, false, &BurnBundleSection::default())? > max_block_bytes { bail!("transaction exceeds max block size"); @@ -115,22 +112,6 @@ pub(super) fn ensure_transaction_fits_empty_block( Ok(()) } -pub(super) fn ensure_blinded_transaction_fits_empty_block( - transaction: &BlindedTransaction, - max_block_bytes: usize, -) -> Result<()> { - let selection = BlockSelection { - transactions: Vec::new(), - blinded_transactions: vec![transaction.clone()], - }; - if estimated_block_selection_size_bytes(&selection, false, &RevealBundleSection::default())? - > max_block_bytes - { - bail!("blinded transaction exceeds max block size"); - } - Ok(()) -} - pub(super) fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> { let Some(proof) = &block.leader_proof else { bail!("block is missing leader proof"); @@ -197,11 +178,11 @@ pub(super) fn verify_address_signature( pub(super) fn vdf_seed_for_child( prev_hash: &str, height: u64, - bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], + bundle_hashes: &[String; super::BURN_COMMITTEE_SIZE], ) -> String { hex_hash(format!( "iuna-vdf-child:{prev_hash}:{height}:{}", - canonical_reveal_bundle_hashes(bundle_hashes) + canonical_burn_bundle_hashes(bundle_hashes) )) } @@ -209,11 +190,11 @@ pub(super) fn recovery_vdf_seed_for_child( prev_hash: &str, height: u64, timestamp_ms: u64, - bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], + bundle_hashes: &[String; super::BURN_COMMITTEE_SIZE], ) -> String { hex_hash(format!( "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}:{}", - canonical_reveal_bundle_hashes(bundle_hashes) + canonical_burn_bundle_hashes(bundle_hashes) )) } @@ -273,79 +254,10 @@ pub(super) fn apply_transaction( } pub(super) fn validate_block_fee_policy(block: &Block) -> Result<()> { - if block.height < super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT { - return Ok(()); - } - - let mut free_finalizer_anchor_burns = 0usize; for transaction in &block.transactions { - if transaction.fee() > 0 { - continue; - } - if transaction.is_burn() && transaction.sender() == block.miner { - free_finalizer_anchor_burns += 1; - if free_finalizer_anchor_burns > 1 { - bail!( - "block may include only one zero-fee finalizer anchor burn from height {}", - super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT - ); - } - continue; - } - bail!( - "block transaction must pay a fee from height {} unless it is the finalizer anchor burn", - super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT - ); - } - - for transaction in &block.blinded_transactions { - if transaction.fee == 0 { - bail!( - "blinded transaction must pay a fee from height {}", - super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT - ); - } - } - Ok(()) -} - -pub(super) fn validate_block_blinded_items(block: &Block, ledger: &Ledger) -> Result<()> { - let mut commitments = BTreeSet::new(); - for transaction in &block.blinded_transactions { - if !commitments.insert(transaction.commitment.clone()) { - bail!("duplicate blinded transaction in block"); - } - ledger.validate_blinded_transaction(transaction)?; - if transaction.expires_at_height <= block.height { - bail!("blinded transaction is expired for block height"); - } - if ledger.active_blinded.contains_key(&transaction.commitment) { - bail!("blinded transaction is already active"); - } - if ledger.chain.iter().any(|block| { - block - .blinded_transactions - .iter() - .any(|existing| existing.commitment == transaction.commitment) - }) { - bail!("blinded transaction is already on chain"); - } - } - - let mut reveals = BTreeSet::new(); - for reveal in block.all_blinded_reveals() { - if !reveals.insert(reveal.commitment.clone()) { - bail!("duplicate blinded reveal in block"); + if transaction.fee() == 0 { + bail!("block transaction fee must be greater than zero"); } - if ledger.chain.iter().any(|block| { - block - .all_blinded_reveals() - .iter() - .any(|existing| existing.commitment == reveal.commitment) - }) { - bail!("blinded reveal is already on chain"); - } - ledger.pending_reveal_transaction(reveal)?; } Ok(()) } @@ -358,10 +270,10 @@ pub(super) fn fee_reward(transactions: &[Transaction]) -> Result<Amount> { pub(super) fn block_reward( transactions: &[Transaction], - aggregated_reveal_finalizer_fees: Amount, + additional_finalizer_fees: Amount, ) -> Result<Amount> { fee_reward(transactions)? - .checked_add(aggregated_reveal_finalizer_fees) + .checked_add(additional_finalizer_fees) .context("block reward overflow") } @@ -454,89 +366,6 @@ pub(super) fn transaction_input_total( Ok(total) } -pub(super) fn spend_blinded_inputs( - transaction: &BlindedTransaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<Vec<TxOutput>> { - verify_blinded_input_signatures(transaction)?; - if transaction.inputs.is_empty() { - return Ok(Vec::new()); - } - let mut seen = BTreeSet::new(); - let mut locked = Vec::new(); - for input in &transaction.inputs { - if !seen.insert(input.outpoint.clone()) { - bail!("duplicate input in blinded transaction"); - } - let output = utxos.remove(&input.outpoint).with_context(|| { - format!( - "blinded transaction spends missing output {}", - input.outpoint.id() - ) - })?; - if output.address != input.owner { - bail!("blinded transaction input owner does not match spent output"); - } - locked.push(output); - } - let locked_total = locked.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("blinded transaction locked input total overflows") - })?; - if transaction.fee > locked_total { - bail!("blinded transaction fee exceeds locked inputs"); - } - Ok(locked) -} - -pub(super) fn spend_spendable_blinded_inputs( - transaction: &BlindedTransaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<Vec<TxOutput>> { - let locked = blinded_input_outputs(transaction, utxos)?; - for input in &transaction.inputs { - utxos.remove(&input.outpoint); - } - Ok(locked) -} - -pub(super) fn blinded_input_outputs( - transaction: &BlindedTransaction, - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> Result<Vec<TxOutput>> { - verify_blinded_input_signatures(transaction)?; - if transaction.inputs.is_empty() { - return Ok(Vec::new()); - } - let mut seen = BTreeSet::new(); - let mut locked = Vec::new(); - for input in &transaction.inputs { - if !seen.insert(input.outpoint.clone()) { - bail!("duplicate input in blinded transaction"); - } - let output = utxos.get(&input.outpoint).with_context(|| { - format!( - "blinded transaction spends missing output {}", - input.outpoint.id() - ) - })?; - if output.address != input.owner { - bail!("blinded transaction input owner does not match spent output"); - } - locked.push(output.clone()); - } - let locked_total = locked.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("blinded transaction locked input total overflows") - })?; - if transaction.fee > locked_total { - bail!("blinded transaction fee exceeds locked inputs"); - } - Ok(locked) -} - pub(super) fn transaction_has_missing_inputs( transaction: &Transaction, utxos: &BTreeMap<OutPoint, TxOutput>, @@ -631,26 +460,6 @@ pub(super) fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Resu ensure_block_has_burn_from(&block.transactions, &block.miner) } -pub(super) fn best_selectable_blinded_index( - transactions: &[BlindedTransaction], - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> Option<usize> { - transactions - .iter() - .enumerate() - .filter(|(_, transaction)| { - let mut utxos = utxos.clone(); - spend_blinded_inputs(transaction, &mut utxos).is_ok() - }) - .max_by(|(_, left), (_, right)| { - blinded_fee_rate_key(left) - .cmp(&blinded_fee_rate_key(right)) - .then_with(|| left.fee.cmp(&right.fee)) - .then_with(|| right.commitment.cmp(&left.commitment)) - }) - .map(|(index, _)| index) -} - pub(super) fn best_selectable_transaction_index( transactions: &[Transaction], utxos: &BTreeMap<OutPoint, TxOutput>, diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs @@ -2,25 +2,17 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; -use super::blinded::{ - ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, blinded_reveal_inputs_match, - blinded_transaction_commitment, decrypt_blinded_transaction, verify_blinded_input_signatures, -}; use super::ledger_mempool::pending_pool_item_bytes; use super::ledger_ops::{ - apply_spendable_pending_transaction, apply_transaction, best_selectable_blinded_index, - best_selectable_burn_from_index, best_selectable_transaction_index, - ensure_blinded_transaction_fits_empty_block, ensure_transaction_fits_empty_block, - estimated_block_selection_size_bytes, spend_blinded_inputs, spend_spendable_blinded_inputs, - transaction_has_missing_inputs, validate_transaction_inputs, validate_transaction_outputs, + apply_spendable_pending_transaction, apply_transaction, best_selectable_burn_from_index, + best_selectable_transaction_index, ensure_transaction_fits_empty_block, + estimated_block_selection_size_bytes, transaction_has_missing_inputs, + validate_transaction_inputs, validate_transaction_outputs, }; use super::mine_policy::{ MINE_MAX_ANCHOR_AGE_BLOCKS, mine_anchor, mine_anchor_count_before_height, }; -use super::selection::{ - BlockSelection, SelectableItem, TransactionKind, best_selectable_item, blinded_fee_rate_key, - fee_rate_key, -}; +use super::selection::{BlockSelection, TransactionKind}; use super::transaction::{ UnsignedTxInput, transaction_inputs_available, transaction_inputs_spent_by, }; @@ -28,11 +20,8 @@ use super::validation::{ validate_address, validate_hash, validate_signature, validate_stratum_header, }; use super::{ - Amount, BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT, - BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BlindedReveal, BlindedTransaction, Ledger, - MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_PENDING_POOL_BYTES, MAX_PENDING_TRANSACTIONS, - MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, RevealBundleSection, Transaction, TxOutput, - decode_hex, decode_hex_array, + Amount, BurnBundleSection, Ledger, MAX_PENDING_POOL_BYTES, MAX_PENDING_TRANSACTIONS, + MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, Transaction, TxOutput, }; impl Ledger { @@ -86,29 +75,29 @@ impl Ledger { valid } - pub(super) fn select_block_transactions_with_reveal_section( + pub(super) fn select_block_transactions_with_burn_section( &self, miner: &str, required_burn_signature: Option<&str>, - reveal_bundle_section: &RevealBundleSection, + burn_bundle_section: &BurnBundleSection, ) -> Result<BlockSelection> { self.select_block_transactions_with_required_burn_owner( Some(miner), required_burn_signature, - reveal_bundle_section, + burn_bundle_section, ) } - pub(super) fn select_recovery_block_transactions_with_reveal_section( + pub(super) fn select_recovery_block_transactions_with_burn_section( &self, miner: &str, required_burn_signature: Option<&str>, - reveal_bundle_section: &RevealBundleSection, + burn_bundle_section: &BurnBundleSection, ) -> Result<BlockSelection> { self.select_block_transactions_with_required_burn_owner( Some(miner), required_burn_signature, - reveal_bundle_section, + burn_bundle_section, ) } @@ -116,49 +105,73 @@ impl Ledger { &self, required_burn_owner: Option<&str>, required_burn_signature: Option<&str>, - reveal_bundle_section: &RevealBundleSection, + burn_bundle_section: &BurnBundleSection, ) -> Result<BlockSelection> { let mut utxos = self.utxos.clone(); let mut remaining = self.valid_pending_transactions(); - let mut remaining_blinded = self.valid_pending_blinded_transactions(); let mut selected = Vec::new(); - let mut selected_blinded = Vec::new(); - let next_height = self.height().saturating_add(1); - if let Some(signature) = required_burn_signature { - let index = remaining - .iter() - .position(|transaction| transaction.signature() == signature) - .with_context(|| format!("required burn {signature} is not pending"))?; + let required_burn_signatures = burn_bundle_section + .required_burns() + .into_iter() + .map(|burn| burn.signature().to_string()) + .collect::<BTreeSet<_>>(); + let mut selected_required_burn_signatures = BTreeSet::new(); + let mut index = 0; + while index < remaining.len() + && selected_required_burn_signatures.len() < required_burn_signatures.len() + { + if !required_burn_signatures.contains(remaining[index].signature()) { + index += 1; + continue; + } let tx = remaining.remove(index); if !tx.is_burn() { - bail!("required block anchor must be a burn transaction"); + bail!("attested transaction must be a burn"); } - if let Some(owner) = required_burn_owner { - if tx.sender() != owner { - bail!("required block anchor burn must be from the recovery finalizer"); - } - } - let candidate = BlockSelection { - transactions: vec![tx.clone()], - blinded_transactions: selected_blinded.clone(), + let signature = tx.signature().to_string(); + let mut candidate = BlockSelection { + transactions: selected.clone(), }; + candidate.transactions.push(tx.clone()); if estimated_block_selection_size_bytes( &candidate, required_burn_owner.is_some(), - reveal_bundle_section, + burn_bundle_section, )? > self.launch_profile.max_block_bytes { - bail!("required block anchor burn does not fit in the block"); + bail!("attested burns do not fit in the block"); } - apply_transaction(&tx, &mut utxos) - .context("required block anchor burn is not spendable")?; + apply_transaction(&tx, &mut utxos).context("attested burn is not spendable")?; selected.push(tx); - remove_extra_zero_fee_transactions_after_anchor( - &mut remaining, - required_burn_owner, - next_height, - ); + selected_required_burn_signatures.insert(signature); + } + if selected_required_burn_signatures.len() != required_burn_signatures.len() { + let missing = required_burn_signatures + .difference(&selected_required_burn_signatures) + .next() + .expect("required burn set differs"); + bail!("attested burn {missing} is not pending"); + } + + if let Some(signature) = required_burn_signature { + if !selected + .iter() + .any(|transaction| transaction.signature() == signature) + { + let index = remaining + .iter() + .position(|transaction| transaction.signature() == signature) + .with_context(|| format!("required burn {signature} is not pending"))?; + let tx = remaining.remove(index); + self.select_required_anchor_burn( + tx, + required_burn_owner, + burn_bundle_section, + &mut utxos, + &mut selected, + )?; + } } let needs_first_burn = !selected.iter().any(Transaction::is_burn); @@ -175,98 +188,73 @@ impl Ledger { }; if let Some(index) = first_burn_index { let tx = remaining.remove(index); - let mut candidate = BlockSelection { - transactions: selected.clone(), - blinded_transactions: selected_blinded.clone(), - }; - candidate.transactions.push(tx.clone()); - if estimated_block_selection_size_bytes( - &candidate, - required_burn_owner.is_some(), - reveal_bundle_section, - )? <= self.launch_profile.max_block_bytes - { - apply_transaction(&tx, &mut utxos)?; - selected.push(tx); - remove_extra_zero_fee_transactions_after_anchor( - &mut remaining, - required_burn_owner, - next_height, - ); - } + self.select_required_anchor_burn( + tx, + required_burn_owner, + burn_bundle_section, + &mut utxos, + &mut selected, + )?; } } while selected.len() < self.launch_profile.max_block_transactions { - let selected_count = selected.len() + selected_blinded.len(); - if selected_count >= self.launch_profile.max_block_transactions { - break; - } - - let best_plain = best_selectable_transaction_index(&remaining, &utxos, None) - .map(|index| SelectableItem::Plain(index, fee_rate_key(&remaining[index]))); - let best_blinded = - best_selectable_blinded_index(&remaining_blinded, &utxos).map(|index| { - SelectableItem::Blinded(index, blinded_fee_rate_key(&remaining_blinded[index])) - }); - let Some(item) = best_selectable_item(best_plain, best_blinded) else { + let Some(index) = best_selectable_transaction_index(&remaining, &utxos, None) else { break; }; - - match item { - SelectableItem::Plain(index, _) => { - let tx = remaining.remove(index); - if !zero_fee_transaction_is_selectable( - &tx, - required_burn_owner, - selected.iter().filter(|tx| tx.fee() == 0).count(), - next_height, - ) { - continue; - } - let mut candidate = BlockSelection { - transactions: selected.clone(), - blinded_transactions: selected_blinded.clone(), - }; - candidate.transactions.push(tx.clone()); - if estimated_block_selection_size_bytes( - &candidate, - required_burn_owner.is_some(), - reveal_bundle_section, - )? <= self.launch_profile.max_block_bytes - { - apply_transaction(&tx, &mut utxos)?; - selected.push(tx); - } - } - SelectableItem::Blinded(index, _) => { - let transaction = remaining_blinded.remove(index); - if next_height >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT && transaction.fee == 0 { - continue; - } - let mut candidate = BlockSelection { - transactions: selected.clone(), - blinded_transactions: selected_blinded.clone(), - }; - candidate.blinded_transactions.push(transaction.clone()); - if estimated_block_selection_size_bytes( - &candidate, - required_burn_owner.is_some(), - reveal_bundle_section, - )? <= self.launch_profile.max_block_bytes - { - spend_blinded_inputs(&transaction, &mut utxos)?; - selected_blinded.push(transaction); - } - } + let tx = remaining.remove(index); + let mut candidate = BlockSelection { + transactions: selected.clone(), + }; + candidate.transactions.push(tx.clone()); + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + burn_bundle_section, + )? <= self.launch_profile.max_block_bytes + { + apply_transaction(&tx, &mut utxos)?; + selected.push(tx); } } Ok(BlockSelection { transactions: selected, - blinded_transactions: selected_blinded, }) } + fn select_required_anchor_burn( + &self, + tx: Transaction, + required_burn_owner: Option<&str>, + burn_bundle_section: &BurnBundleSection, + utxos: &mut BTreeMap<OutPoint, TxOutput>, + selected: &mut Vec<Transaction>, + ) -> Result<()> { + if !tx.is_burn() { + bail!("required block anchor must be a burn transaction"); + } + if let Some(owner) = required_burn_owner { + if tx.sender() != owner { + bail!("required block anchor burn must be from the recovery finalizer"); + } + } + let mut candidate = BlockSelection { + transactions: selected.clone(), + }; + candidate.transactions.push(tx.clone()); + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + burn_bundle_section, + )? > self.launch_profile.max_block_bytes + { + bail!("required block anchor burn does not fit in the block"); + } + apply_transaction(&tx, utxos).context("required block anchor burn is not spendable")?; + selected.push(tx); + Ok(()) + } + pub(super) fn select_inputs( &self, address: &str, @@ -369,7 +357,7 @@ impl Ledger { return Ok(()); } let mut promoted = None; - let mut utxos = self.utxos_after_valid_pending_and_blinded()?; + let mut utxos = self.utxos_after_valid_pending()?; for (index, transaction) in self.orphans.iter().enumerate() { if transaction_inputs_spent_by(transaction, &self.pending) { continue; @@ -408,9 +396,13 @@ impl Ledger { Transaction::Transfer { inputs, outputs, + fee, signature, .. } => { + if *fee == 0 { + bail!("transfer transaction fee must be greater than zero"); + } validate_transaction_inputs(inputs)?; validate_transaction_outputs(outputs)?; validate_signature(signature, "transaction signature")?; @@ -418,9 +410,13 @@ impl Ledger { Transaction::Burn { inputs, change, + fee, signature, .. } => { + if *fee == 0 { + bail!("burn transaction fee must be greater than zero"); + } validate_transaction_inputs(inputs)?; validate_transaction_outputs(change)?; validate_signature(signature, "transaction signature")?; @@ -458,136 +454,6 @@ impl Ledger { Ok(()) } - pub(super) fn validate_blinded_transaction( - &self, - transaction: &BlindedTransaction, - ) -> Result<()> { - validate_hash(&transaction.commitment, "blinded transaction commitment")?; - validate_hash( - &transaction.payload_hash, - "blinded transaction payload hash", - )?; - decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce) - .context("invalid blinded transaction nonce")?; - let ciphertext = decode_hex(&transaction.ciphertext) - .context("invalid blinded transaction ciphertext")?; - if ciphertext.is_empty() { - bail!("blinded transaction ciphertext is empty"); - } - if ciphertext.len() != transaction.encrypted_size as usize { - bail!("blinded transaction encrypted size is invalid"); - } - if transaction.expires_at_height <= self.height() { - bail!("blinded transaction is expired"); - } - if transaction.expires_at_height - > self - .height() - .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS) - { - bail!("blinded transaction expiry is too far in the future"); - } - validate_transaction_inputs(&transaction.inputs)?; - let next_height = self.height().saturating_add(1); - if transaction.inputs.is_empty() && next_height >= BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT { - bail!( - "blinded transaction must lock visible inputs from height {}", - BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - ); - } - if next_height >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT && transaction.fee == 0 { - bail!( - "blinded transaction must pay a fee from height {}", - BLOCK_ITEM_FEES_REQUIRED_HEIGHT - ); - } - if transaction.inputs.is_empty() && transaction.fee > 0 { - bail!("blinded transaction with a fee must lock visible inputs"); - } - if !transaction.inputs.is_empty() { - verify_blinded_input_signatures(transaction)?; - } - let expected = blinded_transaction_commitment(transaction)?; - if transaction.commitment != expected { - bail!("blinded transaction commitment is invalid"); - } - ensure_blinded_transaction_fits_empty_block( - transaction, - self.launch_profile.max_block_bytes, - )?; - Ok(()) - } - - pub(super) fn validate_blinded_reveal_terms(&self, reveal: &BlindedReveal) -> Result<()> { - validate_hash(&reveal.commitment, "blinded reveal commitment")?; - decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?; - Ok(()) - } - - pub(super) fn valid_pending_blinded_transactions(&self) -> Vec<BlindedTransaction> { - let next_height = self.height().saturating_add(1); - self.pending_blinded - .iter() - .filter(|transaction| { - transaction.expires_at_height > next_height - && self.validate_blinded_transaction(transaction).is_ok() - }) - .cloned() - .collect() - } - - pub(super) fn valid_pending_blinded_reveals(&self) -> Vec<BlindedReveal> { - self.pending_reveals - .iter() - .filter(|reveal| self.pending_reveal_transaction(reveal).is_ok()) - .cloned() - .collect() - } - - pub(super) fn reveal_fee_order_key(&self, reveal: &BlindedReveal) -> (u128, Amount) { - let Some(active) = self.active_blinded.get(&reveal.commitment) else { - return (0, 0); - }; - let size = active.transaction.fee_rate_size_bytes(); - let rate = if size == 0 { - 0 - } else { - u128::from(active.transaction.fee) * 1_000_000 / size as u128 - }; - (rate, active.transaction.fee) - } - - pub(super) fn pending_reveal_transaction(&self, reveal: &BlindedReveal) -> Result<Transaction> { - self.validate_blinded_reveal_terms(reveal)?; - let active = self - .active_blinded - .get(&reveal.commitment) - .context("blinded reveal does not reference an active blinded transaction")?; - self.decrypt_active_blinded(active, reveal) - } - - pub(super) fn decrypt_active_blinded( - &self, - active: &ActiveBlindedTransaction, - reveal: &BlindedReveal, - ) -> Result<Transaction> { - if self.height() >= active.transaction.expires_at_height { - bail!("blinded transaction reveal is expired"); - } - let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?; - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee { - bail!("blinded transaction reveal fee does not match envelope"); - } - if !blinded_reveal_inputs_match(active, &transaction) { - bail!("blinded transaction reveal inputs do not match envelope"); - } - self.validate_transaction_terms(&transaction)?; - Ok(transaction) - } - pub(super) fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { let mut utxos = self.utxos.clone(); for pending in self.valid_pending_transactions() { @@ -596,16 +462,6 @@ impl Ledger { Ok(utxos) } - pub(super) fn utxos_after_valid_pending_and_blinded( - &self, - ) -> Result<BTreeMap<OutPoint, TxOutput>> { - let mut utxos = self.utxos_after_valid_pending()?; - for pending in self.valid_pending_blinded_transactions() { - spend_blinded_inputs(&pending, &mut utxos)?; - } - Ok(utxos) - } - pub(super) fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { let mut utxos = self.utxos.clone(); for pending in self.valid_pending_transactions() { @@ -616,40 +472,6 @@ impl Ledger { continue; } } - for pending in self.valid_pending_blinded_transactions() { - if spend_spendable_blinded_inputs(&pending, &mut utxos).is_err() { - continue; - } - } Ok(utxos) } } - -fn zero_fee_transaction_is_selectable( - transaction: &Transaction, - finalizer: Option<&str>, - selected_zero_fee_transactions: usize, - height: u64, -) -> bool { - if height < BLOCK_ITEM_FEES_REQUIRED_HEIGHT || transaction.fee() > 0 { - return true; - } - selected_zero_fee_transactions == 0 - && transaction.is_burn() - && finalizer.is_some_and(|owner| transaction.sender() == owner) -} - -fn remove_extra_zero_fee_transactions_after_anchor( - remaining: &mut Vec<Transaction>, - finalizer: Option<&str>, - height: u64, -) { - if height < BLOCK_ITEM_FEES_REQUIRED_HEIGHT { - return; - } - remaining.retain(|transaction| { - transaction.fee() > 0 - || !(transaction.is_burn() - && finalizer.is_some_and(|owner| transaction.sender() == owner)) - }); -} diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs @@ -1,7 +1,7 @@ use anyhow::{Result, bail}; use super::{ - FinalizerMode, Ledger, PreparedBlock, RECOVERY_BLOCK_DELAY_MS, RevealBundle, Wallet, + BurnBundle, FinalizerMode, Ledger, PreparedBlock, RECOVERY_BLOCK_DELAY_MS, Wallet, ensure_block_has_burn, ensure_block_has_burn_from, recovery_vdf_seed_for_child, run_vdf, ticket_block_min_timestamp, vdf_seed_for_child, }; @@ -20,28 +20,28 @@ impl Ledger { } pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { - self.prepare_next_block_with_reveal_bundles(miner, timestamp_ms, Vec::new()) + self.prepare_next_block_with_burn_bundles(miner, timestamp_ms, Vec::new()) } - pub fn prepare_next_block_with_reveal_bundles( + pub fn prepare_next_block_with_burn_bundles( &self, miner: &str, timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, + burn_bundles: Vec<BurnBundle>, ) -> Result<PreparedBlock> { - self.prepare_next_block_with_required_burn_and_reveal_bundles( + self.prepare_next_block_with_required_burn_and_burn_bundles( miner, timestamp_ms, - reveal_bundles, + burn_bundles, None, ) } - pub(crate) fn prepare_next_block_with_required_burn_and_reveal_bundles( + pub(crate) fn prepare_next_block_with_required_burn_and_burn_bundles( &self, miner: &str, timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, + burn_bundles: Vec<BurnBundle>, required_burn_signature: Option<&str>, ) -> Result<PreparedBlock> { let height = self.tip().height + 1; @@ -53,19 +53,19 @@ impl Ledger { bail!("no selected leader for block {height}"); } - let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; - let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); - let selection = self.select_block_transactions_with_reveal_section( + let burn_bundles = self.validate_next_block_burn_bundles(burn_bundles)?; + let burn_bundle_section = self.burn_bundle_section_from_bundles(burn_bundles); + let selection = self.select_block_transactions_with_burn_section( miner, required_burn_signature, - &reveal_bundle_section, + &burn_bundle_section, )?; ensure_block_has_burn(&selection.transactions)?; let tip = self.tip(); let prev_hash = tip.hash.clone(); let timestamp_ms = timestamp_ms.max(ticket_block_min_timestamp(tip, finalizer_rank)?); - let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash, miner); + let bundle_hashes = burn_bundle_section.burn_bundle_hashes(height, &prev_hash, miner); let vdf_seed = vdf_seed_for_child(&prev_hash, height, &bundle_hashes); Ok(PreparedBlock { height, @@ -74,13 +74,12 @@ impl Ledger { miner: miner.to_string(), finalizer_mode: FinalizerMode::Ticket, reward: self - .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?, + .expected_reward_for_next_block(&selection.transactions, &burn_bundle_section)?, vdf_rounds: self.vdf_rounds_for_finalizer_rank(finalizer_rank)?, vdf_seed, finalizer_rank, leader_ticket: Some(leader_ticket), - blinded_transactions: selection.blinded_transactions, - reveal_bundle_section, + burn_bundle_section, transactions: selection.transactions, }) } @@ -96,28 +95,28 @@ impl Ledger { } pub fn prepare_recovery_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { - self.prepare_recovery_block_with_reveal_bundles(miner, timestamp_ms, Vec::new()) + self.prepare_recovery_block_with_burn_bundles(miner, timestamp_ms, Vec::new()) } - pub fn prepare_recovery_block_with_reveal_bundles( + pub fn prepare_recovery_block_with_burn_bundles( &self, miner: &str, timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, + burn_bundles: Vec<BurnBundle>, ) -> Result<PreparedBlock> { - self.prepare_recovery_block_with_required_burn_and_reveal_bundles( + self.prepare_recovery_block_with_required_burn_and_burn_bundles( miner, timestamp_ms, - reveal_bundles, + burn_bundles, None, ) } - pub(crate) fn prepare_recovery_block_with_required_burn_and_reveal_bundles( + pub(crate) fn prepare_recovery_block_with_required_burn_and_burn_bundles( &self, miner: &str, timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, + burn_bundles: Vec<BurnBundle>, required_burn_signature: Option<&str>, ) -> Result<PreparedBlock> { let height = self.tip().height + 1; @@ -126,12 +125,12 @@ impl Ledger { bail!("recovery block is not available before timestamp {min_timestamp}"); } - let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; - let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); - let selection = self.select_recovery_block_transactions_with_reveal_section( + let burn_bundles = self.validate_next_block_burn_bundles(burn_bundles)?; + let burn_bundle_section = self.burn_bundle_section_from_bundles(burn_bundles); + let selection = self.select_recovery_block_transactions_with_burn_section( miner, required_burn_signature, - &reveal_bundle_section, + &burn_bundle_section, )?; ensure_block_has_burn(&selection.transactions)?; ensure_block_has_burn_from(&selection.transactions, miner)?; @@ -139,7 +138,7 @@ impl Ledger { let tip = self.tip(); let prev_hash = tip.hash.clone(); let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1); - let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash, miner); + let bundle_hashes = burn_bundle_section.burn_bundle_hashes(height, &prev_hash, miner); let vdf_seed = recovery_vdf_seed_for_child(&prev_hash, height, timestamp_ms, &bundle_hashes); Ok(PreparedBlock { @@ -150,12 +149,11 @@ impl Ledger { finalizer_mode: FinalizerMode::Recovery, finalizer_rank: 0, reward: self - .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?, + .expected_reward_for_next_block(&selection.transactions, &burn_bundle_section)?, vdf_rounds: self.recovery_vdf_rounds()?, vdf_seed, leader_ticket: None, - blinded_transactions: selection.blinded_transactions, - reveal_bundle_section, + burn_bundle_section, transactions: selection.transactions, }) } diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -1,11 +1,8 @@ use std::collections::{BTreeMap, BTreeSet}; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; -use super::blinded::{ - ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, decrypt_blinded_transaction, -}; use super::genesis::balances_from_utxos; use super::mine_policy::mine_anchor; use super::ticket::{ @@ -13,61 +10,23 @@ use super::ticket::{ tickets_created_by_block, tickets_created_by_transactions, }; use super::{ - Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, ChainStatus, - LaunchProfile, Ledger, OutPoint, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, - RevealCommitteeMember, RevealedBlindedTransaction, Transaction, TxOutput, - UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, UtxoLineageRoot, reveal_committee_slot_count, - reveal_committee_slot_count_for_height, + Amount, Block, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, ChainStatus, LaunchProfile, + Ledger, OutPoint, Transaction, TxOutput, UtxoLineageRoot, }; fn apply_historical_ticket_block( parent: &Block, block: &Block, - launch_profile: &LaunchProfile, + launch_profile: &super::LaunchProfile, tickets: &mut Vec<BurnTicket>, - active_blinded: &mut BTreeMap<String, ActiveBlindedTransaction>, ) -> Result<()> { apply_finalizer_ticket_effects(parent, block, tickets)?; tickets.extend(tickets_created_by_block(block, launch_profile)?); - let mut revealed_transactions = Vec::new(); - for reveal in block.all_blinded_reveals() { - let active = active_blinded.get(&reveal.commitment).with_context(|| { - format!( - "block {} reveals unknown blinded transaction {}", - block.height, reveal.commitment - ) - })?; - let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?; - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee { - bail!( - "block {} blinded reveal fee does not match envelope", - block.height - ); - } - revealed_transactions.push(transaction); - active_blinded.remove(&reveal.commitment); - } tickets.extend(tickets_created_by_transactions( block.height, - &revealed_transactions, + &block.transactions, launch_profile, )?); - active_blinded.retain(|_, active| block.height < active.transaction.expires_at_height); - for transaction in &block.blinded_transactions { - active_blinded.insert( - transaction.commitment.clone(), - ActiveBlindedTransaction { - transaction: transaction.clone(), - locked_outputs: Vec::new(), - locked_lineage_root: None, - included_height: block.height, - included_by: block.miner.clone(), - }, - ); - } Ok(()) } @@ -96,7 +55,7 @@ pub(super) fn select_weighted_lineage_index( return None; } let seed = format!( - "iuna-reveal-lineage-draw-v1:{target_height}:{}:{}:{slot}", + "iuna-burn-lineage-draw-v1:{target_height}:{}:{}:{slot}", parent.hash, parent.vdf_output ); let digest = Sha256::digest(seed.as_bytes()); @@ -142,9 +101,7 @@ impl Ledger { balances: include_balances .then(|| balances_from_utxos(&self.utxos)) .unwrap_or_default(), - pending_transactions: self.pending.len() - + self.pending_blinded.len() - + self.pending_reveals.len(), + pending_transactions: self.pending.len(), } } @@ -184,7 +141,6 @@ impl Ledger { &self.chain[0], &self.launch_profile, )?; - let mut active_blinded = BTreeMap::<String, ActiveBlindedTransaction>::new(); let mut next_block_index = 1; for height in requested { @@ -206,7 +162,6 @@ impl Ledger { block, &self.launch_profile, &mut tickets, - &mut active_blinded, )?; next_block_index += 1; } @@ -231,77 +186,28 @@ impl Ledger { Ok(ranks_by_height) } - pub fn reveal_committee_for_next_block(&self) -> Vec<RevealCommitteeMember> { - self.reveal_committee_for_height(self.tip().height + 1) + pub fn burn_committee_for_next_block(&self) -> Vec<BurnCommitteeMember> { + self.burn_committee_for_height(self.tip().height + 1) } - pub fn reveal_committee_for_height(&self, height: u64) -> Vec<RevealCommitteeMember> { + pub fn burn_committee_for_height(&self, height: u64) -> Vec<BurnCommitteeMember> { let ranked = ranked_tickets_for_height(self.tip(), height, &self.tickets); - if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT { - return self.lineage_reveal_committee_for_height(height, ranked); - } - let selected = if height < UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT { - let mut selected = Vec::new(); - if !ranked.is_empty() { - selected.push(0); - } - for index in (0..ranked.len()).rev() { - if selected.len() >= reveal_committee_slot_count(ranked.len()) { - break; - } - if !selected.contains(&index) { - selected.push(index); - } - } - selected - } else { - let target_slots = reveal_committee_slot_count_for_height( - height, - ranked.len(), - ranked.iter().map(|ticket| ticket.owner.as_str()), - ); - let mut seen_owners = BTreeSet::new(); - let mut selected = Vec::new(); - for (index, ticket) in ranked.iter().enumerate() { - if selected.len() >= target_slots { - break; - } - if seen_owners.insert(ticket.owner.clone()) { - selected.push(index); - } - } - selected - }; - selected - .into_iter() - .enumerate() - .filter_map(|(slot, rank)| { - let ticket = ranked.get(rank)?.clone(); - Some(RevealCommitteeMember { - slot: u8::try_from(slot).ok()?, - rank: u32::try_from(rank).ok()?, - ticket_id: ticket.id, - owner: ticket.owner, - amount: ticket.amount, - }) - }) - .collect() + self.lineage_burn_committee_for_height(height, ranked) } - fn lineage_reveal_committee_for_height( + fn lineage_burn_committee_for_height( &self, height: u64, ranked: Vec<BurnTicket>, - ) -> Vec<RevealCommitteeMember> { + ) -> Vec<BurnCommitteeMember> { let Some(finalizer) = ranked.first() else { return Vec::new(); }; - let mut committee = vec![RevealCommitteeMember { + let mut committee = vec![BurnCommitteeMember { slot: 0, - rank: 0, - ticket_id: finalizer.id.clone(), + root: finalizer.id.clone(), owner: finalizer.owner.clone(), - amount: finalizer.amount, + weight: finalizer.amount, }]; let mut skipped_owners = BTreeSet::from([finalizer.owner.clone()]); let mut remaining = self @@ -319,7 +225,7 @@ impl Ledger { }) .collect::<Vec<_>>(); - for slot in 1..super::REVEAL_COMMITTEE_SIZE { + for slot in 1..super::BURN_COMMITTEE_SIZE { let Some(index) = select_weighted_lineage_index(self.tip(), height, slot as u8, &remaining) else { @@ -327,12 +233,11 @@ impl Ledger { }; let selected = remaining.remove(index); skipped_owners.insert(selected.owner.clone()); - committee.push(RevealCommitteeMember { + committee.push(BurnCommitteeMember { slot: slot as u8, - rank: slot as u32, - ticket_id: selected.root.outpoint.id(), + root: selected.root.outpoint.id(), owner: selected.owner, - amount: selected.value, + weight: selected.value, }); remaining.retain(|candidate| { candidate.root != selected.root @@ -358,7 +263,7 @@ impl Ledger { **value > 0 && root .height - .saturating_add(super::REVEAL_LINEAGE_MATURITY_HEIGHTS) + .saturating_add(super::BURN_LINEAGE_MATURITY_HEIGHTS) <= parent_height && !self.lineage_root_has_owner(root, finalizer) }) @@ -459,53 +364,6 @@ impl Ledger { &self.pending } - pub fn pending_blinded_transactions(&self) -> &[BlindedTransaction] { - &self.pending_blinded - } - - pub fn pending_blinded_reveals(&self) -> &[BlindedReveal] { - &self.pending_reveals - } - - pub fn pending_revealed_blinded_transactions(&self) -> Vec<RevealedBlindedTransaction> { - self.pending_reveals - .iter() - .filter_map(|reveal| { - let active = self.active_blinded.get(&reveal.commitment)?; - let transaction = self.pending_reveal_transaction(reveal).ok()?; - Some(RevealedBlindedTransaction { - height: self.height().saturating_add(1), - commitment: reveal.commitment.clone(), - included_by: active.included_by.clone(), - transaction, - }) - }) - .collect() - } - - pub(crate) fn drop_pending_blinded_conflicting_with_transaction( - &mut self, - transaction: &Transaction, - ) { - let spent = transaction - .inputs() - .iter() - .map(|input| input.outpoint.clone()) - .collect::<BTreeSet<_>>(); - self.pending_blinded.retain(|blinded| { - !blinded - .inputs - .iter() - .any(|input| spent.contains(&input.outpoint)) - }); - let _ = self.refresh_pending_pool_byte_counters(); - } - - pub(crate) fn clear_pending_blinded_transactions(&mut self) { - self.pending_blinded.clear(); - self.pending_blinded_bytes = 0; - } - pub(crate) fn clear_pending_transactions(&mut self) { self.pending.clear(); self.pending_bytes = 0; @@ -539,42 +397,6 @@ impl Ledger { .count() } - pub fn has_blinded_transaction(&self, commitment: &str) -> bool { - self.pending_blinded - .iter() - .any(|transaction| transaction.commitment == commitment) - || self.active_blinded.contains_key(commitment) - || self.chain.iter().any(|block| { - block - .blinded_transactions - .iter() - .any(|tx| tx.commitment == commitment) - }) - } - - pub fn has_unrevealed_blinded_transaction(&self, commitment: &str) -> bool { - self.pending_blinded - .iter() - .any(|transaction| transaction.commitment == commitment) - || self.active_blinded.contains_key(commitment) - } - - pub fn has_active_blinded_transaction(&self, commitment: &str) -> bool { - self.active_blinded.contains_key(commitment) - } - - pub fn has_blinded_reveal(&self, commitment: &str) -> bool { - self.pending_reveals - .iter() - .any(|reveal| reveal.commitment == commitment) - || self.chain.iter().any(|block| { - block - .all_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == commitment) - }) - } - pub fn vdf_rounds(&self) -> u64 { self.vdf_rounds } diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -3,142 +3,145 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; use super::ledger_ops::verify_address_signature; -use super::reveal::{reveal_bundle_slot_mask, reveal_committee_mask}; +use super::reveal::{burn_bundle_slot_mask, burn_committee_mask}; use super::{ - Amount, FinalizerMode, Ledger, MAX_REVEAL_BUNDLE_BYTES, MaskedBlindedReveal, - REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, REVEAL_COMMITTEE_SIZE, RevealBundle, - RevealBundlePayload, RevealBundleSection, RevealBundleSignature, Wallet, + Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection, + BurnBundleSignature, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES, MaskedBurn, Transaction, + Wallet, }; impl Ledger { - pub fn reveal_bundle_attestations_required_for_next_block(&self) -> bool { - self.tip().height.saturating_add(1) >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - && !self.active_blinded.is_empty() + pub fn burn_bundle_attestations_required_for_next_block(&self) -> bool { + self.pending.iter().any(Transaction::is_burn) } - pub fn build_reveal_bundle(&self, wallet: &Wallet) -> Result<Option<RevealBundle>> { + pub fn explicit_burn_bundle_signatures_required_for_next_block( + &self, + finalizer_mode: FinalizerMode, + finalizer_rank: u32, + finalizer: &str, + ) -> usize { + self.required_explicit_burn_signatures( + finalizer_mode, + finalizer_rank, + self.burn_committee_for_next_block().len(), + transactions_have_attestable_burns(&self.pending, finalizer), + ) + } + + pub fn build_burn_bundle(&self, wallet: &Wallet) -> Result<Option<BurnBundle>> { let height = self.tip().height + 1; let prev_hash = self.tip().hash.clone(); let Some(member) = self - .reveal_committee_for_next_block() + .burn_committee_for_next_block() .into_iter() .find(|member| member.owner == wallet.address()) else { return Ok(None); }; - let mut reveals = self.valid_pending_blinded_reveals(); - reveals.sort_by(|left, right| { - self.reveal_fee_order_key(right) - .cmp(&self.reveal_fee_order_key(left)) - .then_with(|| left.commitment.cmp(&right.commitment)) + let mut burns = self + .valid_pending_transactions() + .into_iter() + .filter(Transaction::is_burn) + .collect::<Vec<_>>(); + burns.sort_by(|left, right| { + right + .fee() + .cmp(&left.fee()) + .then_with(|| left.signature().cmp(right.signature())) }); let mut selected = Vec::new(); - for reveal in reveals { + for burn in burns { let mut candidate = selected.clone(); - candidate.push(reveal); - let bundle = wallet.reveal_bundle(RevealBundlePayload { + candidate.push(burn); + let bundle = wallet.burn_bundle(BurnBundlePayload { height, prev_hash: prev_hash.clone(), slot: member.slot, member: wallet.address().to_string(), - reveals: candidate.clone(), + burns: candidate.clone(), }); - if bundle.serialized_size_bytes()? <= MAX_REVEAL_BUNDLE_BYTES { + if bundle.serialized_size_bytes()? <= MAX_BURN_BUNDLE_BYTES { selected = candidate; } } - if selected.is_empty() && !self.reveal_bundle_attestations_required_for_next_block() { + if selected.is_empty() && !self.burn_bundle_attestations_required_for_next_block() { return Ok(None); } - Ok(Some(wallet.reveal_bundle(RevealBundlePayload { + Ok(Some(wallet.burn_bundle(BurnBundlePayload { height, prev_hash, slot: member.slot, member: wallet.address().to_string(), - reveals: selected, + burns: selected, }))) } - pub fn validate_next_block_reveal_bundles( + pub fn validate_next_block_burn_bundles( &self, - bundles: Vec<RevealBundle>, - ) -> Result<Vec<RevealBundle>> { + bundles: Vec<BurnBundle>, + ) -> Result<Vec<BurnBundle>> { let expected_height = self.tip().height + 1; let expected_prev_hash = self.tip().hash.clone(); - self.validate_reveal_bundles_for_block(expected_height, &expected_prev_hash, bundles) + self.validate_burn_bundles_for_block(expected_height, &expected_prev_hash, bundles) } - pub(super) fn reveal_bundle_section_from_bundles( + pub(super) fn burn_bundle_section_from_bundles( &self, - bundles: Vec<RevealBundle>, - ) -> RevealBundleSection { - let height = self.tip().height + 1; + bundles: Vec<BurnBundle>, + ) -> BurnBundleSection { let signatures = bundles .iter() - .filter(|bundle| height < REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT || bundle.slot != 0) - .map(|bundle| RevealBundleSignature { + .filter(|bundle| bundle.slot != 0) + .map(|bundle| BurnBundleSignature { slot: bundle.slot, member: bundle.member.clone(), signature: bundle.signature.clone(), }) .collect::<Vec<_>>(); - let mut by_commitment: BTreeMap<String, MaskedBlindedReveal> = BTreeMap::new(); + let mut by_signature: BTreeMap<String, MaskedBurn> = BTreeMap::new(); for bundle in bundles { - let slot_mask = - if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT && bundle.slot == 0 { - 0 - } else { - reveal_bundle_slot_mask(bundle.slot).unwrap_or(0) - }; - for reveal in bundle.reveals { - by_commitment - .entry(reveal.commitment.clone()) + let slot_mask = if bundle.slot == 0 { + 0 + } else { + burn_bundle_slot_mask(bundle.slot).unwrap_or(0) + }; + for burn in bundle.burns { + by_signature + .entry(burn.signature().to_string()) .and_modify(|masked| masked.bundle_mask |= slot_mask) - .or_insert(MaskedBlindedReveal { - reveal, + .or_insert(MaskedBurn { + burn, bundle_mask: slot_mask, }); } } - let mut reveals = by_commitment.into_values().collect::<Vec<_>>(); - reveals.sort_by(|left, right| { - self.reveal_fee_order_key(&right.reveal) - .cmp(&self.reveal_fee_order_key(&left.reveal)) - .then_with(|| left.reveal.commitment.cmp(&right.reveal.commitment)) + let mut burns = by_signature.into_values().collect::<Vec<_>>(); + burns.sort_by(|left, right| { + right + .burn + .fee() + .cmp(&left.burn.fee()) + .then_with(|| left.burn.signature().cmp(right.burn.signature())) }); - RevealBundleSection { - signatures, - reveals, - } + BurnBundleSection { signatures, burns } } - pub(super) fn validate_reveal_bundle_section_for_block( - &self, - expected_height: u64, - expected_prev_hash: &str, - finalizer_mode: FinalizerMode, - finalizer_rank: u32, - section: &RevealBundleSection, - ) -> Result<()> { - let explicit_signature_limit = - if expected_height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT { - REVEAL_COMMITTEE_SIZE.saturating_sub(1) - } else { - REVEAL_COMMITTEE_SIZE - }; - if section.signatures.len() > explicit_signature_limit { - bail!("block has too many reveal bundle signatures"); + pub(super) fn validate_burn_bundle_section_for_block(&self, block: &Block) -> Result<()> { + let section = &block.burn_bundle_section; + if section.signatures.len() > BURN_COMMITTEE_SIZE.saturating_sub(1) { + bail!("block has too many burn bundle signatures"); } if section .signatures .windows(2) .any(|pair| pair[0].slot >= pair[1].slot) { - bail!("reveal bundle signatures are not in slot order"); + bail!("burn bundle signatures are not in slot order"); } let committee = self - .reveal_committee_for_height(expected_height) + .burn_committee_for_height(block.height) .into_iter() .map(|member| (member.slot, member)) .collect::<BTreeMap<_, _>>(); @@ -146,174 +149,165 @@ impl Ledger { let mut seen_members = BTreeSet::new(); let mut included_mask = 0_u8; for signature in &section.signatures { - if usize::from(signature.slot) >= REVEAL_COMMITTEE_SIZE { - bail!("reveal bundle slot is invalid"); + if usize::from(signature.slot) >= BURN_COMMITTEE_SIZE { + bail!("burn bundle slot is invalid"); } - if expected_height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT && signature.slot == 0 { - bail!("finalizer reveal attestation must be implicit from height 1500"); + if signature.slot == 0 { + bail!("finalizer burn attestation is implicit"); } if !seen_slots.insert(signature.slot) { - bail!("duplicate reveal bundle slot"); + bail!("duplicate burn bundle slot"); } if !seen_members.insert(signature.member.clone()) { - bail!("duplicate reveal bundle member"); + bail!("duplicate burn bundle member"); } let member = committee .get(&signature.slot) - .context("reveal bundle slot is not assigned")?; + .context("burn bundle slot is not assigned")?; if signature.member != member.owner { - bail!("reveal bundle member is not assigned to slot"); + bail!("burn bundle member is not assigned to slot"); } - included_mask |= reveal_bundle_slot_mask(signature.slot)?; + included_mask |= burn_bundle_slot_mask(signature.slot)?; } - let required_signatures = self.required_reveal_bundle_signatures( - expected_height, - finalizer_mode, - finalizer_rank, + let required_signatures = self.required_explicit_burn_signatures( + block.finalizer_mode, + block.finalizer_rank, committee.len(), + transactions_have_attestable_burns(&block.transactions, &block.miner), ); if section.signatures.len() < required_signatures { bail!( - "block has too few reveal bundle signatures: got {}, need {required_signatures}", + "block has too few burn bundle signatures: got {}, need {required_signatures}", section.signatures.len() ); } - let mut seen_reveals = BTreeSet::new(); - let mut previous_key: Option<((u128, Amount), String)> = None; - for masked in &section.reveals { - if masked.bundle_mask == 0 - && expected_height < REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - { - bail!("masked blinded reveal is not assigned to a reveal bundle"); - } - if masked.bundle_mask & !reveal_committee_mask() != 0 { - bail!("masked blinded reveal references an invalid reveal bundle slot"); + let mut seen_burns = BTreeSet::new(); + let mut previous_key: Option<(Amount, String)> = None; + for masked in &section.burns { + if masked.bundle_mask & !burn_committee_mask() != 0 { + bail!("masked burn references an invalid burn bundle slot"); } if masked.bundle_mask & !included_mask != 0 { - bail!("masked blinded reveal references a missing reveal bundle signature"); + bail!("masked burn references a missing burn bundle signature"); } - if !seen_reveals.insert(masked.reveal.commitment.clone()) { - bail!("duplicate blinded reveal in reveal bundle section"); + if !seen_burns.insert(masked.burn.signature().to_string()) { + bail!("duplicate burn in burn bundle section"); } - self.pending_reveal_transaction(&masked.reveal)?; - let key = ( - self.reveal_fee_order_key(&masked.reveal), - masked.reveal.commitment.clone(), - ); - if let Some((previous_fee_key, previous_commitment)) = &previous_key { - if key.0 > *previous_fee_key - || key.0 == *previous_fee_key && key.1 < *previous_commitment - { - bail!("reveal bundle section is not fee ordered"); + if !masked.burn.is_burn() { + bail!("burn bundle section contains a non-burn transaction"); + } + if matching_burn_by_signature(&masked.burn, &block.transactions).is_none() { + bail!("attested burn is not included in the block"); + } + self.validate_transaction_terms(&masked.burn)?; + let key = (masked.burn.fee(), masked.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 { + bail!("burn bundle section is not fee ordered"); } } previous_key = Some(key); } - for bundle in section.expand(expected_height, expected_prev_hash) { - if bundle.serialized_size_bytes()? > MAX_REVEAL_BUNDLE_BYTES { - bail!("reveal bundle exceeds max size"); + for bundle in section.expand(block.height, &block.prev_hash) { + if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { + bail!("burn bundle exceeds max size"); } verify_address_signature( &bundle.member, &bundle.canonical_payload(), &bundle.signature, - "reveal bundle", + "burn bundle", )?; } Ok(()) } - fn required_reveal_bundle_signatures( + fn required_explicit_burn_signatures( &self, - height: u64, finalizer_mode: FinalizerMode, finalizer_rank: u32, committee_size: usize, + has_attested_burns: bool, ) -> usize { - if height < REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - || self.active_blinded.is_empty() - || committee_size == 0 - { + if !has_attested_burns || committee_size == 0 { return 0; } match finalizer_mode { - FinalizerMode::Ticket if finalizer_rank == 0 => { - required_explicit_reveal_signatures(height, committee_size) - } - FinalizerMode::Ticket if finalizer_rank == 1 => { - required_explicit_reveal_signatures(height, committee_size.min(2)) - } - FinalizerMode::Ticket => required_explicit_reveal_signatures(height, 1), + FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.saturating_sub(1), + FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.saturating_sub(2), + FinalizerMode::Ticket => 0, FinalizerMode::Recovery => 0, } } - fn validate_reveal_bundles_for_block( + fn validate_burn_bundles_for_block( &self, expected_height: u64, expected_prev_hash: &str, - mut bundles: Vec<RevealBundle>, - ) -> Result<Vec<RevealBundle>> { - if bundles.len() > REVEAL_COMMITTEE_SIZE { - bail!("block has too many reveal bundles"); - } - if bundles.windows(2).any(|pair| pair[0].slot >= pair[1].slot) { - bail!("reveal bundles are not in slot order"); + mut bundles: Vec<BurnBundle>, + ) -> Result<Vec<BurnBundle>> { + if bundles.len() > BURN_COMMITTEE_SIZE { + bail!("block has too many burn bundles"); } bundles.sort_by_key(|bundle| bundle.slot); + if bundles.windows(2).any(|pair| pair[0].slot == pair[1].slot) { + bail!("duplicate burn bundle slot"); + } let committee = self - .reveal_committee_for_height(expected_height) + .burn_committee_for_height(expected_height) .into_iter() .map(|member| (member.slot, member)) .collect::<BTreeMap<_, _>>(); - let mut seen_slots = BTreeSet::new(); let mut seen_members = BTreeSet::new(); for bundle in &bundles { if bundle.height != expected_height { - bail!("reveal bundle height is invalid"); + bail!("burn bundle height is invalid"); } if bundle.prev_hash != expected_prev_hash { - bail!("reveal bundle parent hash is invalid"); + bail!("burn bundle parent hash is invalid"); } - if usize::from(bundle.slot) >= REVEAL_COMMITTEE_SIZE { - bail!("reveal bundle slot is invalid"); - } - if !seen_slots.insert(bundle.slot) { - bail!("duplicate reveal bundle slot"); + if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE { + bail!("burn bundle slot is invalid"); } if !seen_members.insert(bundle.member.clone()) { - bail!("duplicate reveal bundle member"); + bail!("duplicate burn bundle member"); } let member = committee .get(&bundle.slot) - .context("reveal bundle slot is not assigned")?; + .context("burn bundle slot is not assigned")?; if bundle.member != member.owner { - bail!("reveal bundle member is not assigned to slot"); + bail!("burn bundle member is not assigned to slot"); } - if bundle.serialized_size_bytes()? > MAX_REVEAL_BUNDLE_BYTES { - bail!("reveal bundle exceeds max size"); + if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { + bail!("burn bundle exceeds max size"); } verify_address_signature( &bundle.member, &bundle.canonical_payload(), &bundle.signature, - "reveal bundle", + "burn bundle", )?; - let mut seen_bundle_reveals = BTreeSet::new(); - let mut previous_key: Option<((u128, Amount), String)> = None; - for reveal in &bundle.reveals { - if !seen_bundle_reveals.insert(reveal.commitment.clone()) { - bail!("duplicate blinded reveal in reveal bundle"); + let mut seen_bundle_burns = BTreeSet::new(); + let mut previous_key: Option<(Amount, String)> = None; + for burn in &bundle.burns { + if !seen_bundle_burns.insert(burn.signature().to_string()) { + bail!("duplicate burn in burn bundle"); + } + if !burn.is_burn() { + bail!("burn bundle contains a non-burn transaction"); } - self.pending_reveal_transaction(reveal)?; - let key = (self.reveal_fee_order_key(reveal), reveal.commitment.clone()); - if let Some((previous_fee_key, previous_commitment)) = &previous_key { - if key.0 > *previous_fee_key - || key.0 == *previous_fee_key && key.1 < *previous_commitment + if matching_burn_by_signature(burn, &self.pending).is_none() { + bail!("burn bundle references a burn that is not in the mempool"); + } + self.validate_transaction_terms(burn)?; + 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 { - bail!("reveal bundle is not fee ordered"); + bail!("burn bundle is not fee ordered"); } } previous_key = Some(key); @@ -323,10 +317,177 @@ impl Ledger { } } -fn required_explicit_reveal_signatures(height: u64, attestations: usize) -> usize { - if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT { - attestations.saturating_sub(1) - } else { - attestations +fn matching_burn_by_signature<'a>( + attested: &Transaction, + transactions: &'a [Transaction], +) -> Option<&'a Transaction> { + transactions.iter().find(|transaction| { + transaction.is_burn() + && transaction.signature() == attested.signature() + && transaction.canonical() == attested.canonical() + }) +} + +fn transactions_have_attestable_burns(transactions: &[Transaction], finalizer: &str) -> bool { + let mut finalizer_anchor_seen = false; + for transaction in transactions { + if !transaction.is_burn() { + continue; + } + if transaction.sender() == finalizer && !finalizer_anchor_seen { + finalizer_anchor_seen = true; + continue; + } + return true; + } + false +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::domain::{GenesisBurn, MICRO_IUNA, OutPoint, TxInput, TxOutput, Wallet}; + + fn ledger() -> Ledger { + Ledger::new(BTreeMap::new(), 1) + } + + fn burn(signature: &str, fee: Amount) -> Transaction { + Transaction::Burn { + inputs: vec![TxInput { + outpoint: OutPoint { + txid: format!("{signature:0<64}"), + index: 0, + }, + owner: "owner".to_string(), + signature: signature.to_string(), + }], + change: vec![TxOutput { + address: "owner".to_string(), + amount: 1, + }], + amount: 1, + fee, + signature: signature.to_string(), + } + } + + fn funded_ledger(wallets: &[Wallet]) -> Ledger { + let allocations = wallets + .iter() + .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA)) + .collect::<BTreeMap<_, _>>(); + let genesis_burns = wallets + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect::<Vec<_>>(); + Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap() + } + + #[test] + fn burn_quorum_depends_on_rank_and_included_burns() { + let ledger = ledger(); + + assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 3, true), + 2 + ); + assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 1, 3, true), + 1 + ); + assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 2, 3, true), + 0 + ); + assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Recovery, 0, 3, true), + 0 + ); + assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 3, false), + 0 + ); + } + + #[test] + fn burn_bundle_section_deduplicates_burns_and_tracks_member_masks() { + let ledger = ledger(); + let high_fee_burn = burn("a", 10); + let low_fee_burn = burn("b", 1); + let bundles = vec![ + BurnBundle { + height: 1, + prev_hash: "parent".to_string(), + slot: 1, + member: "member-1".to_string(), + burns: vec![low_fee_burn.clone(), high_fee_burn.clone()], + signature: "sig-1".to_string(), + }, + BurnBundle { + height: 1, + prev_hash: "parent".to_string(), + slot: 2, + member: "member-2".to_string(), + burns: vec![high_fee_burn.clone()], + signature: "sig-2".to_string(), + }, + ]; + + let section = ledger.burn_bundle_section_from_bundles(bundles); + + assert_eq!(section.signatures.len(), 2); + assert_eq!(section.burns.len(), 2); + assert_eq!(section.burns[0].burn.signature(), high_fee_burn.signature()); + assert_eq!( + section.burns[0].bundle_mask, + burn_bundle_slot_mask(1).unwrap() | burn_bundle_slot_mask(2).unwrap() + ); + assert_eq!(section.burns[1].burn.signature(), low_fee_burn.signature()); + assert_eq!( + section.burns[1].bundle_mask, + burn_bundle_slot_mask(1).unwrap() + ); + + let expanded = section.expand(1, "parent"); + assert_eq!(expanded[0].burns, vec![high_fee_burn.clone(), low_fee_burn]); + assert_eq!(expanded[1].burns, vec![high_fee_burn]); + } + + #[test] + fn burn_bundle_rejects_different_burn_with_same_signature() { + let alice = Wallet::from_seed("bundle-match-alice"); + let bob = Wallet::from_seed("bundle-match-bob"); + let mut ledger = funded_ledger(&[alice.clone(), bob.clone()]); + let pending_burn = ledger.build_burn(&bob, 1, 1).unwrap(); + ledger.submit_transaction(pending_burn.clone()).unwrap(); + let member = ledger + .burn_committee_for_next_block() + .into_iter() + .find(|member| member.owner == alice.address()) + .expect("alice should be in the burn committee"); + let mut attested_burn = pending_burn.clone(); + if let Transaction::Burn { amount, .. } = &mut attested_burn { + *amount += 1; + } + let bundle = alice.burn_bundle(BurnBundlePayload { + height: ledger.height() + 1, + prev_hash: ledger.tip_hash().to_string(), + slot: member.slot, + member: alice.address().to_string(), + burns: vec![attested_burn], + }); + + let error = ledger + .validate_next_block_burn_bundles(vec![bundle]) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("burn bundle references a burn that is not in the mempool") + ); } } diff --git a/src/domain/ledger_state.rs b/src/domain/ledger_state.rs @@ -4,8 +4,8 @@ use std::{ }; use super::{ - ActiveBlindedTransaction, Amount, BlindedReveal, BlindedTransaction, Block, BurnTicket, - LaunchProfile, LineageOwnerValues, OutPoint, Transaction, TxOutput, UtxoLineageRoot, + Amount, Block, BurnTicket, LaunchProfile, LineageOwnerValues, OutPoint, Transaction, TxOutput, + UtxoLineageRoot, }; #[derive(Clone, Debug)] @@ -19,13 +19,8 @@ pub struct Ledger { pub(super) tickets: Vec<BurnTicket>, pub(super) pending: Vec<Transaction>, pub(super) orphans: Vec<Transaction>, - pub(super) pending_blinded: Vec<BlindedTransaction>, - pub(super) pending_reveals: Vec<BlindedReveal>, pub(super) pending_bytes: usize, pub(super) orphan_bytes: usize, - pub(super) pending_blinded_bytes: usize, - pub(super) pending_reveal_bytes: usize, - pub(super) active_blinded: BTreeMap<String, ActiveBlindedTransaction>, pub(super) mine_reward: Amount, pub(super) initial_vdf_rounds: u64, pub(super) vdf_rounds: u64, diff --git a/src/domain/mine_policy.rs b/src/domain/mine_policy.rs @@ -10,12 +10,11 @@ pub(super) const MINE_MIN_DIFFICULTY_BITS: u32 = 10; pub(super) const MINE_MAX_ANCHOR_AGE_BLOCKS: u64 = MINE_RETARGET_WINDOW_BLOCKS; const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; -const MINE_MAX_DIFFICULTY_BITS: u32 = 32; pub(super) fn retarget_mine_difficulty_bits(current: u32, mine_actions: u64) -> u32 { let target = MINE_RETARGET_WINDOW_BLOCKS.saturating_mul(MINE_TARGET_ACTIONS_PER_BLOCK); if target == 0 || mine_actions == target { - return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); + return current.max(MINE_MIN_DIFFICULTY_BITS); } let step = if mine_actions > target { @@ -27,16 +26,12 @@ pub(super) fn retarget_mine_difficulty_bits(current: u32, mine_actions: u64) -> }; if step == 0 { - return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); + return current.max(MINE_MIN_DIFFICULTY_BITS); } if mine_actions > target { - current - .saturating_add(step) - .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) + current.saturating_add(step).max(MINE_MIN_DIFFICULTY_BITS) } else { - current - .saturating_sub(step) - .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) + current.saturating_sub(step).max(MINE_MIN_DIFFICULTY_BITS) } } @@ -95,7 +90,7 @@ mod tests { use crate::domain::MINE_DIFFICULTY_BITS; #[test] - fn retarget_bounds_match_protocol_constants() { + fn retarget_keeps_protocol_minimum() { assert_eq!( retarget_mine_difficulty_bits(MINE_DIFFICULTY_BITS, 0), MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS @@ -105,4 +100,10 @@ mod tests { MINE_MIN_DIFFICULTY_BITS ); } + + #[test] + fn retarget_does_not_cap_difficulty_at_32_bits() { + assert_eq!(retarget_mine_difficulty_bits(33, 20), 34); + assert_eq!(retarget_mine_difficulty_bits(40, 10), 40); + } } diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -13,19 +13,9 @@ pub const RECOVERY_BLOCK_DELAY_MS: u64 = VDF_TARGET_BLOCK_MS * 6; pub const MAX_VDF_ROUNDS: u64 = i64::MAX as u64; pub const MINE_DIFFICULTY_BITS: u32 = 12; pub const MINE_ACTIONS_PER_ANCHOR_LIMIT: usize = 2; -pub const MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20; -pub const REVEAL_COMMITTEE_SIZE: usize = 3; -pub const UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT: u64 = 500; -pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000; -pub const REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT: u64 = 1_500; -pub const REVEAL_LINEAGE_MATURITY_HEIGHTS: u64 = 20; -pub const BLINDED_FEE_BPS_DENOMINATOR: u64 = 10_000; -pub const BLINDED_COMMITTER_FEE_BPS: u64 = 3_500; -pub const BLINDED_REVEAL_FINALIZER_FEE_BPS: u64 = 3_500; -pub const BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS: u64 = 1_000; -pub const REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT: u64 = 750; -pub const BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT: u64 = 750; -pub const BLOCK_ITEM_FEES_REQUIRED_HEIGHT: u64 = 750; +pub const BURN_COMMITTEE_SIZE: usize = 3; +pub const MAX_BURN_BUNDLE_BYTES: usize = 10_000; +pub const BURN_LINEAGE_MATURITY_HEIGHTS: u64 = 20; pub const MAX_PENDING_TRANSACTIONS: usize = 10_000; pub(super) const MAX_PENDING_POOL_BYTES: usize = 8 * 1024 * 1024; @@ -39,8 +29,6 @@ pub(super) const FORK_FINALITY_DEPTH: u64 = 6; pub(super) const PUBLIC_KEY_BYTES: usize = 32; pub(super) const HASH_BYTES: usize = 32; pub(super) const SIGNATURE_BYTES: usize = 64; -pub(super) const BLINDED_KEY_BYTES: usize = 32; -pub(super) const BLINDED_NONCE_BYTES: usize = 12; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TransactionSubmitOutcome { diff --git a/src/domain/reveal.rs b/src/domain/reveal.rs @@ -1,30 +1,27 @@ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use super::{ - Amount, BlindedReveal, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, REVEAL_COMMITTEE_SIZE, - hex_hash, -}; +use super::{Amount, BURN_COMMITTEE_SIZE, Transaction, hex_hash}; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct RevealBundle { +pub struct BurnBundle { pub height: u64, pub prev_hash: String, pub slot: u8, pub member: String, - pub reveals: Vec<BlindedReveal>, + pub burns: Vec<Transaction>, pub signature: String, } -impl RevealBundle { +impl BurnBundle { pub fn canonical_payload(&self) -> String { - RevealBundlePayload { + BurnBundlePayload { height: self.height, prev_hash: self.prev_hash.clone(), slot: self.slot, member: self.member.clone(), - reveals: self.reveals.clone(), + burns: self.burns.clone(), } .canonical() } @@ -40,13 +37,13 @@ impl RevealBundle { pub fn serialized_size_bytes(&self) -> Result<usize> { serde_json::to_vec(self) .map(|bytes| bytes.len()) - .context("failed to serialize reveal bundle for size check") + .context("failed to serialize burn bundle for size check") } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct RevealBundleSignature { +pub struct BurnBundleSignature { pub slot: u8, pub member: String, pub signature: String, @@ -54,67 +51,65 @@ pub struct RevealBundleSignature { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct MaskedBlindedReveal { - pub reveal: BlindedReveal, +pub struct MaskedBurn { + pub burn: Transaction, pub bundle_mask: u8, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct RevealBundleSection { +pub struct BurnBundleSection { #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub signatures: Vec<RevealBundleSignature>, + pub signatures: Vec<BurnBundleSignature>, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub reveals: Vec<MaskedBlindedReveal>, + pub burns: Vec<MaskedBurn>, } -impl RevealBundleSection { +impl BurnBundleSection { pub fn is_empty(&self) -> bool { - self.signatures.is_empty() && self.reveals.is_empty() + self.signatures.is_empty() && self.burns.is_empty() } - pub fn all_reveals(&self) -> Vec<&BlindedReveal> { - self.reveals.iter().map(|masked| &masked.reveal).collect() + pub fn required_burns(&self) -> Vec<&Transaction> { + self.burns.iter().map(|masked| &masked.burn).collect() } pub fn included_bundle_count(&self) -> usize { self.signatures.len() } - pub fn expand(&self, height: u64, prev_hash: &str) -> Vec<RevealBundle> { + pub fn expand(&self, height: u64, prev_hash: &str) -> Vec<BurnBundle> { self.signatures .iter() .map(|signature| { - let slot_mask = reveal_bundle_slot_mask(signature.slot).unwrap_or(0); - let reveals = self - .reveals + let slot_mask = burn_bundle_slot_mask(signature.slot).unwrap_or(0); + let burns = self + .burns .iter() .filter(|masked| masked.bundle_mask & slot_mask != 0) - .map(|masked| masked.reveal.clone()) + .map(|masked| masked.burn.clone()) .collect(); - RevealBundle { + BurnBundle { height, prev_hash: prev_hash.to_string(), slot: signature.slot, member: signature.member.clone(), - reveals, + burns, signature: signature.signature.clone(), } }) .collect() } - pub fn reveal_bundle_hashes( + pub fn burn_bundle_hashes( &self, height: u64, prev_hash: &str, finalizer: &str, - ) -> [String; REVEAL_COMMITTEE_SIZE] { + ) -> [String; BURN_COMMITTEE_SIZE] { let bundles = self.expand(height, prev_hash); - let mut hashes = reveal_bundle_hashes(&bundles); - if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT { - hashes[0] = finalizer_attestation_hash(height, prev_hash, finalizer, &self.reveals); - } + let mut hashes = burn_bundle_hashes(&bundles); + hashes[0] = finalizer_attestation_hash(height, prev_hash, finalizer, &self.burns); hashes } @@ -130,72 +125,71 @@ impl RevealBundleSection { }) .collect::<Vec<_>>() .join("|"); - let reveals = self - .reveals + let burns = self + .burns .iter() - .map(|masked| format!("{}:{}", masked.bundle_mask, masked.reveal.canonical())) + .map(|masked| format!("{}:{}", masked.bundle_mask, masked.burn.canonical())) .collect::<Vec<_>>() .join("|"); - format!("reveal-bundle-section-v1:{signatures}:reveals:{reveals}") + format!("burn-bundle-section-v1:{signatures}:burns:{burns}") } } #[derive(Clone, Debug, Eq, PartialEq)] -pub(super) struct RevealBundlePayload { - pub(super) height: u64, - pub(super) prev_hash: String, - pub(super) slot: u8, - pub(super) member: String, - pub(super) reveals: Vec<BlindedReveal>, +pub struct BurnBundlePayload { + pub height: u64, + pub prev_hash: String, + pub slot: u8, + pub member: String, + pub burns: Vec<Transaction>, } -impl RevealBundlePayload { - pub(super) fn canonical(&self) -> String { - let reveals = self - .reveals +impl BurnBundlePayload { + pub fn canonical(&self) -> String { + let burns = self + .burns .iter() - .map(BlindedReveal::canonical) + .map(Transaction::canonical) .collect::<Vec<_>>() .join("|"); format!( - "iuna-reveal-bundle-v1:{}:{}:{}:{}:{}", - self.height, self.prev_hash, self.slot, self.member, reveals + "iuna-burn-bundle-v1:{}:{}:{}:{}:{}", + self.height, self.prev_hash, self.slot, self.member, burns ) } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct RevealCommitteeMember { +pub struct BurnCommitteeMember { pub slot: u8, - pub rank: u32, - pub ticket_id: String, + pub root: String, pub owner: String, - pub amount: Amount, + pub weight: Amount, } -pub fn default_reveal_bundle_hash(slot: usize) -> String { - hex_hash(format!("iuna-default-reveal-bundle-v1:{slot}")) +pub fn default_burn_bundle_hash(slot: usize) -> String { + hex_hash(format!("iuna-default-burn-bundle-v1:{slot}")) } -pub(super) fn reveal_bundle_slot_mask(slot: u8) -> Result<u8> { - if usize::from(slot) >= REVEAL_COMMITTEE_SIZE || slot >= 8 { - bail!("reveal bundle slot is invalid"); +pub(super) fn burn_bundle_slot_mask(slot: u8) -> Result<u8> { + if usize::from(slot) >= BURN_COMMITTEE_SIZE || slot >= 8 { + bail!("burn bundle slot is invalid"); } Ok(1_u8 << slot) } -pub(super) fn reveal_committee_mask() -> u8 { - (0..REVEAL_COMMITTEE_SIZE).fold(0_u8, |mask, slot| mask | (1_u8 << slot)) +pub(super) fn burn_committee_mask() -> u8 { + (0..BURN_COMMITTEE_SIZE).fold(0_u8, |mask, slot| mask | (1_u8 << slot)) } -pub(super) fn reveal_bundle_hashes(bundles: &[RevealBundle]) -> [String; REVEAL_COMMITTEE_SIZE] { +pub(super) fn burn_bundle_hashes(bundles: &[BurnBundle]) -> [String; BURN_COMMITTEE_SIZE] { std::array::from_fn(|slot| { bundles .iter() .find(|bundle| usize::from(bundle.slot) == slot) - .map(RevealBundle::bundle_hash) - .unwrap_or_else(|| default_reveal_bundle_hash(slot)) + .map(BurnBundle::bundle_hash) + .unwrap_or_else(|| default_burn_bundle_hash(slot)) }) } @@ -203,98 +197,20 @@ pub(super) fn finalizer_attestation_hash( height: u64, prev_hash: &str, finalizer: &str, - reveals: &[MaskedBlindedReveal], + burns: &[MaskedBurn], ) -> String { - let canonical_reveals = reveals + let canonical_burns = burns .iter() - .map(|masked| masked.reveal.canonical()) + .map(|masked| masked.burn.canonical()) .collect::<Vec<_>>() .join("|"); hex_hash(format!( - "iuna-finalizer-reveal-attestation-v1:{height}:{prev_hash}:{finalizer}:{canonical_reveals}" + "iuna-finalizer-burn-attestation-v1:{height}:{prev_hash}:{finalizer}:{canonical_burns}" )) } -pub(super) fn canonical_reveal_bundle_hashes( - bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], +pub(super) fn canonical_burn_bundle_hashes( + bundle_hashes: &[String; BURN_COMMITTEE_SIZE], ) -> String { bundle_hashes.join("|") } - -#[cfg(test)] -mod tests { - use super::{ - MaskedBlindedReveal, RevealBundle, RevealBundlePayload, RevealBundleSection, - RevealBundleSignature, default_reveal_bundle_hash, reveal_bundle_hashes, - reveal_bundle_slot_mask, - }; - use crate::domain::BlindedReveal; - - #[test] - fn reveal_bundle_payload_is_canonical() { - let payload = RevealBundlePayload { - height: 7, - prev_hash: "prev".to_string(), - slot: 1, - member: "member".to_string(), - reveals: vec![BlindedReveal { - commitment: "commitment".to_string(), - key: "key".to_string(), - }], - }; - - assert_eq!( - payload.canonical(), - "iuna-reveal-bundle-v1:7:prev:1:member:blinded-reveal:commitment:key" - ); - } - - #[test] - fn reveal_bundle_hashes_fill_missing_slots_with_defaults() { - let bundle = RevealBundle { - height: 1, - prev_hash: "prev".to_string(), - slot: 1, - member: "member".to_string(), - reveals: Vec::new(), - signature: "sig".to_string(), - }; - - let hashes = reveal_bundle_hashes(&[bundle.clone()]); - - assert_eq!(hashes[0], default_reveal_bundle_hash(0)); - assert_eq!(hashes[1], bundle.bundle_hash()); - assert_eq!(hashes[2], default_reveal_bundle_hash(2)); - } - - #[test] - fn reveal_bundle_section_expands_masked_reveals_by_slot() { - let reveal = BlindedReveal { - commitment: "commitment".to_string(), - key: "key".to_string(), - }; - let section = RevealBundleSection { - signatures: vec![ - RevealBundleSignature { - slot: 0, - member: "a".to_string(), - signature: "sig-a".to_string(), - }, - RevealBundleSignature { - slot: 1, - member: "b".to_string(), - signature: "sig-b".to_string(), - }, - ], - reveals: vec![MaskedBlindedReveal { - reveal: reveal.clone(), - bundle_mask: reveal_bundle_slot_mask(1).unwrap(), - }], - }; - - let expanded = section.expand(3, "prev"); - - assert!(expanded[0].reveals.is_empty()); - assert_eq!(expanded[1].reveals, vec![reveal]); - } -} diff --git a/src/domain/selection.rs b/src/domain/selection.rs @@ -1,4 +1,4 @@ -use super::{BlindedTransaction, Transaction}; +use super::Transaction; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum TransactionKind { @@ -8,13 +8,6 @@ pub(super) enum TransactionKind { #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(super) struct BlockSelection { pub(super) transactions: Vec<Transaction>, - pub(super) blinded_transactions: Vec<BlindedTransaction>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum SelectableItem { - Plain(usize, u128), - Blinded(usize, u128), } pub(super) fn fee_rate_key(transaction: &Transaction) -> u128 { @@ -24,55 +17,3 @@ pub(super) fn fee_rate_key(transaction: &Transaction) -> u128 { } u128::from(transaction.fee()) * 1_000_000 / size as u128 } - -pub(super) fn blinded_fee_rate_key(transaction: &BlindedTransaction) -> u128 { - let size = transaction.fee_rate_size_bytes(); - if size == 0 { - return 0; - } - u128::from(transaction.fee) * 1_000_000 / size as u128 -} - -pub(super) fn best_selectable_item( - plain: Option<SelectableItem>, - blinded: Option<SelectableItem>, -) -> Option<SelectableItem> { - match (plain, blinded) { - ( - Some(SelectableItem::Plain(_, plain_rate)), - Some(SelectableItem::Blinded(_, blind_rate)), - ) => { - if blind_rate > plain_rate { - blinded - } else { - plain - } - } - (Some(item), None) | (None, Some(item)) => Some(item), - (None, None) => None, - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::{SelectableItem, best_selectable_item}; - - #[test] - fn selectable_item_prefers_blinded_only_when_fee_rate_is_higher() { - assert_eq!( - best_selectable_item( - Some(SelectableItem::Plain(1, 10)), - Some(SelectableItem::Blinded(2, 11)) - ), - Some(SelectableItem::Blinded(2, 11)) - ); - assert_eq!( - best_selectable_item( - Some(SelectableItem::Plain(1, 10)), - Some(SelectableItem::Blinded(2, 10)) - ), - Some(SelectableItem::Plain(1, 10)) - ); - } -} diff --git a/src/domain/tests.rs b/src/domain/tests.rs @@ -1,4190 +0,0 @@ -use super::*; -use crate::domain::ledger_queries::{ - LineageCommitteeCandidate, lineage_committee_weight, select_weighted_lineage_index, -}; - -#[test] -fn target_block_time_is_five_minutes() { - assert_eq!(VDF_TARGET_BLOCK_MS, 5 * 60 * 1_000); -} - -fn test_utxo_outpoint(index: usize) -> OutPoint { - OutPoint { - txid: format!("{index:064x}"), - index: 0, - } -} - -fn named_test_outpoint(name: &str) -> OutPoint { - OutPoint { - txid: hex_hash(format!("test-utxo:{name}")), - index: 0, - } -} - -fn test_lineage_root(name: &str, height: u64) -> UtxoLineageRoot { - UtxoLineageRoot { - outpoint: named_test_outpoint(name), - height, - } -} - -fn insert_lineage_utxo( - ledger: &mut Ledger, - outpoint: OutPoint, - wallet: &Wallet, - amount: Amount, - root: UtxoLineageRoot, -) { - ledger.utxos.insert( - outpoint.clone(), - TxOutput { - address: wallet.address().to_string(), - amount, - }, - ); - ledger.utxo_lineage.insert(outpoint.clone(), root.clone()); - let value = ledger.lineage_values.entry(root.clone()).or_insert(0); - *value = value.checked_add(amount).unwrap(); - ledger - .lineage_owners - .entry(root) - .or_default() - .entry(wallet.address().to_string()) - .or_default() - .insert(outpoint, amount); -} - -fn ledger_with_wallet_utxos(wallet: &Wallet, amounts: &[Amount]) -> Ledger { - let mut ledger = Ledger::new(BTreeMap::new(), 1); - ledger.utxos = amounts - .iter() - .enumerate() - .map(|(index, amount)| { - ( - test_utxo_outpoint(index), - TxOutput { - address: wallet.address().to_string(), - amount: *amount, - }, - ) - }) - .collect(); - ledger -} - -fn pending_balances(ledger: &Ledger) -> BTreeMap<String, Amount> { - balances_from_utxos(&ledger.utxos_after_valid_pending().unwrap()) -} - -fn ledger_with_allocation(wallet: &Wallet, amount: Amount) -> Ledger { - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), amount); - Ledger::new(genesis, 1) -} - -fn mine_burn_block_with_mines(ledger: &mut Ledger, wallet: &Wallet, mine_actions: usize) { - let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - for _ in 0..mine_actions { - let mine = ledger.build_mine(wallet.address()).unwrap(); - ledger.submit_transaction(mine).unwrap(); - } - let block = ledger.mine_next_block(wallet, ledger.height() + 1).unwrap(); - ledger.apply_block(block).unwrap(); -} - -fn test_mine_with_salt(ledger: &Ledger, recipient: &str, salt: u64) -> Transaction { - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - for nonce in 0..u64::MAX { - let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits); - if hash_meets_difficulty(&signature, difficulty_bits) { - return Transaction::Mine { - recipient: recipient.to_string(), - anchor, - salt, - nonce, - difficulty_bits, - proof_header: None, - signature, - }; - } - } - panic!("test should find a valid mine action"); -} - -fn apply_preverified_burn_block_at( - ledger: &mut Ledger, - wallet: &Wallet, - timestamp_ms: u64, -) -> Block { - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger - .prepare_next_block(wallet.address(), timestamp_ms) - .unwrap(); - let block = work.finish(wallet, "preverified-vdf".to_string()); - ledger - .apply_preverified_block_at(block.clone(), u64::MAX) - .unwrap(); - block -} - -fn apply_preverified_burn_block_with_mines( - ledger: &mut Ledger, - wallet: &Wallet, - mine_actions: usize, -) { - let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let mut block = ledger - .prepare_next_block(wallet.address(), timestamp_ms) - .unwrap() - .finish(wallet, "preverified-vdf".to_string()); - for salt in 0..mine_actions { - block.transactions.push(test_mine_with_salt( - ledger, - wallet.address(), - salt as u64 + 1, - )); - } - block.reward = fee_reward(&block.transactions).unwrap(); - block.hash = block.compute_hash(); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); -} - -fn vdf_retarget_sample_block( - timestamp_ms: u64, - finalizer_mode: FinalizerMode, - finalizer_rank: u32, -) -> Block { - Block { - height: 1, - prev_hash: String::new(), - timestamp_ms, - miner: String::new(), - finalizer_mode, - finalizer_rank, - reward: 0, - vdf_rounds: 1, - vdf_output: String::new(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions: Vec::new(), - hash: String::new(), - } -} - -const TEST_BURN_AMOUNT: Amount = MICRO_IUNA / 10; - -fn unsigned_mine(ledger: &Ledger, recipient: &str) -> Transaction { - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - for nonce in 0..u64::MAX { - let salt = 1; - let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits); - if hash_meets_difficulty(&signature, difficulty_bits) { - return Transaction::Mine { - recipient: recipient.to_string(), - anchor, - salt, - nonce, - difficulty_bits, - proof_header: None, - signature, - }; - } - } - panic!("expected to find mine proof"); -} - -fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { - wallets - .iter() - .find(|wallet| wallet.address() == address) - .unwrap_or_else(|| panic!("missing wallet for address {address}")) -} - -fn ledger_with_finalizers( - finalizers: &[Wallet], - extra_allocations: &[(&Wallet, Amount)], -) -> Ledger { - let mut allocations = BTreeMap::new(); - for wallet in finalizers { - allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); - } - for (wallet, amount) in extra_allocations { - allocations.insert(wallet.address().to_string(), *amount); - } - Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap() -} - -fn mine_preverified_as_next_leader( - ledger: &mut Ledger, - wallets: &[Wallet], - timestamp_ms: u64, -) -> Block { - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(wallets, &leader); - let reveal_bundles = if ledger.reveal_bundle_attestations_required_for_next_block() { - reveal_bundles_for_next_block(ledger, wallets) - } else { - Vec::new() - }; - let prepared = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, reveal_bundles) - .unwrap(); - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - ledger - .apply_preverified_block_at(block.clone(), u64::MAX) - .unwrap(); - block -} - -fn mine_valid_as_next_leader(ledger: &mut Ledger, wallets: &[Wallet], timestamp_ms: u64) -> Block { - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(wallets, &leader); - let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); - ledger.apply_block_at(block.clone(), u64::MAX).unwrap(); - block -} - -fn mine_own_burn_only_without_reveal_bundles( - ledger: &mut Ledger, - wallet: &Wallet, - burn_amount: Amount, - timestamp_ms: u64, -) -> anyhow::Result<Block> { - let burn = ledger.build_burn(wallet, burn_amount, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let prepared = ledger.prepare_next_block_with_reveal_bundles( - wallet.address(), - timestamp_ms, - Vec::new(), - )?; - assert!(prepared.reveal_bundle_section.is_empty()); - assert_eq!(prepared.blinded_transactions.len(), 0); - assert_eq!(prepared.transactions.len(), 1); - assert!(prepared.transactions[0].is_burn()); - assert_eq!(prepared.transactions[0].sender(), wallet.address()); - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - ledger.apply_preverified_block_at(block.clone(), u64::MAX)?; - Ok(block) -} - -fn next_rank_wallet<'a>(ledger: &Ledger, wallets: &'a [Wallet], rank: u32) -> &'a Wallet { - wallets - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(rank)) - .unwrap_or_else(|| panic!("missing wallet for next finalizer rank {rank}")) -} - -fn reveal_bundles_for_next_block(ledger: &Ledger, wallets: &[Wallet]) -> Vec<RevealBundle> { - let mut bundles = wallets - .iter() - .filter_map(|wallet| ledger.build_reveal_bundle(wallet).unwrap()) - .collect::<Vec<_>>(); - bundles.sort_by_key(|bundle| bundle.slot); - bundles -} - -fn prepare_active_blinded_burn_for_reveal_thresholds( - seeds: [&str; 4], -) -> (Ledger, Vec<Wallet>, BuiltBlindedTransaction) { - prepare_active_blinded_burn_for_reveal_thresholds_at_next_height( - seeds, - REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, - ) -} - -fn prepare_active_blinded_burn_for_reveal_thresholds_at_next_height( - seeds: [&str; 4], - next_height: u64, -) -> (Ledger, Vec<Wallet>, BuiltBlindedTransaction) { - let attacker = Wallet::from_seed(seeds[0]); - let bob = Wallet::from_seed(seeds[1]); - let carol = Wallet::from_seed(seeds[2]); - let victim = Wallet::from_seed(seeds[3]); - let finalizers = vec![attacker.clone(), bob.clone(), carol.clone()]; - let wallets = vec![attacker.clone(), bob, carol, victim.clone()]; - let mut ledger = ledger_with_finalizers( - &finalizers, - &[(&attacker, 100 * MICRO_IUNA), (&victim, 20 * MICRO_IUNA)], - ); - set_tip_height_for_validation(&mut ledger, next_height.saturating_sub(2)); - install_finalizer_tickets_for_height(&mut ledger, &finalizers, next_height.saturating_sub(1)); - let blinded = ledger - .build_blinded_burn(&victim, 3, MICRO_IUNA, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - let leader = next_rank_wallet(&ledger, &finalizers, 0); - let burn = ledger.build_burn(leader, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let commit_block = ledger.mine_next_block(leader, 1).unwrap(); - ledger.apply_block_at(commit_block, u64::MAX).unwrap(); - install_finalizer_tickets_for_height(&mut ledger, &finalizers, next_height); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - if next_height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT { - let mature_root_height = next_height.saturating_sub(REVEAL_LINEAGE_MATURITY_HEIGHTS + 1); - for (index, wallet) in finalizers.iter().enumerate().skip(1) { - insert_lineage_utxo( - &mut ledger, - named_test_outpoint(&format!("threshold-lineage-{index}")), - wallet, - MICRO_IUNA, - test_lineage_root(&format!("threshold-root-{index}"), mature_root_height), - ); - } - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("threshold-lineage-victim"), - &victim, - MICRO_IUNA, - test_lineage_root("threshold-root-victim", mature_root_height), - ); - } - assert_eq!(ledger.height() + 1, next_height); - assert_eq!(ledger.reveal_committee_for_next_block().len(), 3); - (ledger, wallets, blinded) -} - -fn install_finalizer_tickets_for_height(ledger: &mut Ledger, finalizers: &[Wallet], height: u64) { - ledger.tickets = finalizers - .iter() - .enumerate() - .map(|(index, wallet)| BurnTicket { - id: hex_hash(format!( - "test-reveal-threshold-ticket:{}:{}", - wallet.address(), - height - )), - owner: wallet.address().to_string(), - amount: MICRO_IUNA.saturating_sub(index as u64), - eligible_from_height: height, - eligible_until_height: height, - }) - .collect(); -} - -fn try_mine_rank_with_reveal_bundles( - ledger: &mut Ledger, - wallet: &Wallet, - burn_amount: Amount, - timestamp_ms: u64, - bundles: Vec<RevealBundle>, -) -> anyhow::Result<Block> { - let burn = ledger.build_burn(wallet, burn_amount, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let prepared = - ledger.prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, bundles)?; - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - ledger.apply_preverified_block_at(block.clone(), u64::MAX)?; - Ok(block) -} - -fn mine_preverified_as_next_leader_with_reveal_bundles( - ledger: &mut Ledger, - wallets: &[Wallet], - timestamp_ms: u64, -) -> Block { - let block = - prepare_preverified_as_next_leader_with_reveal_bundles(ledger, wallets, timestamp_ms); - ledger - .apply_preverified_block_at(block.clone(), u64::MAX) - .unwrap(); - block -} - -fn prepare_preverified_as_next_leader_with_reveal_bundles( - ledger: &Ledger, - wallets: &[Wallet], - timestamp_ms: u64, -) -> Block { - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(wallets, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(wallets, &leader); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, bundles) - .unwrap(); - prepared.finish(wallet, "preverified-vdf".to_string()) -} - -fn queue_next_leader_burn(ledger: &mut Ledger, wallets: &[Wallet]) { - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(wallets, &leader); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); -} - -fn transfer_with_extra_zero_outputs( - ledger: &Ledger, - wallet: &Wallet, - to: &str, - amount: Amount, - fee: Amount, - extra_outputs: usize, -) -> Transaction { - let required = amount.checked_add(fee).unwrap(); - let (inputs, input_total) = ledger.select_inputs(wallet.address(), required).unwrap(); - let mut outputs = vec![TxOutput { - address: to.to_string(), - amount, - }]; - outputs.extend((0..extra_outputs).map(|_| TxOutput { - address: to.to_string(), - amount: 0, - })); - let change = input_total - required; - if change > 0 { - outputs.push(TxOutput { - address: wallet.address().to_string(), - amount: change, - }); - } - UnsignedUtxoTransaction::Transfer { - inputs, - outputs, - fee, - } - .sign(wallet) -} - -#[test] -fn wallet_utxos_only_include_outputs_owned_by_address() { - let alice = Wallet::from_seed("wallet-utxos-alice"); - let bob = Wallet::from_seed("wallet-utxos-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3]); - ledger.utxos.insert( - named_test_outpoint("bob"), - TxOutput { - address: bob.address().to_string(), - amount: 5, - }, - ); - - let alice_utxos = ledger.utxos_for_address(alice.address()); - let total = alice_utxos - .iter() - .map(|(_, output)| output.amount) - .sum::<Amount>(); - - assert_eq!(alice_utxos.len(), 2); - assert_eq!(total, ledger.balance_of(alice.address())); - assert!( - alice_utxos - .iter() - .all(|(_, output)| output.address == alice.address()) - ); -} - -#[test] -fn transfer_combines_multiple_small_utxos_to_cover_amount_and_fee() { - let alice = Wallet::from_seed("combine-small-utxos-alice"); - let bob = Wallet::from_seed("combine-small-utxos-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]); - - let tx = ledger.build_transfer(&alice, bob.address(), 2, 1).unwrap(); - - let Transaction::Transfer { - inputs, - outputs, - fee, - .. - } = &tx - else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 3); - assert_eq!(*fee, 1); - assert_eq!( - outputs, - &[TxOutput { - address: bob.address().to_string(), - amount: 2 - }] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!( - balances.get(alice.address()).copied().unwrap_or_default(), - 0 - ); - assert_eq!(balances.get(bob.address()).copied().unwrap_or_default(), 2); -} - -#[test] -fn transfer_returns_change_when_combined_utxos_exceed_payment() { - let alice = Wallet::from_seed("combine-change-alice"); - let bob = Wallet::from_seed("combine-change-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); - - let tx = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - - let Transaction::Transfer { - inputs, outputs, .. - } = &tx - else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 3); - assert_eq!( - outputs, - &[ - TxOutput { - address: bob.address().to_string(), - amount: 3 - }, - TxOutput { - address: alice.address().to_string(), - amount: 1 - } - ] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!(balances.get(alice.address()).copied(), Some(1)); - assert_eq!(balances.get(bob.address()).copied(), Some(3)); -} - -#[test] -fn transaction_economic_size_uses_compact_canonical_fields() { - let alice = Wallet::from_seed("economic-size-alice"); - let bob = Wallet::from_seed("economic-size-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); - let selected = vec![ - test_utxo_outpoint(0), - test_utxo_outpoint(1), - test_utxo_outpoint(2), - ]; - - let tx = ledger - .build_transfer_with_inputs(&alice, bob.address(), 3, 0, &selected) - .unwrap(); - - assert!(tx.economic_size_bytes() < tx.serialized_size_bytes().unwrap()); - assert_eq!( - tx.economic_size_bytes(), - 1 + 1 + (3 * (32 + 1 + 32)) + 1 + (2 * (32 + 1)) + 1 + 64 - ); -} - -#[test] -fn blinded_fee_rate_size_uses_visible_envelope_bytes() { - let alice = Wallet::from_seed("blinded-fee-size-alice"); - let ledger = ledger_with_wallet_utxos(&alice, &[10]); - let built = ledger - .build_blinded_burn(&alice, 1, 2, ledger.height() + 4) - .unwrap(); - - assert_eq!( - built.transaction.fee_rate_size_bytes(), - built.transaction.serialized_size_bytes().unwrap() - ); - assert!(built.transaction.fee_rate_size_bytes() > built.transaction.encrypted_size as usize); -} - -#[test] -fn blinded_fee_split_burns_rounding_dust() { - let committer = Wallet::from_seed("blinded-split-committer"); - let executor = Wallet::from_seed("blinded-split-executor"); - let commitment = "01".repeat(32); - let active = ActiveBlindedTransaction { - transaction: BlindedTransaction { - commitment: commitment.clone(), - inputs: Vec::new(), - fee: 1, - encrypted_size: 1, - expires_at_height: 2, - nonce: "02".repeat(BLINDED_NONCE_BYTES), - ciphertext: "03".to_string(), - payload_hash: "04".repeat(32), - }, - locked_outputs: Vec::new(), - locked_lineage_root: None, - included_height: 1, - included_by: committer.address().to_string(), - }; - let transaction = Transaction::Transfer { - inputs: Vec::new(), - outputs: Vec::new(), - fee: 1, - signature: String::new(), - }; - let mut utxos = BTreeMap::new(); - - credit_blinded_fee_outputs( - &mut utxos, - &active, - executor.address(), - &transaction, - &[], - 3, - false, - ) - .unwrap(); - - assert!(!utxos.contains_key(&blinded_committer_fee_outpoint(&commitment))); - assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); -} - -#[test] -fn blinded_fee_split_pays_no_reveal_finalizer_without_signed_reveal_lists() { - let committer = Wallet::from_seed("blinded-no-list-committer"); - let executor = Wallet::from_seed("blinded-no-list-executor"); - let commitment = "06".repeat(32); - let active = ActiveBlindedTransaction { - transaction: BlindedTransaction { - commitment: commitment.clone(), - inputs: Vec::new(), - fee: 100, - encrypted_size: 1, - expires_at_height: 2, - nonce: "02".repeat(BLINDED_NONCE_BYTES), - ciphertext: "03".to_string(), - payload_hash: "04".repeat(32), - }, - locked_outputs: Vec::new(), - locked_lineage_root: None, - included_height: 1, - included_by: committer.address().to_string(), - }; - let transaction = Transaction::Transfer { - inputs: Vec::new(), - outputs: Vec::new(), - fee: 100, - signature: String::new(), - }; - let mut utxos = BTreeMap::new(); - - credit_blinded_fee_outputs( - &mut utxos, - &active, - executor.address(), - &transaction, - &[], - 3, - false, - ) - .unwrap(); - - assert_eq!( - utxos.get(&blinded_committer_fee_outpoint(&commitment)), - Some(&TxOutput { - address: committer.address().to_string(), - amount: 35, - }) - ); - assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); -} - -#[test] -fn blinded_fee_split_pays_committer_executor_and_reveal_bundle_signers() { - let committer = Wallet::from_seed("blinded-scale-committer"); - let executor = Wallet::from_seed("blinded-scale-executor"); - let signer_a = Wallet::from_seed("blinded-scale-signer-a"); - let signer_b = Wallet::from_seed("blinded-scale-signer-b"); - let commitment = "05".repeat(32); - let active = ActiveBlindedTransaction { - transaction: BlindedTransaction { - commitment: commitment.clone(), - inputs: Vec::new(), - fee: 7, - encrypted_size: 1, - expires_at_height: 2, - nonce: "02".repeat(BLINDED_NONCE_BYTES), - ciphertext: "03".to_string(), - payload_hash: "04".repeat(32), - }, - locked_outputs: Vec::new(), - locked_lineage_root: None, - included_height: 1, - included_by: committer.address().to_string(), - }; - let transaction = Transaction::Transfer { - inputs: Vec::new(), - outputs: Vec::new(), - fee: 100, - signature: String::new(), - }; - let mut utxos = BTreeMap::new(); - let signatures = vec![ - RevealBundleSignature { - slot: 0, - member: signer_a.address().to_string(), - signature: "11".repeat(SIGNATURE_BYTES), - }, - RevealBundleSignature { - slot: 2, - member: signer_b.address().to_string(), - signature: "22".repeat(SIGNATURE_BYTES), - }, - ]; - - credit_blinded_fee_outputs( - &mut utxos, - &active, - executor.address(), - &transaction, - &signatures, - 3, - false, - ) - .unwrap(); - - assert_eq!( - utxos.get(&blinded_committer_fee_outpoint(&commitment)), - Some(&TxOutput { - address: committer.address().to_string(), - amount: 35, - }) - ); - assert_eq!( - utxos.get(&blinded_executor_fee_outpoint(&commitment)), - Some(&TxOutput { - address: executor.address().to_string(), - amount: 23, - }) - ); - assert_eq!( - utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 0)), - Some(&TxOutput { - address: signer_a.address().to_string(), - amount: 10, - }) - ); - assert_eq!( - utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 2)), - Some(&TxOutput { - address: signer_b.address().to_string(), - amount: 10, - }) - ); -} - -#[test] -fn blinded_reveal_finalizer_fee_scales_by_available_reveal_bundle_slots() { - let fee = 300_000; - let full_share = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); - - assert_eq!(blinded_reveal_finalizer_fee(fee, 0, 3), 0); - assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 3), full_share / 3); - assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 2), full_share / 2); - assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 1), full_share); - assert_eq!(blinded_reveal_finalizer_fee(fee, 2, 3), full_share * 2 / 3); - assert_eq!(blinded_reveal_finalizer_fee(fee, 3, 3), full_share); - assert_eq!(blinded_reveal_finalizer_fee(fee, 4, 3), full_share); -} - -#[test] -fn reveal_fee_attribution_uses_reveal_mask_from_height_750() { - let alice = Wallet::from_seed("mask-fee-alice"); - let bob = Wallet::from_seed("mask-fee-bob"); - let carol = Wallet::from_seed("mask-fee-carol"); - let reveal = MaskedBlindedReveal { - reveal: BlindedReveal { - commitment: hex_hash("mask-fee-reveal"), - key: "00".repeat(BLINDED_KEY_BYTES), - }, - bundle_mask: 0b0000_0101, - }; - let section = RevealBundleSection { - signatures: vec![ - RevealBundleSignature { - slot: 0, - member: alice.address().to_string(), - signature: "11".repeat(SIGNATURE_BYTES), - }, - RevealBundleSignature { - slot: 1, - member: bob.address().to_string(), - signature: "22".repeat(SIGNATURE_BYTES), - }, - RevealBundleSignature { - slot: 2, - member: carol.address().to_string(), - signature: "33".repeat(SIGNATURE_BYTES), - }, - ], - reveals: vec![reveal.clone()], - }; - - let legacy_signers = - reveal_fee_signatures_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT - 1, &section, &reveal); - assert_eq!(legacy_signers.len(), 3); - assert_eq!( - reveal_fee_bundle_count_for_height( - REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT - 1, - &section, - &reveal - ), - 3 - ); - - let masked_signers = - reveal_fee_signatures_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, &section, &reveal); - assert_eq!( - masked_signers - .iter() - .map(|signature| signature.member.as_str()) - .collect::<Vec<_>>(), - vec![alice.address(), carol.address()] - ); - assert_eq!( - reveal_fee_bundle_count_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, &section, &reveal), - 2 - ); -} - -#[test] -fn transfer_rejects_invalid_recipient_address() { - let alice = Wallet::from_seed("invalid-transfer-recipient-alice"); - let ledger = ledger_with_wallet_utxos(&alice, &[10]); - - let error = ledger.build_transfer(&alice, "aa", 1, 0).unwrap_err(); - - assert!(format!("{error:#}").contains("invalid transfer recipient address")); -} - -#[test] -fn mine_rejects_invalid_recipient_address_before_pow() { - let ledger = Ledger::new(BTreeMap::new(), 1); - - let error = ledger.build_mine("aa").unwrap_err(); - - assert!(format!("{error:#}").contains("invalid mine recipient address")); -} - -#[test] -fn mine_search_respects_nonce_attempt_limit() { - let alice = Wallet::from_seed("bounded-mine-search-alice"); - let ledger = Ledger::new(BTreeMap::new(), 1); - - let outcome = ledger.search_mine(alice.address(), 1, 0, 0).unwrap(); - - assert!(outcome.transaction.is_none()); - assert_eq!(outcome.next_nonce, 0); - assert_eq!(outcome.attempts, 0); -} - -#[test] -fn mempool_rejects_invalid_input_outpoint_id() { - let alice = Wallet::from_seed("invalid-outpoint-alice"); - let bob = Wallet::from_seed("invalid-outpoint-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); - let unsigned = UnsignedUtxoTransaction::Transfer { - inputs: vec![UnsignedTxInput { - outpoint: OutPoint { - txid: "aa".to_string(), - index: 0, - }, - owner: alice.address().to_string(), - }], - outputs: vec![TxOutput { - address: bob.address().to_string(), - amount: 1, - }], - fee: 0, - }; - let transaction = unsigned.sign(&alice); - - let error = ledger.submit_transaction(transaction).unwrap_err(); - - assert!(format!("{error:#}").contains("invalid input outpoint txid")); - assert!(ledger.pending().is_empty()); -} - -#[test] -fn missing_input_transaction_goes_to_orphan_pool_not_pending_mempool() { - let alice = Wallet::from_seed("missing-input-orphan-alice"); - let bob = Wallet::from_seed("missing-input-orphan-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); - let transaction = UnsignedUtxoTransaction::Transfer { - inputs: vec![UnsignedTxInput { - outpoint: OutPoint { - txid: hex_hash("missing-input-orphan"), - index: 0, - }, - owner: alice.address().to_string(), - }], - outputs: vec![TxOutput { - address: bob.address().to_string(), - amount: 1, - }], - fee: 0, - } - .sign(&alice); - - let outcome = ledger.submit_transaction_with_outcome(transaction).unwrap(); - - assert_eq!(outcome, TransactionSubmitOutcome::Added); - assert!(ledger.pending().is_empty()); - assert_eq!(ledger.orphan_transactions().len(), 1); -} - -#[test] -fn vdf_retarget_observed_block_time_is_clamped() { - assert_eq!( - clamped_vdf_retarget_observed_block_ms(1), - MIN_VDF_RETARGET_OBSERVED_BLOCK_MS - ); - assert_eq!( - clamped_vdf_retarget_observed_block_ms(VDF_TARGET_BLOCK_MS), - VDF_TARGET_BLOCK_MS - ); - assert_eq!( - clamped_vdf_retarget_observed_block_ms(u64::MAX), - MAX_VDF_RETARGET_OBSERVED_BLOCK_MS - ); -} - -#[test] -fn vdf_retarget_observed_block_time_ignores_ticket_fallback_ranks() { - let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); - let fallback_child = - vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1); - - assert_eq!( - vdf_retarget_observed_block_ms(&parent, &fallback_child), - None - ); -} - -#[test] -fn vdf_retarget_observed_block_time_ignores_recovery_blocks() { - let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); - let recovery_child = - vdf_retarget_sample_block(RECOVERY_BLOCK_DELAY_MS, FinalizerMode::Recovery, 0); - - assert_eq!( - vdf_retarget_observed_block_ms(&parent, &recovery_child), - None - ); -} - -#[test] -fn vdf_retarget_keeps_rounds_inside_deadband() { - let current = 1_000; - let low_deadband_edge = - VDF_TARGET_BLOCK_MS - VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; - let high_deadband_edge = - VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; - - assert_eq!(retarget_vdf_rounds(current, low_deadband_edge), current); - assert_eq!(retarget_vdf_rounds(current, VDF_TARGET_BLOCK_MS), current); - assert_eq!(retarget_vdf_rounds(current, high_deadband_edge), current); -} - -#[test] -fn vdf_retarget_limits_each_step_to_two_percent() { - let current = 1_000; - - assert_eq!( - retarget_vdf_rounds(current, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS), - 1_020 - ); - assert_eq!( - retarget_vdf_rounds(current, MAX_VDF_RETARGET_OBSERVED_BLOCK_MS), - 980 - ); -} - -#[test] -fn vdf_rounds_retarget_below_legacy_u32_limit_after_slow_blocks() { - let wallet = Wallet::from_seed("vdf-rounds-slow-above-u32"); - let initial_rounds = u64::from(u32::MAX); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), 1)], - initial_rounds, - ) - .unwrap(); - - let block1 = apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); - assert_eq!(block1.vdf_rounds, initial_rounds); - assert_eq!(ledger.vdf_rounds(), initial_rounds); - - let block2 = apply_preverified_burn_block_at( - &mut ledger, - &wallet, - VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * 2, - ); - assert_eq!(block2.vdf_rounds, initial_rounds); - - assert!( - ledger.vdf_rounds() < initial_rounds, - "slow blocks should retarget below the legacy u32 VDF rounds ceiling" - ); -} - -#[test] -fn fallback_block_is_excluded_from_vdf_retarget_observations() { - let alice = Wallet::from_seed("fallback-retarget-alice"); - let bob = Wallet::from_seed("fallback-retarget-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 1_000); - genesis.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 100, - ) - .unwrap(); - - let primary = ledger.expected_leader_for_next_block().unwrap(); - let primary_wallet = wallets - .into_iter() - .find(|wallet| wallet.address() == primary) - .unwrap(); - apply_preverified_burn_block_at(&mut ledger, primary_wallet, VDF_TARGET_BLOCK_MS); - assert_eq!(ledger.vdf_rounds(), 100); - - let primary = ledger.expected_leader_for_next_block().unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != primary) - .unwrap(); - let timestamp_ms = ledger.tip().timestamp_ms + 1; - let block = apply_preverified_burn_block_at(&mut ledger, fallback, timestamp_ms); - - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.vdf_rounds, 200); - assert_eq!(ledger.vdf_rounds(), 100); -} - -#[test] -fn recovery_block_is_excluded_from_vdf_retarget_observations() { - let alice = Wallet::from_seed("recovery-retarget-alice"); - let bob = Wallet::from_seed("recovery-retarget-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 1_000); - genesis.insert(bob.address().to_string(), 1_000); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 100) - .unwrap(); - - apply_preverified_burn_block_at(&mut ledger, &alice, VDF_TARGET_BLOCK_MS); - assert_eq!(ledger.vdf_rounds(), 100); - - let burn = ledger.build_burn(&bob, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger - .mine_recovery_block(&bob, VDF_TARGET_BLOCK_MS + RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.vdf_rounds(), 100); -} - -#[test] -fn generated_vdf_retarget_decreases_after_slow_blocks_above_legacy_limit() { - let legacy_limit = u64::from(u32::MAX); - let slow_observed_ms = [ - VDF_TARGET_BLOCK_MS * 6 / 5, - VDF_TARGET_BLOCK_MS * 2, - VDF_TARGET_BLOCK_MS * 3, - MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, - ]; - - for seed in 0..16_u64 { - let wallet = Wallet::from_seed(&format!("generated-vdf-retarget-{seed}")); - let initial_rounds = legacy_limit + 1 + seed * 1_000_003; - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), 1)], - initial_rounds, - ) - .unwrap(); - let observed_ms = slow_observed_ms[seed as usize % slow_observed_ms.len()]; - - apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); - let second = apply_preverified_burn_block_at( - &mut ledger, - &wallet, - VDF_TARGET_BLOCK_MS + observed_ms, - ); - - assert_eq!(second.vdf_rounds, initial_rounds); - assert!( - ledger.vdf_rounds() < initial_rounds, - "seed {seed} with observed {observed_ms}ms should lower VDF rounds from {initial_rounds}, got {}", - ledger.vdf_rounds() - ); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let next_work = ledger - .prepare_next_block(wallet.address(), second.timestamp_ms + VDF_TARGET_BLOCK_MS) - .unwrap(); - assert_eq!(next_work.vdf_rounds(), ledger.vdf_rounds()); - } -} - -#[test] -fn block_timestamp_future_check_uses_supplied_network_time() { - let wallet = Wallet::from_seed("adjusted-time-domain"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new(allocations, 1); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - assert!(ledger.submit_transaction(burn).unwrap()); - let block = ledger.mine_next_block(&wallet, 10 * 60 * 1_000).unwrap(); - - let error = ledger.apply_block_at(block, 1_000).unwrap_err(); - - assert!(format!("{error:#}").contains("too far in the future")); -} - -#[test] -fn ticket_block_timestamp_uses_finalizer_rank_time_slot() { - let alice = Wallet::from_seed("rank-slot-alice"); - let bob = Wallet::from_seed("rank-slot-bob"); - let wallets = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 100, - ) - .unwrap(); - - let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap()); - let burn = ledger.build_burn(primary, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger.prepare_next_block(primary.address(), 1).unwrap(); - assert_eq!(work.timestamp_ms(), 1); - let block = work.finish(primary, "preverified-vdf".to_string()); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - - let fallback = wallets - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) - .expect("expected rank 1 fallback"); - let burn = ledger.build_burn(fallback, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let parent_timestamp = ledger.tip().timestamp_ms; - let work = ledger - .prepare_next_block(fallback.address(), parent_timestamp + 1) - .unwrap(); - - assert_eq!( - work.timestamp_ms(), - parent_timestamp + VDF_TARGET_BLOCK_MS * 2 - ); - assert_eq!(work.vdf_rounds(), ledger.vdf_rounds() * 2); -} - -#[test] -fn required_anchor_burn_is_selected_before_higher_fee_burns_when_block_is_full() { - let wallet = Wallet::from_seed("required-anchor-priority-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let high_fee_outpoint = named_test_outpoint("required-anchor-priority-high-fee"); - let anchor_outpoint = named_test_outpoint("required-anchor-priority-anchor"); - ledger.utxos.insert( - high_fee_outpoint.clone(), - TxOutput { - address: wallet.address().to_string(), - amount: 3, - }, - ); - ledger.utxos.insert( - anchor_outpoint.clone(), - TxOutput { - address: wallet.address().to_string(), - amount: 2, - }, - ); - let high_fee_burn = ledger - .build_burn_with_inputs(&wallet, 1, 1, &[high_fee_outpoint]) - .unwrap(); - let anchor_burn = ledger - .build_burn_with_inputs(&wallet, 1, 0, &[anchor_outpoint]) - .unwrap(); - ledger.submit_transaction(high_fee_burn).unwrap(); - ledger.submit_transaction(anchor_burn.clone()).unwrap(); - ledger.launch_profile.max_block_transactions = 1; - - let work = ledger - .prepare_next_block_with_required_burn_and_reveal_bundles( - wallet.address(), - 1, - Vec::new(), - Some(anchor_burn.signature()), - ) - .unwrap(); - let block = work.finish(&wallet, "preverified-vdf".to_string()); - - assert_eq!(block.transactions.len(), 1); - assert_eq!(block.transactions[0].signature(), anchor_burn.signature()); -} - -#[test] -fn late_ticket_vdf_completion_is_visible_to_retarget() { - let wallet = Wallet::from_seed("late-ticket-vdf-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), 1)], - 100, - ) - .unwrap(); - - apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); - assert_eq!(ledger.vdf_rounds(), 100); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger - .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - let scheduled_timestamp = work.timestamp_ms(); - let late_timestamp = ledger.tip().timestamp_ms + VDF_TARGET_BLOCK_MS * 3; - assert!(late_timestamp > scheduled_timestamp); - - let block = work.finish_at(&wallet, "preverified-vdf".to_string(), late_timestamp); - assert_eq!(block.timestamp_ms, late_timestamp); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - - assert!( - ledger.vdf_rounds() < 100, - "late VDF completion should lower future VDF rounds" - ); -} - -#[test] -fn block_before_finalizer_rank_time_slot_is_rejected() { - let alice = Wallet::from_seed("rank-slot-reject-alice"); - let bob = Wallet::from_seed("rank-slot-reject-bob"); - let wallets = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 100, - ) - .unwrap(); - - let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap()); - let burn = ledger.build_burn(primary, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger.prepare_next_block(primary.address(), 1).unwrap(); - let block = work.finish(primary, "preverified-vdf".to_string()); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - - let fallback = wallets - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) - .expect("expected rank 1 fallback"); - let burn = ledger.build_burn(fallback, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let parent_timestamp = ledger.tip().timestamp_ms; - let work = ledger - .prepare_next_block(fallback.address(), parent_timestamp + 1) - .unwrap(); - let mut block = work.finish(fallback, "preverified-vdf".to_string()); - block.timestamp_ms = parent_timestamp + VDF_TARGET_BLOCK_MS * 2 - 1; - block.hash = block.compute_hash(); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!(format!("{error:#}").contains("before finalizer rank 1 time slot")); -} - -#[test] -fn mempool_rejects_transaction_that_cannot_fit_in_a_block() { - let alice = Wallet::from_seed("oversized-select-alice"); - let bob = Wallet::from_seed("oversized-select-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1); - allocations.insert(bob.address().to_string(), 300_000); - let mut ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - let burn = ledger.build_burn(&alice, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let oversized = - transfer_with_extra_zero_outputs(&ledger, &bob, alice.address(), 1, 100_000, 4_000); - assert!(oversized.serialized_size_bytes().unwrap() > MAX_BLOCK_BYTES); - - let error = ledger.submit_transaction(oversized).unwrap_err(); - - assert!(format!("{error:#}").contains("transaction exceeds max block size")); -} - -#[test] -fn transfer_builder_rejects_selected_utxos_that_make_transaction_too_large() { - let alice = Wallet::from_seed("oversized-selected-utxos-alice"); - let bob = Wallet::from_seed("oversized-selected-utxos-bob"); - let amounts = vec![1; 2_000]; - let ledger = ledger_with_wallet_utxos(&alice, &amounts); - let selected = (0..amounts.len()) - .map(test_utxo_outpoint) - .collect::<Vec<_>>(); - - let error = ledger - .build_transfer_with_inputs(&alice, bob.address(), 1, 0, &selected) - .unwrap_err(); - - assert!(format!("{error:#}").contains("transaction exceeds max block size")); -} - -#[test] -fn transfer_can_spend_selected_utxos_when_they_cover_amount_and_fee() { - let alice = Wallet::from_seed("selected-utxos-alice"); - let bob = Wallet::from_seed("selected-utxos-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); - let selected = vec![test_utxo_outpoint(2)]; - - let tx = ledger - .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) - .unwrap(); - - let Transaction::Transfer { - inputs, outputs, .. - } = &tx - else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 1); - assert_eq!(inputs[0].outpoint, selected[0]); - assert_eq!( - outputs, - &[ - TxOutput { - address: bob.address().to_string(), - amount: 2 - }, - TxOutput { - address: alice.address().to_string(), - amount: 2 - } - ] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!(balances.get(bob.address()).copied(), Some(2)); - assert_eq!(balances.get(alice.address()).copied(), Some(7)); -} - -#[test] -fn burn_can_spend_selected_utxos_when_they_cover_amount_and_fee() { - let alice = Wallet::from_seed("selected-burn-utxos-alice"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); - let selected = vec![test_utxo_outpoint(1)]; - - let tx = ledger - .build_burn_with_inputs(&alice, 1, 1, &selected) - .unwrap(); - - let Transaction::Burn { - inputs, - change, - amount, - fee, - .. - } = &tx - else { - panic!("expected burn"); - }; - assert_eq!(*amount, 1); - assert_eq!(*fee, 1); - assert_eq!(inputs.len(), 1); - assert_eq!(inputs[0].outpoint, selected[0]); - assert_eq!( - change, - &[TxOutput { - address: alice.address().to_string(), - amount: 1 - }] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!(balances.get(alice.address()).copied(), Some(8)); -} - -#[test] -fn transfer_rejects_selected_utxos_that_do_not_cover_amount_plus_fee() { - let alice = Wallet::from_seed("selected-utxos-insufficient-alice"); - let bob = Wallet::from_seed("selected-utxos-insufficient-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); - let selected = vec![test_utxo_outpoint(0)]; - - let error = ledger - .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) - .unwrap_err(); - - assert!(format!("{error:#}").contains("selected UTXOs do not cover")); -} - -#[test] -fn transfer_rejects_selected_utxos_owned_by_someone_else() { - let alice = Wallet::from_seed("selected-utxos-owner-alice"); - let bob = Wallet::from_seed("selected-utxos-owner-bob"); - let carol = Wallet::from_seed("selected-utxos-owner-carol"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[5]); - ledger.utxos.insert( - named_test_outpoint("carol"), - TxOutput { - address: carol.address().to_string(), - amount: 5, - }, - ); - let selected = vec![named_test_outpoint("carol")]; - - let error = ledger - .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) - .unwrap_err(); - - assert!(format!("{error:#}").contains("is not owned")); -} - -#[test] -fn transfer_rejects_when_combined_utxos_do_not_cover_amount_plus_fee() { - let alice = Wallet::from_seed("combine-insufficient-alice"); - let bob = Wallet::from_seed("combine-insufficient-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]); - - let error = ledger - .build_transfer(&alice, bob.address(), 3, 1) - .unwrap_err(); - - assert!(format!("{error:#}").contains("insufficient funds")); -} - -#[test] -fn pending_change_from_combined_utxos_can_fund_next_transaction() { - let alice = Wallet::from_seed("combine-pending-change-alice"); - let bob = Wallet::from_seed("combine-pending-change-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); - - let first = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - let first_signature = first.signature().to_string(); - ledger.submit_transaction(first).unwrap(); - - let second = ledger.build_transfer(&alice, bob.address(), 1, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &second else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 1); - assert_eq!(inputs[0].outpoint.txid, first_signature); - assert_eq!(inputs[0].outpoint.index, 1); - - ledger.submit_transaction(second).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!( - balances.get(alice.address()).copied().unwrap_or_default(), - 0 - ); - assert_eq!(balances.get(bob.address()).copied(), Some(4)); -} - -#[test] -fn winning_burn_ticket_is_consumed_even_when_window_remains() { - let parent = ticket_test_block(3, FinalizerMode::Ticket, 0, "parent"); - let mut tickets = vec![ - BurnTicket { - id: "high-burn".to_string(), - owner: "alice".to_string(), - amount: 10_000, - eligible_from_height: 4, - eligible_until_height: 6, - }, - BurnTicket { - id: "small-burn".to_string(), - owner: "bob".to_string(), - amount: 1, - eligible_from_height: 5, - eligible_until_height: 7, - }, - ]; - let block = ticket_test_child(&parent, 4, "alice", 0, "high-burn"); - - consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); - - assert!( - tickets.iter().all(|ticket| ticket.id != "high-burn"), - "a winning burn must not remain eligible for the rest of its window" - ); - assert!( - tickets.iter().any(|ticket| ticket.id == "small-burn"), - "unselected future tickets should remain pending" - ); -} - -fn ticket_test_block( - height: u64, - finalizer_mode: FinalizerMode, - finalizer_rank: u32, - seed: &str, -) -> Block { - let mut block = Block { - height, - prev_hash: hex_hash(format!("ticket-test-prev:{seed}:{height}")), - timestamp_ms: height, - miner: seed.to_string(), - finalizer_mode, - finalizer_rank, - reward: BLOCK_REWARD, - vdf_rounds: 1, - vdf_output: hex_hash(format!("ticket-test-vdf:{seed}:{height}")), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions: Vec::new(), - hash: String::new(), - }; - block.hash = block.compute_hash(); - block -} - -fn ticket_test_child( - parent: &Block, - height: u64, - miner: &str, - finalizer_rank: u32, - ticket_id: &str, -) -> Block { - let mut block = ticket_test_block(height, FinalizerMode::Ticket, finalizer_rank, miner); - block.prev_hash = parent.hash.clone(); - block.leader_proof = Some(LeaderProof { - ticket_id: ticket_id.to_string(), - public_key: miner.to_string(), - signature: "signature".to_string(), - }); - block.hash = block.compute_hash(); - block -} - -fn fallback_invalidation_tickets(height: u64) -> Vec<BurnTicket> { - vec![ - BurnTicket { - id: "alice-main".to_string(), - owner: "alice".to_string(), - amount: 10, - eligible_from_height: height, - eligible_until_height: height + 2, - }, - BurnTicket { - id: "alice-other-eligible".to_string(), - owner: "alice".to_string(), - amount: 7, - eligible_from_height: height, - eligible_until_height: height + 2, - }, - BurnTicket { - id: "bob-main".to_string(), - owner: "bob".to_string(), - amount: 9, - eligible_from_height: height, - eligible_until_height: height + 2, - }, - BurnTicket { - id: "bob-other-eligible".to_string(), - owner: "bob".to_string(), - amount: 6, - eligible_from_height: height, - eligible_until_height: height + 2, - }, - BurnTicket { - id: "carol-main".to_string(), - owner: "carol".to_string(), - amount: 8, - eligible_from_height: height, - eligible_until_height: height + 2, - }, - BurnTicket { - id: "future-same-owner".to_string(), - owner: "alice".to_string(), - amount: 5, - eligible_from_height: height + 1, - eligible_until_height: height + 3, - }, - ] -} - -#[test] -fn primary_ticket_at_activation_height_only_consumes_winning_ticket() { - let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT; - let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "primary-parent"); - let mut tickets = fallback_invalidation_tickets(height); - let ranked = ranked_tickets_for_height(&parent, height, &tickets); - let leader = &ranked[0]; - let leader_id = leader.id.clone(); - let leader_owner = leader.owner.clone(); - let eligible_before = tickets - .iter() - .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) - .count(); - let block = ticket_test_child(&parent, height, &leader_owner, 0, &leader_id); - - consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); - - assert!(tickets.iter().all(|ticket| ticket.id != leader_id)); - let eligible_after = tickets - .iter() - .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) - .count(); - assert_eq!(eligible_after, eligible_before - 1); -} - -#[test] -fn fallback_ticket_before_activation_height_only_consumes_winning_ticket() { - let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT - 1; - let parent = ticket_test_block( - height - 1, - FinalizerMode::Ticket, - 0, - "legacy-fallback-parent", - ); - let mut tickets = fallback_invalidation_tickets(height); - let ranked = ranked_tickets_for_height(&parent, height, &tickets); - let fallback = &ranked[1]; - let fallback_id = fallback.id.clone(); - let fallback_owner = fallback.owner.clone(); - let eligible_before = tickets - .iter() - .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) - .count(); - let block = ticket_test_child(&parent, height, &fallback_owner, 1, &fallback_id); - - consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); - - assert!(tickets.iter().all(|ticket| ticket.id != fallback_id)); - let eligible_after = tickets - .iter() - .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) - .count(); - assert_eq!(eligible_after, eligible_before - 1); -} - -#[test] -fn fallback_ticket_invalidates_missed_ranks_and_current_sibling_tickets_from_height_300() { - let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT; - let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "fallback-parent"); - let mut tickets = fallback_invalidation_tickets(height); - let ranked = ranked_tickets_for_height(&parent, height, &tickets); - let prefix_owners = ranked - .iter() - .take(2) - .map(|ticket| ticket.owner.clone()) - .collect::<BTreeSet<_>>(); - let fallback = &ranked[1]; - let block = ticket_test_child(&parent, height, &fallback.owner, 1, &fallback.id); - - consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); - - assert!( - tickets.iter().all(|ticket| { - !ticket_is_eligible_for_height(ticket, height) || !prefix_owners.contains(&ticket.owner) - }), - "eligible tickets from missed ranks and the fallback finalizer should be invalidated" - ); - assert!( - tickets - .iter() - .any(|ticket| ticket.id == "future-same-owner"), - "future tickets from invalidated owners should stay pending" - ); -} - -#[test] -fn burn_leader_ranks_for_block_reconstructs_historical_ticket_order() { - let alice = Wallet::from_seed("burn-rank-alice"); - let bob = Wallet::from_seed("burn-rank-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(alice.address(), MICRO_IUNA), - GenesisBurn::new(bob.address(), MICRO_IUNA), - ], - 1, - ) - .unwrap(); - - let ranks = ledger.burn_leader_ranks_for_block(1).unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - - assert_eq!(ranks.len(), 2); - assert_eq!(ranks[0].rank, 0); - assert_eq!(ranks[0].owner, leader); - assert!(ranks.iter().all(|rank| rank.amount == MICRO_IUNA)); - assert_eq!(ledger.burn_leader_ranks_for_block(0).unwrap(), Vec::new()); - - let batch = ledger.burn_leader_ranks_for_blocks([0, 1]).unwrap(); - assert!(batch.get(&0).unwrap().is_empty()); - assert_eq!(batch.get(&1), Some(&ranks)); -} - -#[test] -fn mine_recipient_is_bound_to_proof_hash() { - let alice = Wallet::from_seed("mine-proof-alice"); - let bob = Wallet::from_seed("mine-proof-bob"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let mut forged = unsigned_mine(&ledger, alice.address()); - if let Transaction::Mine { recipient, .. } = &mut forged { - *recipient = bob.address().to_string(); - } - - let error = ledger.submit_transaction(forged).unwrap_err(); - - assert!(format!("{error:#}").contains("proof hash is invalid")); -} - -#[test] -fn mine_action_uses_fixed_reward_and_fixed_finalizer_fee() { - let alice = Wallet::from_seed("mine-fixed-reward-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - - let mine = ledger.build_mine(alice.address()).unwrap(); - - assert_eq!(mine.amount(), MINE_REWARD); - assert_eq!(mine.fee(), MINE_FINALIZER_FEE); - assert!(ledger.submit_transaction(mine).unwrap()); -} - -#[test] -fn burn_fee_goes_to_block_finalizer() { - let alice = Wallet::from_seed("burn-fee-finalizer-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - - let burn_fee = 12; - let burn = ledger - .build_burn(&alice, TEST_BURN_AMOUNT, burn_fee) - .unwrap(); - ledger.submit_transaction(burn).unwrap(); - let prepared = ledger.prepare_next_block(alice.address(), 1).unwrap(); - - assert_eq!(prepared.reward, burn_fee); -} - -#[test] -fn blinded_burn_commits_ciphertext_and_reveal_executes_later() { - let alice = Wallet::from_seed("blinded-burn-finalizer-alice"); - let bob = Wallet::from_seed("blinded-burn-finalizer-bob"); - let carol = Wallet::from_seed("blinded-burn-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let fee = 100; - let burn_amount = 3; - let before_carol = ledger.balance_of(carol.address()); - - let blinded = ledger - .build_blinded_burn(&carol, burn_amount, fee, ledger.height() + 4) - .unwrap(); - assert!(!blinded.transaction.ciphertext.contains("burn")); - assert!(!blinded.transaction.ciphertext.contains(carol.address())); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - - let commit_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - let inclusion_finalizer = commit_block.miner.clone(); - assert_eq!( - commit_block - .transactions - .iter() - .filter(|transaction| transaction.is_burn()) - .count(), - 1 - ); - assert_eq!(commit_block.blinded_transactions, vec![blinded.transaction]); - assert_eq!(commit_block.reward, 0); - let before_inclusion_finalizer = ledger.balance_of(&inclusion_finalizer); - - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let reveal_block = - mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2); - let reveal_executor = reveal_block.miner.clone(); - - assert_eq!(reveal_block.all_blinded_reveals().len(), 1); - assert_eq!( - ledger.balance_of(carol.address()), - before_carol - burn_amount - fee - ); - assert!(ledger.tickets.iter().any(|ticket| { - ticket.owner == carol.address() - && ticket.amount == burn_amount - && ticket.eligible_from_height - == reveal_block.height + ledger.launch_profile.ticket_maturity_delay_heights - })); - let reveal_plaintext_burn_spent_by_inclusion_finalizer = reveal_block - .transactions - .iter() - .filter(|transaction| { - transaction.is_burn() && transaction.sender() == inclusion_finalizer.as_str() - }) - .fold(0_u64, |total, transaction| { - total + transaction.amount() + transaction.fee() - }); - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - let reveal_finalizer_fee = blinded_reveal_finalizer_fee( - fee, - reveal_block.included_reveal_bundle_count(), - ledger - .burn_leader_ranks_for_block(reveal_block.height) - .unwrap() - .len(), - ); - let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - let commitment = &commit_block.blinded_transactions[0].commitment; - assert_eq!( - ledger - .utxos - .get(&blinded_committer_fee_outpoint(commitment)) - .unwrap(), - &TxOutput { - address: inclusion_finalizer.clone(), - amount: committer_fee, - } - ); - assert!( - !ledger - .utxos - .contains_key(&blinded_executor_fee_outpoint(commitment)) - ); - assert_eq!(reveal_block.reward, reveal_finalizer_fee); - assert_eq!( - ledger - .utxos - .get(&reward_outpoint(&reveal_block.hash)) - .unwrap(), - &TxOutput { - address: reveal_executor.clone(), - amount: reveal_finalizer_fee, - } - ); - for signature in &reveal_block.reveal_bundle_section.signatures { - assert_eq!( - ledger - .utxos - .get(&blinded_reveal_bundle_signer_fee_outpoint( - commitment, - signature.slot - )) - .unwrap(), - &TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - } - ); - } - let mut inclusion_finalizer_fee = committer_fee; - if inclusion_finalizer == reveal_executor { - inclusion_finalizer_fee += reveal_finalizer_fee; - } - inclusion_finalizer_fee += reveal_block - .reveal_bundle_section - .signatures - .iter() - .filter(|signature| signature.member == inclusion_finalizer) - .count() as u64 - * reveal_bundle_signer_fee; - assert_eq!( - ledger.balance_of(&inclusion_finalizer), - before_inclusion_finalizer + inclusion_finalizer_fee - - reveal_plaintext_burn_spent_by_inclusion_finalizer - ); -} - -#[test] -fn reveal_bundle_signature_thresholds_are_inactive_before_activation_height() { - let (mut ledger, finalizers, _) = - prepare_active_blinded_burn_for_reveal_thresholds_at_next_height( - [ - "reveal-threshold-preactivation-attacker", - "reveal-threshold-preactivation-bob", - "reveal-threshold-preactivation-carol", - "reveal-threshold-preactivation-victim", - ], - REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - 1, - ); - let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone(); - - let block = mine_own_burn_only_without_reveal_bundles(&mut ledger, &rank0, 1, 2).unwrap(); - - assert_eq!(block.height, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - 1); - assert_eq!(block.included_reveal_bundle_count(), 0); -} - -#[test] -fn rank0_finalizer_needs_all_reveal_bundle_signatures_when_blinded_is_active() { - let (ledger, finalizers, blinded) = prepare_active_blinded_burn_for_reveal_thresholds([ - "reveal-threshold-r0-attacker", - "reveal-threshold-r0-bob", - "reveal-threshold-r0-carol", - "reveal-threshold-r0-victim", - ]); - let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone(); - let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); - assert_eq!(bundles.len(), 3); - assert!(bundles.iter().all(|bundle| { - bundle - .reveals - .iter() - .any(|reveal| reveal.commitment == blinded.transaction.commitment) - })); - - let mut missing_one = ledger.clone(); - let error = - try_mine_rank_with_reveal_bundles(&mut missing_one, &rank0, 1, 2, bundles[..1].to_vec()) - .unwrap_err(); - assert!(format!("{error:#}").contains("got 0, need 2")); - - let mut complete = ledger; - let block = try_mine_rank_with_reveal_bundles(&mut complete, &rank0, 1, 2, bundles).unwrap(); - assert_eq!(block.finalizer_rank, 0); - assert_eq!(block.included_reveal_bundle_count(), 2); - assert_eq!(block.all_blinded_reveals().len(), 1); -} - -#[test] -fn rank1_finalizer_needs_two_reveal_bundle_signatures_when_blinded_is_active() { - let (ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ - "reveal-threshold-r1-attacker", - "reveal-threshold-r1-bob", - "reveal-threshold-r1-carol", - "reveal-threshold-r1-victim", - ]); - let rank1 = next_rank_wallet(&ledger, &finalizers, 1).clone(); - let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); - assert_eq!(bundles.len(), 3); - - let mut missing_one = ledger.clone(); - let error = try_mine_rank_with_reveal_bundles( - &mut missing_one, - &rank1, - 1, - VDF_TARGET_BLOCK_MS * 2, - Vec::new(), - ) - .unwrap_err(); - assert!(format!("{error:#}").contains("got 0, need 1")); - - let mut enough = ledger; - let block = try_mine_rank_with_reveal_bundles( - &mut enough, - &rank1, - 1, - VDF_TARGET_BLOCK_MS * 2, - bundles[1..2].to_vec(), - ) - .unwrap(); - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.included_reveal_bundle_count(), 1); -} - -#[test] -fn rank2_finalizer_can_publish_without_explicit_reveal_bundle_signature_when_blinded_is_active() { - let (ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ - "reveal-threshold-r2-attacker", - "reveal-threshold-r2-bob", - "reveal-threshold-r2-carol", - "reveal-threshold-r2-victim", - ]); - let rank2 = next_rank_wallet(&ledger, &finalizers, 2).clone(); - let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); - assert_eq!(bundles.len(), 3); - - let mut solo = ledger; - let block = try_mine_rank_with_reveal_bundles( - &mut solo, - &rank2, - 1, - VDF_TARGET_BLOCK_MS * 4, - Vec::new(), - ) - .unwrap(); - assert_eq!(block.finalizer_rank, 2); - assert_eq!(block.included_reveal_bundle_count(), 0); -} - -#[test] -fn active_blinded_envelope_rejects_own_burn_only_block_without_reveal_list_threshold() { - let (mut ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ - "reveal-threshold-empty-attacker", - "reveal-threshold-empty-bob", - "reveal-threshold-empty-carol", - "reveal-threshold-empty-victim", - ]); - let rank1 = next_rank_wallet(&ledger, &finalizers, 1).clone(); - - let error = - mine_own_burn_only_without_reveal_bundles(&mut ledger, &rank1, 1, VDF_TARGET_BLOCK_MS * 2) - .unwrap_err(); - assert!(format!("{error:#}").contains("got 0, need 1")); -} - -#[test] -fn explicit_slot_zero_reveal_signature_is_rejected_from_height_1500() { - let (mut ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ - "reveal-slot0-reject-attacker", - "reveal-slot0-reject-bob", - "reveal-slot0-reject-carol", - "reveal-slot0-reject-victim", - ]); - let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone(); - let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); - let burn = ledger.build_burn(&rank0, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(rank0.address(), 1, bundles.clone()) - .unwrap(); - let mut block = prepared.finish(&rank0, "preverified-vdf".to_string()); - block.reveal_bundle_section.signatures.insert( - 0, - RevealBundleSignature { - slot: bundles[0].slot, - member: bundles[0].member.clone(), - signature: bundles[0].signature.clone(), - }, - ); - block.reveal_bundle_section.signatures.pop(); - let error = ledger - .validate_reveal_bundle_section_for_block( - block.height, - &block.prev_hash, - block.finalizer_mode, - block.finalizer_rank, - &block.reveal_bundle_section, - ) - .unwrap_err(); - - assert!( - format!("{error:#}").contains("finalizer reveal attestation must be implicit"), - "{error:#}" - ); -} - -#[test] -fn finalizer_reveal_attestation_changes_vdf_seed_without_explicit_signature() { - let (mut ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ - "reveal-synthetic-seed-attacker", - "reveal-synthetic-seed-bob", - "reveal-synthetic-seed-carol", - "reveal-synthetic-seed-victim", - ]); - let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone(); - let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); - let burn = ledger.build_burn(&rank0, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(rank0.address(), 1, bundles) - .unwrap(); - let block = prepared.finish(&rank0, "preverified-vdf".to_string()); - let mut other_finalizer = block.clone(); - other_finalizer.miner = Wallet::from_seed("reveal-synthetic-other") - .address() - .to_string(); - - assert!( - block - .reveal_bundle_section - .signatures - .iter() - .all(|signature| signature.slot != 0) - ); - assert_ne!( - block.reveal_bundle_hashes()[0], - default_reveal_bundle_hash(0) - ); - assert_ne!( - block.reveal_bundle_hashes()[0], - other_finalizer.reveal_bundle_hashes()[0] - ); - assert_ne!(block.vdf_seed(), other_finalizer.vdf_seed()); -} - -#[test] -fn reveal_fee_count_includes_implicit_finalizer_attestation_from_height_1500() { - let reveal = BlindedReveal { - commitment: "implicit-fee-count".to_string(), - key: "key".to_string(), - }; - let section = RevealBundleSection { - signatures: Vec::new(), - reveals: vec![MaskedBlindedReveal { - reveal, - bundle_mask: 0, - }], - }; - - assert_eq!( - reveal_fee_bundle_count_for_height( - REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, - &section, - &section.reveals[0], - ), - 1 - ); - assert!( - reveal_fee_signatures_for_height( - REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, - &section, - &section.reveals[0], - ) - .is_empty() - ); -} - -#[test] -fn blinded_reveal_finalizer_fees_are_aggregated_into_block_reward() { - let alice = Wallet::from_seed("aggregated-finalizer-fee-alice"); - let bob = Wallet::from_seed("aggregated-finalizer-fee-bob"); - let carol = Wallet::from_seed("aggregated-finalizer-fee-carol"); - let dave = Wallet::from_seed("aggregated-finalizer-fee-dave"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers( - &finalizers, - &[(&carol, 10 * MICRO_IUNA), (&dave, 10 * MICRO_IUNA)], - ); - let first_fee = 100; - let second_fee = 200; - let first_blinded = ledger - .build_blinded_burn(&carol, 3, first_fee, ledger.height() + 4) - .unwrap(); - let second_blinded = ledger - .build_blinded_burn(&dave, 4, second_fee, ledger.height() + 4) - .unwrap(); - let first_commitment = first_blinded.transaction.commitment.clone(); - let second_commitment = second_blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(first_blinded.transaction) - .unwrap(); - ledger - .submit_blinded_transaction(second_blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let commit_timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let commit_block = - mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms); - assert_eq!(commit_block.height, 1); - - ledger.submit_blinded_reveal(first_blinded.reveal).unwrap(); - ledger.submit_blinded_reveal(second_blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let reveal_timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let reveal_block = prepare_preverified_as_next_leader_with_reveal_bundles( - &ledger, - &finalizers, - reveal_timestamp_ms, - ); - let first_reveal_finalizer_fee = blinded_reveal_finalizer_fee( - first_fee, - reveal_block.included_reveal_bundle_count(), - ledger - .burn_leader_ranks_for_block(reveal_block.height) - .unwrap() - .len(), - ); - let second_reveal_finalizer_fee = blinded_reveal_finalizer_fee( - second_fee, - reveal_block.included_reveal_bundle_count(), - ledger - .burn_leader_ranks_for_block(reveal_block.height) - .unwrap() - .len(), - ); - let aggregate_reveal_finalizer_fee = first_reveal_finalizer_fee - .checked_add(second_reveal_finalizer_fee) - .unwrap(); - - assert_eq!(reveal_block.height, 2); - assert_eq!(reveal_block.reward, aggregate_reveal_finalizer_fee); - let mut plain_fee_reward_block = reveal_block.clone(); - plain_fee_reward_block.reward = fee_reward(&plain_fee_reward_block.transactions).unwrap(); - plain_fee_reward_block.hash = plain_fee_reward_block.compute_hash(); - let error = ledger - .clone() - .apply_preverified_block_at(plain_fee_reward_block, u64::MAX) - .unwrap_err(); - assert!(format!("{error:#}").contains("block reward is invalid")); - - ledger - .apply_preverified_block_at(reveal_block.clone(), u64::MAX) - .unwrap(); - assert!( - !ledger - .utxos - .contains_key(&blinded_executor_fee_outpoint(&first_commitment)) - ); - assert!( - !ledger - .utxos - .contains_key(&blinded_executor_fee_outpoint(&second_commitment)) - ); - assert_eq!( - ledger.utxos.get(&reward_outpoint(&reveal_block.hash)), - Some(&TxOutput { - address: reveal_block.miner.clone(), - amount: aggregate_reveal_finalizer_fee, - }) - ); -} - -#[test] -fn blinded_utxo_commit_exposes_and_locks_inputs_until_reveal_or_expiry() { - let alice = Wallet::from_seed("blinded-lock-alice"); - let bob = Wallet::from_seed("blinded-lock-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); - let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap(); - let visible_inputs = transfer.inputs().to_vec(); - - let blinded = ledger - .build_blinded_transaction(&alice, transfer, ledger.height() + 4) - .unwrap(); - - assert_eq!(blinded.transaction.inputs.len(), visible_inputs.len()); - assert_eq!( - unsigned_inputs(&blinded.transaction.inputs), - unsigned_inputs(&visible_inputs) - ); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - let error = ledger - .build_transfer(&alice, bob.address(), 1, 0) - .unwrap_err(); - assert!(format!("{error:#}").contains("insufficient funds")); -} - -#[test] -fn blinded_payload_omits_visible_inputs_and_reconstructs_transaction_on_reveal() { - let alice = Wallet::from_seed("blinded-compact-payload-alice"); - let bob = Wallet::from_seed("blinded-compact-payload-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[10]); - let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap(); - let full_transaction_bytes = serde_json::to_vec(&transfer).unwrap().len(); - let blinded = ledger - .build_blinded_transaction(&alice, transfer.clone(), ledger.height() + 4) - .unwrap(); - let key = decode_hex_array::<BLINDED_KEY_BYTES>(&blinded.reveal.key).unwrap(); - let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&blinded.transaction.nonce).unwrap(); - let ciphertext = decode_hex(&blinded.transaction.ciphertext).unwrap(); - - let plaintext = decrypt_blinded_payload( - &key, - &nonce, - &signed_blinded_inputs(&unsigned_inputs(&blinded.transaction.inputs), ""), - blinded.transaction.fee, - blinded.transaction.expires_at_height, - &ciphertext, - ) - .unwrap(); - let payload: serde_json::Value = serde_json::from_slice(&plaintext).unwrap(); - let revealed = decrypt_blinded_transaction(&blinded.transaction, &blinded.reveal).unwrap(); - - assert_eq!( - payload.get("kind").and_then(|kind| kind.as_str()), - Some("transfer") - ); - assert!(payload.get("inputs").is_none()); - assert!(plaintext.len() < full_transaction_bytes); - assert_eq!(revealed, transfer); -} - -#[test] -fn unrevealed_blinded_utxo_commit_burns_fee_and_returns_change() { - let alice = Wallet::from_seed("blinded-expiry-alice"); - let bob = Wallet::from_seed("blinded-expiry-bob"); - let carol = Wallet::from_seed("blinded-expiry-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let carol_balance = 10 * MICRO_IUNA; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, carol_balance)]); - let fee = 100; - let blinded = ledger - .build_blinded_burn(&carol, 3, fee, ledger.height() + 2) - .unwrap(); - let commitment = blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - assert_eq!(ledger.balance_of(carol.address()), 0); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); - - assert_eq!( - ledger - .utxos - .get(&blinded_committer_fee_outpoint(&commitment)), - None - ); - assert_eq!( - ledger - .utxos - .get(&blinded_expiry_change_outpoint(&commitment)), - Some(&TxOutput { - address: carol.address().to_string(), - amount: carol_balance - fee, - }) - ); - assert_eq!(ledger.balance_of(carol.address()), carol_balance - fee); -} - -#[test] -fn fee_bearing_blinded_commit_without_inputs_is_rejected() { - let alice = Wallet::from_seed("blinded-no-input-fee-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let mut blinded = ledger - .build_blinded_burn(&alice, 1, 1, ledger.height() + 4) - .unwrap() - .transaction; - blinded.inputs.clear(); - blinded.commitment = blinded_transaction_commitment(&blinded).unwrap(); - - let error = ledger.submit_blinded_transaction(blinded).unwrap_err(); - - assert!(format!("{error:#}").contains("must lock visible inputs")); -} - -fn set_tip_height_for_validation(ledger: &mut Ledger, height: u64) { - ledger.chain.last_mut().unwrap().height = height; -} - -fn inputless_zero_fee_blinded_burn(ledger: &Ledger, wallet: &Wallet) -> BlindedTransaction { - let mut builder = ledger.clone(); - if builder.height().saturating_add(1) >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT { - set_tip_height_for_validation(&mut builder, BLOCK_ITEM_FEES_REQUIRED_HEIGHT - 2); - } - let mut blinded = builder - .build_blinded_burn(wallet, 1, 0, ledger.height() + 4) - .unwrap() - .transaction; - blinded.inputs.clear(); - blinded.commitment = blinded_transaction_commitment(&blinded).unwrap(); - blinded -} - -#[test] -fn inputless_zero_fee_blinded_commit_is_allowed_before_height_750() { - let alice = Wallet::from_seed("blinded-no-input-before-activation-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - set_tip_height_for_validation(&mut ledger, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 2); - let blinded = inputless_zero_fee_blinded_burn(&ledger, &alice); - - assert!(ledger.submit_blinded_transaction(blinded).unwrap()); -} - -#[test] -fn inputless_zero_fee_blinded_commit_is_rejected_from_height_750() { - let alice = Wallet::from_seed("blinded-no-input-after-activation-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - set_tip_height_for_validation(&mut ledger, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 1); - let blinded = inputless_zero_fee_blinded_burn(&ledger, &alice); - - let error = ledger.submit_blinded_transaction(blinded).unwrap_err(); - - assert!(format!("{error:#}").contains("must lock visible inputs from height 750")); -} - -#[test] -fn zero_fee_blinded_transaction_creation_is_rejected_from_height_750() { - let alice = Wallet::from_seed("blinded-zero-fee-builder-after-activation-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - set_tip_height_for_validation(&mut ledger, BLOCK_ITEM_FEES_REQUIRED_HEIGHT - 1); - - let error = ledger - .build_blinded_burn(&alice, 1, 0, ledger.height() + 4) - .unwrap_err(); - - assert!(format!("{error:#}").contains("must pay a fee from height 750")); -} - -#[test] -fn automatic_burn_builds_paid_blinded_transaction_from_height_750() { - let alice = Wallet::from_seed("auto-burn-after-fee-activation-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - set_tip_height_for_validation(&mut ledger, BLOCK_ITEM_FEES_REQUIRED_HEIGHT - 1); - let mut node = - crate::app::NodeCore::from_ledger_with_burn_fee_and_enabled(alice, ledger, true, 50, 1); - - let plan = node.prepare_automatic_mining(1); - - let burned = plan - .burned - .as_ref() - .expect("automatic burn should be built at fee activation"); - assert_eq!(burned.amount(), 50); - assert!(burned.fee() > 0); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - assert_eq!( - node.ledger().pending_blinded_transactions()[0].fee, - burned.fee() - ); -} - -fn block_for_fee_policy(miner: &Wallet, height: u64, transactions: Vec<Transaction>) -> Block { - Block { - height, - prev_hash: "0".repeat(64), - timestamp_ms: height, - miner: miner.address().to_string(), - finalizer_mode: FinalizerMode::Ticket, - finalizer_rank: 0, - reward: fee_reward(&transactions).unwrap(), - vdf_rounds: 1, - vdf_output: "vdf".to_string(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions, - hash: String::new(), - } -} - -#[test] -fn block_fee_policy_allows_one_zero_fee_finalizer_anchor_from_height_750() { - let alice = Wallet::from_seed("fee-policy-finalizer-anchor-alice"); - let ledger = ledger_with_allocation(&alice, 3 * MICRO_IUNA); - let anchor = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - let paid = ledger.build_burn(&alice, MICRO_IUNA, 1).unwrap(); - let block = block_for_fee_policy(&alice, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, vec![anchor, paid]); - - validate_block_fee_policy(&block).unwrap(); -} - -#[test] -fn block_fee_policy_rejects_zero_fee_non_finalizer_burn_from_height_750() { - let alice = Wallet::from_seed("fee-policy-finalizer-alice"); - let bob = Wallet::from_seed("fee-policy-non-finalizer-bob"); - let ledger = ledger_with_allocation(&bob, MICRO_IUNA); - let burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); - let block = block_for_fee_policy(&alice, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, vec![burn]); - - let error = validate_block_fee_policy(&block).unwrap_err(); - - assert!(format!("{error:#}").contains("must pay a fee from height 750")); -} - -#[test] -fn block_fee_policy_rejects_second_zero_fee_finalizer_burn_from_height_750() { - let alice = Wallet::from_seed("fee-policy-second-anchor-alice"); - let ledger = ledger_with_wallet_utxos(&alice, &[MICRO_IUNA, MICRO_IUNA]); - let first = ledger - .build_burn_with_inputs(&alice, MICRO_IUNA, 0, &[test_utxo_outpoint(0)]) - .unwrap(); - let second = ledger - .build_burn_with_inputs(&alice, MICRO_IUNA, 0, &[test_utxo_outpoint(1)]) - .unwrap(); - let block = block_for_fee_policy(&alice, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, vec![first, second]); - - let error = validate_block_fee_policy(&block).unwrap_err(); - - assert!(format!("{error:#}").contains("only one zero-fee finalizer anchor burn")); -} - -fn large_inputless_zero_fee_blinded_spam(index: usize) -> BlindedTransaction { - let ciphertext = format!("{index:08x}{}", "ab".repeat(40_000)); - let mut transaction = BlindedTransaction { - commitment: String::new(), - inputs: Vec::new(), - fee: 0, - encrypted_size: 40_004, - expires_at_height: BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 1, - nonce: format!("{index:024x}"), - ciphertext, - payload_hash: hex_hash(format!("large-blinded-spam:{index}")), - }; - transaction.commitment = blinded_transaction_commitment(&transaction).unwrap(); - transaction -} - -#[test] -fn blinded_mempool_rejects_byte_limit_even_before_height_750() { - let alice = Wallet::from_seed("blinded-byte-limit-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - set_tip_height_for_validation(&mut ledger, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 3); - - let sample = large_inputless_zero_fee_blinded_spam(0); - let sample_bytes = serde_json::to_vec(&sample).unwrap().len(); - let existing_count = MAX_PENDING_POOL_BYTES / sample_bytes; - ledger.pending_blinded = (0..existing_count) - .map(large_inputless_zero_fee_blinded_spam) - .collect(); - ledger.refresh_pending_pool_byte_counters().unwrap(); - - let error = ledger - .submit_blinded_transaction(large_inputless_zero_fee_blinded_spam(existing_count)) - .unwrap_err(); - - assert!(existing_count > 1); - assert!(existing_count < MAX_PENDING_TRANSACTIONS); - assert!(format!("{error:#}").contains("blinded mempool byte limit exceeded")); -} - -#[test] -fn mine_actions_cannot_be_blinded() { - let alice = Wallet::from_seed("blinded-mine-collateral-alice"); - let ledger = ledger_with_finalizers(&[alice.clone()], &[]); - let mine = ledger.build_mine(alice.address()).unwrap(); - let error = ledger - .build_blinded_transaction(&alice, mine, ledger.height() + 4) - .unwrap_err(); - - assert!(format!("{error:#}").contains("mine actions are public")); -} - -#[test] -fn reveal_bundle_hashes_are_bound_to_next_block_vdf_seed() { - let alice = Wallet::from_seed("bundle-seed-alice"); - let bob = Wallet::from_seed("bundle-seed-bob"); - let carol = Wallet::from_seed("bundle-seed-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(&finalizers, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - if bundles.len() > 1 { - let mut reversed = bundles.clone(); - reversed.reverse(); - let error = ledger - .validate_next_block_reveal_bundles(reversed) - .unwrap_err(); - assert!(format!("{error:#}").contains("reveal bundles are not in slot order")); - } - - let without_bundles = ledger - .prepare_next_block(leader_wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - let with_bundles = ledger - .prepare_next_block_with_reveal_bundles( - leader_wallet.address(), - ledger.tip().timestamp_ms + 1, - bundles, - ) - .unwrap(); - - assert_ne!(without_bundles.vdf_seed(), with_bundles.vdf_seed()); -} - -#[test] -fn reveal_committee_includes_next_block_finalizer_as_slot_zero() { - let alice = Wallet::from_seed("bundle-finalizer-slot-alice"); - let bob = Wallet::from_seed("bundle-finalizer-slot-bob"); - let carol = Wallet::from_seed("bundle-finalizer-slot-carol"); - let dave = Wallet::from_seed("bundle-finalizer-slot-dave"); - let erin = Wallet::from_seed("bundle-finalizer-slot-erin"); - let finalizers = [alice.clone(), bob.clone(), carol.clone(), dave.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&erin, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&erin, 3, 7, ledger.height() + 4) - .unwrap(); - let commitment = blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let committee = ledger.reveal_committee_for_next_block(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let bundle = ledger.build_reveal_bundle(leader_wallet).unwrap().unwrap(); - - assert_eq!(committee.first().map(|member| member.slot), Some(0)); - assert_eq!(committee.first().map(|member| member.rank), Some(0)); - assert_eq!( - committee.first().map(|member| member.owner.as_str()), - Some(leader.as_str()) - ); - assert_eq!(bundle.slot, 0); - assert_eq!(bundle.member, leader); - assert!( - bundle - .reveals - .iter() - .any(|reveal| reveal.commitment == commitment) - ); -} - -#[test] -fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() { - let alice = Wallet::from_seed("bundle-compact-alice"); - let bob = Wallet::from_seed("bundle-compact-bob"); - let carol = Wallet::from_seed("bundle-compact-carol"); - let dave = Wallet::from_seed("bundle-compact-dave"); - let finalizers = [alice.clone(), bob.clone(), carol.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&dave, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&dave, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - - let mut bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(&finalizers, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - assert!(bundles.len() >= 2); - bundles.truncate(2); - let expected_hashes = reveal_bundle_hashes(&bundles); - let expected_mask = bundles - .iter() - .fold(0_u8, |mask, bundle| mask | (1_u8 << bundle.slot)); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let prepared = ledger - .prepare_next_block_with_reveal_bundles( - leader_wallet.address(), - ledger.tip().timestamp_ms + 1, - bundles.clone(), - ) - .unwrap(); - let block = prepared.finish(leader_wallet, "preverified-vdf".to_string()); - - assert_eq!(block.reveal_bundle_section.signatures.len(), 2); - assert_eq!(block.reveal_bundle_section.reveals.len(), 1); - assert_eq!( - block.reveal_bundle_section.reveals[0].bundle_mask, - expected_mask - ); - assert_eq!(block.all_blinded_reveals(), vec![&blinded.reveal]); - assert_eq!(block.reveal_bundle_hashes(), expected_hashes); - assert_eq!( - block - .reveal_bundle_section - .expand(block.height, &block.prev_hash), - bundles - ); -} - -fn ledger_with_duplicate_owner_reveal_tickets() -> Ledger { - let alice = Wallet::from_seed("bundle-unique-owner-alice"); - let bob = Wallet::from_seed("bundle-unique-owner-bob"); - let mut ledger = Ledger::new(BTreeMap::new(), 1); - ledger.tickets = vec![ - BurnTicket { - id: "alice-0".to_string(), - owner: alice.address().to_string(), - amount: MICRO_IUNA, - eligible_from_height: 499, - eligible_until_height: 500, - }, - BurnTicket { - id: "alice-1".to_string(), - owner: alice.address().to_string(), - amount: MICRO_IUNA, - eligible_from_height: 499, - eligible_until_height: 500, - }, - BurnTicket { - id: "bob-0".to_string(), - owner: bob.address().to_string(), - amount: MICRO_IUNA, - eligible_from_height: 499, - eligible_until_height: 500, - }, - ]; - ledger -} - -#[test] -fn reveal_committee_allows_duplicate_owners_before_height_500() { - let ledger = ledger_with_duplicate_owner_reveal_tickets(); - - let committee = ledger.reveal_committee_for_height(499); - let owners = committee - .iter() - .map(|member| member.owner.as_str()) - .collect::<BTreeSet<_>>(); - - assert_eq!(committee.len(), 3); - assert_eq!(owners.len(), 2); -} - -#[test] -fn reveal_committee_uses_unique_ticket_owners_from_height_500() { - let ledger = ledger_with_duplicate_owner_reveal_tickets(); - - let committee = ledger.reveal_committee_for_height(500); - let owners = committee - .iter() - .map(|member| member.owner.as_str()) - .collect::<BTreeSet<_>>(); - - assert_eq!(committee.len(), 2); - assert_eq!(owners.len(), 2); - assert_eq!(committee.first().map(|member| member.rank), Some(0)); -} - -fn lineage_committee_test_ledger(finalizer: &Wallet) -> Ledger { - let mut ledger = Ledger::new(BTreeMap::new(), 1); - set_tip_height_for_validation(&mut ledger, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - 1); - ledger.tickets = vec![BurnTicket { - id: "finalizer-ticket".to_string(), - owner: finalizer.address().to_string(), - amount: MICRO_IUNA, - eligible_from_height: REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, - eligible_until_height: REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, - }]; - ledger -} - -#[test] -fn reveal_committee_uses_lineage_roots_from_height_1500_without_split_multiplier() { - let finalizer = Wallet::from_seed("lineage-split-finalizer"); - let bob = Wallet::from_seed("lineage-split-bob"); - let carol = Wallet::from_seed("lineage-split-carol"); - let dave = Wallet::from_seed("lineage-split-dave"); - let mut ledger = lineage_committee_test_ledger(&finalizer); - let root_a = test_lineage_root("split-root-a", 1_470); - let root_b = test_lineage_root("split-root-b", 1_470); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("split-a-0"), - &bob, - 5 * MICRO_IUNA, - root_a.clone(), - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("split-a-1"), - &carol, - 5 * MICRO_IUNA, - root_a.clone(), - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("split-b-0"), - &dave, - 5 * MICRO_IUNA, - root_b, - ); - - let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT); - let roots = committee - .iter() - .skip(1) - .map(|member| member.ticket_id.as_str()) - .collect::<BTreeSet<_>>(); - - assert_eq!(committee.len(), 3); - assert_eq!(roots.len(), committee.len() - 1); - assert!(roots.contains(root_a.outpoint.id().as_str())); -} - -#[test] -fn reveal_committee_excludes_finalizer_owned_lineage_roots_from_extra_slots() { - let finalizer = Wallet::from_seed("lineage-finalizer-root-finalizer"); - let bob = Wallet::from_seed("lineage-finalizer-root-bob"); - let mut ledger = lineage_committee_test_ledger(&finalizer); - let finalizer_root = test_lineage_root("finalizer-root", 1_470); - let bob_root = test_lineage_root("bob-root", 1_470); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("finalizer-root-utxo"), - &finalizer, - 100 * MICRO_IUNA, - finalizer_root.clone(), - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("bob-root-utxo"), - &bob, - MICRO_IUNA, - bob_root.clone(), - ); - - let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT); - - assert_eq!(committee.len(), 2); - assert_eq!(committee[0].owner, finalizer.address()); - assert_eq!(committee[1].owner, bob.address()); - assert_eq!(committee[1].ticket_id, bob_root.outpoint.id()); - assert_ne!(committee[1].ticket_id, finalizer_root.outpoint.id()); -} - -#[test] -fn reveal_committee_excludes_root_even_when_finalizer_split_to_another_owner() { - let finalizer = Wallet::from_seed("lineage-finalizer-split-finalizer"); - let bob = Wallet::from_seed("lineage-finalizer-split-bob"); - let carol = Wallet::from_seed("lineage-finalizer-split-carol"); - let mut ledger = lineage_committee_test_ledger(&finalizer); - let finalizer_root = test_lineage_root("finalizer-split-root", 1_470); - let carol_root = test_lineage_root("carol-root", 1_470); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("finalizer-owned-root-piece"), - &finalizer, - MICRO_IUNA, - finalizer_root.clone(), - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("bob-owned-root-piece"), - &bob, - 100 * MICRO_IUNA, - finalizer_root.clone(), - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("carol-root-utxo"), - &carol, - MICRO_IUNA, - carol_root.clone(), - ); - - let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT); - - assert_eq!(committee.len(), 2); - assert_eq!(committee[1].ticket_id, carol_root.outpoint.id()); - assert_ne!(committee[1].ticket_id, finalizer_root.outpoint.id()); -} - -#[test] -fn lineage_merge_inherits_newest_root_and_tie_breaks_by_outpoint() { - let alice = Wallet::from_seed("lineage-merge-alice"); - let old_root = test_lineage_root("merge-old-root", 1_460); - let new_root = test_lineage_root("merge-new-root", 1_470); - let tie_left = test_lineage_root("merge-tie-left", 1_470); - let tie_right = test_lineage_root("merge-tie-right", 1_470); - - assert_eq!( - newest_lineage_root(Some(old_root.clone()), Some(new_root.clone())), - Some(new_root) - ); - assert_eq!( - newest_lineage_root(Some(tie_left.clone()), Some(tie_right.clone())), - Some(tie_left.max(tie_right)) - ); - - let mut ledger = Ledger::new(BTreeMap::new(), 1); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("merge-old-input"), - &alice, - 3, - old_root, - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("merge-new-input"), - &alice, - 4, - test_lineage_root("merge-applied-new-root", 1_480), - ); - let transaction = Transaction::Transfer { - inputs: vec![ - TxInput { - outpoint: named_test_outpoint("merge-old-input"), - owner: alice.address().to_string(), - signature: "genesis".to_string(), - }, - TxInput { - outpoint: named_test_outpoint("merge-new-input"), - owner: alice.address().to_string(), - signature: "genesis".to_string(), - }, - ], - outputs: vec![TxOutput { - address: alice.address().to_string(), - amount: 7, - }], - fee: 0, - signature: "lineage-merge-transfer".to_string(), - }; - let (_, inherited) = spend_inputs_with_lineage( - &transaction, - &mut ledger.utxos, - &mut ledger.utxo_lineage, - &mut ledger.lineage_values, - &mut ledger.lineage_owners, - ) - .unwrap(); - - assert_eq!(inherited.unwrap().height, 1_480); -} - -#[test] -fn lineage_representative_owner_uses_largest_unspent_output_for_root() { - let finalizer = Wallet::from_seed("lineage-representative-finalizer"); - let bob = Wallet::from_seed("lineage-representative-bob"); - let carol = Wallet::from_seed("lineage-representative-carol"); - let mut ledger = lineage_committee_test_ledger(&finalizer); - let root = test_lineage_root("representative-root", 1_470); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("representative-small"), - &bob, - MICRO_IUNA, - root.clone(), - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("representative-large"), - &carol, - 2 * MICRO_IUNA, - root, - ); - - let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT); - - assert_eq!(committee.len(), 2); - assert_eq!(committee[1].owner, carol.address()); -} - -#[test] -fn lineage_committee_reassigns_root_representative_after_owner_is_selected() { - let finalizer = Wallet::from_seed("lineage-reassign-finalizer"); - let bob = Wallet::from_seed("lineage-reassign-bob"); - let carol = Wallet::from_seed("lineage-reassign-carol"); - let mut ledger = lineage_committee_test_ledger(&finalizer); - let mut roots = [ - test_lineage_root("reassign-root-a", 1_470), - test_lineage_root("reassign-root-b", 1_470), - ]; - roots.sort(); - let first_index = select_weighted_lineage_index( - ledger.tip(), - REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, - 1, - &roots - .iter() - .cloned() - .map(|root| LineageCommitteeCandidate { - root, - value: 10 * MICRO_IUNA, - weight: lineage_committee_weight(10 * MICRO_IUNA), - owner: bob.address().to_string(), - }) - .collect::<Vec<_>>(), - ) - .unwrap(); - let selected_first_root = roots[first_index].clone(); - let fallback_root = roots[1 - first_index].clone(); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("reassign-selected-first"), - &bob, - 10 * MICRO_IUNA, - selected_first_root, - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("reassign-fallback-bob"), - &bob, - 9 * MICRO_IUNA, - fallback_root.clone(), - ); - insert_lineage_utxo( - &mut ledger, - named_test_outpoint("reassign-fallback-carol"), - &carol, - MICRO_IUNA, - fallback_root.clone(), - ); - - let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT); - - assert_eq!(committee.len(), 3); - assert_eq!(committee[1].owner, bob.address()); - assert_eq!(committee[2].owner, carol.address()); - assert_eq!(committee[2].ticket_id, fallback_root.outpoint.id()); -} - -#[test] -fn reserved_transaction_inputs_update_lineage_indexes() { - let alice = Wallet::from_seed("lineage-reserve-alice"); - let mut ledger = Ledger::new(BTreeMap::new(), 1); - let outpoint = named_test_outpoint("lineage-reserve-input"); - let root = test_lineage_root("lineage-reserve-root", 1); - insert_lineage_utxo( - &mut ledger, - outpoint.clone(), - &alice, - MICRO_IUNA, - root.clone(), - ); - let burn = ledger - .build_burn_with_inputs(&alice, MICRO_IUNA, 0, &[outpoint.clone()]) - .unwrap(); - - ledger.reserve_transaction_inputs(&burn).unwrap(); - - assert!(!ledger.utxos.contains_key(&outpoint)); - assert!(!ledger.utxo_lineage.contains_key(&outpoint)); - assert!(!ledger.lineage_values.contains_key(&root)); - assert!(!ledger.lineage_owners.contains_key(&root)); -} - -#[test] -fn lineage_cache_roundtrips_through_snapshot_replay() { - let alice = Wallet::from_seed("lineage-snapshot-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - apply_preverified_burn_block_with_mines(&mut ledger, &alice, 1); - let restored = - Ledger::from_snapshot_with_vdf_policy(ledger.snapshot(), false, u64::MAX).unwrap(); - - assert!(!ledger.utxo_lineage.is_empty()); - assert_eq!(restored.utxo_lineage, ledger.utxo_lineage); - assert_eq!(restored.lineage_values, ledger.lineage_values); - assert_eq!(restored.lineage_owners, ledger.lineage_owners); -} - -#[test] -fn lineage_cache_is_replaced_on_snapshot_reorg() { - let alice = Wallet::from_seed("lineage-reorg-alice"); - let common = ledger_with_allocation(&alice, 20 * MICRO_IUNA); - let mut local = common.clone(); - let mut remote = common; - - apply_preverified_burn_block_with_mines(&mut local, &alice, 1); - apply_preverified_burn_block_with_mines(&mut remote, &alice, 2); - apply_preverified_burn_block_with_mines(&mut remote, &alice, 1); - - assert!( - local - .extend_from_snapshot_with_vdf_policy(remote.snapshot(), false, u64::MAX) - .unwrap() - ); - assert_eq!(local.utxo_lineage, remote.utxo_lineage); - assert_eq!(local.lineage_values, remote.lineage_values); - assert_eq!(local.lineage_owners, remote.lineage_owners); -} - -#[test] -fn transfer_output_inherits_mine_lineage_through_block_apply() { - let alice = Wallet::from_seed("lineage-transfer-apply-alice"); - let bob = Wallet::from_seed("lineage-transfer-apply-bob"); - let finalizers = [alice.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&bob, 10 * MICRO_IUNA)]); - let mine = ledger.build_mine(bob.address()).unwrap(); - let mine_outpoint = OutPoint { - txid: mine.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(mine).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - let transfer = ledger - .build_transfer_with_inputs( - &bob, - alice.address(), - MINE_REWARD, - 0, - &[mine_outpoint.clone()], - ) - .unwrap(); - let transfer_outpoint = OutPoint { - txid: transfer.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(transfer).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); - - assert_eq!( - ledger.utxo_lineage.get(&transfer_outpoint), - Some(&UtxoLineageRoot { - outpoint: mine_outpoint, - height: 1, - }) - ); -} - -#[test] -fn blinded_reveal_output_inherits_locked_input_lineage_through_block_apply() { - let alice = Wallet::from_seed("lineage-blinded-apply-alice"); - let bob = Wallet::from_seed("lineage-blinded-apply-bob"); - let carol = Wallet::from_seed("lineage-blinded-apply-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let mine = ledger.build_mine(carol.address()).unwrap(); - let mine_outpoint = OutPoint { - txid: mine.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(mine).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - let transfer = ledger - .build_transfer_with_inputs( - &carol, - bob.address(), - MINE_REWARD, - 0, - &[mine_outpoint.clone()], - ) - .unwrap(); - let transfer_outpoint = OutPoint { - txid: transfer.signature().to_string(), - index: 0, - }; - let blinded = ledger - .build_blinded_transaction(&carol, transfer, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); - - assert_eq!( - ledger - .active_blinded - .get(&blinded.transaction.commitment) - .and_then(|active| active.locked_lineage_root.as_ref()), - Some(&UtxoLineageRoot { - outpoint: mine_outpoint.clone(), - height: 1, - }) - ); - - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 3); - - assert_eq!( - ledger.utxo_lineage.get(&transfer_outpoint), - Some(&UtxoLineageRoot { - outpoint: mine_outpoint, - height: 1, - }) - ); -} - -#[test] -fn reveal_bundle_validation_rejects_wrong_signature_and_slot() { - let alice = Wallet::from_seed("bundle-invalid-alice"); - let bob = Wallet::from_seed("bundle-invalid-bob"); - let carol = Wallet::from_seed("bundle-invalid-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - let member = ledger.reveal_committee_for_next_block()[0].clone(); - let wallet = wallet_for_address(&finalizers, &member.owner); - let bundle = ledger.build_reveal_bundle(wallet).unwrap().unwrap(); - - let mut wrong_signature = bundle.clone(); - wrong_signature.signature = "00".repeat(SIGNATURE_BYTES); - let error = ledger - .validate_next_block_reveal_bundles(vec![wrong_signature]) - .unwrap_err(); - assert!(format!("{error:#}").contains("reveal bundle signature is invalid")); - - let mut wrong_slot = bundle; - wrong_slot.slot = REVEAL_COMMITTEE_SIZE as u8 - 1; - let error = ledger - .validate_next_block_reveal_bundles(vec![wrong_slot]) - .unwrap_err(); - assert!( - format!("{error:#}").contains("reveal bundle slot is not assigned") - || format!("{error:#}").contains("reveal bundle member is not assigned to slot") - ); -} - -#[test] -fn blinded_reveal_with_wrong_key_is_rejected_in_block() { - let alice = Wallet::from_seed("blinded-wrong-key-finalizer-alice"); - let bob = Wallet::from_seed("blinded-wrong-key-finalizer-bob"); - let carol = Wallet::from_seed("blinded-wrong-key-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(filler_burn).unwrap(); - let mut prepared = ledger - .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - let committee = ledger.reveal_committee_for_next_block(); - let committee_member = committee[0].clone(); - let committee_wallet = wallet_for_address(&finalizers, &committee_member.owner); - let wrong_reveal = BlindedReveal { - commitment: blinded.transaction.commitment, - key: "00".repeat(BLINDED_KEY_BYTES), - }; - let wrong_bundle = committee_wallet.reveal_bundle(RevealBundlePayload { - height: prepared.height, - prev_hash: prepared.prev_hash.clone(), - slot: committee_member.slot, - member: committee_wallet.address().to_string(), - reveals: vec![wrong_reveal], - }); - let empty_member = committee[1].clone(); - let empty_wallet = wallet_for_address(&finalizers, &empty_member.owner); - let empty_bundle = empty_wallet.reveal_bundle(RevealBundlePayload { - height: prepared.height, - prev_hash: prepared.prev_hash.clone(), - slot: empty_member.slot, - member: empty_wallet.address().to_string(), - reveals: Vec::new(), - }); - prepared.reveal_bundle_section = - ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle, empty_bundle]); - prepared.reward = blinded_reveal_finalizer_fee( - blinded.transaction.fee, - prepared.reveal_bundle_section.signatures.len(), - ledger.reveal_committee_for_next_block().len(), - ); - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!( - format!("{error:#}").contains("decrypt blinded transaction"), - "{error:#}" - ); -} - -#[test] -fn expired_blinded_reveal_is_not_selected() { - let alice = Wallet::from_seed("blinded-expire-finalizer-alice"); - let bob = Wallet::from_seed("blinded-expire-finalizer-bob"); - let carol = Wallet::from_seed("blinded-expire-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 2) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(filler_burn).unwrap(); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); - - let error = ledger.submit_blinded_reveal(blinded.reveal).unwrap_err(); - - assert!( - format!("{error:#}").contains("does not reference an active blinded transaction"), - "{error:#}" - ); -} - -#[test] -fn unknown_blinded_reveal_spam_is_rejected() { - let alice = Wallet::from_seed("blinded-unknown-reveal-spam-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - - let error = ledger - .submit_blinded_reveal(BlindedReveal { - commitment: hex_hash("unknown-blinded-reveal-spam"), - key: "00".repeat(BLINDED_KEY_BYTES), - }) - .unwrap_err(); - - assert!(format!("{error:#}").contains("does not reference an active blinded transaction")); - assert!(ledger.pending_blinded_reveals().is_empty()); -} - -#[test] -fn active_blinded_reveal_displaces_invalid_legacy_reveal_when_pool_is_full() { - let alice = Wallet::from_seed("blinded-spam-finalizer-alice"); - let bob = Wallet::from_seed("blinded-spam-finalizer-bob"); - let carol = Wallet::from_seed("blinded-spam-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - ledger.pending_reveals = (0..MAX_PENDING_TRANSACTIONS) - .map(|index| BlindedReveal { - commitment: hex_hash(format!("unknown-blinded-reveal-spam:{index}")), - key: "00".repeat(BLINDED_KEY_BYTES), - }) - .collect(); - ledger.refresh_pending_pool_byte_counters().unwrap(); - assert_eq!( - ledger.pending_blinded_reveals().len(), - MAX_PENDING_TRANSACTIONS - ); - assert!(ledger.valid_pending_blinded_reveals().is_empty()); - - assert!( - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap() - ); - - assert_eq!( - ledger.pending_blinded_reveals().len(), - MAX_PENDING_TRANSACTIONS - ); - assert_eq!(ledger.valid_pending_blinded_reveals(), vec![blinded.reveal]); -} - -#[test] -fn recovery_block_includes_pending_blinded_transactions_when_space_allows() { - let alice = Wallet::from_seed("recovery-blinded-commit-alice"); - let bob = Wallet::from_seed("recovery-blinded-commit-bob"); - let carol = Wallet::from_seed("recovery-blinded-commit-carol"); - let mut ledger = ledger_with_finalizers( - &[alice], - &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], - ); - let blinded = ledger - .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(recovery_burn).unwrap(); - - let block = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - - assert!( - block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.transaction.commitment) - ); -} - -#[test] -fn recovery_block_size_selection_uses_recovery_skeleton() { - let alice = Wallet::from_seed("recovery-size-commit-alice"); - let bob = Wallet::from_seed("recovery-size-commit-bob"); - let carol = Wallet::from_seed("recovery-size-commit-carol"); - let mut ledger = ledger_with_finalizers( - &[alice], - &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], - ); - let blinded = ledger - .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(recovery_burn.clone()).unwrap(); - let recovery_selection = BlockSelection { - transactions: vec![recovery_burn], - blinded_transactions: vec![blinded.transaction.clone()], - }; - let recovery_estimate = estimated_block_selection_size_bytes( - &recovery_selection, - true, - &RevealBundleSection::default(), - ) - .unwrap(); - let ticket_estimate = estimated_block_selection_size_bytes( - &recovery_selection, - false, - &RevealBundleSection::default(), - ) - .unwrap(); - assert!(recovery_estimate < ticket_estimate); - - ledger.launch_profile.max_block_bytes = recovery_estimate; - let tight_block = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - - assert!( - tight_block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.transaction.commitment) - ); -} - -#[test] -fn recovery_block_includes_pending_blinded_reveals_when_space_allows() { - let alice = Wallet::from_seed("recovery-blinded-reveal-alice"); - let bob = Wallet::from_seed("recovery-blinded-reveal-bob"); - let carol = Wallet::from_seed("recovery-blinded-reveal-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(recovery_burn).unwrap(); - - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(&finalizers, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - let prepared = ledger - .prepare_recovery_block_with_reveal_bundles( - bob.address(), - ledger.recovery_block_min_timestamp(), - bundles, - ) - .unwrap(); - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - let block = prepared.finish(&bob, vdf_output); - - assert!( - block - .all_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == blinded.transaction.commitment) - ); -} - -#[test] -fn block_size_estimate_includes_reveal_bundle_section() { - let alice = Wallet::from_seed("size-estimate-reveal-alice"); - let bob = Wallet::from_seed("size-estimate-reveal-bob"); - let carol = Wallet::from_seed("size-estimate-reveal-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(&finalizers, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - let reveal_bundle_section = ledger.reveal_bundle_section_from_bundles(bundles); - let selection = BlockSelection::default(); - - let empty_estimate = - estimated_block_selection_size_bytes(&selection, true, &RevealBundleSection::default()) - .unwrap(); - let reveal_estimate = - estimated_block_selection_size_bytes(&selection, true, &reveal_bundle_section).unwrap(); - - assert!(reveal_estimate > empty_estimate); -} - -#[test] -fn blinded_transaction_expiring_at_next_height_is_not_selected() { - let alice = Wallet::from_seed("blinded-next-expire-finalizer-alice"); - let bob = Wallet::from_seed("blinded-next-expire-finalizer-bob"); - let carol = Wallet::from_seed("blinded-next-expire-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 1) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err(); - - assert!(format!("{error:#}").contains("block must include at least one burn transaction")); -} - -#[test] -fn blinded_transaction_expiry_cannot_exceed_protocol_window() { - let alice = Wallet::from_seed("blinded-window-finalizer-alice"); - let bob = Wallet::from_seed("blinded-window-finalizer-bob"); - let carol = Wallet::from_seed("blinded-window-carol"); - let finalizers = [alice, bob]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let max_expiry = ledger - .height() - .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS); - - ledger.build_blinded_burn(&carol, 3, 7, max_expiry).unwrap(); - - let error = ledger - .build_blinded_burn(&carol, 3, 7, max_expiry + 1) - .unwrap_err(); - assert!(format!("{error:#}").contains("expiry is too far in the future")); - - let mut forged = ledger - .build_blinded_burn(&carol, 3, 7, max_expiry) - .unwrap() - .transaction; - forged.expires_at_height = max_expiry + 1; - forged.commitment = blinded_transaction_commitment(&forged).unwrap(); - let error = ledger.submit_blinded_transaction(forged).unwrap_err(); - assert!(format!("{error:#}").contains("expiry is too far in the future")); -} - -#[test] -fn blinded_transaction_does_not_satisfy_plaintext_burn_requirement() { - let alice = Wallet::from_seed("blinded-no-burn-finalizer-alice"); - let bob = Wallet::from_seed("blinded-no-burn-finalizer-bob"); - let carol = Wallet::from_seed("blinded-no-burn-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err(); - - assert!(format!("{error:#}").contains("block must include at least one burn transaction")); -} - -#[test] -fn revealed_blinded_transaction_cannot_be_included_again() { - let alice = Wallet::from_seed("blinded-duplicate-finalizer-alice"); - let bob = Wallet::from_seed("blinded-duplicate-finalizer-bob"); - let carol = Wallet::from_seed("blinded-duplicate-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 6) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(filler_burn).unwrap(); - let mut prepared = ledger - .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - prepared - .blinded_transactions - .push(blinded.transaction.clone()); - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!(format!("{error:#}").contains("blinded transaction is already on chain")); -} - -#[test] -fn pending_blinded_reveals_expose_revealed_transaction_data() { - let alice = Wallet::from_seed("pending-reveal-data-finalizer-alice"); - let bob = Wallet::from_seed("pending-reveal-data-finalizer-bob"); - let carol = Wallet::from_seed("pending-reveal-data-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 6) - .unwrap(); - let commitment = blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - - let revealed = ledger.pending_revealed_blinded_transactions(); - assert_eq!(revealed.len(), 1); - assert_eq!(revealed[0].commitment, commitment); - assert_eq!(revealed[0].height, ledger.height() + 1); - assert_eq!(revealed[0].transaction.amount(), 3); - assert_eq!(revealed[0].transaction.fee(), 7); - assert!(revealed[0].transaction.is_burn()); -} - -#[test] -fn abandoned_fork_blinded_transactions_return_to_mempool() { - let alice = Wallet::from_seed("blinded-reorg-finalizer-alice"); - let bob = Wallet::from_seed("blinded-reorg-finalizer-bob"); - let carol = Wallet::from_seed("blinded-reorg-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut local = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let mut remote = local.clone(); - let blinded = local - .build_blinded_burn(&carol, 3, 7, local.height() + 8) - .unwrap(); - local - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut local, &finalizers); - mine_valid_as_next_leader(&mut local, &finalizers, 1); - - for timestamp_ms in [1, 2] { - let leader = remote.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let burn = remote.build_burn(wallet, 1, 0).unwrap(); - remote.submit_transaction(burn).unwrap(); - mine_valid_as_next_leader(&mut remote, &finalizers, timestamp_ms); - } - - assert!( - local - .extend_from_snapshot_at(remote.snapshot(), u64::MAX) - .unwrap() - ); - assert!(local.has_blinded_transaction(&blinded.transaction.commitment)); - assert_eq!( - local.pending_blinded_transactions(), - std::slice::from_ref(&blinded.transaction) - ); -} - -#[test] -fn block_selection_includes_mine_action_after_required_block_burn() { - let alice = Wallet::from_seed("mine-fixed-reward-select-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(mine.clone()).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - assert_eq!( - block.transactions.iter().filter(|tx| tx.is_burn()).count(), - 1 - ); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == mine.signature()) - ); - assert_eq!(block.reward, MINE_FINALIZER_FEE); -} - -#[test] -fn block_selection_can_skip_mine_action_when_space_is_limited() { - let alice = Wallet::from_seed("mine-space-limit-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - ledger.launch_profile.max_block_transactions = 2; - - let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let first_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(first_mine).unwrap(); - let second_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(second_mine).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - assert_eq!(block.transactions.len(), 2); - assert!(block.transactions.iter().any(Transaction::is_burn)); - assert_eq!(block.reward, MINE_FINALIZER_FEE); -} - -#[test] -fn pending_mine_outputs_are_not_spendable_until_confirmed() { - let alice = Wallet::from_seed("pending-mine-spend-alice"); - let bob = Wallet::from_seed("pending-mine-spend-bob"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let mine = ledger.build_mine(alice.address()).unwrap(); - let mine_outpoint = OutPoint { - txid: mine.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(mine.clone()).unwrap(); - - assert!( - !ledger - .available_utxos_for_address(alice.address()) - .unwrap() - .iter() - .any(|(outpoint, _)| outpoint == &mine_outpoint) - ); - let pending_error = ledger - .build_transfer_with_inputs( - &alice, - bob.address(), - TEST_BURN_AMOUNT, - 0, - std::slice::from_ref(&mine_outpoint), - ) - .unwrap_err(); - assert!(format!("{pending_error:#}").contains("not spendable")); - - let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == mine.signature()) - ); - ledger.apply_locally_mined_block(block).unwrap(); - - assert!( - ledger - .available_utxos_for_address(alice.address()) - .unwrap() - .iter() - .any(|(outpoint, _)| outpoint == &mine_outpoint) - ); - ledger - .build_transfer_with_inputs( - &alice, - bob.address(), - TEST_BURN_AMOUNT, - 0, - std::slice::from_ref(&mine_outpoint), - ) - .unwrap(); -} - -#[test] -fn burns_built_after_pending_mine_do_not_spend_pending_mine_output() { - let alice = Wallet::from_seed("pending-mine-burn-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let mine = ledger.build_mine(alice.address()).unwrap(); - let mine_outpoint = OutPoint { - txid: mine.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(mine).unwrap(); - - let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); - - let Transaction::Burn { inputs, .. } = &burn else { - panic!("expected burn transaction"); - }; - assert!(!inputs.iter().any(|input| input.outpoint == mine_outpoint)); -} - -#[test] -fn pending_blinded_transactions_with_spent_inputs_are_pruned_after_block_apply() { - let alice = Wallet::from_seed("pending-blind-spent-prune-alice"); - let mut mempool_ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - let mut block_ledger = mempool_ledger.clone(); - let amount = mempool_ledger.balance_of(alice.address()); - let blinded = mempool_ledger - .build_blinded_burn(&alice, amount, 0, mempool_ledger.height() + 4) - .unwrap(); - mempool_ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - - let burn = block_ledger.build_burn(&alice, amount, 0).unwrap(); - let Transaction::Burn { inputs, .. } = &burn else { - panic!("expected burn transaction"); - }; - assert!(blinded.transaction.inputs.iter().any(|input| { - inputs - .iter() - .any(|burn_input| burn_input.outpoint == input.outpoint) - })); - block_ledger.submit_transaction(burn).unwrap(); - let block = block_ledger.mine_next_block(&alice, 1).unwrap(); - - mempool_ledger.apply_block(block).unwrap(); - - assert!(mempool_ledger.pending_blinded_transactions().is_empty()); -} - -#[test] -fn block_selection_limits_mine_actions_per_anchor() { - let alice = Wallet::from_seed("mine-anchor-limit-selection-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let first_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(first_mine.clone()).unwrap(); - let second_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(second_mine.clone()).unwrap(); - assert_eq!(ledger.pending().len(), 3); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - assert_eq!(block.transactions.len(), 3); - assert!(block.transactions.iter().any(Transaction::is_burn)); - let included_mines = block - .transactions - .iter() - .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) - .count(); - assert_eq!(included_mines, MINE_ACTIONS_PER_ANCHOR_LIMIT); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == first_mine.signature()) - ); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == second_mine.signature()) - ); - assert_ne!(first_mine.signature(), second_mine.signature()); - assert_eq!(block.reward, first_mine.fee() + second_mine.fee()); -} - -#[test] -fn blocks_reject_too_many_mine_actions_for_one_anchor() { - let alice = Wallet::from_seed("mine-anchor-limit-block-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); - let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); - let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); - let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let mut block = ledger - .prepare_next_block( - alice.address(), - ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS), - ) - .unwrap() - .finish(&alice, "preverified-vdf".to_string()); - block.transactions.push(first_mine); - block.transactions.push(second_mine); - block.transactions.push(third_mine); - block.reward = fee_reward(&block.transactions).unwrap(); - block.hash = block.compute_hash(); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!(format!("{error:#}").contains("mine actions per anchor limit")); -} - -#[test] -fn mempool_rejects_mine_actions_above_anchor_limit() { - let alice = Wallet::from_seed("mine-anchor-limit-mempool-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); - let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); - let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); - ledger.submit_transaction(first_mine).unwrap(); - ledger.submit_transaction(second_mine).unwrap(); - let error = ledger.submit_transaction(third_mine).unwrap_err(); - - assert!(format!("{error:#}").contains("mine transaction anchor limit reached")); -} - -#[test] -fn mine_difficulty_increases_when_issuance_exceeds_target_window() { - let alice = Wallet::from_seed("mine-difficulty-up-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - apply_preverified_burn_block_with_mines(&mut ledger, &alice, 2); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_DIFFICULTY_BITS + 1 - ); - let mine = ledger.build_mine(alice.address()).unwrap(); - let Transaction::Mine { - difficulty_bits, .. - } = mine - else { - panic!("expected mine action"); - }; - assert_eq!(difficulty_bits, MINE_DIFFICULTY_BITS + 1); -} - -#[test] -fn mine_difficulty_decreases_when_issuance_is_below_target_window() { - let alice = Wallet::from_seed("mine-difficulty-down-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS - ); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_MIN_DIFFICULTY_BITS - ); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_MIN_DIFFICULTY_BITS - ); -} - -#[test] -fn mine_actions_expire_when_anchor_is_too_old() { - let alice = Wallet::from_seed("mine-anchor-expiry-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - let stale_mine = ledger.build_mine(alice.address()).unwrap(); - - for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - let error = ledger.submit_transaction(stale_mine).unwrap_err(); - assert!(format!("{error:#}").contains("mine transaction anchor is too old")); -} - -#[test] -fn pending_mine_actions_are_removed_when_anchor_expires() { - let alice = Wallet::from_seed("pending-mine-anchor-expiry-alice"); - let bob = Wallet::from_seed("pending-mine-anchor-expiry-bob"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - ledger.launch_profile.max_block_transactions = 1; - let stale_mine = ledger.build_mine(bob.address()).unwrap(); - ledger.submit_transaction(stale_mine.clone()).unwrap(); - - for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert!( - ledger - .pending() - .iter() - .all(|tx| tx.signature() != stale_mine.signature()) - ); -} - -#[test] -fn stratum_mine_header_proof_is_validated() { - let alice = Wallet::from_seed("stratum-proof-alice"); - let ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - let template = ledger - .stratum_mine_template(alice.address(), anchor, 1, difficulty_bits) - .unwrap(); - - let mut accepted = None; - for nonce in 0_u32..50_000 { - let result = ledger.build_stratum_mine( - template.clone(), - StratumMineShare { - extranonce2: [0, 0, 0, 0], - header_nonce: nonce.to_le_bytes(), - }, - ); - if let Ok(tx) = result { - accepted = Some(tx); - break; - } - } - - let tx = accepted.expect("expected Stratum proof within search range"); - let Transaction::Mine { - proof_header, - signature, - .. - } = tx - else { - panic!("expected mine action"); - }; - assert_eq!(proof_header.as_deref().unwrap_or_default().len(), 160); - assert!(hash_meets_difficulty(&signature, difficulty_bits)); -} - -#[test] -fn stratum_mine_salt_allows_multiple_actions_for_same_anchor() { - let alice = Wallet::from_seed("stratum-salt-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - - for salt in [1, 2] { - let template = ledger - .stratum_mine_template(alice.address(), anchor.clone(), salt, difficulty_bits) - .unwrap(); - let mut accepted = None; - for nonce in 0_u32..50_000 { - let result = ledger.build_stratum_mine( - template.clone(), - StratumMineShare { - extranonce2: [0, 0, 0, 0], - header_nonce: nonce.to_le_bytes(), - }, - ); - if let Ok(tx) = result { - accepted = Some(tx); - break; - } - } - let tx = accepted.expect("expected Stratum proof within search range"); - assert!(ledger.submit_transaction(tx).unwrap()); - } - - assert_eq!(ledger.pending().len(), 2); - let salts = ledger - .pending() - .iter() - .map(|tx| match tx { - Transaction::Mine { salt, .. } => *salt, - _ => panic!("expected mine action"), - }) - .collect::<BTreeSet<_>>(); - assert_eq!(salts, BTreeSet::from([1, 2])); -} diff --git a/src/domain/transaction.rs b/src/domain/transaction.rs @@ -61,65 +61,6 @@ pub enum Transaction { }, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub(super) enum BlindedTransactionPayload { - Transfer { - outputs: Vec<TxOutput>, - signature: String, - }, - Burn { - change: Vec<TxOutput>, - amount: Amount, - signature: String, - }, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BlindedTransaction { - pub commitment: String, - #[serde(default)] - pub inputs: Vec<TxInput>, - pub fee: Amount, - pub encrypted_size: u32, - pub expires_at_height: u64, - pub nonce: String, - pub ciphertext: String, - pub payload_hash: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BlindedReveal { - pub commitment: String, - pub key: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BuiltBlindedTransaction { - pub payload: Transaction, - pub transaction: BlindedTransaction, - pub reveal: BlindedReveal, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OwnedBlindedTransaction { - pub transaction: BlindedTransaction, - pub payload: Transaction, - pub reveal: BlindedReveal, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RevealedBlindedTransaction { - pub height: u64, - pub commitment: String, - pub included_by: String, - pub transaction: Transaction, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct MineSearchOutcome { pub transaction: Option<Transaction>, @@ -360,42 +301,6 @@ impl Transaction { } } -impl BlindedTransaction { - pub fn id(&self) -> &str { - &self.commitment - } - - pub fn canonical(&self) -> String { - format!( - "blinded-tx:{}:{}:{}:{}:{}:{}:{}", - canonical_signed_inputs(&self.inputs), - self.fee, - self.encrypted_size, - self.expires_at_height, - self.nonce, - self.ciphertext, - self.payload_hash - ) - } - - pub fn fee_rate_size_bytes(&self) -> usize { - self.serialized_size_bytes() - .unwrap_or(self.encrypted_size as usize) - } - - pub fn serialized_size_bytes(&self) -> Result<usize> { - serde_json::to_vec(self) - .map(|bytes| bytes.len()) - .context("failed to serialize blinded transaction for size check") - } -} - -impl BlindedReveal { - pub fn canonical(&self) -> String { - format!("blinded-reveal:{}:{}", self.commitment, self.key) - } -} - impl TxInput { pub(super) fn without_signature(&self) -> UnsignedTxInput { UnsignedTxInput { @@ -501,17 +406,6 @@ pub(super) fn unsigned_inputs(inputs: &[TxInput]) -> Vec<UnsignedTxInput> { inputs.iter().map(TxInput::without_signature).collect() } -pub(super) fn signed_blinded_inputs(inputs: &[UnsignedTxInput], signature: &str) -> Vec<TxInput> { - inputs - .iter() - .map(|input| TxInput { - outpoint: input.outpoint.clone(), - owner: input.owner.clone(), - signature: signature.to_string(), - }) - .collect() -} - pub(super) fn canonical_inputs(inputs: &[UnsignedTxInput]) -> String { inputs .iter() @@ -525,19 +419,6 @@ pub(super) fn canonical_inputs(inputs: &[UnsignedTxInput]) -> String { .join("|") } -pub(super) fn canonical_signed_inputs(inputs: &[TxInput]) -> String { - inputs - .iter() - .map(|input| { - format!( - "{}:{}:{}:{}", - input.outpoint.txid, input.outpoint.index, input.owner, input.signature - ) - }) - .collect::<Vec<_>>() - .join("|") -} - fn canonical_outputs(outputs: &[TxOutput]) -> String { outputs .iter() @@ -564,33 +445,6 @@ pub(super) fn transaction_inputs_spent_by( .any(|input| spent.contains(&input.outpoint)) } -pub(super) fn transaction_inputs_spent_by_inputs( - inputs: &[TxInput], - pending: &[Transaction], -) -> bool { - let spent = pending_spent_outpoints(pending); - inputs.iter().any(|input| spent.contains(&input.outpoint)) -} - -pub(super) fn blinded_transaction_inputs_spent_by( - transaction: &BlindedTransaction, - pending: &[BlindedTransaction], -) -> bool { - let spent = pending - .iter() - .flat_map(|transaction| { - transaction - .inputs - .iter() - .map(|input| input.outpoint.clone()) - }) - .collect::<BTreeSet<_>>(); - transaction - .inputs - .iter() - .any(|input| spent.contains(&input.outpoint)) -} - pub(super) fn transaction_inputs_available( transaction: &Transaction, utxos: &BTreeMap<OutPoint, TxOutput>, @@ -600,13 +454,3 @@ pub(super) fn transaction_inputs_available( .iter() .all(|input| utxos.contains_key(&input.outpoint)) } - -pub(super) fn blinded_transaction_inputs_available( - transaction: &BlindedTransaction, - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> bool { - transaction - .inputs - .iter() - .all(|input| utxos.contains_key(&input.outpoint)) -} diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -3,7 +3,7 @@ use sha2::{Digest, Sha256}; use super::block::LeaderProofPayload; use super::{ - LeaderProof, PUBLIC_KEY_BYTES, RevealBundle, RevealBundlePayload, decode_hex_array, hex_encode, + BurnBundle, BurnBundlePayload, LeaderProof, PUBLIC_KEY_BYTES, decode_hex_array, hex_encode, }; const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed"; @@ -46,14 +46,14 @@ impl Wallet { } } - pub(super) fn reveal_bundle(&self, payload: RevealBundlePayload) -> RevealBundle { + pub(super) fn burn_bundle(&self, payload: BurnBundlePayload) -> BurnBundle { let signature = self.sign_payload(&payload.canonical()); - RevealBundle { + BurnBundle { height: payload.height, prev_hash: payload.prev_hash, slot: payload.slot, member: self.address.clone(), - reveals: payload.reveals, + burns: payload.burns, signature, } } diff --git a/src/main.rs b/src/main.rs @@ -86,20 +86,13 @@ async fn main() -> Result<()> { let initial_burn_fee = initial_burn_fee(&opts, &ui_config); let mut node_core = match wallet_load { - StartupWallet::Unlocked { + StartupWallet::Unlocked { wallet } => NodeCore::from_ledger_with_burn_fee_and_enabled( wallet, - owned_blinded_transactions, - } => { - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet, - ledger, - ui_config.mining_enabled, - initial_burn_per_block, - initial_burn_fee, - ); - node.restore_owned_blinded_transactions(owned_blinded_transactions)?; - node - } + ledger, + ui_config.mining_enabled, + initial_burn_per_block, + initial_burn_fee, + ), StartupWallet::Locked { address } => NodeCore::from_locked_wallet_address( address, ledger, @@ -235,13 +228,8 @@ async fn main() -> Result<()> { } enum StartupWallet { - Unlocked { - wallet: iuna::domain::Wallet, - owned_blinded_transactions: Vec<iuna::domain::OwnedBlindedTransaction>, - }, - Locked { - address: String, - }, + Unlocked { wallet: iuna::domain::Wallet }, + Locked { address: String }, } impl StartupWallet { @@ -255,14 +243,7 @@ impl StartupWallet { fn load_startup_wallet(wallet_path: &Path) -> Result<StartupWallet> { match wallet_store::load_or_create(wallet_path) { - Ok(wallet) => { - let owned_blinded_transactions = - wallet_store::load_owned_blinded_transactions(wallet_path, None)?; - Ok(StartupWallet::Unlocked { - wallet, - owned_blinded_transactions, - }) - } + Ok(wallet) => Ok(StartupWallet::Unlocked { wallet }), Err(error) => { let Some(metadata) = wallet_store::metadata(wallet_path)? else { return Err(error); diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -34,13 +34,13 @@ fn help_mentions_dev_seed_verify_bypass_env() { fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger { let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 2); + genesis.insert(wallet.address().to_string(), 3); Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1).unwrap() } fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger { let mut ledger = ledger_with_one_spendable_iuna(wallet); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + let burn = ledger.build_burn(wallet, 1, 1).unwrap(); ledger.submit_transaction(burn).unwrap(); let block = ledger.mine_next_block(wallet, 1_000).unwrap(); ledger.apply_locally_mined_block(block).unwrap(); @@ -510,7 +510,7 @@ async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() { let store = SqliteChainStore::open(&chain_path).unwrap(); let persisted_wallet = Wallet::from_seed("persisted-future-chain-owner"); let mut persisted = ledger_with_one_spendable_iuna(&persisted_wallet); - let burn = persisted.build_burn(&persisted_wallet, 1, 0).unwrap(); + let burn = persisted.build_burn(&persisted_wallet, 1, 1).unwrap(); persisted.submit_transaction(burn).unwrap(); let future_tip_ms = iuna::app::now_ms().saturating_add(VDF_TARGET_BLOCK_MS); let future_block = persisted @@ -629,7 +629,7 @@ async fn persistence_loop_saves_new_tip_after_node_changes() { )); { let mut node = node.lock().await; - let burn = node.ledger().build_burn(&wallet, 1, 0).unwrap(); + let burn = node.ledger().build_burn(&wallet, 1, 1).unwrap(); node.receive_transaction(burn).unwrap(); node.mine_one_at(1_000).unwrap(); } diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -1,2702 +0,0 @@ -use std::{ - collections::BTreeMap, - time::{SystemTime, UNIX_EPOCH}, -}; - -use iuna::{ - adapters::chain_store::SqliteChainStore, - app::{ - DEFAULT_BURN_PER_BLOCK, GossipEnvelope, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, - PeerDirection, TRANSACTION_BATCH_LIMIT, - }, - domain::{ - Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, FinalizerMode, GenesisBurn, Ledger, - MAX_BLOCK_BYTES, MICRO_IUNA, MINE_REWARD, RECOVERY_BLOCK_DELAY_MS, - TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, revealed_blinded_transactions, - run_vdf, verify_vdf, - }, -}; -use tempfile::tempdir; - -fn iuna(amount: Amount) -> Amount { - amount * MICRO_IUNA -} - -fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { - NodeCore::new(NodeConfig { - wallet, - genesis_allocations: allocations, - vdf_rounds: 25, - burn_per_block: DEFAULT_BURN_PER_BLOCK, - burn_fee: 1, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }) -} - -fn wallets(names: &[&str]) -> Vec<Wallet> { - names.iter().map(|name| Wallet::from_seed(name)).collect() -} - -fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { - wallets - .iter() - .map(|wallet| (wallet.address().to_string(), amount)) - .collect() -} - -fn unix_now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} - -fn next_ticket_slot_timestamp(ledger: &Ledger, offset_ms: u64) -> u64 { - ledger - .chain() - .last() - .expect("ledger has genesis") - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS) - .saturating_add(offset_ms) -} - -fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String { - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); - let hash = block.hash.clone(); - ledger.apply_block(block).unwrap(); - hash -} - -fn submit_burn(ledger: &mut Ledger, wallet: &Wallet, amount: Amount) { - let tx = ledger.build_burn(wallet, amount, 0).unwrap(); - ledger.submit_transaction(tx).unwrap(); -} - -fn queue_plaintext_burn( - node: &mut NodeCore, - wallet: &Wallet, - amount: Amount, -) -> iuna::domain::Transaction { - let tx = node.ledger().build_burn(wallet, amount, 0).unwrap(); - node.receive_transaction(tx.clone()).unwrap(); - tx -} - -fn queue_plaintext_transfer( - node: &mut NodeCore, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, -) -> iuna::domain::Transaction { - let tx = node.ledger().build_transfer(wallet, to, amount, 0).unwrap(); - node.receive_transaction(tx.clone()).unwrap(); - tx -} - -fn burn_tx(ledger: &Ledger, wallet: &Wallet, amount: Amount) -> iuna::domain::Transaction { - ledger.build_burn(wallet, amount, 0).unwrap() -} - -fn transfer_tx( - ledger: &Ledger, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, -) -> iuna::domain::Transaction { - ledger.build_transfer(wallet, to, amount, 0).unwrap() -} - -fn transfer_fee_tx( - ledger: &Ledger, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, - fee: Amount, -) -> iuna::domain::Transaction { - ledger.build_transfer(wallet, to, amount, fee).unwrap() -} - -fn fork_with_better_vrf_block( - base: &Ledger, - wallet: &Wallet, - local_fork_block_hash: &str, - first_timestamp_ms: u64, -) -> Option<Ledger> { - for offset in 0..10_000 { - let mut candidate = base.clone(); - let timestamp_ms = next_ticket_slot_timestamp(&candidate, first_timestamp_ms + offset); - let hash = mine_wallet_burn_block(&mut candidate, wallet, timestamp_ms); - if hash.as_str() < local_fork_block_hash { - return Some(candidate); - } - } - None -} - -fn fork_with_worse_vrf_block( - base: &Ledger, - wallet: &Wallet, - local_fork_block_hash: &str, - first_timestamp_ms: u64, -) -> Option<Ledger> { - for offset in 0..10_000 { - let mut candidate = base.clone(); - let timestamp_ms = next_ticket_slot_timestamp(&candidate, first_timestamp_ms + offset); - let hash = mine_wallet_burn_block(&mut candidate, wallet, timestamp_ms); - if hash.as_str() > local_fork_block_hash { - return Some(candidate); - } - } - None -} - -fn starter_node(wallet: Wallet) -> NodeCore { - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25) - .unwrap(); - NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet, - ledger, - true, - DEFAULT_BURN_PER_BLOCK, - DEFAULT_FEE_PER_BYTE, - ) -} - -#[test] -fn genesis_burn_starts_chain_with_reward_and_first_leader() { - let alice = Wallet::from_seed("alice"); - let node = starter_node(alice.clone()); - - let genesis = &node.ledger().chain()[0]; - assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); - assert_eq!(genesis.height, 0); - assert_eq!(genesis.miner, alice.address()); - assert_eq!(genesis.reward, BLOCK_REWARD); - assert_eq!(genesis.transactions.len(), 1); - assert!(genesis.transactions[0].is_burn()); - assert_eq!(genesis.transactions[0].amount(), 1); - assert_eq!( - node.ledger().expected_leader_for_next_block().as_deref(), - Some(alice.address()) - ); -} - -#[test] -fn burn_amount_weights_leader_selection() { - let mut high_weight_leaders = 0; - for sample in 0..100 { - let low = Wallet::from_seed(&format!("weighted-low-{sample}")); - let high = Wallet::from_seed(&format!("weighted-high-{sample}")); - let mut allocations = BTreeMap::new(); - allocations.insert(low.address().to_string(), 1_000); - allocations.insert(high.address().to_string(), 1_000); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(low.address(), 1), - GenesisBurn::new(high.address(), 99), - ], - 25, - ) - .unwrap(); - - if ledger.expected_leader_for_next_block().as_deref() == Some(high.address()) { - high_weight_leaders += 1; - } - } - - assert!( - high_weight_leaders >= 90, - "high burn ticket should win most weighted draws, won {high_weight_leaders}/100" - ); -} - -#[test] -fn starter_node_waits_for_a_burn_before_vdf_work() { - let alice = Wallet::from_seed("alice"); - let mut node = starter_node(alice.clone()); - - let outcome = node.automatic_mine_once(1); - assert_eq!(outcome.burned.as_ref().map(|tx| tx.amount()), Some(1)); - assert!(outcome.block.is_some(), "{outcome:?}"); - assert_eq!(node.ledger().status().height, 1); - assert!(node.ledger().balance_of(alice.address()) < BLOCK_REWARD); -} - -#[test] -fn burn_in_block_creates_ticket_after_maturity_delay() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &bob, 80); - - let launch_leader = - if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) { - &alice - } else { - &bob - }; - let first = ledger.mine_next_block(launch_leader, 1).unwrap(); - ledger.apply_block(first).unwrap(); - - for height in 2..=3 { - let leader_wallet = - if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) { - &alice - } else { - &bob - }; - submit_burn(&mut ledger, leader_wallet, 1); - let block = ledger.mine_next_block(leader_wallet, height).unwrap(); - ledger.apply_block(block).unwrap(); - } - - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - - assert!( - ledger.mine_next_block(&alice, 4).is_err(), - "the block 1 burn should not be eligible before its maturity delay, and only Bob should hold it at block 4" - ); - - submit_burn(&mut ledger, &bob, 1); - assert!(ledger.mine_next_block(&bob, 4).is_ok()); -} - -#[test] -fn transfer_and_burn_update_balances_when_block_is_applied() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), iuna(1_000)); - allocations.insert(bob.address().to_string(), iuna(100)); - - let mut ledger = Ledger::new(allocations, 10); - let transfer = transfer_tx(&ledger, &alice, bob.address(), iuna(125)); - ledger.submit_transaction(transfer).unwrap(); - let burn = burn_tx(&ledger, &alice, iuna(25)); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), iuna(850)); - assert_eq!(ledger.balance_of(bob.address()), iuna(225)); -} - -#[test] -fn forged_transaction_is_rejected() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let mut ledger = Ledger::new(allocations, 10); - - let mut forged = burn_tx(&ledger, &bob, 10); - if let iuna::domain::Transaction::Burn { inputs, .. } = &mut forged { - inputs[0].owner = alice.address().to_string(); - } - - let error = ledger.submit_transaction(forged).unwrap_err(); - assert!(error.to_string().contains("signature")); -} - -#[test] -fn block_with_forged_transaction_is_rejected() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &alice, 1); - - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - if let iuna::domain::Transaction::Burn { signature, .. } = &mut block.transactions[0] { - signature.push_str("00"); - } - block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds); - block.hash = block.compute_hash(); - - let error = ledger.apply_block(block).unwrap_err(); - assert!(error.to_string().contains("signature")); -} - -#[test] -fn mine_action_uses_fixed_reward_and_finalizer_fee() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 2 * MICRO_IUNA); - - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &alice, MICRO_IUNA); - let mine = ledger.build_mine(alice.address()).unwrap(); - assert_eq!(mine.amount(), MINE_REWARD); - assert_eq!(mine.fee(), MICRO_IUNA); - ledger.submit_transaction(mine.clone()).unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, MICRO_IUNA); - - ledger.apply_block(block).unwrap(); - assert_eq!( - ledger.balance_of(alice.address()), - 2 * MICRO_IUNA + MINE_REWARD - ); - assert!(!ledger.submit_transaction(mine).unwrap()); - assert_eq!( - ledger.balance_of(alice.address()), - 2 * MICRO_IUNA + MINE_REWARD - ); -} - -#[test] -fn mine_action_protocol_fee_is_paid_to_block_finalizer() { - let alice = Wallet::from_seed("mine-fee-alice"); - let bob = Wallet::from_seed("mine-fee-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(bob.address().to_string(), 2 * MICRO_IUNA); - - let mut ledger = Ledger::new(allocations, 10); - let mine = ledger.build_mine(alice.address()).unwrap(); - assert_eq!(mine.amount(), MINE_REWARD); - assert_eq!(mine.fee(), MICRO_IUNA); - ledger.submit_transaction(mine).unwrap(); - submit_burn(&mut ledger, &bob, MICRO_IUNA); - - let block = ledger.mine_next_block(&bob, 1).unwrap(); - assert_eq!(block.reward, MICRO_IUNA); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), MINE_REWARD); - assert_eq!(ledger.balance_of(bob.address()), 2 * MICRO_IUNA); -} - -#[test] -fn forged_mine_action_cannot_introduce_iuna() { - let alice = Wallet::from_seed("forged-mine-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - let mut ledger = Ledger::new(allocations, 10); - - let mut forged = ledger.build_mine(alice.address()).unwrap(); - if let iuna::domain::Transaction::Mine { nonce, .. } = &mut forged { - *nonce += 1; - } - - let error = ledger.submit_transaction(forged).unwrap_err(); - assert!(format!("{error:#}").contains("mine transaction proof hash is invalid")); - assert_eq!(ledger.balance_of(alice.address()), MICRO_IUNA); -} - -#[test] -fn burn_larger_than_mine_reward_is_paid_from_existing_utxos() { - let alice = Wallet::from_seed("large-burn-existing-utxos-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), BLOCK_REWARD + iuna(50)); - - let mut ledger = Ledger::new(allocations, 10); - let burn_amount = BLOCK_REWARD + iuna(25); - let burn = ledger.build_burn(&alice, burn_amount, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.transactions[0].amount(), burn_amount); - assert_eq!(block.reward, 0); - - ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), iuna(25)); -} - -#[test] -fn burn_larger_than_existing_balance_cannot_use_next_block_reward() { - let alice = Wallet::from_seed("large-burn-cannot-use-next-reward-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), BLOCK_REWARD); - - let ledger = Ledger::new(allocations, 10); - let error = ledger - .build_burn(&alice, BLOCK_REWARD + MICRO_IUNA, 0) - .unwrap_err(); - - assert!(format!("{error:#}").contains("insufficient funds")); -} - -#[test] -fn transaction_fees_are_paid_to_the_block_finalizer() { - let alice = Wallet::from_seed("fee-miner-alice"); - let bob = Wallet::from_seed("fee-payer-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), iuna(200)); - - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], - 10, - ) - .unwrap(); - submit_burn(&mut ledger, &alice, MICRO_IUNA); - let tx = transfer_fee_tx(&ledger, &bob, alice.address(), iuna(10), iuna(7)); - ledger.submit_transaction(tx).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, iuna(7)); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.balance_of(alice.address()), BLOCK_REWARD + iuna(16)); - assert_eq!(ledger.balance_of(bob.address()), iuna(183)); -} - -#[test] -fn miner_orders_block_transactions_by_fee_rate_after_required_burn() { - let wallets = wallets(&["fee-order-alice", "fee-order-bob", "fee-order-carol"]); - let alice = &wallets[0]; - let bob = &wallets[1]; - let carol = &wallets[2]; - let mut allocations = allocations(&wallets, 1_000); - allocations.insert(alice.address().to_string(), 1); - let mut ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - let required_burn = burn_tx(&ledger, alice, 1); - let low_fee = transfer_fee_tx(&ledger, bob, alice.address(), 1, 1); - let high_fee = transfer_fee_tx(&ledger, carol, alice.address(), 1, 20); - ledger.submit_transaction(low_fee.clone()).unwrap(); - ledger.submit_transaction(high_fee.clone()).unwrap(); - ledger.submit_transaction(required_burn.clone()).unwrap(); - - let block = ledger.mine_next_block(alice, 1).unwrap(); - let signatures = block - .transactions - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<Vec<_>>(); - - assert_eq!(signatures[0], required_burn.signature()); - assert!( - signatures - .iter() - .position(|signature| signature == high_fee.signature()) - < signatures - .iter() - .position(|signature| signature == low_fee.signature()) - ); -} - -#[test] -fn oversized_blocks_are_rejected() { - let alice = Wallet::from_seed("oversized-block-alice"); - let bob = Wallet::from_seed("oversized-block-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1); - allocations.insert(bob.address().to_string(), 1_000); - let mut ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) - .unwrap(); - submit_burn(&mut ledger, &alice, 1); - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - let mut oversized_transfer = transfer_fee_tx(&ledger, &bob, alice.address(), 1, 3); - if let iuna::domain::Transaction::Transfer { outputs, .. } = &mut oversized_transfer { - outputs[0].address = "x".repeat(MAX_BLOCK_BYTES); - } - block.transactions.push(oversized_transfer); - block.reward = 3; - block.hash = block.compute_hash(); - - let error = ledger.apply_block(block).unwrap_err(); - - assert!(format!("{error:#}").contains("max block size")); -} - -#[test] -fn transfer_that_would_overflow_recipient_balance_is_rejected() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1); - allocations.insert(bob.address().to_string(), Amount::MAX); - - let ledger = Ledger::new(allocations, 10); - let error = ledger - .build_transfer(&alice, bob.address(), 1, 0) - .unwrap_err(); - - assert!(format!("{error:#}").contains("balance overflow")); -} - -#[test] -fn mine_reward_that_would_overflow_recipient_balance_is_rejected() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), Amount::MAX); - - let ledger = Ledger::new(allocations, 10); - let error = ledger.build_mine(alice.address()).unwrap_err(); - - assert!(format!("{error:#}").contains("balance overflow")); -} - -#[test] -fn block_without_mature_ticket_cannot_be_mined() { - let alice = Wallet::from_seed("alice"); - let allocations = BTreeMap::new(); - - let ledger = Ledger::new(allocations, 10); - let error = ledger.mine_next_block(&alice, 1).unwrap_err(); - assert!(error.to_string().contains("mature burn ticket")); -} - -#[test] -fn vdf_work_requires_at_least_one_pending_burn() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - - let ledger = Ledger::new(allocations, 10); - let error = ledger.prepare_next_block(alice.address(), 1).unwrap_err(); - - assert!(format!("{error:#}").contains("at least one burn")); -} - -#[test] -fn leader_block_without_burn_is_rejected() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &alice, 1); - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - block.transactions.clear(); - block.hash = block.compute_hash(); - - let error = ledger.apply_block(block).unwrap_err(); - assert!(format!("{error:#}").contains("at least one burn")); -} - -#[test] -fn block_hash_is_bound_to_block_contents() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &alice, 1); - let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - block.timestamp_ms += 1; - - let error = ledger.apply_block(block).unwrap_err(); - assert!(error.to_string().contains("block hash is invalid")); -} - -#[test] -fn automatic_mining_burns_configured_amount_once_per_height() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("auto-once-leader"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), iuna(1_000)); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], - 10, - ) - .unwrap(); - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - alice.clone(), - ledger, - true, - iuna(25), - DEFAULT_FEE_PER_BYTE, - ); - - let first = node.automatic_mine_once(1); - assert!(first.burned.is_some()); - let burned = first.burned.as_ref().unwrap(); - assert_eq!(burned.amount(), iuna(25)); - assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE); - assert!(first.block.is_none()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - - let second = node.automatic_mine_once(2); - assert!(second.burned.is_none()); - assert!(second.block.is_none()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); -} - -#[test] -fn default_automatic_mining_does_not_burn() { - assert_eq!(DEFAULT_BURN_PER_BLOCK, 0); - - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - let mut node = node("alice", alice.clone(), allocations); - - let outcome = node.automatic_mine_once(1); - assert!(outcome.burned.is_none()); - assert!(outcome.block.is_none()); - assert!( - outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("automatic mining is off")) - ); - assert_eq!(node.ledger().balance_of(alice.address()), 1_000); -} - -#[test] -fn burn_per_block_can_be_set_to_zero() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - let mut node = node("alice", alice, allocations); - - let burned = node.set_burn_per_block(25).unwrap(); - assert!(burned.is_some()); - assert!(node.status().mining.automatic); - assert_eq!(node.status().mining.burn_per_block, 25); - let burned = node.set_burn_per_block(0).unwrap(); - assert!(burned.is_none()); - assert!(!node.status().mining.automatic); - assert_eq!(node.status().mining.burn_per_block, 0); -} - -#[test] -fn automatic_burn_status_shows_configured_fee() { - let alice = Wallet::from_seed("auto-fee-status-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - let mut node = node("alice", alice, allocations); - - assert_eq!(node.status().mining.automatic_burn_fee, 1); - - node.set_burn_per_block(1).unwrap(); - assert_eq!(node.status().mining.burn_per_block, 1); - assert_eq!(node.status().mining.automatic_burn_fee, 1); - - node.set_automatic_burn(50, 3).unwrap(); - assert_eq!(node.status().mining.burn_per_block, 50); - assert_eq!(node.status().mining.automatic_burn_fee, 3); -} - -#[test] -fn automatic_mining_uses_configured_burn_fee() { - let alice = Wallet::from_seed("auto-fee-burn-alice"); - let bob = Wallet::from_seed("auto-fee-burn-leader"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], - 25, - ) - .unwrap(); - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(alice, ledger, true, 50, 3); - - let outcome = node.automatic_mine_once(1); - let burned = outcome.burned.as_ref().unwrap(); - assert_eq!(burned.amount(), 50); - assert!(burned.fee() > burned.economic_size_bytes() as u64 * 3); - assert!(outcome.block.is_none()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); -} - -#[test] -fn automatic_mining_caps_burn_to_spendable_balance_after_fee() { - let alice = Wallet::from_seed("auto-burn-cap-alice"); - let bob = Wallet::from_seed("auto-burn-cap-leader"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), BLOCK_REWARD); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], - 10, - ) - .unwrap(); - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - let planning_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - alice.clone(), - ledger.clone(), - true, - BLOCK_REWARD + iuna(50), - DEFAULT_FEE_PER_BYTE, - ); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - alice.clone(), - ledger, - true, - BLOCK_REWARD + iuna(50), - DEFAULT_FEE_PER_BYTE, - ); - - let outcome = node.automatic_mine_once(1); - - let burned = outcome.burned.as_ref().unwrap(); - let next_amount = burned.amount() + 1; - if let Ok(next_estimate) = planning_node.estimate_burn_fee(next_amount, DEFAULT_FEE_PER_BYTE) { - assert!(next_amount + next_estimate.fee > BLOCK_REWARD); - } - assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE); - assert!(outcome.block.is_none()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); -} - -#[test] -fn automatic_mining_preserves_configured_burn_when_only_fee_is_short() { - let alice = Wallet::from_seed("auto-burn-exact-target-alice"); - let bob = Wallet::from_seed("auto-burn-exact-target-leader"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], - 10, - ) - .unwrap(); - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - alice.clone(), - ledger, - true, - MICRO_IUNA, - DEFAULT_FEE_PER_BYTE, - ); - - let outcome = node.automatic_mine_once(1); - - let burned = outcome.burned.as_ref().unwrap(); - assert_eq!(burned.amount(), MICRO_IUNA); - assert_eq!(burned.fee(), 0); - assert!(outcome.block.is_none()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); -} - -#[test] -fn setting_burn_rate_after_running_at_zero_prepares_private_anchor_burn() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - - let mut ledger = Ledger::new(allocations.clone(), 25); - submit_burn(&mut ledger, &alice, 1); - let first = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(first).unwrap(); - - let mut alice_node = node("alice", alice.clone(), allocations); - alice_node - .receive(iuna::app::GossipEnvelope::ChainSnapshot(ledger.snapshot())) - .unwrap(); - - let skipped = alice_node.automatic_mine_once(2); - assert!(skipped.burned.is_none()); - assert!(skipped.block.is_none()); - - let burned = alice_node.set_burn_per_block(1).unwrap(); - - assert!(burned.is_some()); - assert!(alice_node.ledger().pending().is_empty()); - let outcome = alice_node.automatic_mine_once(2); - assert!(outcome.block.is_some()); -} - -#[test] -fn automatic_mining_waits_when_wallet_is_not_selected_leader() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - - let mut ledger = Ledger::new(allocations.clone(), 25); - submit_burn(&mut ledger, &alice, 1); - let first = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(first).unwrap(); - - let mut bob_node = node("bob", bob.clone(), allocations); - bob_node.set_burn_per_block(10).unwrap(); - bob_node - .receive(iuna::app::GossipEnvelope::Block(ledger.chain()[1].clone())) - .unwrap(); - - let outcome = bob_node.automatic_mine_once(2); - assert!(outcome.burned.is_some()); - assert!(outcome.block.is_none()); - assert!(outcome.skipped_reason.unwrap().contains(alice.address())); - assert_eq!(bob_node.ledger().chain().len(), 2); -} - -#[test] -fn waiting_wallet_gossips_pending_burn_to_selected_leader() { - let alice = Wallet::from_seed("deadlock-alice"); - let bob = Wallet::from_seed("deadlock-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), iuna(1_000)); - allocations.insert(bob.address().to_string(), iuna(1_000)); - let ledger = - Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(bob.address(), 1)], 25) - .unwrap(); - - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(bob.address()) - ); - let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1); - let mut bob_node = - NodeCore::from_ledger_with_burn_fee_and_enabled(bob.clone(), ledger, true, 1, 1); - - let alice_outcome = alice_node.automatic_mine_once(1); - assert!(alice_outcome.burned.is_some()); - assert!(alice_outcome.block.is_none()); - assert!( - alice_outcome - .skipped_reason - .unwrap() - .contains(bob.address()) - ); - assert!(bob_node.ledger().pending().is_empty()); - - for envelope in alice_node.mempool_gossip() { - bob_node.receive(envelope).unwrap(); - } - - assert!(bob_node.ledger().pending().is_empty()); - assert_eq!(bob_node.ledger().pending_blinded_transactions().len(), 1); - let bob_outcome = bob_node.automatic_mine_once(1); - assert!(bob_outcome.skipped_reason.is_none()); - assert!(bob_outcome.block.is_some()); -} - -#[test] -fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() { - let alice = Wallet::from_seed("pob-only-finalizer-alice"); - let bob = Wallet::from_seed("pow-only-miner-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 2 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], - 25, - ) - .unwrap(); - - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(alice.address()) - ); - let mut alice_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - alice, - ledger.clone(), - true, - DEFAULT_BURN_PER_BLOCK, - DEFAULT_FEE_PER_BYTE, - ); - let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - bob.clone(), - ledger, - false, - DEFAULT_BURN_PER_BLOCK, - DEFAULT_FEE_PER_BYTE, - ); - bob_node.set_pow_mining_enabled(true); - - let bob_plan = (1..10_000) - .map(|timestamp| bob_node.prepare_automatic_mining(timestamp)) - .find(|plan| plan.pow_mined.is_some()) - .expect("B should eventually queue a mine action"); - let mine = bob_plan.pow_mined.expect("B should queue a mine action"); - assert_eq!( - bob_plan.skipped_reason.as_deref(), - Some("automatic mining is off") - ); - assert!(alice_node.ledger().pending().is_empty()); - - for envelope in bob_node.drain_outbox() { - alice_node.receive(envelope).unwrap(); - } - - assert!(!alice_node.ledger().pending().is_empty()); - assert!( - alice_node - .ledger() - .pending_blinded_transactions() - .is_empty() - ); - assert!( - alice_node - .ledger() - .pending() - .iter() - .any(|tx| tx.signature() == mine.signature()) - ); -} - -#[test] -fn block_with_wrong_vdf_rounds_is_rejected() { - let wallet = Wallet::from_seed("alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1_000); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25) - .unwrap(); - submit_burn(&mut ledger, &wallet, 1); - - let mut block = ledger.mine_next_block(&wallet, 1).unwrap(); - block.vdf_rounds = 1; - block.hash = block.compute_hash(); - block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds); - - assert!(ledger.apply_block(block).is_err()); -} - -#[test] -fn fallback_finalizer_can_build_block_with_extra_vdf_rounds() { - let alice = Wallet::from_seed("fallback-finalizer-alice"); - let bob = Wallet::from_seed("fallback-finalizer-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != leader) - .unwrap(); - - submit_burn(&mut ledger, fallback, 1); - let block = ledger.mine_next_block(fallback, 1).unwrap(); - - assert_eq!(block.miner, fallback.address()); - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.vdf_rounds, 50); - ledger.apply_block(block).unwrap(); -} - -#[test] -fn fallback_finalizer_with_primary_vdf_rounds_is_rejected() { - let alice = Wallet::from_seed("fallback-rounds-alice"); - let bob = Wallet::from_seed("fallback-rounds-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != leader) - .unwrap(); - - submit_burn(&mut ledger, fallback, 1); - let mut block = ledger.mine_next_block(fallback, 1).unwrap(); - block.vdf_rounds = 25; - block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds); - block.hash = block.compute_hash(); - - let error = ledger.apply_block(block).unwrap_err(); - assert!(format!("{error:#}").contains("block VDF rounds are invalid")); -} - -#[test] -fn fallback_finalizer_unblocks_network_when_primary_does_not_publish() { - let alice = Wallet::from_seed("fallback-network-alice"); - let bob = Wallet::from_seed("fallback-network-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let primary = ledger.expected_leader_for_next_block().unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != primary) - .unwrap(); - let primary_wallet = wallets - .into_iter() - .find(|wallet| wallet.address() == primary) - .unwrap(); - - let mut network = InMemoryNetwork::default(); - network.insert( - "primary", - NodeCore::from_ledger( - primary_wallet.clone(), - ledger.clone(), - DEFAULT_BURN_PER_BLOCK, - ), - ); - network.insert( - "fallback", - NodeCore::from_ledger(fallback.clone(), ledger, DEFAULT_BURN_PER_BLOCK), - ); - - queue_plaintext_burn(network.node_mut("fallback").unwrap(), fallback, 1); - let block = network - .node_mut("fallback") - .unwrap() - .mine_one_at(1) - .unwrap(); - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.miner, fallback.address()); - network.deliver_until_idle().unwrap(); - - let tip = network.node("fallback").unwrap().ledger().status().tip_hash; - for id in ["primary", "fallback"] { - let status = network.node(id).unwrap().ledger().status(); - assert_eq!(status.height, 1, "{id} did not accept fallback block"); - assert_eq!(status.tip_hash, tip, "{id} has a different tip"); - } -} - -#[test] -fn recovery_block_is_rejected_before_timeout() { - let alice = Wallet::from_seed("recovery-before-timeout-alice"); - let bob = Wallet::from_seed("recovery-before-timeout-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) - .unwrap(); - - submit_burn(&mut ledger, &bob, 1); - let error = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS - 1) - .unwrap_err(); - - assert!(format!("{error:#}").contains("recovery block is not available")); -} - -#[test] -fn recovery_block_unblocks_chain_when_only_ticket_holder_stops() { - let alice = Wallet::from_seed("recovery-unblocks-alice"); - let bob = Wallet::from_seed("recovery-unblocks-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) - .unwrap(); - - assert_eq!( - ledger.expected_leader_for_next_block().as_deref(), - Some(alice.address()) - ); - submit_burn(&mut ledger, &bob, 1); - assert!(ledger.mine_next_block(&bob, 1).is_err()); - - let block = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - - assert_eq!(block.miner, bob.address()); - assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); - assert_eq!(block.finalizer_rank, 0); - assert_eq!(block.vdf_rounds, 25); - assert!(block.leader_proof.is_none()); - ledger.apply_block(block).unwrap(); - assert_eq!(ledger.status().height, 1); -} - -#[test] -fn recovery_block_requires_finalizer_own_burn() { - let alice = Wallet::from_seed("recovery-own-burn-alice"); - let bob = Wallet::from_seed("recovery-own-burn-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) - .unwrap(); - - submit_burn(&mut ledger, &alice, 1); - let error = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap_err(); - - assert!(format!("{error:#}").contains("burn from the finalizer")); -} - -#[test] -fn recovery_block_prioritizes_finalizer_own_burn() { - let alice = Wallet::from_seed("recovery-prioritizes-alice"); - let bob = Wallet::from_seed("recovery-prioritizes-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) - .unwrap(); - - let alice_burn = ledger.build_burn(&alice, 1, 1).unwrap(); - ledger.submit_transaction(alice_burn).unwrap(); - let bob_burn = ledger.build_burn(&bob, 1, 0).unwrap(); - let bob_burn_signature = bob_burn.signature().to_string(); - ledger.submit_transaction(bob_burn).unwrap(); - - let block = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == bob_burn_signature) - ); -} - -#[test] -fn recovery_vdf_seed_is_bound_to_timestamp() { - let alice = Wallet::from_seed("recovery-vdf-seed-alice"); - let bob = Wallet::from_seed("recovery-vdf-seed-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) - .unwrap(); - - submit_burn(&mut ledger, &bob, 1); - let mut block = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - block.timestamp_ms += 1; - block.hash = block.compute_hash(); - - let error = ledger.apply_block(block).unwrap_err(); - assert!(format!("{error:#}").contains("block VDF output is invalid")); -} - -#[test] -fn fork_choice_prefers_ticket_block_over_recovery_block_at_same_height() { - let alice = Wallet::from_seed("recovery-fork-alice"); - let bob = Wallet::from_seed("recovery-fork-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let common = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let leader_address = common.expected_leader_for_next_block().unwrap(); - let leader = wallets - .iter() - .copied() - .find(|wallet| wallet.address() == leader_address) - .unwrap(); - let recovery = wallets - .into_iter() - .find(|wallet| wallet.address() != leader_address) - .unwrap(); - - let mut local = common.clone(); - submit_burn(&mut local, recovery, 1); - let recovery_block = local - .mine_recovery_block(recovery, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - assert_eq!(recovery_block.finalizer_mode, FinalizerMode::Recovery); - local.apply_block(recovery_block).unwrap(); - - let mut remote = common; - submit_burn(&mut remote, leader, 1); - let leader_block = remote.mine_next_block(leader, 1).unwrap(); - assert_eq!(leader_block.finalizer_mode, FinalizerMode::Ticket); - let leader_hash = leader_block.hash.clone(); - remote.apply_block(leader_block).unwrap(); - - assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert_eq!(local.status().tip_hash, leader_hash); -} - -#[test] -fn fork_choice_prefers_primary_finalizer_over_fallback_rank() { - let alice = Wallet::from_seed("fallback-fork-alice"); - let bob = Wallet::from_seed("fallback-fork-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(10)); - genesis.insert(bob.address().to_string(), iuna(10)); - let common = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 25, - ) - .unwrap(); - let leader_address = common.expected_leader_for_next_block().unwrap(); - let leader = wallets - .iter() - .copied() - .find(|wallet| wallet.address() == leader_address) - .unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != leader_address) - .unwrap(); - - let mut local = common.clone(); - submit_burn(&mut local, fallback, 1); - let fallback_block = local.mine_next_block(fallback, 1).unwrap(); - assert_eq!(fallback_block.finalizer_rank, 1); - local.apply_block(fallback_block).unwrap(); - - let mut remote = common; - submit_burn(&mut remote, leader, 1); - let leader_block = remote.mine_next_block(leader, 1).unwrap(); - assert_eq!(leader_block.finalizer_rank, 0); - let leader_hash = leader_block.hash.clone(); - remote.apply_block(leader_block).unwrap(); - - assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert_eq!(local.status().tip_hash, leader_hash); -} - -#[test] -fn vdf_solution_verifies_without_rerunning_delay() { - let solution = run_vdf("test-seed", 128); - - assert!(verify_vdf("test-seed", 128, &solution)); - assert!(!verify_vdf("other-seed", 128, &solution)); - assert!(!verify_vdf("test-seed", 129, &solution)); - assert!(!verify_vdf("test-seed", 128, "not-a-vdf-solution")); -} - -#[test] -fn vdf_rounds_retarget_toward_target_block_time() { - let wallet = Wallet::from_seed("alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new(genesis, 100); - - submit_burn(&mut ledger, &wallet, 1); - let block1 = ledger - .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS) - .unwrap(); - assert_eq!(block1.vdf_rounds, 100); - ledger.apply_block(block1).unwrap(); - assert_eq!(ledger.vdf_rounds(), 100); - - submit_burn(&mut ledger, &wallet, 1); - let block2 = ledger - .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2) - .unwrap(); - assert_eq!(block2.vdf_rounds, 100); - ledger.apply_block(block2).unwrap(); - assert_eq!(ledger.vdf_rounds(), 102); - - submit_burn(&mut ledger, &wallet, 1); - let block3 = ledger - .mine_next_block( - &wallet, - VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2 + VDF_TARGET_BLOCK_MS * 2, - ) - .unwrap(); - assert_eq!(block3.vdf_rounds, 102); - ledger.apply_block(block3).unwrap(); - assert_eq!(ledger.vdf_rounds(), 100); -} - -#[test] -fn block_timestamp_too_far_in_future_is_rejected() { - let wallet = Wallet::from_seed("future-timestamp-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new(genesis, 25); - submit_burn(&mut ledger, &wallet, 1); - - let far_future = unix_now_ms() - .saturating_add(VDF_TARGET_BLOCK_MS) - .saturating_add(1); - let block = ledger.mine_next_block(&wallet, far_future).unwrap(); - - let error = ledger.apply_block(block).unwrap_err(); - - assert!(format!("{error:#}").contains("too far in the future")); -} - -#[test] -fn genesis_wall_clock_gap_does_not_lower_vdf_rounds() { - let wallet = Wallet::from_seed("genesis-gap-vdf-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new(genesis, 100); - let now = unix_now_ms(); - let first_timestamp = now.saturating_sub(VDF_TARGET_BLOCK_MS); - - submit_burn(&mut ledger, &wallet, 1); - let block1 = ledger.mine_next_block(&wallet, first_timestamp).unwrap(); - ledger.apply_block(block1).unwrap(); - assert_eq!(ledger.vdf_rounds(), 100); - - submit_burn(&mut ledger, &wallet, 1); - let block2 = ledger.mine_next_block(&wallet, now).unwrap(); - ledger.apply_block(block2).unwrap(); - - assert_eq!(ledger.vdf_rounds(), 100); -} - -#[test] -fn conflicting_utxo_spends_are_not_accepted_together() { - let wallet = Wallet::from_seed("alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new(genesis, 25); - - let first = burn_tx(&ledger, &wallet, 3); - let conflicting = burn_tx(&ledger, &wallet, 4); - ledger.submit_transaction(first.clone()).unwrap(); - let outcome = ledger - .submit_transaction_with_outcome(conflicting.clone()) - .unwrap(); - assert_eq!(outcome, TransactionSubmitOutcome::ConflictsWithPending); - assert!(!ledger.submit_transaction(conflicting).unwrap()); - assert_eq!(ledger.pending().len(), 1); - - let block = ledger - .prepare_next_block(wallet.address(), 1) - .unwrap() - .finish(&wallet, "test-vdf".to_string()); - assert_eq!(block.transactions.len(), 1); - assert!(block.transactions.contains(&first)); -} - -#[test] -fn in_memory_network_syncs_nodes_without_tcp() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - - let mut network = InMemoryNetwork::default(); - network.insert("alice", node("alice", alice.clone(), allocations.clone())); - network.insert("bob", node("bob", bob.clone(), allocations)); - - queue_plaintext_burn(network.node_mut("alice").unwrap(), &alice, 10); - network.deliver_until_idle().unwrap(); - assert!(network.node("bob").unwrap().ledger().pending().is_empty()); - - network.node_mut("alice").unwrap().mine_one().unwrap(); - network.deliver_until_idle().unwrap(); - - let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; - let bob_tip = network.node("bob").unwrap().ledger().status().tip_hash; - assert_eq!(alice_tip, bob_tip); - assert_eq!(network.node("bob").unwrap().ledger().chain().len(), 2); -} - -#[test] -fn in_memory_network_delivers_transaction_to_multiple_peers() { - let wallets = wallets(&["alice", "bob", "carol", "dave"]); - let allocations = allocations(&wallets, MICRO_IUNA); - let mut network = InMemoryNetwork::default(); - - for (name, wallet) in ["alice", "bob", "carol", "dave"] - .iter() - .zip(wallets.clone()) - { - network.insert(*name, node(name, wallet, allocations.clone())); - } - - network.node_mut("alice").unwrap().burn(15).unwrap(); - network.deliver_until_idle().unwrap(); - - for name in ["bob", "carol", "dave"] { - let ledger = network.node(name).unwrap().ledger(); - assert!( - ledger.pending().is_empty(), - "{name} received a plaintext transaction" - ); - assert_eq!( - ledger.pending_blinded_transactions().len(), - 1, - "{name} did not receive alice's blinded burn" - ); - } -} - -#[test] -fn in_memory_network_syncs_mined_block_to_multiple_peers() { - let wallets = wallets(&["alice", "bob", "carol", "dave"]); - let allocations = allocations(&wallets, MICRO_IUNA); - let mut network = InMemoryNetwork::default(); - - for (name, wallet) in ["alice", "bob", "carol", "dave"] - .iter() - .zip(wallets.clone()) - { - network.insert(*name, node(name, wallet, allocations.clone())); - } - - queue_plaintext_burn(network.node_mut("alice").unwrap(), &wallets[0], 10); - network.deliver_until_idle().unwrap(); - network.node_mut("alice").unwrap().mine_one().unwrap(); - network.deliver_until_idle().unwrap(); - - let tip = network.node("alice").unwrap().ledger().status().tip_hash; - for name in ["alice", "bob", "carol", "dave"] { - let ledger = network.node(name).unwrap().ledger(); - assert_eq!(ledger.status().height, 1, "{name} is at the wrong height"); - assert_eq!(ledger.status().tip_hash, tip, "{name} has a different tip"); - assert!( - ledger.pending().is_empty(), - "{name} kept mined transactions" - ); - } -} - -#[test] -fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob]; - let allocations = allocations(&wallets, 1_000); - let mut network = InMemoryNetwork::default(); - - network.insert("alice", node("alice", alice.clone(), allocations.clone())); - network.insert("bob", node("bob", wallets[1].clone(), allocations)); - - for height in 1..=5 { - let alice_node = network.node_mut("alice").unwrap(); - queue_plaintext_burn(alice_node, &alice, 1); - alice_node - .mine_one_at(height * VDF_TARGET_BLOCK_MS) - .unwrap(); - } - assert_eq!(network.node("alice").unwrap().ledger().status().height, 5); - assert_eq!(network.node("bob").unwrap().ledger().status().height, 0); - - assert!(network.sync_node_from_peer("alice", "bob", 2).unwrap()); - assert_eq!(network.node("bob").unwrap().ledger().status().height, 2); - - assert!(network.sync_node_from_peer("alice", "bob", 10).unwrap()); - let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; - let bob_status = network.node("bob").unwrap().ledger().status(); - assert_eq!(bob_status.height, 5); - assert_eq!(bob_status.tip_hash, alice_tip); - - network.deliver_until_idle().unwrap(); - queue_plaintext_burn(network.node_mut("alice").unwrap(), &alice, 1); - network.deliver_until_idle().unwrap(); - network - .node_mut("alice") - .unwrap() - .mine_one_at(6 * VDF_TARGET_BLOCK_MS) - .unwrap(); - network.deliver_until_idle().unwrap(); - - let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; - let bob_status = network.node("bob").unwrap().ledger().status(); - assert_eq!(bob_status.height, 6); - assert_eq!(bob_status.tip_hash, alice_tip); -} - -#[test] -fn joined_nodes_import_transfer_block_and_blinded_burn_reveal() { - let alice = Wallet::from_seed("flow-alice"); - let bob = Wallet::from_seed("flow-bob"); - let carol = Wallet::from_seed("flow-carol"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), iuna(100)); - let alice_ledger = Ledger::new_with_genesis_burns( - genesis, - vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], - 5, - ) - .unwrap(); - - let mut network = InMemoryNetwork::default(); - network.insert( - "a", - NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK), - ); - let mut mined_by = Vec::new(); - - for height in 1..=2 { - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - network.deliver_until_idle().unwrap(); - let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); - mined_by.push(block.miner.clone()); - network.deliver_until_idle().unwrap(); - } - - let bob_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap(); - network.insert( - "b", - NodeCore::from_ledger(bob.clone(), bob_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - for height in 3..=4 { - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - network.deliver_until_idle().unwrap(); - let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); - mined_by.push(block.miner.clone()); - network.deliver_until_idle().unwrap(); - } - - let carol_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap(); - network.insert( - "c", - NodeCore::from_ledger(carol.clone(), carol_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - queue_plaintext_transfer( - network.node_mut("a").unwrap(), - &alice, - bob.address(), - iuna(30), - ); - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - let block5 = network.node_mut("a").unwrap().mine_one_at(5).unwrap(); - assert!( - block5 - .transactions - .iter() - .any(|tx| tx.to() == Some(bob.address()) && tx.amount() == iuna(30)) - ); - mined_by.push(block5.miner.clone()); - network.deliver_until_idle().unwrap(); - - for id in ["a", "b", "c"] { - assert_eq!( - network.node(id).unwrap().ledger().status().height, - 5, - "{id} did not import block 5" - ); - assert_eq!( - network.node(id).unwrap().ledger().balance_of(bob.address()), - iuna(30), - "{id} did not apply A -> B transfer" - ); - } - - let bob_burn = network.node_mut("b").unwrap().burn(10).unwrap(); - network.deliver_until_idle().unwrap(); - assert_eq!( - network - .node("a") - .unwrap() - .ledger() - .pending_blinded_transactions() - .len(), - 1 - ); - - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - let commit_block = network.node_mut("a").unwrap().mine_one_at(6).unwrap(); - assert_eq!(commit_block.blinded_transactions.len(), 1); - assert_eq!(commit_block.blinded_transactions[0].fee, bob_burn.fee()); - mined_by.push(commit_block.miner.clone()); - network.deliver_until_idle().unwrap(); - assert_eq!( - network - .node("a") - .unwrap() - .ledger() - .pending_blinded_reveals() - .len(), - 1 - ); - - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - let reveal_block = network.node_mut("a").unwrap().mine_one_at(7).unwrap(); - assert_eq!(reveal_block.all_blinded_reveals().len(), 1); - mined_by.push(reveal_block.miner.clone()); - network.deliver_until_idle().unwrap(); - let revealed = revealed_blinded_transactions(&network.node("a").unwrap().chain_snapshot()) - .unwrap() - .into_iter() - .filter(|revealed| revealed.height == 7) - .collect::<Vec<_>>(); - assert_eq!(revealed.len(), 1); - assert!(revealed[0].transaction.is_burn(), "{revealed:?}"); - assert_eq!(revealed[0].transaction.amount(), 10); - - for height in 8..=9 { - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); - mined_by.push(block.miner.clone()); - network.deliver_until_idle().unwrap(); - } - - let final_tip = network.node("a").unwrap().ledger().status().tip_hash; - for id in ["a", "b", "c"] { - assert_eq!(network.node(id).unwrap().ledger().status().height, 9); - assert_eq!( - network.node(id).unwrap().ledger().status().tip_hash, - final_tip - ); - } - let ranks = network - .node("a") - .unwrap() - .burn_leader_ranks_for_block(10) - .unwrap(); - assert!( - ranks.iter().any(|rank| rank.owner == bob.address() - && rank.amount == 10 - && rank.eligible_from_height == 10), - "alice={} bob={} ranks={ranks:?}", - alice.address(), - bob.address() - ); - assert!(mined_by.iter().all(|miner| miner == alice.address())); -} - -#[test] -fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() { - let temp = tempdir().unwrap(); - let alice = Wallet::from_seed("persistent-flow-alice"); - let bob = Wallet::from_seed("persistent-flow-bob"); - let carol = Wallet::from_seed("persistent-flow-carol"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 50); - let alice_ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5) - .unwrap(); - - let mut network = InMemoryNetwork::default(); - network.insert( - "a", - NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - network.node_mut("a").unwrap().mine_one_at(1).unwrap(); - network.deliver_until_idle().unwrap(); - - let bob_store = SqliteChainStore::open(temp.path().join("bob.sqlite3")).unwrap(); - bob_store - .save(&network.node("a").unwrap().chain_snapshot()) - .unwrap(); - let bob_joined_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap(); - network.insert( - "b", - NodeCore::from_ledger(bob.clone(), bob_joined_ledger, DEFAULT_BURN_PER_BLOCK), - ); - assert_eq!( - network.node("b").unwrap().ledger().status().tip_hash, - network.node("a").unwrap().ledger().status().tip_hash - ); - - queue_plaintext_transfer(network.node_mut("a").unwrap(), &alice, bob.address(), 10); - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(2).unwrap(); - network.deliver_until_idle().unwrap(); - assert_eq!( - network - .node("b") - .unwrap() - .ledger() - .balance_of(bob.address()), - 10 - ); - - bob_store - .save(&network.node("b").unwrap().chain_snapshot()) - .unwrap(); - let bob_restarted_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap(); - network.insert( - "b", - NodeCore::from_ledger(bob.clone(), bob_restarted_ledger, DEFAULT_BURN_PER_BLOCK), - ); - assert_eq!( - network.node("b").unwrap().ledger().status().tip_hash, - network.node("a").unwrap().ledger().status().tip_hash, - "restarted Bob should resume the persisted chain tip" - ); - - let carol_store = SqliteChainStore::open(temp.path().join("carol.sqlite3")).unwrap(); - carol_store - .save(&network.node("a").unwrap().chain_snapshot()) - .unwrap(); - let carol_joined_ledger = Ledger::from_snapshot(carol_store.load().unwrap().unwrap()).unwrap(); - network.insert( - "c", - NodeCore::from_ledger(carol, carol_joined_ledger, DEFAULT_BURN_PER_BLOCK), - ); - - for height in 3..=6 { - queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); - network.node_mut("a").unwrap().mine_one_at(height).unwrap(); - network.deliver_until_idle().unwrap(); - } - - let final_status = network.node("a").unwrap().ledger().status(); - for id in ["b", "c"] { - assert_eq!( - network.node(id).unwrap().ledger().status().height, - final_status.height, - "{id} did not catch up after Bob restarted" - ); - assert_eq!( - network.node(id).unwrap().ledger().status().tip_hash, - final_status.tip_hash, - "{id} ended on a different tip after Bob restarted" - ); - } - - bob_store - .save(&network.node("b").unwrap().chain_snapshot()) - .unwrap(); - assert_eq!( - bob_store - .load() - .unwrap() - .unwrap() - .blocks - .last() - .unwrap() - .height, - final_status.height - ); -} - -#[test] -fn mined_block_gossip_does_not_include_full_chain_snapshot() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, MICRO_IUNA); - - let mut alice_node = NodeCore::new(NodeConfig { - wallet: alice, - genesis_allocations: allocations.clone(), - vdf_rounds: 10, - burn_per_block: 1, - burn_fee: 1, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - let plan = alice_node.prepare_automatic_mining(1); - let burn_outbox = alice_node.drain_outbox(); - assert!(burn_outbox.is_empty()); - - let work = plan.work.unwrap(); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - alice_node - .complete_prepared_block(work, vdf_output) - .unwrap(); - let block_outbox = alice_node.drain_outbox(); - - assert_eq!(block_outbox.len(), 1); - assert!(matches!( - block_outbox[0], - iuna::app::GossipEnvelope::Block(_) - )); -} - -#[test] -fn mempool_gossip_splits_blinded_batches_at_receiver_limit() { - let alice = Wallet::from_seed("mempool-batch-alice"); - let bob = Wallet::from_seed("mempool-batch-bob"); - let burn_wallets = (0..=TRANSACTION_BATCH_LIMIT) - .map(|index| Wallet::from_seed(&format!("mempool-batch-burn-{index}"))) - .collect::<Vec<_>>(); - let mut wallets = vec![alice.clone(), bob.clone()]; - wallets.extend(burn_wallets.clone()); - let allocations = allocations(&wallets, 10_000); - let mut alice_node = node("alice", alice, allocations.clone()); - let mut bob_node = node("bob", bob, allocations); - - for wallet in &burn_wallets { - let tx = alice_node.ledger().build_burn(wallet, 1, 0).unwrap(); - let built = alice_node - .ledger() - .build_blinded_transaction(wallet, tx, 20) - .unwrap(); - alice_node - .receive_blinded_transaction(built.transaction) - .unwrap(); - } - alice_node.drain_outbox(); - - let gossip = alice_node.mempool_gossip(); - assert_eq!(gossip.len(), 2); - let total_transactions = gossip - .iter() - .map(|envelope| match envelope { - GossipEnvelope::BlindedTransactions { transactions } => { - assert!(transactions.len() <= TRANSACTION_BATCH_LIMIT); - transactions.len() - } - other => panic!("expected blinded transaction batch, got {other:?}"), - }) - .sum::<usize>(); - assert_eq!(total_transactions, TRANSACTION_BATCH_LIMIT + 1); - - for envelope in gossip { - bob_node.receive(envelope).unwrap(); - } - assert!(bob_node.ledger().pending().is_empty()); - assert_eq!( - bob_node.ledger().pending_blinded_transactions().len(), - TRANSACTION_BATCH_LIMIT + 1 - ); -} - -#[test] -fn received_block_is_rebroadcast_to_other_peers_without_networking() { - let names = ["alice", "bob", "carol"]; - let wallets = wallets(&names); - let allocations = allocations(&wallets, 1_000); - let alice = wallets[0].clone(); - let bob = wallets[1].clone(); - let carol = wallets[2].clone(); - - let mut miner = node("alice", alice.clone(), allocations.clone()); - let mut hub = node("bob", bob, allocations.clone()); - let mut carol_node = node("carol", carol, allocations); - - queue_plaintext_burn(&mut miner, &alice, 10); - miner.drain_outbox(); - let block = miner.mine_one_at(1).unwrap(); - miner.drain_outbox(); - - hub.receive(iuna::app::GossipEnvelope::Block(block.clone())) - .unwrap(); - let forwarded = hub.drain_outbox(); - assert_eq!(forwarded.len(), 1); - assert!(matches!(forwarded[0], iuna::app::GossipEnvelope::Block(_))); - - for envelope in forwarded { - carol_node.receive(envelope).unwrap(); - } - assert_eq!(carol_node.ledger().height(), 1); - assert_eq!( - carol_node.ledger().status().tip_hash, - miner.ledger().status().tip_hash - ); - - hub.receive(iuna::app::GossipEnvelope::Block(block)) - .unwrap(); - assert!(hub.drain_outbox().is_empty()); -} - -#[test] -fn imported_snapshot_blocks_are_rebroadcast_without_networking() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, 1_000); - let mut miner = node("alice", alice.clone(), allocations.clone()); - let mut hub = node("bob", bob, allocations); - - queue_plaintext_burn(&mut miner, &alice, 1); - miner.drain_outbox(); - miner.mine_one_at(1).unwrap(); - miner.drain_outbox(); - - queue_plaintext_burn(&mut miner, &alice, 1); - miner.drain_outbox(); - miner.mine_one_at(2).unwrap(); - miner.drain_outbox(); - - hub.import_chain_snapshot(miner.chain_snapshot()).unwrap(); - let outbox = hub.drain_outbox(); - assert_eq!(outbox.len(), 1); - match &outbox[0] { - iuna::app::GossipEnvelope::Blocks { blocks } => { - assert_eq!(blocks.len(), 2); - assert_eq!(blocks[0].height, 1); - assert_eq!(blocks[1].height, 2); - } - other => panic!("expected imported blocks gossip, got {other:?}"), - } -} - -#[test] -fn multiple_peers_can_contribute_blinded_burns_to_lottery_ranks() { - let finalizer = Wallet::from_seed("blinded-burn-ranks-finalizer"); - let names = ["alice", "bob", "carol", "dave"]; - let wallets = wallets(&names); - let mut all_wallets = vec![finalizer.clone()]; - all_wallets.extend(wallets.clone()); - let allocations = allocations(&all_wallets, 1_000); - let ledger = Ledger::new_with_genesis_burns( - allocations.clone(), - vec![GenesisBurn::new(finalizer.address(), 1)], - 25, - ) - .unwrap(); - let mut network = InMemoryNetwork::default(); - - network.insert( - "finalizer", - NodeCore::from_ledger(finalizer.clone(), ledger.clone(), DEFAULT_BURN_PER_BLOCK), - ); - for (name, wallet) in names.iter().zip(wallets.clone()) { - network.insert( - *name, - NodeCore::from_ledger(wallet, ledger.clone(), DEFAULT_BURN_PER_BLOCK), - ); - } - - for ((name, wallet), amount) in names.iter().zip(wallets.iter()).zip([10, 20, 30, 40]) { - let tx = network.node_mut(name).unwrap().burn(amount).unwrap(); - assert!(tx.is_burn()); - assert_eq!(tx.sender(), wallet.address()); - } - network.deliver_until_idle().unwrap(); - assert_eq!( - network - .node("alice") - .unwrap() - .ledger() - .pending_blinded_transactions() - .len(), - 4 - ); - - queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1); - let commit_block = network - .node_mut("finalizer") - .unwrap() - .mine_one_at(1) - .unwrap(); - assert_eq!(commit_block.blinded_transactions.len(), 4); - network.deliver_until_idle().unwrap(); - - assert_eq!( - network - .node("alice") - .unwrap() - .ledger() - .pending_blinded_reveals() - .len(), - 4 - ); - queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1); - network.gossip_mempools_once().unwrap(); - network.deliver_until_idle().unwrap(); - let reveal_block = network - .node_mut("finalizer") - .unwrap() - .mine_one_at(2) - .unwrap(); - assert_eq!(reveal_block.all_blinded_reveals().len(), 4); - network.deliver_until_idle().unwrap(); - - for height in 3..=4 { - queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1); - network - .node_mut("finalizer") - .unwrap() - .mine_one_at(height) - .unwrap(); - network.deliver_until_idle().unwrap(); - } - - let final_tip = network - .node("finalizer") - .unwrap() - .ledger() - .status() - .tip_hash; - for name in names { - let ledger = network.node(name).unwrap().ledger(); - assert_eq!(ledger.status().height, 4); - assert_eq!(ledger.status().tip_hash, final_tip); - let ranks = network - .node(name) - .unwrap() - .burn_leader_ranks_for_block(5) - .unwrap(); - for (wallet, amount) in wallets.iter().zip([10, 20, 30, 40]) { - assert!( - ranks.iter().any(|rank| rank.owner == wallet.address() - && rank.amount == amount - && rank.eligible_from_height == 5), - "{name} missing revealed burn ticket for {} in {ranks:?}", - wallet.address() - ); - } - } -} - -#[test] -fn peer_book_tracks_multiple_peers_without_networking() { - let mut peers = PeerBook::from_addresses(vec![ - "127.0.0.1:9444".to_string(), - "127.0.0.1:9445".to_string(), - "127.0.0.1:9444".to_string(), - ]); - - peers.record_sent("127.0.0.1:9444", 2); - peers.record_status("127.0.0.1:9444", 12, "tip-hash".to_string()); - peers.record_error("127.0.0.1:9445", "connection refused"); - peers.record_received("127.0.0.1:9555", 1); - peers.record_inbound_error("127.0.0.1:56666", "invalid nonce"); - - let mut list = peers.list(); - list.sort_by(|left, right| left.address.cmp(&right.address)); - assert_eq!(list.len(), 4); - - let outbound_addresses = peers.addresses(); - assert_eq!(outbound_addresses.len(), 2); - assert!(outbound_addresses.contains(&"127.0.0.1:9444".to_string())); - assert!(outbound_addresses.contains(&"127.0.0.1:9445".to_string())); - assert!(!outbound_addresses.contains(&"127.0.0.1:56666".to_string())); - - let sent_peer = list - .iter() - .find(|peer| peer.address == "127.0.0.1:9444") - .unwrap(); - assert_eq!(sent_peer.messages_sent, 2); - assert_eq!(sent_peer.last_known_height, Some(12)); - assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash")); - assert_eq!(sent_peer.last_error, None); - assert!(sent_peer.last_contact_ms.is_some()); - assert!(sent_peer.last_success_ms.is_some()); - assert_eq!(sent_peer.last_error_ms, None); - - let failed_peer = list - .iter() - .find(|peer| peer.address == "127.0.0.1:9445") - .unwrap(); - assert_eq!( - failed_peer.last_error.as_deref(), - Some("connection refused") - ); - assert!(failed_peer.last_contact_ms.is_some()); - assert!(failed_peer.last_error_ms.is_some()); - - let inbound_peer = list - .iter() - .find(|peer| peer.address == "127.0.0.1:9555") - .unwrap(); - assert_eq!(inbound_peer.direction, PeerDirection::Inbound); - assert_eq!(inbound_peer.messages_received, 1); - assert!(inbound_peer.last_contact_ms.is_some()); - assert!(inbound_peer.last_success_ms.is_some()); - - let inbound_error = list - .iter() - .find(|peer| peer.address == "127.0.0.1:56666") - .unwrap(); - assert_eq!(inbound_error.direction, PeerDirection::Inbound); - assert_eq!(inbound_error.last_error.as_deref(), Some("invalid nonce")); - - assert!(peers.remove_peer("127.0.0.1:9445")); - assert!(!peers.addresses().contains(&"127.0.0.1:9445".to_string())); - assert!(!peers.remove_peer("127.0.0.1:9555")); - assert!( - peers - .list() - .iter() - .any(|peer| peer.address == "127.0.0.1:9555") - ); -} - -#[test] -fn peer_book_reports_only_outbound_peers_as_addresses() { - let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); - peers.record_received("127.0.0.1:56666", 1); - peers.add_discovered_peer("127.0.0.1:9445"); - peers.record_inbound_misbehavior("127.0.0.1:57777", "invalid transaction"); - - assert!(peers.is_connectable_peer("127.0.0.1:9444")); - assert!(peers.is_connectable_peer("127.0.0.1:9445")); - assert!(!peers.is_connectable_peer("127.0.0.1:56666")); - assert!(!peers.is_connectable_peer("127.0.0.1:57777")); - assert_eq!(peers.addresses(), vec!["127.0.0.1:9444"]); - - assert!(peers.remove_peer("127.0.0.1:9444")); - assert!(!peers.is_connectable_peer("127.0.0.1:9444")); -} - -#[test] -fn peer_book_caps_discovered_peer_hints() { - let mut peers = PeerBook::default(); - for index in 0..600 { - let octet2 = (index / 16) % 256; - let octet3 = index % 16; - peers.add_discovered_peer(format!("8.{octet2}.{octet3}.1:9444")); - } - - assert_eq!( - peers.discovered_peer_count_for_tests(), - peers.discovered_peer_capacity_for_tests() - ); -} - -#[test] -fn peer_book_caps_inbound_to_discovered_conversions() { - let mut peers = PeerBook::default(); - for index in 0..600 { - let octet2 = (index / 16) % 256; - let octet3 = index % 16; - peers.add_discovered_peer(format!("8.{octet2}.{octet3}.1:9444")); - } - peers.observe_inbound_peer("9.9.9.9:9444"); - - assert!(!peers.add_discovered_peer("9.9.9.9:9444")); - assert_eq!( - peers.discovered_peer_count_for_tests(), - peers.discovered_peer_capacity_for_tests() - ); - assert_eq!( - peers.direction_for_tests("9.9.9.9:9444"), - Some(PeerDirection::Inbound) - ); -} - -#[test] -fn peer_book_limits_discovered_peers_per_ip() { - let mut peers = PeerBook::default(); - for port in 20_000..20_010 { - peers.add_discovered_peer(format!("8.8.8.8:{port}")); - } - - assert_eq!(peers.discovered_peer_count_for_tests(), 4); -} - -#[test] -fn peer_book_prunes_stale_discovered_peer_hints() { - let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); - peers.add_discovered_peer_at("8.8.8.8:9444", 1_000); - peers.add_discovered_peer_at("8.8.4.4:9444", 10_000); - peers.record_status("8.8.4.4:9444", 1, "tip".to_string()); - - assert_eq!(peers.prune_stale_peers_at(3_700_000, 60_000, 3_600_000), 1); - assert!(peers.addresses().contains(&"127.0.0.1:9444".to_string())); - assert!(peers.addresses().contains(&"8.8.4.4:9444".to_string())); - assert!(!peers.addresses().contains(&"8.8.8.8:9444".to_string())); -} - -#[test] -fn peer_book_limits_discovered_outbound_session_candidates() { - let mut peers = PeerBook::from_addresses(vec![ - "127.0.0.1:9444".to_string(), - "127.0.0.1:9445".to_string(), - ]); - for index in 0..50 { - peers.add_discovered_peer(format!("8.{}.{}.1:9444", index / 16, index % 16)); - } - - let candidates = peers.outbound_session_candidates_at(iuna::app::now_ms(), 8); - - assert_eq!(candidates.len(), 10); - assert!(candidates.contains(&"127.0.0.1:9444".to_string())); - assert!(candidates.contains(&"127.0.0.1:9445".to_string())); -} - -#[test] -fn peer_book_advertises_only_outbound_peers() { - let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); - peers.add_discovered_peer("8.8.8.8:9444"); - - assert_eq!( - peers.addresses_except("127.0.0.1:9445"), - vec!["127.0.0.1:9444"] - ); -} - -#[test] -fn peer_book_prunes_stale_inbound_observations() { - let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); - peers.record_status("127.0.0.1:9444", 1, "tip".to_string()); - peers.observe_inbound_peer("127.0.0.1:56666"); - peers.record_received("127.0.0.1:57777", 1); - - assert_eq!( - peers.prune_stale_inbound_peers_at(iuna::app::now_ms(), 60_000), - 1 - ); - let listed = peers.list(); - - assert!(listed.iter().any(|peer| peer.address == "127.0.0.1:9444")); - assert!(listed.iter().any(|peer| peer.address == "127.0.0.1:57777")); - assert!(!listed.iter().any(|peer| peer.address == "127.0.0.1:56666")); -} - -#[test] -fn peer_book_bans_misbehaving_peer_temporarily_and_recovers_on_success() { - let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); - - peers.record_misbehavior_at("127.0.0.1:9444", "invalid transaction", 100); - peers.record_misbehavior_at("127.0.0.1:9444", "invalid block", 200); - assert!(!peers.is_banned_at("127.0.0.1:9444", 200)); - assert_eq!(peers.connectable_addresses_at(200), vec!["127.0.0.1:9444"]); - - peers.record_misbehavior_at("127.0.0.1:9444", "wrong genesis", 300); - assert!(peers.is_banned_at("127.0.0.1:9444", 300)); - assert!(peers.connectable_addresses_at(300).is_empty()); - assert_eq!(peers.addresses(), vec!["127.0.0.1:9444"]); - let banned = peers.list().pop().unwrap(); - assert_eq!(banned.misbehavior_score, 3); - assert_eq!(banned.ban_reason.as_deref(), Some("wrong genesis")); - - assert!(!peers.is_banned_at("127.0.0.1:9444", 11 * 60 * 1_000)); - peers.record_status("127.0.0.1:9444", 1, "tip".to_string()); - let recovered = peers.list().pop().unwrap(); - assert_eq!(recovered.misbehavior_score, 0); - assert_eq!(recovered.banned_until_ms, None); -} - -#[test] -fn peer_book_uses_median_accepted_clock_offset() { - let mut peers = PeerBook::from_addresses(vec![ - "127.0.0.1:9444".to_string(), - "127.0.0.1:9445".to_string(), - "127.0.0.1:9446".to_string(), - ]); - let now = 1_000_000; - peers.record_status("127.0.0.1:9444", 1, "tip-a".to_string()); - peers.record_status("127.0.0.1:9445", 1, "tip-b".to_string()); - peers.record_status("127.0.0.1:9446", 1, "tip-c".to_string()); - peers.record_clock_observation("127.0.0.1:9444", PeerDirection::Outbound, now + 1_000, now); - peers.record_clock_observation("127.0.0.1:9445", PeerDirection::Outbound, now + 2_000, now); - peers.record_clock_observation( - "127.0.0.1:9446", - PeerDirection::Outbound, - now + 20 * 60 * 1_000, - now, - ); - - assert_eq!(peers.network_time_offset_ms_at(now), Some(2_000)); - assert_eq!(peers.adjusted_time_ms_at(now), now + 2_000); - assert_eq!(peers.bad_clock_peer_count_at(now), 1); - assert!(!peers.is_banned_at("127.0.0.1:9446", now)); -} - -#[test] -fn peer_book_address_replacement_keeps_newest_clock_observation() { - let mut peers = PeerBook::from_addresses(vec![ - "seed.example:9444".to_string(), - "142.132.164.59:9444".to_string(), - ]); - peers.record_clock_observation( - "seed.example:9444", - PeerDirection::Outbound, - 1_001_000, - 1_000_000, - ); - peers.record_clock_observation( - "142.132.164.59:9444", - PeerDirection::Outbound, - 2_002_000, - 2_000_000, - ); - - peers.replace_peer_address("seed.example:9444", "142.132.164.59:9444"); - - let peer = peers.list().pop().unwrap(); - assert_eq!(peer.last_clock_offset_ms, Some(2_000)); - assert_eq!(peer.last_clock_observed_ms, Some(2_000_000)); -} - -#[test] -fn chain_snapshot_round_trips_ledger_state() { - let alice = Wallet::from_seed("alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &alice, 10); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - ledger.apply_block(block).unwrap(); - - let restored = Ledger::from_snapshot(ledger.snapshot()).unwrap(); - assert_eq!(restored.status().height, ledger.status().height); - assert_eq!(restored.status().tip_hash, ledger.status().tip_hash); - assert_eq!( - restored.balance_of(alice.address()), - ledger.balance_of(alice.address()) - ); -} - -#[test] -fn friend_node_can_join_snapshot_from_started_chain() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - - let mut alice_genesis = BTreeMap::new(); - alice_genesis.insert(alice.address().to_string(), 1_000); - let mut alice_node = node("alice", alice.clone(), alice_genesis); - alice_node - .set_automatic_burn_settings(true, DEFAULT_BURN_PER_BLOCK, DEFAULT_FEE_PER_BYTE) - .unwrap(); - queue_plaintext_burn(&mut alice_node, &alice, 1); - alice_node.automatic_mine_once(1); - - let joined_ledger = Ledger::from_snapshot(alice_node.chain_snapshot()).unwrap(); - let mut bob_node = NodeCore::from_ledger(bob.clone(), joined_ledger, DEFAULT_BURN_PER_BLOCK); - - assert_eq!( - bob_node.ledger().status().tip_hash, - alice_node.ledger().status().tip_hash - ); - assert_eq!(bob_node.ledger().status().height, 1); - assert_eq!(bob_node.ledger().balance_of(bob.address()), 0); - - let outcome = bob_node.automatic_mine_once(2); - assert!(outcome.burned.is_none()); -} - -#[test] -fn running_node_rejects_snapshot_from_different_genesis() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - - let mut alice_genesis = BTreeMap::new(); - alice_genesis.insert(alice.address().to_string(), 1_000); - let alice_node = node("alice", alice, alice_genesis); - - let mut bob_genesis = BTreeMap::new(); - bob_genesis.insert(bob.address().to_string(), 1_000); - let mut bob_node = node("bob", bob, bob_genesis); - - let error = bob_node - .import_chain_snapshot(alice_node.chain_snapshot()) - .unwrap_err(); - - assert!(error.to_string().contains("genesis")); -} - -#[test] -fn same_height_fork_snapshot_does_not_reorg() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let shared_genesis = allocations(&wallets, 1_000); - let base = Ledger::new_with_genesis_burns( - shared_genesis, - vec![GenesisBurn::new(alice.address(), 1)], - 1, - ) - .unwrap(); - let mut local = base.clone(); - - let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 1); - let remote = fork_with_worse_vrf_block(&base, &alice, &local_first_fork_hash, 1).unwrap(); - - let local_tip = local.status().tip_hash; - assert!(!local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn fork_choice_preflight_rejects_non_matching_genesis_before_scoring() { - let alice = Wallet::from_seed("preflight-genesis-alice"); - let bob = Wallet::from_seed("preflight-genesis-bob"); - let mut local_genesis = BTreeMap::new(); - local_genesis.insert(alice.address().to_string(), 1_000); - let mut remote_genesis = BTreeMap::new(); - remote_genesis.insert(bob.address().to_string(), 1_000); - let mut local = Ledger::new(local_genesis, 1); - let mut remote = Ledger::new(remote_genesis, 1); - - mine_wallet_burn_block(&mut local, &alice, 1); - for timestamp in 1..=3 { - mine_wallet_burn_block(&mut remote, &bob, timestamp); - } - - let local_tip = local.status().tip_hash; - let error = local.extend_from_snapshot(remote.snapshot()).unwrap_err(); - - assert!(error.to_string().contains("genesis")); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() { - let alice = Wallet::from_seed("preflight-invalid-alice"); - let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); - let mut common = Ledger::new(shared_genesis, 1); - for timestamp in 1..=5 { - mine_wallet_burn_block(&mut common, &alice, timestamp); - } - - let mut local = common.clone(); - let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); - mine_wallet_burn_block(&mut local, &alice, 7); - mine_wallet_burn_block(&mut local, &alice, 8); - - let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); - assert!(remote.chain()[6].hash < local.chain()[6].hash); - let mut invalid_snapshot = remote.snapshot(); - if let Some(transaction) = invalid_snapshot.blocks[6].transactions.first_mut() { - match transaction { - iuna::domain::Transaction::Burn { signature, .. } - | iuna::domain::Transaction::Transfer { signature, .. } - | iuna::domain::Transaction::Mine { signature, .. } => signature.push_str("00"), - } - } - - let local_tip = local.status().tip_hash; - let error = local.extend_from_snapshot(invalid_snapshot).unwrap_err(); - - assert!(error.to_string().contains("invalid")); - assert_eq!(local.status().height, 8); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn fork_conflict_before_last_six_blocks_is_finalized_even_if_remote_is_longer() { - let alice = Wallet::from_seed("finality-alice"); - let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); - let mut common = Ledger::new(shared_genesis, 1); - mine_wallet_burn_block(&mut common, &alice, 1); - - let mut local = common.clone(); - for timestamp in 2..=8 { - let timestamp_ms = next_ticket_slot_timestamp(&local, timestamp); - mine_wallet_burn_block(&mut local, &alice, timestamp_ms); - } - - let mut remote = common; - for timestamp in 20..=29 { - let timestamp_ms = next_ticket_slot_timestamp(&remote, timestamp); - mine_wallet_burn_block(&mut remote, &alice, timestamp_ms); - } - - assert_eq!(local.status().height, 8); - assert_eq!(remote.status().height, 11); - let finalized_local_tip = local.status().tip_hash; - - assert!( - !local.extend_from_snapshot(remote.snapshot()).unwrap(), - "forks that rewrite blocks before the last six should not be accepted" - ); - assert_eq!(local.status().height, 8); - assert_eq!(local.status().tip_hash, finalized_local_tip); -} - -#[test] -fn shorter_better_rank_fork_inside_last_six_does_not_beat_positive_quality() { - let alice = Wallet::from_seed("better-vrf-alice"); - let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); - let mut common = Ledger::new(shared_genesis, 1); - for timestamp in 1..=5 { - mine_wallet_burn_block(&mut common, &alice, timestamp); - } - - let mut local = common.clone(); - let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); - mine_wallet_burn_block(&mut local, &alice, 7); - mine_wallet_burn_block(&mut local, &alice, 8); - - let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); - - assert_eq!(local.status().height, 8); - assert_eq!(remote.status().height, 6); - assert!(remote.status().height + 2 >= local.status().height); - assert!( - remote.chain()[6].hash < local.chain()[6].hash, - "test setup should give the remote fork the better VRF leader score" - ); - let remote_tip = remote.status().tip_hash; - - assert!( - !local.extend_from_snapshot(remote.snapshot()).unwrap(), - "a shorter fork should not beat greater positive chain quality" - ); - assert_eq!(local.status().height, 8); - assert_ne!(local.status().tip_hash, remote_tip); -} - -#[test] -fn better_vrf_fork_inside_last_six_loses_when_more_than_two_blocks_shorter() { - let alice = Wallet::from_seed("too-short-vrf-alice"); - let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000); - let mut common = Ledger::new(shared_genesis, 1); - for timestamp in 1..=5 { - mine_wallet_burn_block(&mut common, &alice, timestamp); - } - - let mut local = common.clone(); - let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6); - mine_wallet_burn_block(&mut local, &alice, 7); - mine_wallet_burn_block(&mut local, &alice, 8); - mine_wallet_burn_block(&mut local, &alice, 9); - - let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap(); - - assert_eq!(local.status().height, 9); - assert_eq!(remote.status().height, 6); - assert!(remote.chain()[6].hash < local.chain()[6].hash); - let local_tip = local.status().tip_hash; - - assert!( - !local.extend_from_snapshot(remote.snapshot()).unwrap(), - "even a better VRF fork should not win when more than two blocks shorter" - ); - assert_eq!(local.status().height, 9); - assert_eq!(local.status().tip_hash, local_tip); -} - -#[test] -fn transactions_from_abandoned_fork_blocks_return_to_mempool_after_switch() { - let alice = Wallet::from_seed("reorg-alice"); - let bob = Wallet::from_seed("reorg-bob"); - let carol = Wallet::from_seed("reorg-carol"); - let wallets = vec![alice.clone(), bob.clone(), carol.clone()]; - let shared_genesis = allocations(&wallets, 10_000); - let mut common = Ledger::new_with_genesis_burns( - shared_genesis, - vec![GenesisBurn::new(alice.address(), 1)], - 1, - ) - .unwrap(); - mine_wallet_burn_block(&mut common, &alice, 1); - - let mut local = common.clone(); - let abandoned_transfer = transfer_tx(&local, &bob, carol.address(), 7); - local - .submit_transaction(abandoned_transfer.clone()) - .unwrap(); - mine_wallet_burn_block(&mut local, &alice, 2); - - let mut remote = common; - for timestamp in 20..=23 { - mine_wallet_burn_block(&mut remote, &alice, timestamp); - } - - assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert!( - local - .pending() - .iter() - .any(|tx| tx.signature() == abandoned_transfer.signature()), - "transactions mined only on the abandoned fork should return to the mempool" - ); -} - -#[test] -fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let shared_genesis = allocations(&wallets, 1_000); - let mut local = Ledger::new_with_genesis_burns( - shared_genesis.clone(), - vec![GenesisBurn::new(alice.address(), 1)], - 1, - ) - .unwrap(); - let mut remote = Ledger::new_with_genesis_burns( - shared_genesis, - vec![GenesisBurn::new(alice.address(), 1)], - 1, - ) - .unwrap(); - - let local_burn = burn_tx(&local, &alice, 1); - local.submit_transaction(local_burn.clone()).unwrap(); - let local_block = local.mine_next_block(&alice, 1).unwrap(); - local.apply_block(local_block).unwrap(); - let local_transfer = transfer_tx(&local, &bob, alice.address(), 5); - local.submit_transaction(local_transfer.clone()).unwrap(); - - submit_burn(&mut remote, &alice, 1); - let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap(); - remote.apply_block(remote_block_1).unwrap(); - submit_burn(&mut remote, &alice, 1); - let remote_block_2 = remote.mine_next_block(&alice, 2).unwrap(); - remote.apply_block(remote_block_2).unwrap(); - - let remote_tip = remote.status().tip_hash; - assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); - assert_eq!(local.status().height, 2); - assert_eq!(local.status().tip_hash, remote_tip); - assert!( - local - .pending() - .iter() - .any(|tx| tx.signature() == local_transfer.signature()) - ); -} - -#[test] -fn node_receives_chain_snapshot_envelope_when_joining_without_tcp() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - - let wallets = vec![alice.clone(), bob.clone()]; - let shared_genesis = allocations(&wallets, 1_000); - let mut alice_node = node("alice", alice.clone(), shared_genesis.clone()); - queue_plaintext_burn(&mut alice_node, &alice, 1); - alice_node.mine_one().unwrap(); - - let mut bob_node = node("bob", bob, shared_genesis); - - bob_node - .receive(iuna::app::GossipEnvelope::ChainSnapshot( - alice_node.chain_snapshot(), - )) - .unwrap(); - - assert_eq!( - bob_node.ledger().status().tip_hash, - alice_node.ledger().status().tip_hash - ); -} diff --git a/tests/properties.rs b/tests/properties.rs @@ -1,2222 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use iuna::{ - app::{GossipEnvelope, InMemoryNetwork, NodeCore}, - domain::{ - Amount, ChainSnapshot, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, - MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, RECOVERY_BLOCK_DELAY_MS, RevealBundle, - Transaction, TxInput, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, - revealed_blinded_transactions, run_vdf, verify_vdf, - }, -}; - -const LEDGER_PROPERTY_SEEDS: std::ops::Range<u64> = 0..16; -const LEDGER_PROPERTY_ROUNDS: usize = 18; -const NETWORK_PROPERTY_SEEDS: std::ops::Range<u64> = 100..108; -const NETWORK_PROPERTY_ROUNDS: usize = 12; -const TAMPER_PROPERTY_SEEDS: std::ops::Range<u64> = 200..208; -const FORK_PROPERTY_SEEDS: std::ops::Range<u64> = 300..306; -const NETWORK_CHAOS_SEEDS: std::ops::Range<u64> = 400..405; -const NETWORK_CHAOS_ROUNDS: usize = 10; -const CLOCK_SKEW_NETWORK_SEEDS: std::ops::Range<u64> = 600..608; -const CLOCK_SKEW_NETWORK_ROUNDS: usize = 18; -const PARTITION_HEALING_SEEDS: std::ops::Range<u64> = 700..708; -const MULTI_BLOCK_PARTITION_SEEDS: std::ops::Range<u64> = 800..806; -const MULTI_RECOVERY_CANDIDATE_SEEDS: std::ops::Range<u64> = 900..908; -const LATE_JOIN_SYNC_SEEDS: std::ops::Range<u64> = 1_000..1_006; -const FUTURE_TIMESTAMP_SEEDS: std::ops::Range<u64> = 1_100..1_108; -const REORG_MEMPOOL_SEEDS: std::ops::Range<u64> = 1_200..1_208; -const FULL_BLOCK_SELECTION_SEEDS: std::ops::Range<u64> = 1_300..1_302; -const BLINDED_PARTITION_SEEDS: std::ops::Range<u64> = 1_400..1_404; -const BLINDED_EXPIRY_SEEDS: std::ops::Range<u64> = 1_500..1_506; -const SOAK_CHAOS_SEEDS: std::ops::Range<u64> = 1_600..1_602; -const SOAK_CHAOS_ROUNDS: usize = 16; -const VDF_STABILITY_SEEDS: std::ops::Range<u64> = 500..502; -const VDF_STABILITY_BLOCKS: usize = 24; -const VDF_STABILITY_INITIAL_ROUNDS: u64 = 100; -const TEST_REVEAL_BUNDLE_COLLECTION_MS: u64 = 30_000; - -#[derive(Clone, Debug)] -struct TestRng { - state: u64, -} - -impl TestRng { - fn new(seed: u64) -> Self { - Self { - state: seed ^ 0x9e37_79b9_7f4a_7c15, - } - } - - fn next_u64(&mut self) -> u64 { - self.state = self - .state - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - self.state - } - - fn index(&mut self, len: usize) -> usize { - assert!(len > 0); - (self.next_u64() as usize) % len - } - - fn amount(&mut self, max_inclusive: Amount) -> Amount { - 1 + self.next_u64() % max_inclusive - } -} - -fn test_wallets(seed: u64, count: usize) -> Vec<Wallet> { - (0..count) - .map(|index| Wallet::from_seed(&format!("property-wallet-{seed}-{index}"))) - .collect() -} - -fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { - wallets - .iter() - .map(|wallet| (wallet.address().to_string(), amount)) - .collect() -} - -fn genesis_burns(wallets: &[Wallet], amount: Amount) -> Vec<GenesisBurn> { - wallets - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), amount)) - .collect() -} - -fn queue_plaintext_burn(node: &mut NodeCore, wallet: &Wallet, amount: Amount, fee: Amount) -> bool { - node.ledger() - .build_burn(wallet, amount, fee) - .and_then(|tx| node.receive_transaction(tx).map(|_| ())) - .is_ok() -} - -fn queue_plaintext_transfer( - node: &mut NodeCore, - wallet: &Wallet, - recipient: impl Into<String>, - amount: Amount, - fee: Amount, -) -> bool { - node.ledger() - .build_transfer(wallet, recipient, amount, fee) - .and_then(|tx| node.receive_transaction(tx).map(|_| ())) - .is_ok() -} - -fn queue_plaintext_mine(node: &mut NodeCore, wallet: &Wallet) -> bool { - node.ledger() - .build_mine(wallet.address()) - .and_then(|tx| node.receive_transaction(tx).map(|_| ())) - .is_ok() -} - -fn property_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) { - let wallets = test_wallets(seed, wallet_count); - let ledger = Ledger::new_with_genesis_burns( - allocations(&wallets, 250 * MICRO_IUNA), - genesis_burns(&wallets, MICRO_IUNA), - 1, - ) - .expect("property genesis is valid"); - (wallets, ledger) -} - -fn single_finalizer_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) { - let wallets = test_wallets(seed, wallet_count); - let ledger = Ledger::new_with_genesis_burns( - allocations(&wallets, 250 * MICRO_IUNA), - vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)], - 1, - ) - .expect("single-finalizer property genesis is valid"); - (wallets, ledger) -} - -fn vdf_stability_ledger(seed: u64) -> (Wallet, Ledger) { - let wallet = Wallet::from_seed(&format!("vdf-stability-{seed}")); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 250 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], - VDF_STABILITY_INITIAL_ROUNDS, - ) - .expect("vdf stability genesis is valid"); - (wallet, ledger) -} - -fn assert_chain_properties(snapshot: ChainSnapshot) { - let replayed = - Ledger::from_snapshot(snapshot.clone()).expect("snapshot replays as valid chain"); - assert_eq!(replayed.snapshot(), snapshot); - - let chain = replayed.chain(); - assert!(!chain.is_empty()); - assert_eq!(chain[0].height, 0); - assert_eq!(chain[0].prev_hash, "0".repeat(64)); - assert_eq!(chain[0].hash, chain[0].compute_hash()); - - for (index, block) in chain.iter().enumerate() { - assert_eq!(block.height as usize, index); - assert_eq!(block.hash, block.compute_hash()); - if index > 0 { - assert_eq!(block.prev_hash, chain[index - 1].hash); - assert!( - block.timestamp_ms > chain[index - 1].timestamp_ms, - "timestamps must increase at height {}", - block.height - ); - assert!( - verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output), - "VDF must verify at height {}", - block.height - ); - assert!( - block.transactions.iter().any(Transaction::is_burn), - "non-genesis block must include a burn at height {}", - block.height - ); - } - } - - let confirmed_supply = replayed - .status() - .balances - .values() - .try_fold(0_u64, |total, amount| total.checked_add(*amount)) - .expect("confirmed supply does not overflow"); - let expected_supply = expected_confirmed_supply(&snapshot); - assert!(confirmed_supply <= expected_supply); - if snapshot.blocks.iter().all(|block| { - block.blinded_transactions.is_empty() && block.all_blinded_reveals().is_empty() - }) { - assert_eq!(confirmed_supply, expected_supply); - assert_reference_model_matches(&snapshot, &replayed); - } -} - -fn expected_confirmed_supply(snapshot: &ChainSnapshot) -> Amount { - let mut supply = snapshot - .genesis_allocations - .values() - .try_fold(0_u64, |total, amount| total.checked_add(*amount)) - .expect("genesis supply does not overflow"); - - for block in &snapshot.blocks { - for tx in &block.transactions { - match tx { - Transaction::Transfer { fee, .. } => { - supply = supply.checked_sub(*fee).expect("transfer fee is funded"); - } - Transaction::Burn { amount, fee, .. } => { - supply = supply.checked_sub(*amount).expect("burn is funded"); - supply = supply.checked_sub(*fee).expect("burn fee is funded"); - } - Transaction::Mine { .. } => { - supply = supply - .checked_add(MINE_REWARD) - .expect("mine output does not overflow supply"); - } - } - } - supply = supply - .checked_add(block.reward) - .expect("block reward does not overflow supply"); - } - - supply -} - -fn assert_reference_model_matches(snapshot: &ChainSnapshot, replayed: &Ledger) { - let reference_balances = reference_balances(snapshot); - assert_eq!(reference_balances, replayed.status().balances); - - let reference_supply = reference_balances - .values() - .try_fold(0_u64, |total, amount| total.checked_add(*amount)) - .expect("reference supply does not overflow"); - assert_eq!(reference_supply, expected_confirmed_supply(snapshot)); -} - -fn reference_balances(snapshot: &ChainSnapshot) -> BTreeMap<String, Amount> { - let mut utxos = BTreeMap::new(); - - for block in &snapshot.blocks { - if block.height == 0 { - seed_reference_genesis_allocations(snapshot, &mut utxos); - } - - let mut block_signatures = BTreeSet::new(); - let mut block_fees = 0_u64; - for tx in &block.transactions { - assert!( - block_signatures.insert(tx.signature().to_string()), - "duplicate transaction in block {}", - block.height - ); - apply_reference_transaction(tx, &mut utxos); - block_fees = block_fees - .checked_add(tx.fee()) - .expect("reference block fees do not overflow"); - } - - if block.height > 0 { - assert_eq!(block.reward, block_fees); - } - if block.reward > 0 { - let replaced = utxos.insert( - OutPoint { - txid: block.hash.clone(), - index: u32::MAX, - }, - TxOutput { - address: block.miner.clone(), - amount: block.reward, - }, - ); - assert!(replaced.is_none(), "duplicate reference reward output"); - } - } - - balances_from_reference_utxos(&utxos) -} - -fn seed_reference_genesis_allocations( - snapshot: &ChainSnapshot, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) { - for (address, amount) in &snapshot.genesis_allocations { - if *amount == 0 { - continue; - } - utxos.insert( - OutPoint { - txid: hex_hash(format!("iuna-genesis-allocation:{address}")), - index: 0, - }, - TxOutput { - address: address.clone(), - amount: *amount, - }, - ); - } -} - -fn apply_reference_transaction( - transaction: &Transaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) { - if let Transaction::Mine { recipient, .. } = transaction { - let output = TxOutput { - address: recipient.clone(), - amount: MINE_REWARD, - }; - assert_eq!(transaction.fee(), MINE_FINALIZER_FEE); - insert_reference_outputs(transaction, &[output], utxos); - return; - } - - let mut seen_inputs = BTreeSet::new(); - let mut input_total = 0_u64; - for input in reference_inputs(transaction) { - assert!( - seen_inputs.insert(input.outpoint.clone()), - "duplicate reference input" - ); - let spent = utxos - .remove(&input.outpoint) - .expect("reference transaction spends an existing output"); - assert_eq!(spent.address, input.owner); - input_total = input_total - .checked_add(spent.amount) - .expect("reference input total does not overflow"); - } - - let outputs = reference_outputs(transaction); - let output_total = outputs.iter().fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .expect("reference output total does not overflow") - }); - let burn_amount = match transaction { - Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, - }; - let required = output_total - .checked_add(transaction.fee()) - .expect("reference outputs plus fee do not overflow") - .checked_add(burn_amount) - .expect("reference outputs plus burn do not overflow"); - assert_eq!(input_total, required); - - insert_reference_outputs(transaction, &outputs, utxos); -} - -fn insert_reference_outputs( - transaction: &Transaction, - outputs: &[TxOutput], - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) { - for (index, output) in outputs.iter().enumerate() { - let replaced = utxos.insert( - OutPoint { - txid: transaction.signature().to_string(), - index: index as u32, - }, - output.clone(), - ); - assert!(replaced.is_none(), "duplicate reference transaction output"); - } -} - -fn reference_inputs(transaction: &Transaction) -> &[TxInput] { - match transaction { - Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs, - Transaction::Mine { .. } => &[], - } -} - -fn reference_outputs(transaction: &Transaction) -> Vec<TxOutput> { - match transaction { - Transaction::Transfer { outputs, .. } => outputs.clone(), - Transaction::Burn { change, .. } => change.clone(), - Transaction::Mine { recipient, .. } => vec![TxOutput { - address: recipient.clone(), - amount: MINE_REWARD, - }], - } -} - -fn balances_from_reference_utxos(utxos: &BTreeMap<OutPoint, TxOutput>) -> BTreeMap<String, Amount> { - let mut balances = BTreeMap::new(); - for output in utxos.values() { - let balance = balances.entry(output.address.clone()).or_insert(0_u64); - *balance = balance - .checked_add(output.amount) - .expect("reference balance does not overflow"); - } - balances -} - -fn random_wallet_pair<'a>(rng: &mut TestRng, wallets: &'a [Wallet]) -> (&'a Wallet, &'a Wallet) { - let from = rng.index(wallets.len()); - let mut to = rng.index(wallets.len() - 1); - if to >= from { - to += 1; - } - (&wallets[from], &wallets[to]) -} - -fn try_random_transaction( - seed: u64, - round: usize, - rng: &mut TestRng, - wallets: &[Wallet], - ledger: &mut Ledger, -) { - match rng.index(5) { - 0 => { - let (from, to) = random_wallet_pair(rng, wallets); - let amount = rng.amount(5 * MICRO_IUNA); - let fee = rng.next_u64() % 4; - if let Ok(tx) = ledger.build_transfer(from, to.address(), amount, fee) { - let _ = ledger.submit_transaction(tx); - } - } - 1 => { - let wallet = &wallets[rng.index(wallets.len())]; - let amount = rng.amount(3 * MICRO_IUNA); - let fee = rng.next_u64() % 4; - if let Ok(tx) = ledger.build_burn(wallet, amount, fee) { - let _ = ledger.submit_transaction(tx); - } - } - 2 if round % 4 == 0 => { - let wallet = &wallets[rng.index(wallets.len())]; - if let Ok(tx) = ledger.build_mine(wallet.address()) { - let _ = ledger.submit_transaction(tx); - } - } - 3 => { - let wallet = &wallets[rng.index(wallets.len())]; - let impossible = (seed + round as u64 + 1) * MICRO_IUNA * 10_000; - assert!(ledger.build_burn(wallet, impossible, 0).is_err()); - } - _ => {} - } -} - -fn try_finalize_next_block(round: usize, wallets: &[Wallet], ledger: &mut Ledger) { - let Some(leader) = ledger.expected_leader_for_next_block() else { - return; - }; - let Some(wallet) = wallets.iter().find(|wallet| wallet.address() == leader) else { - return; - }; - - if let Ok(tx) = ledger.build_burn(wallet, MICRO_IUNA, 0) { - let _ = ledger.submit_transaction(tx); - } - - let reveal_bundles = reveal_bundles_for_next_block(ledger, wallets); - if let Ok(work) = ledger.prepare_next_block_with_reveal_bundles( - wallet.address(), - (round + 1) as u64, - reveal_bundles, - ) { - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let block = work.finish(wallet, vdf_output); - ledger - .apply_block(block) - .expect("locally mined block applies"); - } -} - -fn finalize_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) { - let burn = ledger - .build_burn(wallet, MICRO_IUNA, 0) - .expect("finalizer can build burn"); - let _ = ledger - .submit_transaction(burn) - .expect("finalizer burn enters mempool"); - let reveal_bundles = reveal_bundles_for_next_block(ledger, std::slice::from_ref(wallet)); - let work = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, reveal_bundles) - .expect("finalizer can prepare next block"); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let block = work.finish(wallet, vdf_output); - ledger.apply_block(block).expect("finalizer block applies"); -} - -fn finalize_preverified_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) { - let burn = ledger - .build_burn(wallet, MICRO_IUNA, 0) - .expect("finalizer can build burn"); - ledger - .submit_transaction(burn) - .expect("finalizer burn enters mempool"); - let reveal_bundles = reveal_bundles_for_next_block(ledger, std::slice::from_ref(wallet)); - let work = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, reveal_bundles) - .expect("finalizer can prepare next block"); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let block = work.finish(wallet, vdf_output); - ledger - .apply_block(block) - .expect("locally mined block applies"); -} - -fn reveal_bundles_for_next_block(ledger: &Ledger, wallets: &[Wallet]) -> Vec<RevealBundle> { - let mut bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - wallets - .iter() - .find(|wallet| wallet.address() == member.owner) - .and_then(|wallet| ledger.build_reveal_bundle(wallet).ok().flatten()) - }) - .collect::<Vec<_>>(); - bundles.sort_by_key(|bundle| bundle.slot); - bundles -} - -fn next_ticket_slot_timestamp(ledger: &Ledger, offset_ms: u64) -> u64 { - ledger - .chain() - .last() - .expect("ledger has genesis") - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS) - .saturating_add(offset_ms) -} - -fn finalize_many(ledger: &mut Ledger, wallet: &Wallet, count: usize, start_timestamp_ms: u64) { - for offset in 0..count { - let timestamp_ms = next_ticket_slot_timestamp(ledger, start_timestamp_ms + offset as u64); - finalize_with_wallet(ledger, wallet, timestamp_ms); - } -} - -#[test] -#[ignore = "long-running VDF retarget stability property"] -fn generated_vdf_retarget_stays_stable_under_noisy_block_times() { - for seed in VDF_STABILITY_SEEDS { - let (wallet, mut ledger) = vdf_stability_ledger(seed); - let mut rng = TestRng::new(seed); - let mut timestamp_ms = 0_u64; - let mut min_rounds = ledger.vdf_rounds(); - let mut max_rounds = ledger.vdf_rounds(); - let mut previous_rounds = ledger.vdf_rounds(); - let mut pair_jitter_ms = 0_u64; - - for block_index in 0..VDF_STABILITY_BLOCKS { - let interval_ms = if block_index == 0 { - VDF_TARGET_BLOCK_MS - } else if block_index % 2 == 1 { - pair_jitter_ms = rng.next_u64() % (VDF_TARGET_BLOCK_MS / 4 + 1); - VDF_TARGET_BLOCK_MS.saturating_sub(pair_jitter_ms) - } else { - VDF_TARGET_BLOCK_MS + pair_jitter_ms - }; - timestamp_ms = timestamp_ms - .checked_add(interval_ms) - .expect("property timestamp does not overflow"); - - finalize_preverified_with_wallet(&mut ledger, &wallet, timestamp_ms); - - let rounds = ledger.vdf_rounds(); - let max_step = (previous_rounds * 2 / 100).max(1); - assert!( - rounds.abs_diff(previous_rounds) <= max_step, - "seed {seed} block {block_index}: VDF rounds changed from {previous_rounds} to {rounds}, above max step {max_step}" - ); - min_rounds = min_rounds.min(rounds); - max_rounds = max_rounds.max(rounds); - previous_rounds = rounds; - } - - let lower_bound = VDF_STABILITY_INITIAL_ROUNDS * 95 / 100; - let upper_bound = VDF_STABILITY_INITIAL_ROUNDS * 105 / 100; - assert!( - min_rounds >= lower_bound && max_rounds <= upper_bound, - "seed {seed}: VDF rounds drifted outside stability band: min {min_rounds}, max {max_rounds}" - ); - } -} - -#[test] -#[ignore = "long-running snapshot replay property"] -fn generated_chain_snapshots_preserve_core_invariants() { - for seed in LEDGER_PROPERTY_SEEDS { - let (wallets, mut ledger) = property_ledger(seed, 4); - let mut rng = TestRng::new(seed); - - assert_chain_properties(ledger.snapshot()); - for round in 0..LEDGER_PROPERTY_ROUNDS { - try_random_transaction(seed, round, &mut rng, &wallets, &mut ledger); - if round % 2 == 0 { - try_finalize_next_block(round, &wallets, &mut ledger); - } - assert_chain_properties(ledger.snapshot()); - } - } -} - -#[test] -fn generated_forks_reorg_only_inside_finality_and_preserve_local_transactions() { - for seed in FORK_PROPERTY_SEEDS { - let (wallets, mut common) = single_finalizer_ledger(seed, 3); - let finalizer = &wallets[0]; - let sender = &wallets[1]; - let recipient = &wallets[2]; - let mut rng = TestRng::new(seed); - - finalize_many(&mut common, finalizer, 2 + rng.index(2), 1); - assert_chain_properties(common.snapshot()); - - let mut local = common.clone(); - let abandoned = local - .build_transfer(sender, recipient.address(), MICRO_IUNA + rng.amount(5), 0) - .expect("abandoned fork transfer builds"); - local - .submit_transaction(abandoned.clone()) - .expect("abandoned fork transfer enters mempool"); - finalize_many(&mut local, finalizer, 1 + rng.index(2), 20); - - let mut remote = common.clone(); - finalize_many(&mut remote, finalizer, 4 + rng.index(3), 100); - - let remote_tip = remote.status().tip_hash; - assert!( - local - .extend_from_snapshot(remote.snapshot()) - .expect("valid fresh fork import succeeds"), - "longer fresh fork should be accepted" - ); - assert_eq!(local.status().tip_hash, remote_tip); - assert!( - local - .pending() - .iter() - .any(|tx| tx.signature() == abandoned.signature()), - "transactions mined only on the abandoned fork should return to the mempool" - ); - assert_chain_properties(local.snapshot()); - - let (wallets, mut common) = single_finalizer_ledger(seed + 10_000, 2); - let finalizer = &wallets[0]; - finalize_with_wallet(&mut common, finalizer, 1); - - let mut finalized_local = common.clone(); - finalize_many(&mut finalized_local, finalizer, 8 + rng.index(2), 10); - let finalized_tip = finalized_local.status().tip_hash; - - let mut too_old_remote = common; - finalize_many(&mut too_old_remote, finalizer, 12 + rng.index(2), 200); - - assert!( - !finalized_local - .extend_from_snapshot(too_old_remote.snapshot()) - .expect("valid old fork import is evaluated"), - "forks that rewrite finalized history must be rejected" - ); - assert_eq!(finalized_local.status().tip_hash, finalized_tip); - assert_chain_properties(finalized_local.snapshot()); - } -} - -#[test] -#[ignore = "long-running generated network convergence property"] -fn in_memory_network_converges_under_generated_node_actions() { - for seed in NETWORK_PROPERTY_SEEDS { - let (wallets, ledger) = property_ledger(seed, 3); - let mut network = InMemoryNetwork::default(); - - for (index, wallet) in wallets.iter().enumerate() { - let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); - network.insert( - format!("n{index}"), - NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA, - 0, - ), - ); - } - - let mut rng = TestRng::new(seed); - network - .deliver_until_idle() - .expect("initial network delivery succeeds"); - - for round in 0..NETWORK_PROPERTY_ROUNDS { - let node_index = rng.index(wallets.len()); - let node_id = format!("n{node_index}"); - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - - match rng.index(4) { - 0 => { - let _ = queue_plaintext_transfer( - network.node_mut(&node_id).expect("node exists"), - &wallets[node_index], - recipient, - rng.amount(2 * MICRO_IUNA), - 0, - ); - } - 1 => { - let _ = queue_plaintext_burn( - network.node_mut(&node_id).expect("node exists"), - &wallets[node_index], - MICRO_IUNA, - 0, - ); - } - 2 => { - let _ = queue_plaintext_mine( - network.node_mut(&node_id).expect("node exists"), - &wallets[node_index], - ); - } - _ => {} - } - - network - .deliver_until_idle() - .expect("transaction gossip converges"); - - let leader = network - .node("n0") - .expect("anchor node exists") - .ledger() - .expected_leader_for_next_block(); - if let Some(leader) = leader { - if let Some((leader_index, _)) = wallets - .iter() - .enumerate() - .find(|(_, wallet)| wallet.address() == leader) - { - let timestamp_ms = (round + 1) as u64; - let mut outcome = network - .node_mut(&format!("n{leader_index}")) - .expect("leader node exists") - .automatic_mine_once(timestamp_ms); - if outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("collecting blinded reveals")) - { - outcome = network - .node_mut(&format!("n{leader_index}")) - .expect("leader node exists") - .automatic_mine_once( - timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1), - ); - } - if let Some(reason) = outcome.skipped_reason { - assert!( - reason.contains("at least one burn") - || reason.contains("selected finalizer") - || reason.contains("required burn") - || reason.contains("could not") - || reason.contains("automatic"), - "unexpected mining skip reason: {reason}" - ); - } - } - } - - network - .deliver_until_idle() - .expect("block gossip converges"); - assert_network_converged(&network, wallets.len()); - } - } -} - -fn assert_network_converged(network: &InMemoryNetwork, nodes: usize) { - let first = network.node("n0").expect("first node exists"); - let height = first.chain_height(); - let tip = first.ledger().status().tip_hash; - assert_chain_properties(first.chain_snapshot()); - - for index in 1..nodes { - let node = network.node(&format!("n{index}")).expect("node exists"); - assert_eq!(node.chain_height(), height, "node {index} height diverged"); - assert_eq!( - node.ledger().status().tip_hash, - tip, - "node {index} tip diverged" - ); - assert_chain_properties(node.chain_snapshot()); - } -} - -#[test] -#[ignore = "long-running generated clock skew property"] -fn in_memory_network_survives_generated_clock_skew() { - for seed in CLOCK_SKEW_NETWORK_SEEDS { - let (wallets, ledger) = property_ledger(seed, 4); - let node_ids = (0..wallets.len()) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let mut network = InMemoryNetwork::default(); - - for (index, wallet) in wallets.iter().enumerate() { - let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA, - 0, - ); - node.set_recovery_vdf_top_rank_percent(100); - network.insert(&node_ids[index], node); - } - - let mut rng = TestRng::new(seed); - let skews = (0..wallets.len()) - .map(|index| { - let magnitude = (rng.next_u64() % (VDF_TARGET_BLOCK_MS * 2 + 1)) as i64; - if index % 2 == 0 { - magnitude - } else { - -magnitude - } - }) - .collect::<Vec<_>>(); - - network - .deliver_until_idle() - .expect("initial network delivery succeeds"); - - for round in 0..CLOCK_SKEW_NETWORK_ROUNDS { - let actor_index = rng.index(wallets.len()); - let actor_id = &node_ids[actor_index]; - match rng.index(4) { - 0 => { - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - let _ = queue_plaintext_transfer( - network.node_mut(actor_id).expect("actor node exists"), - &wallets[actor_index], - recipient, - rng.amount(MICRO_IUNA), - 0, - ); - } - 1 => { - let _ = queue_plaintext_burn( - network.node_mut(actor_id).expect("actor node exists"), - &wallets[actor_index], - MICRO_IUNA, - 0, - ); - } - _ => {} - } - - network - .deliver_until_idle() - .expect("transaction gossip survives skewed producers"); - - let base_timestamp = network - .node("n0") - .expect("anchor node exists") - .ledger() - .chain() - .last() - .expect("anchor chain has a tip") - .timestamp_ms - .saturating_add(1 + (round as u64 % 3)); - let leader = network - .node("n0") - .expect("anchor node exists") - .ledger() - .expected_leader_for_next_block(); - - if let Some(leader) = leader { - if let Some((leader_index, _)) = wallets - .iter() - .enumerate() - .find(|(_, wallet)| wallet.address() == leader) - { - let skewed_timestamp = skew_timestamp(base_timestamp, skews[leader_index]); - let mut outcome = network - .node_mut(&node_ids[leader_index]) - .expect("leader node exists") - .automatic_mine_once(skewed_timestamp); - if outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("collecting blinded reveals")) - { - outcome = network - .node_mut(&node_ids[leader_index]) - .expect("leader node exists") - .automatic_mine_once( - skewed_timestamp - .saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1), - ); - } - if let Some(reason) = outcome.skipped_reason { - assert!( - expected_clock_skew_skip_reason(&reason), - "unexpected skewed mining skip reason: {reason}" - ); - } - } - } - - network - .deliver_until_idle() - .expect("block gossip survives skewed producers"); - - if round % 4 == 3 { - mine_expected_leader_with_network_time(&mut network, &node_ids, &wallets, round); - network - .deliver_until_idle() - .expect("network-time recovery block gossip converges"); - assert_network_converged(&network, wallets.len()); - } - } - - mine_expected_leader_with_network_time( - &mut network, - &node_ids, - &wallets, - CLOCK_SKEW_NETWORK_ROUNDS, - ); - network - .deliver_until_idle() - .expect("final network-time block gossip converges"); - assert_network_converged(&network, wallets.len()); - } -} - -#[test] -fn in_memory_network_heals_generated_ticket_recovery_partitions() { - for seed in PARTITION_HEALING_SEEDS { - let (wallets, ledger) = single_finalizer_ledger(seed, 3); - let mut network = InMemoryNetwork::default(); - let node_ids = (0..wallets.len()) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - - for (index, wallet) in wallets.iter().enumerate() { - let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA, - 0, - ); - node.set_recovery_vdf_top_rank_percent(100); - network.insert(&node_ids[index], node); - } - - network - .deliver_until_idle() - .expect("initial network delivery succeeds"); - - let mut rng = TestRng::new(seed); - let partition_a_timestamp = 1 + rng.next_u64() % VDF_TARGET_BLOCK_MS; - let ticket = network - .node_mut("n0") - .expect("ticket finalizer exists") - .automatic_mine_once(partition_a_timestamp); - assert!( - ticket.block.is_some(), - "ticket side should finalize while partitioned" - ); - - let recovery_index = 1 + rng.index(wallets.len() - 1); - let recovery_id = &node_ids[recovery_index]; - let recovery_timestamp = RECOVERY_BLOCK_DELAY_MS + rng.next_u64() % VDF_TARGET_BLOCK_MS; - let recovery = network - .node_mut(recovery_id) - .expect("recovery finalizer exists") - .automatic_mine_once(recovery_timestamp); - assert!( - recovery.block.is_some(), - "recovery side should finalize while partitioned" - ); - - let ticket_tip = network - .node("n0") - .expect("ticket node exists") - .ledger() - .status() - .tip_hash; - let recovery_tip = network - .node(recovery_id) - .expect("recovery node exists") - .ledger() - .status() - .tip_hash; - assert_ne!( - ticket_tip, recovery_tip, - "partitioned groups should have diverged before reconnect" - ); - - let ticket_snapshot = network - .node("n0") - .expect("ticket node exists") - .chain_snapshot(); - for id in node_ids.iter().skip(1) { - network - .node_mut(id) - .expect("partition peer exists") - .receive(GossipEnvelope::ChainSnapshot(ticket_snapshot.clone())) - .expect("partition peer imports better ticket snapshot"); - } - - drain_all_outboxes(&mut network, &node_ids); - assert_network_converged(&network, wallets.len()); - assert_eq!( - network - .node("n0") - .expect("ticket node exists") - .ledger() - .status() - .tip_hash, - ticket_tip, - "ticket fork should win over same-height recovery fork" - ); - } -} - -#[test] -fn in_memory_network_heals_generated_multi_block_partitions() { - for seed in MULTI_BLOCK_PARTITION_SEEDS { - let (wallets, ledger) = property_ledger(seed, 5); - let node_ids = (0..wallets.len()) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let mut network = network_from_ledger(&wallets, &ledger); - let mut rng = TestRng::new(seed); - - let partition_a = vec![0, 1, 2]; - let partition_b = vec![3, 4]; - for round in 0..3 { - mine_one_partition_block( - &mut network, - &node_ids, - &wallets, - &partition_a, - round, - &mut rng, - ); - } - for round in 0..2 { - mine_one_partition_block( - &mut network, - &node_ids, - &wallets, - &partition_b, - round + 10, - &mut rng, - ); - } - - let partition_a_tip = network - .node("n0") - .expect("partition A anchor exists") - .ledger() - .status() - .tip_hash; - let partition_b_tip = network - .node("n3") - .expect("partition B anchor exists") - .ledger() - .status() - .tip_hash; - assert_ne!( - partition_a_tip, partition_b_tip, - "partitioned chains should diverge before reconnect" - ); - assert!( - network - .node("n0") - .expect("partition A anchor exists") - .chain_height() - > network - .node("n3") - .expect("partition B anchor exists") - .chain_height(), - "partition A should be the taller reconnect candidate" - ); - - let taller_snapshot = network - .node("n0") - .expect("partition A anchor exists") - .chain_snapshot(); - for id in &node_ids { - network - .node_mut(id) - .expect("node exists") - .receive(GossipEnvelope::ChainSnapshot(taller_snapshot.clone())) - .expect("node imports taller partition snapshot"); - } - - drain_all_outboxes(&mut network, &node_ids); - assert_network_converged(&network, wallets.len()); - assert_eq!( - network - .node("n3") - .expect("partition B anchor exists") - .ledger() - .status() - .tip_hash, - partition_a_tip, - "shorter partition should switch to taller chain" - ); - } -} - -#[test] -fn in_memory_network_converges_with_generated_multiple_recovery_candidates() { - for seed in MULTI_RECOVERY_CANDIDATE_SEEDS { - let (wallets, ledger) = single_finalizer_ledger(seed, 4); - let node_ids = (0..wallets.len()) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let mut network = network_from_ledger(&wallets, &ledger); - let mut rng = TestRng::new(seed); - let mut recovery_tips = BTreeSet::new(); - - for (index, node_id) in node_ids.iter().enumerate().skip(1) { - let timestamp_ms = RECOVERY_BLOCK_DELAY_MS - .saturating_add(1) - .saturating_add(rng.next_u64() % VDF_TARGET_BLOCK_MS); - let outcome = network - .node_mut(node_id) - .expect("recovery candidate exists") - .automatic_mine_once(timestamp_ms); - assert!( - outcome.block.is_some(), - "unranked node {index} should produce a recovery candidate" - ); - recovery_tips.insert( - network - .node(node_id) - .expect("recovery candidate exists") - .ledger() - .status() - .tip_hash, - ); - } - assert!( - recovery_tips.len() > 1, - "generated recovery candidates should create competing same-height forks" - ); - - let winning_id = node_ids[1].clone(); - let candidate_snapshots = node_ids - .iter() - .skip(1) - .map(|id| { - network - .node(id) - .expect("candidate node exists") - .chain_snapshot() - }) - .collect::<Vec<_>>(); - for snapshot in candidate_snapshots { - network - .node_mut(&winning_id) - .expect("winning candidate exists") - .receive(GossipEnvelope::ChainSnapshot(snapshot)) - .expect("candidate fork choice accepts recovery snapshot"); - } - let winning_snapshot = network - .node(&winning_id) - .expect("winning candidate exists") - .chain_snapshot(); - let winning_tip = network - .node(&winning_id) - .expect("winning candidate exists") - .ledger() - .status() - .tip_hash; - - for id in node_ids.iter().skip(1) { - network - .node_mut(id) - .expect("candidate node exists") - .receive(GossipEnvelope::ChainSnapshot(winning_snapshot.clone())) - .expect("candidate imports best recovery snapshot"); - } - - for id in node_ids.iter().skip(1) { - let node = network.node(id).expect("candidate node exists"); - assert_eq!( - node.chain_height(), - 1, - "{id} should stay at recovery height" - ); - assert_eq!( - node.ledger().status().tip_hash, - winning_tip, - "{id} should converge to the best recovery candidate" - ); - assert_chain_properties(node.chain_snapshot()); - } - } -} - -#[test] -#[ignore = "long-running late join sync property"] -fn in_memory_network_syncs_generated_late_joiners_from_genesis() { - for seed in LATE_JOIN_SYNC_SEEDS { - let (wallets, ledger) = single_finalizer_ledger(seed, 3); - let mut network = network_from_ledger(&wallets[0..1], &ledger); - let mut rng = TestRng::new(seed); - let produced_blocks = 24 + rng.index(12); - - for round in 0..produced_blocks { - mine_one_partition_block( - &mut network, - &["n0".to_string()], - &wallets, - &[0], - round, - &mut rng, - ); - } - - assert_eq!( - network.node("n0").expect("producer exists").chain_height(), - produced_blocks as u64 - ); - - for (index, wallet) in wallets.iter().enumerate().skip(1) { - let joined = - Ledger::from_snapshot(ledger.snapshot()).expect("late node starts at genesis"); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA, - 0, - ); - node.set_recovery_vdf_top_rank_percent(100); - network.insert(format!("n{index}"), node); - } - - for index in 1..wallets.len() { - let id = format!("n{index}"); - sync_until_idle(&mut network, "n0", &id, 3); - } - network - .deliver_until_idle() - .expect("late joiner block gossip converges"); - assert_network_converged(&network, wallets.len()); - } -} - -#[test] -fn in_memory_network_rejects_generated_future_timestamp_blocks_without_stalling() { - for seed in FUTURE_TIMESTAMP_SEEDS { - let (wallets, ledger) = single_finalizer_ledger(seed, 2); - let node_ids = (0..wallets.len()) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let mut network = network_from_ledger(&wallets, &ledger); - - queue_plaintext_burn( - network.node_mut("n0").expect("producer exists"), - &wallets[0], - MICRO_IUNA, - 0, - ); - let future_block = network - .node("n0") - .expect("producer exists") - .ledger() - .mine_next_block(&wallets[0], u64::MAX) - .expect("producer can build future-dated candidate"); - let rejected = network - .node_mut("n1") - .expect("receiver exists") - .receive(GossipEnvelope::Block(future_block)) - .expect_err("future-dated block should be rejected"); - assert!( - rejected.to_string().contains("too far in the future"), - "unexpected future-block rejection: {rejected:#}" - ); - assert_eq!( - network.node("n1").expect("receiver exists").chain_height(), - 0, - "receiver should keep its local chain after future-block rejection" - ); - - let valid_block = network - .node("n0") - .expect("producer exists") - .ledger() - .mine_next_block(&wallets[0], VDF_TARGET_BLOCK_MS) - .expect("producer can build valid block after future rejection"); - network - .node_mut("n0") - .expect("producer exists") - .receive(GossipEnvelope::Block(valid_block)) - .expect("producer applies valid block after future rejection"); - let valid_snapshot = network - .node("n0") - .expect("producer exists") - .chain_snapshot(); - network - .node_mut("n1") - .expect("receiver exists") - .receive(GossipEnvelope::ChainSnapshot(valid_snapshot)) - .expect("receiver should still import a later valid chain"); - drain_all_outboxes(&mut network, &node_ids); - assert_network_converged(&network, wallets.len()); - } -} - -#[test] -fn generated_reorgs_preserve_valid_mempool_transactions() { - for seed in REORG_MEMPOOL_SEEDS { - let wallets = test_wallets(seed, 3); - let mut common = Ledger::new_with_genesis_burns( - allocations(&wallets, 50 * MICRO_IUNA), - vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)], - 1, - ) - .expect("reorg mempool genesis is valid"); - finalize_with_wallet(&mut common, &wallets[0], VDF_TARGET_BLOCK_MS); - - let mut local = common.clone(); - let abandoned_transfer = local - .build_transfer(&wallets[1], wallets[2].address(), MICRO_IUNA, 0) - .expect("abandoned fork transfer builds"); - local - .submit_transaction(abandoned_transfer.clone()) - .expect("abandoned fork transfer enters mempool"); - finalize_with_wallet(&mut local, &wallets[0], VDF_TARGET_BLOCK_MS * 2); - - let surviving_transfer = local - .build_transfer(&wallets[2], wallets[1].address(), MICRO_IUNA, 0) - .expect("local pending transfer builds"); - local - .submit_transaction(surviving_transfer.clone()) - .expect("local pending transfer enters mempool"); - - let mut remote = common; - for height in 2..=5 { - finalize_with_wallet( - &mut remote, - &wallets[0], - VDF_TARGET_BLOCK_MS.saturating_mul(height), - ); - } - - assert!( - local - .extend_from_snapshot(remote.snapshot()) - .expect("longer remote snapshot is evaluated"), - "longer fork should replace local fork" - ); - let pending_signatures = local - .pending() - .iter() - .map(Transaction::signature) - .collect::<BTreeSet<_>>(); - assert!( - pending_signatures.contains(abandoned_transfer.signature()), - "transaction mined only on abandoned fork should return to mempool" - ); - assert!( - pending_signatures.contains(surviving_transfer.signature()), - "valid local pending transaction should survive reorg" - ); - assert_chain_properties(local.snapshot()); - } -} - -#[test] -fn generated_full_block_selection_stays_valid_and_bounded() { - for seed in FULL_BLOCK_SELECTION_SEEDS { - let wallets = test_wallets(seed, 40); - let mut ledger = Ledger::new_with_genesis_burns( - allocations(&wallets, 20 * MICRO_IUNA), - vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)], - 1, - ) - .expect("full block genesis is valid"); - let mut rng = TestRng::new(seed); - - for wallet in wallets.iter().skip(1) { - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - if let Ok(tx) = ledger.build_transfer(wallet, recipient, MICRO_IUNA, rng.amount(9)) { - let _ = ledger.submit_transaction(tx); - } - } - let anchor = ledger - .build_burn(&wallets[0], MICRO_IUNA, 0) - .expect("anchor burn builds"); - ledger - .submit_transaction(anchor) - .expect("anchor burn enters mempool"); - - let block = ledger - .mine_next_block(&wallets[0], VDF_TARGET_BLOCK_MS) - .expect("full pending pool can produce a bounded block"); - assert!( - block.serialized_size_bytes().expect("block serializes") <= MAX_BLOCK_BYTES, - "selected block should fit max block bytes" - ); - assert!( - block.transactions.iter().any(Transaction::is_burn), - "full block should retain required burn" - ); - assert!( - block.transactions.len() > 1, - "selection should include more than the required anchor when space allows" - ); - ledger - .apply_block(block) - .expect("bounded full block applies"); - assert_chain_properties(ledger.snapshot()); - } -} - -#[test] -fn generated_blinded_commit_reveal_survives_partition_and_reconnect() { - for seed in BLINDED_PARTITION_SEEDS { - let finalizer = Wallet::from_seed(&format!("blinded-partition-finalizer-{seed}")); - let sender = Wallet::from_seed(&format!("blinded-partition-sender-{seed}")); - let observer = Wallet::from_seed(&format!("blinded-partition-observer-{seed}")); - let wallets = vec![finalizer.clone(), sender.clone(), observer.clone()]; - let ledger = Ledger::new_with_genesis_burns( - allocations(&wallets, 100 * MICRO_IUNA), - vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], - 1, - ) - .expect("blinded partition genesis is valid"); - let mut network = network_from_ledger(&wallets, &ledger); - - network - .node_mut("n1") - .expect("sender exists") - .burn(MICRO_IUNA) - .expect("sender creates owned blinded burn"); - let sender_outbox = network - .node_mut("n1") - .expect("sender exists") - .drain_outbox(); - for envelope in sender_outbox { - network - .node_mut("n0") - .expect("finalizer exists") - .receive(envelope) - .expect("finalizer receives blinded commit before partition"); - } - - queue_plaintext_burn( - network.node_mut("n0").expect("finalizer exists"), - &finalizer, - MICRO_IUNA, - 0, - ); - let commit_block = network - .node_mut("n0") - .expect("finalizer exists") - .mine_one_at(VDF_TARGET_BLOCK_MS) - .expect("finalizer mines blinded commit block"); - assert_eq!(commit_block.blinded_transactions.len(), 1); - - network - .node_mut("n1") - .expect("sender exists") - .receive(GossipEnvelope::Block(commit_block.clone())) - .expect("sender imports commit block while reveal path is partitioned"); - assert_eq!( - network - .node("n1") - .expect("sender exists") - .ledger() - .pending_blinded_reveals() - .len(), - 1, - "sender should publish reveal after seeing its commit" - ); - - network - .deliver_until_idle() - .expect("reconnect should gossip delayed reveal"); - queue_plaintext_burn( - network.node_mut("n0").expect("finalizer exists"), - &finalizer, - MICRO_IUNA, - 0, - ); - network - .gossip_mempools_once() - .expect("reveal gossip succeeds"); - let reveal_block = network - .node_mut("n0") - .expect("finalizer exists") - .mine_one_at(VDF_TARGET_BLOCK_MS * 2) - .expect("finalizer mines reveal block after reconnect"); - assert_eq!(reveal_block.all_blinded_reveals().len(), 1); - network - .deliver_until_idle() - .expect("reveal block gossip converges"); - let revealed = revealed_blinded_transactions( - &network - .node("n0") - .expect("finalizer exists") - .chain_snapshot(), - ) - .expect("revealed history is reconstructed"); - assert!( - revealed - .iter() - .any(|revealed| revealed.height == reveal_block.height - && revealed.transaction.is_burn() - && revealed.transaction.sender() == sender.address()), - "blinded burn should reveal after reconnect" - ); - assert_network_converged(&network, wallets.len()); - } -} - -#[test] -fn generated_expired_blinded_transactions_are_pruned_under_progress() { - for seed in BLINDED_EXPIRY_SEEDS { - let wallets = test_wallets(seed, 3); - let mut ledger = Ledger::new_with_genesis_burns( - allocations(&wallets, 40 * MICRO_IUNA), - vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)], - 1, - ) - .expect("blinded expiry genesis is valid"); - - let blinded = ledger - .build_blinded_burn(&wallets[1], MICRO_IUNA, 0, 2) - .expect("short-lived blinded burn builds"); - ledger - .submit_blinded_transaction(blinded.transaction) - .expect("short-lived blinded burn enters mempool"); - assert_eq!(ledger.pending_blinded_transactions().len(), 1); - - for height in 1..=3 { - finalize_with_wallet( - &mut ledger, - &wallets[0], - VDF_TARGET_BLOCK_MS.saturating_mul(height), - ); - } - assert!( - ledger.pending_blinded_transactions().is_empty(), - "expired pending blinded transaction should be pruned as blocks progress" - ); - assert_chain_properties(ledger.snapshot()); - } -} - -#[test] -#[ignore = "long-running testnet soak property"] -fn in_memory_network_soak_generated_chaos() { - for seed in SOAK_CHAOS_SEEDS { - let (wallets, ledger) = property_ledger(seed, 5); - let node_ids = (0..wallets.len()) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let mut network = network_from_ledger(&wallets, &ledger); - let mut rng = TestRng::new(seed); - - for round in 0..SOAK_CHAOS_ROUNDS { - let mut offline = BTreeSet::new(); - if round % 5 == 1 { - offline.insert(node_ids[rng.index(node_ids.len())].clone()); - } - if round % 7 == 3 { - offline.insert(node_ids[rng.index(node_ids.len())].clone()); - } - - let actor = rng.index(wallets.len()); - match rng.index(6) { - 0 => { - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - let _ = queue_plaintext_transfer( - network - .node_mut(&node_ids[actor]) - .expect("actor node exists"), - &wallets[actor], - recipient, - rng.amount(MICRO_IUNA), - rng.next_u64() % 3, - ); - } - 1 => { - let _ = queue_plaintext_burn( - network - .node_mut(&node_ids[actor]) - .expect("actor node exists"), - &wallets[actor], - MICRO_IUNA, - rng.next_u64() % 3, - ); - } - 2 => { - let expiry_height = network - .node(&node_ids[actor]) - .expect("actor node exists") - .chain_height() - + 8; - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - let _ = network - .node_mut(&node_ids[actor]) - .expect("actor node exists") - .blinded_transfer_with_fee(recipient, 1, 0, expiry_height); - } - _ => {} - } - - deliver_chaos_until_idle(&mut network, &node_ids, &offline, &mut rng); - let online = node_ids - .iter() - .enumerate() - .filter_map(|(index, id)| (!offline.contains(id)).then_some(index)) - .collect::<Vec<_>>(); - if !online.is_empty() { - let _ = try_mine_one_partition_block( - &mut network, - &node_ids, - &wallets, - &online, - round, - &mut rng, - ); - } - deliver_chaos_until_idle(&mut network, &node_ids, &offline, &mut rng); - } - - let best_id = node_ids - .iter() - .max_by_key(|id| network.node(id).expect("node exists").chain_height()) - .expect("network has nodes") - .clone(); - let best_snapshot = network - .node(&best_id) - .expect("best node exists") - .chain_snapshot(); - for id in &node_ids { - network - .node_mut(id) - .expect("node exists") - .receive(GossipEnvelope::ChainSnapshot(best_snapshot.clone())) - .expect("node imports best soak snapshot"); - } - drain_all_outboxes(&mut network, &node_ids); - assert_network_converged(&network, wallets.len()); - } -} - -fn network_from_ledger(wallets: &[Wallet], ledger: &Ledger) -> InMemoryNetwork { - let mut network = InMemoryNetwork::default(); - for (index, wallet) in wallets.iter().enumerate() { - let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA, - 0, - ); - node.set_recovery_vdf_top_rank_percent(100); - network.insert(format!("n{index}"), node); - } - network -} - -fn skew_timestamp(base_timestamp: u64, skew_ms: i64) -> u64 { - if skew_ms >= 0 { - base_timestamp.saturating_add(skew_ms as u64) - } else { - base_timestamp.saturating_sub(skew_ms.unsigned_abs()) - } -} - -fn expected_clock_skew_skip_reason(reason: &str) -> bool { - reason.contains("at least one burn") - || reason.contains("selected finalizer") - || reason.contains("required burn") - || reason.contains("could not") - || reason.contains("automatic") - || reason.contains("block timestamp") - || reason.contains("before finalizer rank") - || reason.contains("collecting blinded reveals") -} - -fn mine_expected_leader_with_network_time( - network: &mut InMemoryNetwork, - node_ids: &[String], - wallets: &[Wallet], - round: usize, -) { - let Some(leader) = network - .node("n0") - .expect("anchor node exists") - .ledger() - .expected_leader_for_next_block() - else { - return; - }; - let Some((leader_index, _)) = wallets - .iter() - .enumerate() - .find(|(_, wallet)| wallet.address() == leader) - else { - return; - }; - let timestamp_ms = network - .node("n0") - .expect("anchor node exists") - .ledger() - .chain() - .last() - .expect("anchor chain has a tip") - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS + round as u64 + 1); - let mut outcome = network - .node_mut(&node_ids[leader_index]) - .expect("leader node exists") - .automatic_mine_once(timestamp_ms); - if outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("collecting blinded reveals")) - { - collect_reveal_bundles_for_next_block( - network, - node_ids, - wallets, - &node_ids[leader_index], - timestamp_ms, - ); - outcome = network - .node_mut(&node_ids[leader_index]) - .expect("leader node exists") - .automatic_mine_once(timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1)); - } - if let Some(reason) = outcome.skipped_reason { - assert!( - expected_clock_skew_skip_reason(&reason), - "unexpected network-time mining skip reason: {reason}" - ); - } -} - -fn drain_all_outboxes(network: &mut InMemoryNetwork, node_ids: &[String]) { - for id in node_ids { - let _ = network.node_mut(id).expect("node exists").drain_outbox(); - } -} - -fn collect_reveal_bundles_for_next_block( - network: &mut InMemoryNetwork, - node_ids: &[String], - wallets: &[Wallet], - anchor_id: &str, - _timestamp_ms: u64, -) { - let committee = network - .node(anchor_id) - .expect("anchor node exists") - .ledger() - .reveal_committee_for_next_block(); - let committee_node_ids = committee - .into_iter() - .filter_map(|member| { - wallets - .iter() - .position(|wallet| wallet.address() == member.owner) - .map(|index| node_ids[index].clone()) - }) - .collect::<Vec<_>>(); - - let mut bundles = Vec::new(); - for id in &committee_node_ids { - let Some(index) = node_ids.iter().position(|node_id| node_id == id) else { - continue; - }; - if let Some(bundle) = network - .node(id) - .expect("committee node exists") - .ledger() - .build_reveal_bundle(&wallets[index]) - .expect("committee node builds reveal bundle") - { - bundles.push((id.clone(), bundle)); - } - } - - for (from, bundle) in bundles { - for id in node_ids { - if *id == from { - continue; - } - network - .node_mut(id) - .expect("node exists") - .receive(GossipEnvelope::RevealBundle(bundle.clone())) - .expect("node receives reveal bundle"); - } - } - network - .deliver_until_idle() - .expect("reveal bundle gossip succeeds"); -} - -fn mine_one_partition_block( - network: &mut InMemoryNetwork, - node_ids: &[String], - wallets: &[Wallet], - partition: &[usize], - round: usize, - rng: &mut TestRng, -) { - if let Err(reasons) = - try_mine_one_partition_block(network, node_ids, wallets, partition, round, rng) - { - panic!("partition {partition:?} could not produce a block in round {round}: {reasons:?}"); - } -} - -fn try_mine_one_partition_block( - network: &mut InMemoryNetwork, - node_ids: &[String], - wallets: &[Wallet], - partition: &[usize], - round: usize, - rng: &mut TestRng, -) -> Result<(), Vec<String>> { - let anchor_id = &node_ids[partition[0]]; - let anchor_tip_timestamp = network - .node(anchor_id) - .expect("partition anchor exists") - .ledger() - .chain() - .last() - .expect("partition chain has a tip") - .timestamp_ms; - let mut candidates = partition.to_vec(); - candidates.sort_by_key(|index| { - network - .node(anchor_id) - .expect("partition anchor exists") - .ledger() - .finalizer_rank_for_next_block(wallets[*index].address()) - .unwrap_or(u32::MAX) - }); - - let mut reasons = Vec::new(); - for index in candidates { - let rank_delay = network - .node(anchor_id) - .expect("partition anchor exists") - .ledger() - .finalizer_rank_for_next_block(wallets[index].address()) - .map(|rank| VDF_TARGET_BLOCK_MS.saturating_mul(u64::from(rank + 1) * 2)) - .unwrap_or(RECOVERY_BLOCK_DELAY_MS); - let timestamp_ms = anchor_tip_timestamp - .saturating_add(rank_delay) - .saturating_add(1 + round as u64 + rng.next_u64() % 17); - let mut outcome = network - .node_mut(&node_ids[index]) - .expect("partition candidate exists") - .automatic_mine_once(timestamp_ms); - if outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("collecting blinded reveals")) - { - let partition_offline = node_ids - .iter() - .enumerate() - .filter_map(|(node_index, id)| { - (!partition.contains(&node_index)).then_some(id.clone()) - }) - .collect::<BTreeSet<_>>(); - collect_reveal_bundles_for_next_block_with_chaos( - network, - node_ids, - wallets, - anchor_id, - timestamp_ms, - &partition_offline, - rng, - ); - outcome = network - .node_mut(&node_ids[index]) - .expect("partition candidate exists") - .automatic_mine_once( - timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1), - ); - } - if outcome.block.is_some() { - let snapshot = network - .node(&node_ids[index]) - .expect("partition producer exists") - .chain_snapshot(); - for peer in partition { - if *peer != index { - network - .node_mut(&node_ids[*peer]) - .expect("partition peer exists") - .receive(GossipEnvelope::ChainSnapshot(snapshot.clone())) - .expect("partition peer imports produced block"); - } - } - return Ok(()); - } - reasons.push(format!( - "{}: {}", - node_ids[index], - outcome - .skipped_reason - .unwrap_or_else(|| "no block and no skip reason".to_string()) - )); - } - - Err(reasons) -} - -fn sync_until_idle(network: &mut InMemoryNetwork, from: &str, to: &str, limit: usize) { - for _ in 0..256 { - if !network - .sync_node_from_peer(from, to, limit) - .expect("range sync succeeds") - { - return; - } - } - panic!("range sync did not become idle"); -} - -fn collect_reveal_bundles_for_next_block_with_chaos( - network: &mut InMemoryNetwork, - node_ids: &[String], - wallets: &[Wallet], - anchor_id: &str, - _timestamp_ms: u64, - offline: &BTreeSet<String>, - rng: &mut TestRng, -) { - let committee = network - .node(anchor_id) - .expect("anchor node exists") - .ledger() - .reveal_committee_for_next_block(); - let committee_node_ids = committee - .into_iter() - .filter_map(|member| { - wallets - .iter() - .position(|wallet| wallet.address() == member.owner) - .map(|index| node_ids[index].clone()) - }) - .collect::<Vec<_>>(); - - let mut bundles = Vec::new(); - for id in &committee_node_ids { - if offline.contains(id) { - continue; - } - let Some(index) = node_ids.iter().position(|node_id| node_id == id) else { - continue; - }; - if let Some(bundle) = network - .node(id) - .expect("committee node exists") - .ledger() - .build_reveal_bundle(&wallets[index]) - .expect("committee node builds reveal bundle") - { - bundles.push((id.clone(), bundle)); - } - } - - for (from, bundle) in bundles { - for id in node_ids { - if *id == from || offline.contains(id) { - continue; - } - receive_chaotic_envelope(network, id, GossipEnvelope::RevealBundle(bundle.clone())); - } - } - deliver_chaos_until_idle(network, node_ids, offline, rng); -} - -fn deliver_with_chaos( - network: &mut InMemoryNetwork, - node_ids: &[String], - offline: &BTreeSet<String>, - rng: &mut TestRng, -) -> bool { - let mut outbound = Vec::new(); - for id in node_ids { - if offline.contains(id) { - continue; - } - let node = network.node_mut(id).expect("node exists"); - for envelope in node.drain_outbox() { - outbound.push((id.clone(), envelope)); - } - } - - if outbound.is_empty() { - return false; - } - - while !outbound.is_empty() { - let index = rng.index(outbound.len()); - let (from, envelope) = outbound.swap_remove(index); - for id in node_ids { - if *id == from || offline.contains(id) { - continue; - } - let duplicate = rng.index(5) == 0; - receive_chaotic_envelope(network, id, envelope.clone()); - if duplicate { - receive_chaotic_envelope(network, id, envelope.clone()); - } - } - } - - true -} - -fn receive_chaotic_envelope( - network: &mut InMemoryNetwork, - id: &str, - envelope: iuna::app::GossipEnvelope, -) { - if let Err(error) = network.node_mut(id).expect("node exists").receive(envelope) { - let message = error.to_string(); - assert!( - message.contains("expected block height") - || message.contains("conflicts with local chain") - || message.contains("reveal bundle parent hash is invalid") - || message.contains("mine transaction anchor is not on this chain") - || message.contains("blinded transaction spends missing output") - || message.contains("blinded transaction expiry is too far in the future") - || message - .contains("blinded reveal does not reference an active blinded transaction",), - "unexpected chaotic delivery error: {message}" - ); - } -} - -fn deliver_chaos_until_idle( - network: &mut InMemoryNetwork, - node_ids: &[String], - offline: &BTreeSet<String>, - rng: &mut TestRng, -) { - for _ in 0..32 { - if !deliver_with_chaos(network, node_ids, offline, rng) { - return; - } - } - panic!("chaotic network delivery did not become idle"); -} - -#[test] -fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() { - for seed in NETWORK_CHAOS_SEEDS { - let (wallets, ledger) = single_finalizer_ledger(seed, 3); - let node_ids = (0..wallets.len()) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let mut network = InMemoryNetwork::default(); - - for (index, wallet) in wallets.iter().enumerate() { - let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); - network.insert( - &node_ids[index], - NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA, - 0, - ), - ); - } - - let mut rng = TestRng::new(seed); - deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng); - - for round in 0..NETWORK_CHAOS_ROUNDS { - let mut offline = BTreeSet::new(); - if round % 3 == 0 { - offline.insert("n2".to_string()); - } else if round % 4 == 0 { - offline.insert("n1".to_string()); - } - - let actor_index = 1 + rng.index(wallets.len() - 1); - let actor_id = format!("n{actor_index}"); - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - match rng.index(3) { - 0 => { - let _ = queue_plaintext_transfer( - network.node_mut(&actor_id).expect("actor node exists"), - &wallets[actor_index], - recipient, - MICRO_IUNA + rng.amount(17), - 0, - ); - } - 1 => { - let _ = queue_plaintext_mine( - network.node_mut(&actor_id).expect("actor node exists"), - &wallets[actor_index], - ); - } - _ => { - let _ = queue_plaintext_burn( - network.node_mut("n0").expect("finalizer node exists"), - &wallets[0], - MICRO_IUNA, - 0, - ); - } - } - - let timestamp_ms = (round + 1) as u64; - let mut outcome = network - .node_mut("n0") - .expect("finalizer node exists") - .automatic_mine_once(timestamp_ms); - if outcome - .skipped_reason - .as_deref() - .is_some_and(|reason| reason.contains("collecting blinded reveals")) - { - collect_reveal_bundles_for_next_block_with_chaos( - &mut network, - &node_ids, - &wallets, - "n0", - timestamp_ms, - &offline, - &mut rng, - ); - outcome = network - .node_mut("n0") - .expect("finalizer node exists") - .automatic_mine_once( - timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1), - ); - } - if let Some(reason) = outcome.skipped_reason { - assert!( - reason.contains("at least one burn") - || reason.contains("required burn") - || reason.contains("could not") - || reason.contains("automatic burn failed") - || reason.contains("too few reveal bundle signatures"), - "unexpected chaotic mining skip reason: {reason}" - ); - } - - deliver_chaos_until_idle(&mut network, &node_ids, &offline, &mut rng); - } - - deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng); - for id in node_ids.iter().skip(1) { - while network - .sync_node_from_peer("n0", id, 128) - .expect("lagging node range sync succeeds") - {} - } - deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng); - assert_network_converged(&network, wallets.len()); - } -} - -#[test] -fn generated_snapshot_tampering_is_rejected() { - for seed in TAMPER_PROPERTY_SEEDS { - let (wallets, mut ledger) = property_ledger(seed, 3); - for round in 0..6 { - try_finalize_next_block(round, &wallets, &mut ledger); - } - assert_chain_properties(ledger.snapshot()); - - let mut mutated_hash = ledger.snapshot(); - if let Some(block) = mutated_hash.blocks.last_mut() { - block.timestamp_ms = block.timestamp_ms.saturating_add(1); - } - assert!(Ledger::from_snapshot(mutated_hash).is_err()); - - let mut mutated_transaction = ledger.snapshot(); - if let Some(transaction) = mutated_transaction - .blocks - .iter_mut() - .flat_map(|block| block.transactions.iter_mut()) - .next() - { - match transaction { - Transaction::Transfer { signature, .. } - | Transaction::Burn { signature, .. } - | Transaction::Mine { signature, .. } => signature.push_str("00"), - } - } - assert!(Ledger::from_snapshot(mutated_transaction).is_err()); - } -} diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -990,7 +990,7 @@ window.iunaApp = function iunaApp() { }, mempoolKey(tx) { - return tx?.signature || tx?.commitment || ""; + return tx?.signature || ""; }, mempoolItemClass(tx) { @@ -998,7 +998,6 @@ window.iunaApp = function iunaApp() { const firstSeenHeight = Number(this.mempoolFirstSeenHeights[key]); const markerHeight = Number(this.status.chain?.height ?? this.lastBlockMempoolHeight); const classes = []; - if (isBlindedMempoolItem(tx)) classes.push("blinded-hidden"); if (key && Number.isFinite(firstSeenHeight) && Number.isFinite(markerHeight)) { classes.push(firstSeenHeight >= markerHeight ? "new-since-block" : "before-last-block"); } @@ -2486,21 +2485,16 @@ window.iunaApp = function iunaApp() { return tx?.kind === "mine"; }, - isBlindedMempoolItem(tx) { - return !tx?.revealed && (tx?.kind === "blinded" || tx?.kind === "reveal"); - }, - txFeeLabel(tx) { - if (!tx?.revealed && tx?.kind === "reveal") return "unknown until reveal"; return `IUNA ${this.amountLabel(tx?.fee ?? 0)}`; }, txPillLabel(tx) { - return tx?.revealed ? "revealed" : (tx?.kind || "-"); + return tx?.kind || "-"; }, txPillClass(tx) { - return tx?.revealed ? "revealed" : (tx?.kind || ""); + return tx?.kind || ""; }, txDifficultyBits(tx) { @@ -2727,27 +2721,21 @@ window.iunaApp = function iunaApp() { blockPayloadBytes(block) { return ( Number(block?.transactionBytes ?? block?.transaction_bytes ?? 0) + - Number(block?.blindedTransactionBytes ?? block?.blinded_transaction_bytes ?? 0) + - Number(block?.revealBundleBytes ?? block?.reveal_bundle_bytes ?? 0) + Number(block?.burnBundleBytes ?? block?.burn_bundle_bytes ?? 0) ); }, - blockRevealFeePenalty(block) { - return block?.revealFeePenalty ?? block?.reveal_fee_penalty ?? {}; + blockBurnBundleQuorum(block) { + return block?.burnBundleQuorum ?? block?.burn_bundle_quorum ?? {}; }, - blockRevealListRatio(block) { - const penalty = this.blockRevealFeePenalty(block); - const included = Number(penalty.revealListsIncluded ?? penalty.reveal_lists_included ?? 0); - const committeeSize = Number(penalty.committeeSize ?? penalty.committee_size ?? 0); + blockBurnBundleRatio(block) { + const quorum = this.blockBurnBundleQuorum(block); + const included = Number(quorum.burnBundlesIncluded ?? quorum.burn_bundles_included ?? 0); + const committeeSize = Number(quorum.committeeSize ?? quorum.committee_size ?? 0); return `${included}/${committeeSize}`; }, - blockRevealFeePenaltyAmount(block) { - const penalty = this.blockRevealFeePenalty(block); - return Number(penalty.feePenalty ?? penalty.fee_penalty ?? 0); - }, - blockByteBreakdown(block) { const transactionRows = this.blockTransactionByteBreakdown(block); return [ @@ -2755,8 +2743,7 @@ window.iunaApp = function iunaApp() { ...(transactionRows.length ? transactionRows : [["Transactions", Number(block?.transactionBytes ?? block?.transaction_bytes ?? 0), ""]]), - ["Blinded commits", Number(block?.blindedTransactionBytes ?? block?.blinded_transaction_bytes ?? 0), "blinded"], - ["Reveal bundles", Number(block?.revealBundleBytes ?? block?.reveal_bundle_bytes ?? 0), "reveal"], + ["Burn bundles", Number(block?.burnBundleBytes ?? block?.burn_bundle_bytes ?? 0), "burn"], ]; }, @@ -2785,21 +2772,12 @@ window.iunaApp = function iunaApp() { return this.blockTransactions(block).filter((tx) => tx.kind === "transfer").length; }, - blockCommitCount(block) { - return this.blockTransactions(block).filter((tx) => tx.kind === "blinded").length; - }, - blockMineCount(block) { return this.blockTransactions(block).filter((tx) => tx.kind === "mine").length; }, blockTransactions(block) { - const transactions = block?.transactions || []; - if (transactions.some((tx) => tx?.revealed)) return transactions; - return [ - ...transactions, - ...(block?.revealedTransactions || block?.revealed_transactions || []), - ]; + return block?.transactions || []; }, burnCountLabel(block) { @@ -2812,11 +2790,6 @@ window.iunaApp = function iunaApp() { return `${count} transfer${count === 1 ? "" : "s"}`; }, - commitCountLabel(block) { - const count = this.blockCommitCount(block); - return `${count} commit${count === 1 ? "" : "s"}`; - }, - mineCountLabel(block) { const count = this.blockMineCount(block); return `${count} mine${count === 1 ? "" : "s"}`; @@ -2854,7 +2827,7 @@ window.iunaApp = function iunaApp() { }, txTitle(tx) { - if (tx.status === "pending") return tx.blinded ? "Pending blind" : "Pending"; + if (tx.status === "pending") return "Pending"; return tx.blockHeight === null ? "Confirmed" : `Block ${tx.blockHeight}`; },