Dynamic System Wallet Registry - 224
Another major infrastructure upgrade to the Via L2 network. Due to this new upgrade, every part in the network, including sequencer addresses, verifier keys, bridge controllers, and governance wallets, is tied to governance-approved Bitcoin transactions, creating an immutable, queryable history of who had authority at any point in time.
The Dynamic System Wallet Registry addresses the problem of securely rotating compromised keys without the need for manual configuration updates across hundreds of nodes. With this new system, governance approves wallet changes directly on Bitcoin, and each Via node automatically recognizes the new authority through its database layer.
No coordination windows, no config files to distribute, no risk of nodes running with stale information.
- Key rotations no longer require coordinated config pushes across the network.
- Instead, governance approves changes on Bitcoin L1, and every Via node automatically recognizes the new authority through its database layer.
- The system preserves the complete history
We've designed database schemas optimized for our query patterns, Rust's type system, which prevents invalid states at compile time, and production-hardened patterns for handling distributed systems, such as duplicate processing and partial failures.

Before, in our Via Core code, the wallet addresses for key roles such as the sequencer, verifiers, bridge, and governance were static. Either hardcoded in the node or baked into configuration files.
If one has to be replaced, for example, after a key compromise, the only way to deploy the fix is to ship a new configuration to every node.
That requires a coordinated network-wide rollout, which is slow and operationally has a lot of friction. It also leaves a potential small risk, as it leaves a window where the compromised key could still be accepted.
There were several alternatives, such as configuration files with reload mechanisms, which is simple but risky. As you cannot be assured that all nodes have the same configuration. An external oracle system would introduce new trust assumptions and potentially create a new point of failure.
We chose a time as a first-class concept. Instead of relying on mutable records that overwrite history, we want to preserve every system wallet assignment across time. This architecture allows the system to answer not just "Who has authority now?" but also
Who had authority when this transaction was signed?
Every new governance-approved change should be based on a new record, rather than updating an existing one. Each row should be permanently tied to an authorizing transaction hash, which in turn creates a verifiable transaction hash that generates a verifiable audit trail. The prior assignment should remain intact, so key rotations and emergency changes never destroy evidence.
One write path (append a row) and two read paths (current snapshot, snapshot at a given time). Operationally, this keeps the system easy and fast in practice.
- A single registry table with a targeted index supports both "latest per role" and "as of time T" lookups efficiently.
- The application code maps the raw rows into strongly typed structures and caches the current snapshot briefly to reduce load without introducing complexity.
The schema is straightforward, yet each decision reflects thoughtful consideration of distributed systems.
The field provides a unique identifier for each entity, while another field specifies a wallet's role (such as sequencer, verifier, bridge, or governance), enabling role-based queries.

The tx_hash field links each wallet assignment to the governance transaction that authorized it, creating an immutable audit trail. The created_at timestamp enables temporal queries, allowing it to determine which wallets were valid at any point in history.
The composite unique constraint (tx_hash, address, role) prevents duplicate entries while allowing the same address to serve multiple roles if needed.
The index on (role, created_at DESC) is a performance optimization that makes our most common query, "what are the current wallets for role X?" fast. The descending order on created_at means the database can stop scanning as soon as it finds the first (most recent) entry for each role
Type definitions
The WalletRole enum defines 4 roles in our system, and by deriving, Copy we are sure that roles can be passed around without ownership concerns. A small but important detail that prevents unnecessary cloning. The Hash derivation enables efficient HashMap storage.

The WalletInfo structure pairs wallet addresses with the transaction that authorizes each one.

The use of Vec<Address> Rather than a single address, as this allows roles like Verifier to have multiple addresses, which reflects the reality that verification might be distributed across multiple nodes.
The txid field uses Bitcoin's transaction ID type to ensure type safety and prevent accidental misuse of transaction identifiers.
What is the Systemwallets struct?
The SystemWallets struct presents the current state of all system wallets. This is what most of the code cases will interact with.
You may wonder: What are system wallets in the Via L2 codebase?
Think of the "SystemWallets" object as the authoritative snapshot of who is allowed to do what right now. It's the simple shape that most components consume, as they don't need to interact with the database or understand historical records.
It's a small in-memory snapshot with exactly one sequencer address, one governance address, one bridge address, and a list of verifier addresses (for multiparty validation).
Callers use its validation helpers to answer questions such as whether this block is actually from our sequencer or if this proof came from one of our current verifiers, without reimplementing policy rules all over again.
- Confirm the address is structurally valid for the network (e.g, well-formed and decodable)
- Enforce role semantics for singletons (sequencer, governance, bridge) as the candidate has to match the single configured address.
- Centralize checks to prevent the rest of the codebase from duplicating or deviating from policy.
Anyways, here's a simple, clean representation of who currently has authority. The struct includes validation methods like is_valid_verifier_address and is_valid_sequencer_address encapsulate the business logic for checking wallet validity.

This TryFrom Implementation is where raw database data becomes strongly-typed domain objects. Error handling is used to validate Bitcoin addresses during parsing, ensuring that invalid data is never introduced into the system.
- For Single-address roles (sequencer, governance, bridge): the raw string must contain exactly one well-formed Bitcoin address. We parse it into the Address type and fail fast if it’s missing or malformed.
- Multi-address role for verifiers: The raw string contains multiple addresses
The conversion to an error of course if anything is missing or invalid, so the rest of the system never sees a partial or unsafe snapshot. Keeping the downstream logic simple, everything past this point can assume addresses are real and role cardinalities are correct.
Why does Via L2 use Bitcoin addresses in its codebase?
Via is a Bitcoin-aligned L2. Authority changes and critical flows are ultimately anchored to Bitcoin L1. Governance approvals, key rotations, bridge movements, and other privileged actions are executed or attested to on the Bitcoin blockchain, and we bind each registry update to a specific on-chain transaction hash.
Data Access Layer
This struct is a thin data-access wrapper bound to a live database connection. It scopes wallet-related queries and writes to a dedicated handle so call sites don't pass raw connections around.

Storage: &mut Connection<'a, Core>
- A mutable borrow of an sqlx-style connection typed for the core database.
- The mutable borrow guarantees exclusive access while this DAL exists. No other code can issue queries through the same connection, preventing interleaved use and subtle race conditions.
'a is the lifetime of the underlying database connection (or transaction itself). 'c is the lifetime of this DAL's borrow of that connection. By construction 'c ≤ 'a , which enforces at compile time that ViaWalletDal cannot outlive the connection it uses.
Together &'c mut Connection <a, Core> models a temporarily exclusive handle to a still-alive connection.
Connections typically come from a pool or represent an active transaction managed by higher-level orchestration, such as CoreDal or request scope. Borrowing:
- Allow multiple DALs to share the same connection or transaction during a single operation.
- Prevents accidentally dropping the connection mid-transaction.
- Makes transactional boundaries clear (start, execute, commit/rollback all through the same borrowed handle)
Methods such as insert_wallets or get_system_wallets_raw execute queries via self.storage Often, within a transaction, return typed results. This keeps all wallet-related SQL localized and testable while leveraging Rust's borrow checker to enforce correct connection lifetimes and exclusivity.

DAL file: core/lib/dal/src/via_wallet_dal.rs and DAL integration factory method lives in core/lib/dal/src/lib.rs
with the insert_wallets method, we had to keep something in mind. System wallets can be updated through Bitcoin transactions, and when these updates occur, we need to store them reliably, even in the event of errors. Imagine a scenario where we store wallet information for multiple roles, all derived from a single Bitcoin transaction.

If we 'naively' insert these one by one without a transaction wrapper, and if, for example, the third transaction fails due to a connection timeout, we would be left with an incomplete picture. Some roles are updated, while others still point to old addresses. The system would be in an inconsistent state, and components reading this data wouldn't know which address to trust.
To support multiple roles with multiple addresses, we combine them into one comma-separated field, meeting the multi-address requirement without complicating the schema. Verifiers operate as a committee, meaning a single "verifier role" actually encompasses multiple addresses that must work together.
We could have created a separate table with a one-to-many relationship, but that would complicate the write process and make transactions more difficult. Instead, we make a pragmatic trade-off by joining all verifier addresses into a single comma-separated string and storing it in one row.
This keeps the data model simple and the transaction straightforward, while higher-level code that understands the verifier logic can easily split these addresses again when necessary.
The ON CONFLICT DO NOTHING clause addresses a concern. A distributed system often processes messages at least once, meaning the same Bitcoin transaction might be seen and processed multiple times. Perhaps a watcher restart or a message queue delivers a duplicate. Without idempotency, duplicating processing would cause unique constraint violations. With this, we can process the same update multiple times. The first write succeeds, subsequent attempts recognize the date is already there and do nothing. This allows the system to continue 'gracefully'.
The get_system_wallets_raw method resolves a query problem that seems simple at first, but there's a performance trap. The via_wallets table accumulates records every time the system wallets are updated. Over time, we may end up with a dozen entries per role, which represent wallet configurations across many Bitcoin transactions. However, most of the time, the code only needs to know, "What are the current wallet addresses right now?"

One approach could have to just fetch all rows and sort through them to find the newest entry per role. Or worse, a separate query for each role. Both waste database resources and network bandwidth by transferring rows we don't actually need. Another approach could have been to GROUP BY with MAX(created_at) then join back to get the full roles, but that requires the database to scan more data than needed.
Here we use PostgreSQL SELECT DISTINCT ON (ROLE) combined with ORDER BY ROLE and created_at DESC which tells PostgreSQL that within each role, we want exactly one row. The first one we encounter is when sorting by creation time in descending order.
Because we have an index on (role, created_at) the database can efficiently locate the newest entry for each role without scanning historical records. It's a single-pass query that returns exactly what we need. Nothing more, nothing less.
The data access layer's main goal is to efficiently and reliably transfer data in and out of the database. Parsing those comma-separated verifier addresses, validating Bitcoin address formats, and handling potential encoding issues.
These are domain logic concerns that belong in higher layers, where they can be tested independently and where error handling can be more nuanced. By keeping this method focused solely on retrieval, we keep a clean separation. The DAL stays simple and fast, while the layers above can do their parsing and validation without touching database code.
Special thanks to Idir TxFusion for being the co-author and original author of patch 224