diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d72a0b71..00d512323a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,8 @@ # 2026-05-20 - #2893 Adds a new interface for providing custom EVM precompiles that can access sov-state (read-only). Precompiles must implement the `EvmPrecopmile` trait. The `sov-evm::Evm` module is now generic on `EvmPrecompileSet`; a Set can be generated from several `EvmPrecopmile`s using the `generate_precompile_set!` macro. See e.g. `BankBalancePrecompile` or `SequencingTimestampPrecompile` for implementation examples, and the demo-rollup changes for a usage example. * Breaking(code): This PR also changes the visibility of some `sov-evm` export which are intended for internal usage, as well as the signature and generic on `sov-evm` construction. Normal rollup usage should be unaffected. +# 2026-05-20 +- #2890 **Breaking Change**: Borsh deserialization is now metered by work done. Adds `MeteredReader` + `MeteredBorshDeserialize` (blanket-impl'd for `T: BorshDeserialize`) and three new gas constants (`BIAS_BORSH_DESERIALIZATION`, `BORSH_PER_BYTE_READ`, `BORSH_PER_READ_BIAS`) replacing the per-type `*_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION` / `*_BIAS_BORSH_DESERIALIZATION` constants (DEFAULT, TX, PROOF, STRING). Changes the chain hash. # 2026-05-15 - #2875 Upgrades SP1 crates from 6.1.0 to 6.2.1. Centralizes the `sp1-*` and `slop-algebra` workspace dependencies in the root `Cargo.toml`; the upgrade also drops the transitive `halo2` / `zkhash` chain, allowing the `halo2` proprietary-license exception to be removed from `deny.toml`. Stale Plonky-3 / SP1 v5 entries in `.cargo/config.toml` are refreshed to match the v6 architecture. @@ -135,6 +137,36 @@ - #2682 Upgrades axum from 0.7 to 0.8. OpenAPI specs are now served as version 3.1.0 (previously 3.0.2). Manual intervention for upgrading pinned dependencies might be needed. Check Cargo.lock after the upgrade - #2683 Removes JMT based rollup from demo-rollup examples. JMT-based storage is still available in sov-state. +# MULTISIG UPGRADE +Temporary section for maintaining breaking changes from individual PRs, which will be consolidated into a single changelog entry when the feature branch is merged into `dev`. +- #2773 **Breaking change (code, wire, consensus)**: `sov_accounts::CallMessage` gains three variants — `AddCredentialToAddress { address, credential }`, `RemoveCredentialFromAddress { address, credential }`, and `RotateCredentialOnAddress { address, old_credential, new_credential }` — and is now generic in `S: Spec`. The enum's wire format changes, which changes the chain hash. All three new variants require `context.sender() == address` (callers can only modify credentials on the address they are currently signing as); `InsertCredentialId` semantics are unchanged. `Rotate` is functionally equivalent to a `Remove` + `Add` collapsed into a single call for key rotation. There is no orphan guard on remove. +- #2850 **Breaking change (code, wire, consensus)**: `sov_accounts::CallMessage` gains a fourth variant, `CreateSyntheticAddress { salt: [u8; 32] }`, for creating synthetic addresses (the same construct other ecosystems call *counterfactual* addresses, cf. ERC-4337 / CREATE2 — addresses with no canonical-signer derivation, suitable for DAO/treasury/pre-funded use cases). The new address is derived deterministically by hashing `(domain || visible_slot_hash || sender_addr || sender_credential || salt)` with `S::CryptoSpec::Hasher`, converting the resulting 32 bytes to `CredentialId`, and then to `S::Address`. The caller's current credential is auto-authorized for it. Re-issuing the same `(caller, salt)` in the same visible slot is an idempotent no-op. `sov-accounts` gains a `sov-chain-state` module dependency for reading the visible slot hash; no runtime wiring change is required for runtimes that already include `chain_state` (all current runtimes do). The enum's wire format changes, which changes the chain hash. The module also begins emitting `CredentialAdded`, `CredentialRemoved`, `CredentialRotated`, and `SyntheticAddressCreated` events — one per call message (`InsertCredentialId` and `AddCredentialToAddress` both emit `CredentialAdded`); `sov_accounts::Module::Event` changes from `()` to `Event`. +- #2853 Accounts: `AuthorizationResponse` (REST `GET /authorizations/{address}/{credential_id}`) gains `admit_as_override` and `admit_as_default` fields that mirror the on-chain admit-path predicates directly. The existing `authorized` field is deprecated — it uses the canonical-fallback semantic, which can disagree with the chain for authenticators whose `default_address` is not the credential's canonical address (notably EVM, where `default_address` is a `MultiAddress::Vm` variant while `canonical(credential_id)` is `MultiAddress::Standard`). REST consumers should read `admit_as_default` for the equivalent of the chain's admit-path. Additive change; existing callers continue to work. +- #2688 **Breaking change(code, state, consensus)**: The transaction formats have changed in this version. This is a consensus breaking change and requires coordinating an upgrade using `--stop-at-rollup-height`, and using sov-rollup-manager for full resyncs from this point onwards for existing rollups. This fixes Credential ID malleability that allowed the same set of signed bytes to be replayed for different credentials, across V0 and V1 (multisig) transactions and with different V1 multisig parameters. + * #2864 Hard fork version tracking: a new `chain_state.state_version` state item has been added, and a corresponding `STATE_VERSION` entry in constants.toml. These must match for the rollup to start. The binary `STATE_VERSION` will be incremented on breaking hard forks. On genesis, the state value must be initialised to the starting rollup binary's version, and it will subsequently be incremented by migrations between versions. + * Existing rollups must now run a migration, and make use of the rollup manager (`sov-rollup-manager`). `sov-migration::v1` provides the migration logic for this upgrade. Refer to the rollup manager documentation for details on how to orchestrate multiple versions. + * The on-chain transaction data has changed, and multisig transactions now explicitly include the multisig id as part of the signed bytes. + * Previous signatures are no longer valid. Clients will need to upgrade to our latest SDKs to be able to sign transactions in the new format. + * #2876 The transaction signing preimage types have been renamed to `TransactionSigningPayload{,V0,V1}` and now include the chain hash directly. `UnsignedTransaction` is now the user-created payload containing only the runtime call, uniqueness data, and transaction details. For creating a multisig, use `UnsignedTransaction::to_multisig_tx()`. + * The CHAIN_HASH has changed. + * The `Generation` uniqueness (replay protection) mechanism has been amended to fix a transaction hash malleability vulnerability with V1 transactions. New, non-malleable hashes are used to prevent replay. **If the rollup has had public V1 transactions submitted**, a migration script must increment the current generation of every user that has submitted a V1 transaction by `PAST_TRANSACTION_GENERATIONS` to invalidate prior existing hashes. No action is needed if there are no users with a prior V1 submission. +- #2904 **Breaking change**: for EVM rollups only: removes the `EVM_RECEIPT_ACTUAL_FEE_HEIGHT` constant. + Receipt `effectiveGasPrice` and projected `gasUsed` are now unconditionally derived from the actual metered fee. + Forks that had set this to a non-zero future activation height must migrate; the new behavior is mandatory. +- #2934 **Breaking Change**: Removes the `CHANGE_GAS_LIMIT_AFTER_HEIGHT` gas-limit fork. The `INITIAL_GAS_LIMIT` and `UPDATED_GAS_LIMIT` constants are replaced by a single `BLOCK_GAS_LIMIT` constant (test override env var `SOV_TEST_CONST_OVERRIDE_INITIAL_GAS_LIMIT` → `SOV_TEST_CONST_OVERRIDE_BLOCK_GAS_LIMIT`). Removes the `sequencer.height_for_gas_limit_computation` rate-limiter config field and the `ChainStateCapability::block_gas_limit` / `ChainState::block_gas_limit{,_at}` methods (use `::block_gas_limit()` instead). The block gas limit is now constant for all heights; the per-block sequencer safeguards (preferred-sequencer pre-exec escrow, zero-bond preferred-sequencer penalization, and the slot-gas-limit pre-check) remain unconditionally enabled. +- #3032 Generation and windowed-nonce based uniqueness checks now reject transactions that provide a value 2x greater than the last observed value (except for very low numbers where the limit is more lenient). This provides some protection against user error accidentally making an account unusable by submitting a transaction with a very high value, irreversibly ratcheting the generation or nonce window to an unergonomic value or even u64::MAX. +- #2965 sov-migrations: the v1 (state_version 0->1) migration is now idempotent. Re-running it against an already-migrated DB (state_version >= 1) logs a message and exits Ok instead of erroring, so a restarted rollup that re-invokes the migration binary no longer fails. +- #3034 **Breaking change (code)**: Refactors Runtime traits to make custom sequencing data support more ergonomic. As a side-effect, rollups that don't use sequencing data must now define +``` +impl HasSequencingData for Runtime +where + S::Address: FromVmAddress + FromVmAddress + HyperlaneAddress, +{ + type SequencingData = (); +} +``` +since a fully default blanket implementation is not possible with the new traits. + # 2026-04-01 - #2670 **Breaking change** Remove `InnerVm` and `OuterVm` generic type parameters from `StateTransitionFunction` trait and all downstream types. diff --git a/Cargo.lock b/Cargo.lock index 4a335d66e2..1cdbf1eb87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11055,7 +11055,10 @@ dependencies = [ "serde", "serde_with", "sov-accounts", + "sov-bank", + "sov-chain-state", "sov-modules-api", + "sov-rollup-interface", "sov-state", "sov-test-utils", "strum 0.28.0", @@ -11504,6 +11507,7 @@ dependencies = [ "sov-full-node-configs", "sov-hyperlane-integration", "sov-kernels", + "sov-migrations", "sov-mock-da", "sov-mock-zkvm", "sov-modules-api", @@ -11566,6 +11570,7 @@ dependencies = [ "jsonrpsee", "serde_json", "sov-address", + "sov-eip712-auth", "sov-modules-api", "sov-state", "sov-universal-wallet", @@ -11935,6 +11940,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "sov-migrations" +version = "0.3.0" +dependencies = [ + "anyhow", + "rockbound", + "serde", + "serde_json", + "sov-accounts", + "sov-chain-state", + "sov-db", + "sov-full-node-configs", + "sov-modules-api", + "sov-rollup-interface", + "sov-state", +] + [[package]] name = "sov-mock-da" version = "0.3.0" @@ -13326,6 +13348,7 @@ version = "0.3.0" dependencies = [ "anyhow", "bincode", + "borsh", "clap", "serde", "sov-celestia-adapter", diff --git a/Cargo.toml b/Cargo.toml index ed6749cfab..5c95a57209 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ members = [ "crates/utils/sov-evm-test-utils", "crates/utils/sov-proxy-utils", "crates/utils/sov-shutdown", + "crates/utils/sov-migrations", # Module System "crates/module-system/sov-cli", "crates/module-system/sov-modules-stf-blueprint", @@ -106,7 +107,7 @@ members = [ "crates/web3", "python/py_sovereign_web3/rust", ] -default-members = ["crates/rollup-interface", "crates/adapters/mock-da", "crates/adapters/mock-zkvm", "crates/full-node/sov-blob-sender", "crates/full-node/sov-db", "crates/full-node/sov-sequencer", "crates/full-node/sov-ledger-apis", "crates/full-node/sov-rollup-apis", "crates/full-node/sov-rollup-full-node-interface", "crates/full-node/sov-stf-runner", "crates/full-node/sov-metrics", "crates/full-node/sov-api-spec", "crates/full-node/full-node-configs", "crates/utils/sov-rest-utils", "crates/utils/nearly-linear", "crates/utils/sov-zkvm-utils", "crates/utils/sov-build", "crates/module-system/hyperlane", "crates/module-system/sov-cli", "crates/module-system/sov-modules-stf-blueprint", "crates/module-system/sov-modules-rollup-blueprint", "crates/module-system/sov-modules-macros", "crates/module-system/sov-kernels", "crates/module-system/sov-state", "crates/module-system/sov-modules-api", "crates/module-system/sov-address", "crates/utils/sov-test-utils", "crates/utils/sov-evm-test-utils", "crates/module-system/module-implementations/sov-accounts", "crates/module-system/module-implementations/sov-bank", "crates/module-system/module-implementations/sov-chain-state", "crates/module-system/module-implementations/sov-blob-storage", "crates/module-system/module-implementations/sov-evm", "crates/module-system/module-implementations/sov-paymaster", "crates/module-system/module-implementations/sov-prover-incentives", "crates/module-system/module-implementations/sov-attester-incentives", "crates/module-system/module-implementations/sov-sequencer-registry", "crates/module-system/module-implementations/sov-value-setter", "crates/module-system/module-implementations/sov-revenue-share", "crates/module-system/module-implementations/sov-synthetic-load", "crates/module-system/module-implementations/module-template", "crates/module-system/module-implementations/integration-tests", "crates/module-system/sov-capabilities", "crates/module-system/module-implementations/sov-uniqueness", "crates/module-system/module-implementations/sov-timelock", "crates/utils/sov-node-client", "crates/universal-wallet/schema", "crates/universal-wallet/macros", "crates/universal-wallet/macro-helpers", "crates/utils/workspace-hack", "crates/web3", "python/py_sovereign_web3/rust", "crates/module-system/module-implementations/extern/hyperlane-solana-register"] +default-members = ["crates/rollup-interface", "crates/adapters/mock-da", "crates/adapters/mock-zkvm", "crates/full-node/sov-blob-sender", "crates/full-node/sov-db", "crates/full-node/sov-sequencer", "crates/full-node/sov-ledger-apis", "crates/full-node/sov-rollup-apis", "crates/full-node/sov-rollup-full-node-interface", "crates/full-node/sov-stf-runner", "crates/full-node/sov-metrics", "crates/full-node/sov-api-spec", "crates/full-node/full-node-configs", "crates/utils/sov-rest-utils", "crates/utils/nearly-linear", "crates/utils/sov-zkvm-utils", "crates/utils/sov-build", "crates/utils/sov-migrations", "crates/module-system/hyperlane", "crates/module-system/sov-cli", "crates/module-system/sov-modules-stf-blueprint", "crates/module-system/sov-modules-rollup-blueprint", "crates/module-system/sov-modules-macros", "crates/module-system/sov-kernels", "crates/module-system/sov-state", "crates/module-system/sov-modules-api", "crates/module-system/sov-address", "crates/utils/sov-test-utils", "crates/utils/sov-evm-test-utils", "crates/module-system/module-implementations/sov-accounts", "crates/module-system/module-implementations/sov-bank", "crates/module-system/module-implementations/sov-chain-state", "crates/module-system/module-implementations/sov-blob-storage", "crates/module-system/module-implementations/sov-evm", "crates/module-system/module-implementations/sov-paymaster", "crates/module-system/module-implementations/sov-prover-incentives", "crates/module-system/module-implementations/sov-attester-incentives", "crates/module-system/module-implementations/sov-sequencer-registry", "crates/module-system/module-implementations/sov-value-setter", "crates/module-system/module-implementations/sov-revenue-share", "crates/module-system/module-implementations/sov-synthetic-load", "crates/module-system/module-implementations/module-template", "crates/module-system/module-implementations/integration-tests", "crates/module-system/sov-capabilities", "crates/module-system/module-implementations/sov-uniqueness", "crates/module-system/module-implementations/sov-timelock", "crates/utils/sov-node-client", "crates/universal-wallet/schema", "crates/universal-wallet/macros", "crates/universal-wallet/macro-helpers", "crates/utils/workspace-hack", "crates/web3", "python/py_sovereign_web3/rust", "crates/module-system/module-implementations/extern/hyperlane-solana-register"] [workspace.package] version = "0.3.0" edition = "2021" @@ -194,6 +195,7 @@ sov-modules-macros = { path = "crates/module-system/sov-modules-macros", default sov-modules-stf-blueprint = { path = "crates/module-system/sov-modules-stf-blueprint", default-features = false, version = "0.3" } sov-test-utils = { path = "crates/utils/sov-test-utils", default-features = false, version = "0.3" } sov-evm-test-utils = { path = "crates/utils/sov-evm-test-utils", default-features = false, version = "0.3" } +sov-migrations = { path = "crates/utils/sov-migrations", default-features = false, version = "0.3" } sov-rpc-eth-types = { path = "crates/utils/sov-rpc-eth-types", default-features = false, version = "0.3" } sov-db = { path = "crates/full-node/sov-db", default-features = false, version = "0.3" } sov-db-types = { path = "crates/full-node/sov-db-types", version = "0.3" } diff --git a/constants.testing.toml b/constants.testing.toml index 589b5d030f..d99a45fd98 100644 --- a/constants.testing.toml +++ b/constants.testing.toml @@ -9,6 +9,7 @@ freeze = [1, 1] # We use the ID 4321 for demo purposes. Change this value before deploying! CHAIN_ID = 4321 CHAIN_NAME = "TestChain" +STATE_VERSION = 1 CHAIN_HASH_OVERRIDES = [] # The number of bytes a transaction can have. MAX_TX_SIZE=1_048_576 @@ -34,9 +35,8 @@ EVM_BLOCK_PRUNING_THRESHOLD = 10000 EVM_GAS_METERING_MODE = "Rollup" # After this evm block height, the max_fee check will be enforced. EVM_MAX_FEE_CHECK_HEIGHT = 0 -# After this evm block height, RPC receipts derive effectiveGasPrice -# from the actual fee charged by the Sovereign gas meter. -EVM_RECEIPT_ACTUAL_FEE_HEIGHT = 0 +# Before this rollup height, custom EVM precompiles are disabled without reading their enabled set. +ENABLE_EVM_CUSTOM_PRECOMPILES_AT = 0 # How many rollup blocks to wait before terminating setup mode. While setup mode is enabled, # the rollup does not charge gas. This is useful when initializing the rollup with a bridged gas token. SETUP_MODE_TERMINATION_HEIGHT = 0 # Disable setup mode entirely by default. @@ -124,26 +124,15 @@ PROCESS_TX_PRE_EXEC_GAS = [1, 1] PROCESS_TX_PRE_EXEC_GAS_PER_TX_BYTE = [1, 1] # --- End Gas parameters to specify how to charge gas for signature verification --- -# The cost of deserializing a message using Borsh -DEFAULT_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] +# --- Borsh deserialization gas constants --- BIAS_BORSH_DESERIALIZATION = [1, 1] +BORSH_PER_BYTE_READ = [1, 1] +BORSH_PER_READ_BIAS = [0, 0] -# The cost of deserializing a tx using Borsh -TX_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] -TX_BIAS_BORSH_DESERIALIZATION = [1, 1] - -# The cost of deserializing a tx using Borsh +# --- JSON deserialization gas constants --- TX_GAS_TO_CHARGE_PER_BYTE_JSON_DESERIALIZATION = [1, 1] TX_BIAS_JSON_DESERIALIZATION = [1, 1] -# The cost of deserializing a proof using Borsh -PROOF_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] -PROOF_BIAS_BORSH_DESERIALIZATION = [1, 1] - -# The cost of deserializing a string using Borsh -STRING_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] -STRING_BIAS_BORSH_DESERIALIZATION = [1, 1] - # The amount of gas to charge per zk-proof byte GAS_TO_CHARGE_PER_PROOF_BYTE = [1, 1] # The amount of gas to charge per zk-proof @@ -154,10 +143,8 @@ FIXED_GAS_TO_CHARGE_PER_PROOF = [100, 100] # The portion that is not burned is awarded to provers and/or attesters on the network. PERCENT_BASE_FEE_TO_BURN = 10 # --- Gas fee adjustment parameters: See https://eips.ethereum.org/EIPS/eip-1559 for a detailed description --- -# The initial gas limit of the rollup. -INITIAL_GAS_LIMIT = [100_000_000_000, 100_000_000_000] -UPDATED_GAS_LIMIT = [100_000_000_000, 100_000_000_000] -CHANGE_GAS_LIMIT_AFTER_HEIGHT = 9223372036854775807 +# The gas limit applied to every rollup block. +BLOCK_GAS_LIMIT = [100_000_000_000, 100_000_000_000] # The initial "base fee" that every transaction emits when executed. INITIAL_BASE_FEE_PER_GAS = [10, 10] # The maximum change denominator of the base fee. diff --git a/constants.toml b/constants.toml index ddd114dd1d..d955eedb36 100644 --- a/constants.toml +++ b/constants.toml @@ -10,6 +10,11 @@ freeze = [1, 1] # We use the ID 4321 for demo purposes. Change this value before deploying! CHAIN_ID = 4321 CHAIN_NAME = "TestChain" +# The global on-chain Sovereign state version expected by this binary. +# This must match the `sov_chain_state::state_version` value in the current on-disk state. The compiled +# rollup will refuse to run if there is a mismatch. +# Increment this whenever an SDK upgrade requires an out-of-band state migration. +STATE_VERSION = 1 # Chain hash overrides by rollup block height range. # When the rollup schema changes (e.g., adding a new transaction type), the CHAIN_HASH changes. @@ -57,9 +62,8 @@ EVM_BLOCK_PRUNING_THRESHOLD = 10000 EVM_GAS_METERING_MODE = "Rollup" # After this evm block height, the max_fee check will be enforced. EVM_MAX_FEE_CHECK_HEIGHT = 0 -# After this evm block height, RPC receipts derive effectiveGasPrice -# from the actual fee charged by the Sovereign gas meter. -EVM_RECEIPT_ACTUAL_FEE_HEIGHT = 0 +# Before this rollup height, custom EVM precompiles are disabled without reading their enabled set. +ENABLE_EVM_CUSTOM_PRECOMPILES_AT = 0 # How many rollup blocks to wait before terminating setup mode. While setup mode is enabled, # the rollup does not charge gas. This is useful when initializing the rollup with a bridged gas token. SETUP_MODE_TERMINATION_HEIGHT = 0 # Disable setup mode entirely by default. @@ -155,26 +159,18 @@ PROCESS_TX_PRE_EXEC_GAS = [1, 1] PROCESS_TX_PRE_EXEC_GAS_PER_TX_BYTE = [1, 1] # --- End Gas parameters to specify how to charge gas for signature verification --- -# The cost of deserializing a message using Borsh -DEFAULT_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] +# --- Borsh deserialization gas constants --- +# Entry bias charged once per borsh decode. BIAS_BORSH_DESERIALIZATION = [1, 1] +# Per-byte cost charged inside `MeteredReader` and upfront against opaque payloads. +BORSH_PER_BYTE_READ = [1, 1] +# Per-read fixed cost charged inside `MeteredReader`. +BORSH_PER_READ_BIAS = [0, 0] -# The cost of deserializing a tx using Borsh -TX_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] -TX_BIAS_BORSH_DESERIALIZATION = [1, 1] - -# The cost of deserializing a tx using JSON +# --- JSON deserialization gas constants --- TX_GAS_TO_CHARGE_PER_BYTE_JSON_DESERIALIZATION = [1, 1] TX_BIAS_JSON_DESERIALIZATION = [1, 1] -# The cost of deserializing a proof using Borsh -PROOF_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] -PROOF_BIAS_BORSH_DESERIALIZATION = [1, 1] - -# The cost of deserializing a string using Borsh -STRING_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION = [1, 1] -STRING_BIAS_BORSH_DESERIALIZATION = [1, 1] - # The percentage of the "base fee" that is burned when a transaction is processed. # The portion that is not burned is awarded to provers and/or attesters on the network. PERCENT_BASE_FEE_TO_BURN = 10 @@ -185,10 +181,8 @@ GAS_TO_CHARGE_PER_PROOF_BYTE = [1, 1] FIXED_GAS_TO_CHARGE_PER_PROOF = [100, 100] # --- Gas fee adjustment parameters: See https://eips.ethereum.org/EIPS/eip-1559 for a detailed description --- -# The initial gas limit of the rollup. -INITIAL_GAS_LIMIT = [100_000_000_000, 100_000_000_000] -UPDATED_GAS_LIMIT = [100_000_000_000, 100_000_000_000] -CHANGE_GAS_LIMIT_AFTER_HEIGHT = 9223372036854775807 +# The gas limit applied to every rollup block. +BLOCK_GAS_LIMIT = [100_000_000_000, 100_000_000_000] # The initial "base fee" that every transaction emits when executed. INITIAL_BASE_FEE_PER_GAS = [10, 10] # The maximum change denominator of the base fee. diff --git a/crates/bench/sp1-microbenches/Cargo.toml b/crates/bench/sp1-microbenches/Cargo.toml index 2021f0bd64..c9dd8adb31 100644 --- a/crates/bench/sp1-microbenches/Cargo.toml +++ b/crates/bench/sp1-microbenches/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } bincode = { workspace = true } +borsh = { workspace = true } clap = { workspace = true } sov-gas-tools = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/bench/sp1-microbenches/README.md b/crates/bench/sp1-microbenches/README.md index c3b78c7df8..b92d7e9d4d 100644 --- a/crates/bench/sp1-microbenches/README.md +++ b/crates/bench/sp1-microbenches/README.md @@ -53,6 +53,17 @@ guest reads and deserializes the full block, runs `CelestiaVerifier::verify_relevant_tx_list`, and commits block/hash/blob stats. This is a fixed fixture bench rather than a byte-size sweep. +**Borsh** — `borsh` runs three internal sweeps to calibrate the three borsh +deserialization constants. A `reader-bytes` sweep (read length at one read per +iteration) calibrates `BORSH_PER_BYTE_READ`; a `reader-count` sweep (read count +at one byte per read) calibrates `BORSH_PER_READ_BIAS`; a `decode-vec` sweep +(full `MeteredBorshDeserialize::deserialize_from_slice::>`) calibrates +`BIAS_BORSH_DESERIALIZATION`, subtracting out the reader-level contributions. +The latter two are derived by subtracting the earlier sweeps' slopes, so the +constants are coupled and always calibrated together in one run. Borsh decode +is cheap per cycle (no precompiles), so this bench uses two-iteration +differencing — see "Methodology choice" below. + ## Run ```sh @@ -61,6 +72,13 @@ cargo run --release -p sp1-microbenches -- ed25519 cargo run --release -p sp1-microbenches -- celestia ``` +`borsh` runs its three sweeps in sequence and prints the final calibrated +constants: + +```sh +cargo run --release -p sp1-microbenches -- borsh +``` + Optional override: ```sh @@ -106,6 +124,20 @@ binary) — they're pure clap-derive glue with no library role. Shared infrastructure lives in `lib.rs` (`BenchResult`, `load_guest_elf`) and `fit.rs` (OLS fit). +## Methodology choice: single-pass vs two-iteration differencing + +Per-execution setup cost (zkVM bootstrap, stdin reads, guest allocations) +contaminates the fit when per-operation work is small. Precompile-heavy +benches (hashes, sig verify, big-int) use single-pass — setup is a +fraction of a percent of signal. Cheap-per-cycle benches (memcpy, +decoding) use two-iteration differencing: run the sweep at two iteration +counts, compute `per_iter = (gas_high - gas_low) / (iter_high - iter_low)` +per N, fit `fit_linear` on the result. Setup cancels exactly because it's +identical between the two runs at the same N. + +Heuristic: precompile (sha256, keccak, secp256k1, curve ops) → single-pass. +Generic Rust (allocs, memcpy, decoding) → differencing. + ## Adding a new microbench 1. Create `guest-{name}/` mirroring `guest-sha256/` — same SP1 patches, same @@ -114,8 +146,10 @@ Shared infrastructure lives in `lib.rs` (`BenchResult`, `load_guest_elf`) and work (cheap insurance — see `guest-sha256/src/main.rs`). 2. Add a `build_program_with_args` call in `build.rs`. 3. Create `src/cmd/{name}.rs` with `{Name}Args` and - `pub fn run(args: {Name}Args) -> anyhow::Result<()>`. Inside, run the - sweep, call `fit_prover_gas_per_byte`, and `println!` the results. + `pub fn run(args: {Name}Args) -> anyhow::Result<()>`. Pick single-pass or + two-iter differencing per the methodology heuristic above. For single-pass + call `fit_prover_gas_per_byte`; for differencing call `fit_linear` + directly on the differential. 4. Add `pub mod {name};` to `src/cmd/mod.rs`. 5. Add the variant to `BenchCmd` in `src/main.rs` and route it in `BenchCmd::run`. diff --git a/crates/bench/sp1-microbenches/build.rs b/crates/bench/sp1-microbenches/build.rs index d054f1ad19..016e0d4fd6 100644 --- a/crates/bench/sp1-microbenches/build.rs +++ b/crates/bench/sp1-microbenches/build.rs @@ -33,5 +33,19 @@ fn main() -> anyhow::Result<()> { }, ); + build_program_with_args( + "./guest-borsh", + BuildArgs { + ..Default::default() + }, + ); + + build_program_with_args( + "./guest-storage", + BuildArgs { + ..Default::default() + }, + ); + Ok(()) } diff --git a/crates/bench/sp1-microbenches/guest-borsh/Cargo.lock b/crates/bench/sp1-microbenches/guest-borsh/Cargo.lock new file mode 100644 index 0000000000..50a85132fc --- /dev/null +++ b/crates/bench/sp1-microbenches/guest-borsh/Cargo.lock @@ -0,0 +1,2824 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addchain" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e33f6a175ec6a9e0aca777567f9ff7c3deefc255660df887e7fa3585e9801d8" +dependencies = [ + "num-bigint 0.3.3", + "num-integer", + "num-traits", +] + +[[package]] +name = "alloy-primitives" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +dependencies = [ + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "paste", + "rand 0.9.4", + "ruint", + "rustc-hash", + "serde", + "sha3", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f2b5fa7b76b498158ec1079dc0ea842c5b622b6b3f675005fddd889f2c9a7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "const-hex" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "git+https://github.com/sp1-patches/curve25519-dalek?tag=patch-4.1.3-sp1-6.0.0#ade3f90a82efc7c58c4ae1ecb4d48bbe673c2a8f" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "sp1-lib", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "git+https://github.com/sp1-patches/curve25519-dalek?tag=patch-4.1.3-sp1-6.0.0#ade3f90a82efc7c58c4ae1ecb4d48bbe673c2a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elf" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "byteorder", + "ff_derive", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f10d12652036b0e99197587c6ba87a8fc3031986499973c030d8b44fcc151b60" +dependencies = [ + "addchain", + "num-bigint 0.3.3", + "num-integer", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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", + "wasip2", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nearly-linear" +version = "0.3.0" + +[[package]] +name = "nmt-rs" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d9149cb486570ac43944740ac8ea83d309d44d6a2cd2cd856606f43e40c6429" +dependencies = [ + "borsh", + "bytes", + "serde", + "sha2", +] + +[[package]] +name = "nomt-core" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b43245e3ed2b32eaf8c062506ec16a4ff3f08e0373c29c2b0b202e8c325409" +dependencies = [ + "arrayvec", + "bitvec", + "borsh", + "digest 0.10.7", + "ruint", + "serde", +] + +[[package]] +name = "num-bigint" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p3-bn254-fr" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577200e3fa7e49e2b21e940a6dc7399dc63acb8581da088558cdf7c455adafc0" +dependencies = [ + "ff", + "num-bigint 0.4.6", + "p3-field", + "p3-poseidon2", + "p3-symmetric", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "p3-challenger" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75358edd6e2562752c01f5064a66d88144a3e75ace0407166dbdf8a727597f52" +dependencies = [ + "p3-field", + "p3-maybe-rayon", + "p3-symmetric", + "p3-util", + "serde", + "tracing", +] + +[[package]] +name = "p3-dft" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761f1e1b014f2b1b69bd0309124e233d64aa3590e6a41ee786000dd849506d51" +dependencies = [ + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", + "tracing", +] + +[[package]] +name = "p3-field" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df7cebaa4079b24e0dd7e3aad59eebcbb99a67c1271f79ad884a7c032f5f183" +dependencies = [ + "itertools 0.12.1", + "num-bigint 0.4.6", + "num-traits", + "p3-util", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "p3-koala-bear" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cea0ba3389b034b6088d566aea8b57aa29dd2e180966e0c8056f61331c92b4e" +dependencies = [ + "cfg-if", + "num-bigint 0.4.6", + "p3-field", + "p3-mds", + "p3-poseidon2", + "p3-symmetric", + "rand 0.8.6", + "rustc_version", + "serde", +] + +[[package]] +name = "p3-matrix" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae5cc6ce726cc265cc687c1214e3f1ac1f5c6e973442286ba00d1e75da1c3cb" +dependencies = [ + "itertools 0.12.1", + "p3-field", + "p3-maybe-rayon", + "p3-util", + "rand 0.8.6", + "serde", + "tracing", +] + +[[package]] +name = "p3-maybe-rayon" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac1d2f102cf8c71dba1b449575c99697781fcc028831e83d2245787bd7a650" + +[[package]] +name = "p3-mds" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f072643e385d65fb9eb089ee6824b320417f78671a0db748566e057e28b250e" +dependencies = [ + "itertools 0.12.1", + "p3-dft", + "p3-field", + "p3-matrix", + "p3-symmetric", + "p3-util", + "rand 0.8.6", +] + +[[package]] +name = "p3-poseidon2" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00cc4b6e8a439f79541b0910a016da9e6e12a05a24309bbb713e1db0db396952" +dependencies = [ + "gcd", + "p3-field", + "p3-mds", + "p3-symmetric", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "p3-symmetric" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebff7fea7deb08a57ccf731a0ed39df25cc66a0e0c2d92c4472c4dee02ee21" +dependencies = [ + "itertools 0.12.1", + "p3-field", + "serde", +] + +[[package]] +name = "p3-util" +version = "0.3.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8164df89bbc92e29938f916cc5f1ccbfe6a36fb5040f21ba93c1f21985b9868" +dependencies = [ + "serde", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettier-please" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32db37eb2b0ec0af154e9c1b33425902d8cd9481e35167c4e9ffb28fec3916bb" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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", + "serde", +] + +[[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 = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "ruint" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" +dependencies = [ + "proptest", + "rand 0.8.6", + "rand 0.9.4", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "git+https://github.com/sp1-patches/RustCrypto-hashes?tag=patch-sha2-0.10.9-sp1-6.0.0#e48b656ebc806117554bb33c2f8687e4637e37ff" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slop-algebra" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a473c3a06b466dd0708829415a8a9fab451740da066e07862c8c098904aaad6" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "serde", +] + +[[package]] +name = "slop-bn254" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7fbae5dd16a3d1e87c9e99cfd557338171710be01458bd5b12dded3878d3fd8" +dependencies = [ + "ff", + "p3-bn254-fr", + "serde", + "slop-algebra", + "slop-challenger", + "slop-poseidon2", + "slop-symmetric", +] + +[[package]] +name = "slop-challenger" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e80df718cef7d3100658dc8b46fafcc994b814421ec9a7d0763a6ee1e5070c" +dependencies = [ + "futures", + "p3-challenger", + "serde", + "slop-algebra", + "slop-symmetric", +] + +[[package]] +name = "slop-koala-bear" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6586b1c0e66c503e4026a8cb007349fa99c2466957c5b09d18fe658d1391ed8" +dependencies = [ + "lazy_static", + "p3-koala-bear", + "serde", + "slop-algebra", + "slop-challenger", + "slop-poseidon2", + "slop-symmetric", +] + +[[package]] +name = "slop-poseidon2" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c956b11fff1b8a071fa4ba982dc35e458cff1620dc7b33d9cf22d8df30895f79" +dependencies = [ + "p3-poseidon2", +] + +[[package]] +name = "slop-primitives" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de169e0ca381847f9efa0db5a54533371c10558d7aaed4cb3b2a9bae24a0fe83" +dependencies = [ + "slop-algebra", +] + +[[package]] +name = "slop-symmetric" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955145ad6e3a1d083a428f9274071cfbb44c3b29013aae9d6c4c29fb7328cfc0" +dependencies = [ + "p3-symmetric", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "sov-db-types" +version = "0.3.0" +dependencies = [ + "borsh", + "derivative", + "digest 0.10.7", + "hex", + "serde", + "serde_with", + "sov-universal-wallet", +] + +[[package]] +name = "sov-metrics" +version = "0.3.0" +dependencies = [ + "anyhow", + "derive-new", + "hex", + "serde", +] + +[[package]] +name = "sov-microbench-guest-borsh" +version = "0.3.0" +dependencies = [ + "sov-mock-da", + "sov-mock-zkvm", + "sov-modules-api", + "sp1-zkvm", +] + +[[package]] +name = "sov-mock-da" +version = "0.3.0" +dependencies = [ + "anyhow", + "async-trait", + "borsh", + "bytes", + "derive_more", + "hex", + "schemars 1.2.1", + "serde", + "serde_json", + "sha2", + "sov-rollup-interface", + "sov-universal-wallet", + "tracing", + "url", +] + +[[package]] +name = "sov-mock-zkvm" +version = "0.3.0" +dependencies = [ + "anyhow", + "bincode", + "borsh", + "ed25519-dalek", + "hex", + "schemars 1.2.1", + "serde", + "sha2", + "sov-rollup-interface", + "sov-universal-wallet", + "thiserror", +] + +[[package]] +name = "sov-modules-api" +version = "0.3.0" +dependencies = [ + "anyhow", + "bech32", + "borsh", + "bs58", + "derivative", + "derive_more", + "digest 0.10.7", + "hex", + "nearly-linear", + "schemars 1.2.1", + "serde", + "serde_json", + "sha2", + "sov-metrics", + "sov-modules-macros", + "sov-rollup-interface", + "sov-state", + "sov-universal-wallet", + "strum", + "thiserror", + "toml", + "tracing", + "unwrap-infallible", +] + +[[package]] +name = "sov-modules-macros" +version = "0.3.0" +dependencies = [ + "anyhow", + "bech32", + "blake2", + "convert_case 0.6.0", + "darling 0.21.3", + "derive_more", + "hex", + "prettier-please", + "proc-macro2", + "quote", + "serde", + "sov-universal-wallet-macro-helpers", + "syn 2.0.117", + "toml", +] + +[[package]] +name = "sov-rollup-interface" +version = "0.3.0" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "borsh", + "bytes", + "derive_more", + "digest 0.10.7", + "hex", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "sov-universal-wallet", + "thiserror", +] + +[[package]] +name = "sov-state" +version = "0.3.0" +dependencies = [ + "anyhow", + "bcs", + "borsh", + "derivative", + "derive_more", + "hex", + "nomt-core", + "serde", + "serde-big-array", + "sov-db-types", + "sov-metrics", + "sov-rollup-interface", + "sov-universal-wallet", +] + +[[package]] +name = "sov-universal-wallet" +version = "0.4.1" +dependencies = [ + "alloy-primitives", + "arrayvec", + "bech32", + "borsh", + "bs58", + "hex", + "nmt-rs", + "once_cell", + "schemars 1.2.1", + "serde", + "serde_json", + "sha2", + "sov-universal-wallet-macros", + "thiserror", +] + +[[package]] +name = "sov-universal-wallet-macro-helpers" +version = "0.4.1" +dependencies = [ + "bech32", + "borsh", + "bs58", + "darling 0.21.3", + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "sov-universal-wallet-macros" +version = "0.4.1" +dependencies = [ + "sov-universal-wallet-macro-helpers", + "syn 2.0.117", +] + +[[package]] +name = "sp1-lib" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cd166e010c80e542585bf74585ea80eff117c361656cae43f2968cf0af12d4" +dependencies = [ + "bincode", + "elliptic-curve", + "serde", + "sp1-primitives", +] + +[[package]] +name = "sp1-primitives" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df14efe799ebd675cf530c853153a4787327a2385067716dfad4ede79ff31ad" +dependencies = [ + "bincode", + "blake3", + "elf", + "hex", + "itertools 0.14.0", + "lazy_static", + "num-bigint 0.4.6", + "serde", + "sha2", + "slop-algebra", + "slop-bn254", + "slop-challenger", + "slop-koala-bear", + "slop-poseidon2", + "slop-primitives", + "slop-symmetric", +] + +[[package]] +name = "sp1-zkvm" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10f689c4384daf7ece04176c727e6f2e56193e10a406406aa7c9c6c8f8f478" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "libm", + "rand 0.8.6", + "sha2", + "sp1-lib", + "sp1-primitives", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64 0.13.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[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" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unwrap-infallible" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151ac09978d3c2862c4e39b557f4eceee2cc72150bc4cb4f16abf061b6e381fb" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[patch.unused]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "git+https://github.com/sp1-patches/curve25519-dalek-ng?tag=patch-4.1.1-sp1-6.0.0#5854f8bab8842ff6232469b0d93ae4160e4a503d" + +[[patch.unused]] +name = "ed25519-consensus" +version = "2.1.0" +source = "git+https://github.com/sp1-patches/ed25519-consensus?rev=78f83b21767077f602f30148e513a52e844abbb6#78f83b21767077f602f30148e513a52e844abbb6" + +[[patch.unused]] +name = "tiny-keccak" +version = "2.0.2" +source = "git+https://github.com/sp1-patches/tiny-keccak?tag=patch-2.0.2-sp1-6.0.0#957430a459f7a2332ab5bab4a12f9b473bb95c87" diff --git a/crates/bench/sp1-microbenches/guest-borsh/Cargo.toml b/crates/bench/sp1-microbenches/guest-borsh/Cargo.toml new file mode 100644 index 0000000000..da11656e4c --- /dev/null +++ b/crates/bench/sp1-microbenches/guest-borsh/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "sov-microbench-guest-borsh" +version = "0.3.0" +license = "MIT OR Apache-2.0" +edition = "2021" +resolver = "2" + +[workspace] + +[dependencies] +sp1-zkvm = { version = "6.1.0" } +sov-modules-api = { path = "../../../module-system/sov-modules-api", default-features = false } +sov-mock-da = { path = "../../../adapters/mock-da", default-features = false } +sov-mock-zkvm = { path = "../../../adapters/mock-zkvm", default-features = false } + +[patch.crates-io] +sha2-v0-10-9 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha2", tag = "patch-sha2-0.10.9-sp1-6.0.0" } +curve25519-dalek = { git = "https://github.com/sp1-patches/curve25519-dalek", tag = "patch-4.1.3-sp1-6.0.0" } +curve25519-dalek-ng = { git = "https://github.com/sp1-patches/curve25519-dalek-ng", tag = "patch-4.1.1-sp1-6.0.0" } +ed25519-consensus = { git = "https://github.com/sp1-patches/ed25519-consensus", rev = "78f83b21767077f602f30148e513a52e844abbb6" } +tiny-keccak = { git = "https://github.com/sp1-patches/tiny-keccak", tag = "patch-2.0.2-sp1-6.0.0" } + +[profile.release] +debug = 1 +lto = true + +[profile.release.build-override] +opt-level = 3 diff --git a/crates/bench/sp1-microbenches/guest-borsh/src/main.rs b/crates/bench/sp1-microbenches/guest-borsh/src/main.rs new file mode 100644 index 0000000000..9a96948834 --- /dev/null +++ b/crates/bench/sp1-microbenches/guest-borsh/src/main.rs @@ -0,0 +1,106 @@ +#![no_main] + +sp1_zkvm::entrypoint!(main); + +use core::hint::black_box; +use std::io::{Cursor, Read}; + +use sov_mock_da::MockDaSpec; +use sov_mock_zkvm::MockZkvm; +use sov_modules_api::default_spec::DefaultSpec; +use sov_modules_api::execution_mode::Zk; +use sov_modules_api::{MeteredBorshDeserialize, MeteredReader, UnlimitedGasMeter}; + +type MicrobenchSpec = DefaultSpec; + +const MODE_READER_BYTES: u8 = 0; +const MODE_READER_COUNT: u8 = 1; +const MODE_DECODE_VEC: u8 = 2; + +// Sized to the sweep max regardless of n, so setup cost stays N-independent. +const MAX_BYTES: usize = 65536; +const MAX_READS: usize = 512; + +pub fn main() { + let mode: u8 = sp1_zkvm::io::read(); + let iterations: u32 = sp1_zkvm::io::read(); + + match mode { + MODE_READER_BYTES => run_reader_bytes(iterations), + MODE_READER_COUNT => run_reader_count(iterations), + MODE_DECODE_VEC => run_decode_vec(iterations), + other => panic!("unknown borsh bench mode {other}"), + } +} + +fn run_reader_bytes(iterations: u32) { + let n_bytes: u32 = sp1_zkvm::io::read(); + let n = n_bytes as usize; + assert!(n <= MAX_BYTES, "n_bytes exceeds MAX_BYTES"); + + let source: Vec = vec![0xABu8; MAX_BYTES]; + let mut out: Vec = vec![0u8; MAX_BYTES]; + let mut meter = UnlimitedGasMeter::::default(); + let source = black_box(source); + let mut cursor = Cursor::new(source.as_slice()); + + println!("cycle-tracker-report-start: borsh_loop"); + for _ in 0..iterations { + cursor.set_position(0); + let mut reader = MeteredReader::new(black_box(&mut cursor), &mut meter); + reader + .read_exact(black_box(&mut out[..n])) + .expect("UnlimitedGasMeter never errors"); + let _ = black_box(&out); + } + println!("cycle-tracker-report-end: borsh_loop"); + + sp1_zkvm::io::commit(&out); +} + +fn run_reader_count(iterations: u32) { + let n_reads: u32 = sp1_zkvm::io::read(); + assert!(n_reads as usize <= MAX_READS, "n_reads exceeds MAX_READS"); + + let source: Vec = vec![0xABu8; MAX_READS]; + let mut byte_buf = [0u8; 1]; + let mut meter = UnlimitedGasMeter::::default(); + let source = black_box(source); + let mut cursor = Cursor::new(source.as_slice()); + + println!("cycle-tracker-report-start: borsh_loop"); + for _ in 0..iterations { + cursor.set_position(0); + let mut reader = MeteredReader::new(black_box(&mut cursor), &mut meter); + for _ in 0..n_reads { + reader + .read_exact(black_box(&mut byte_buf)) + .expect("UnlimitedGasMeter never errors"); + let _ = black_box(&byte_buf); + } + } + println!("cycle-tracker-report-end: borsh_loop"); + + sp1_zkvm::io::commit(&byte_buf); +} + +fn run_decode_vec(iterations: u32) { + let buf: Vec = sp1_zkvm::io::read_vec(); + let mut meter = UnlimitedGasMeter::::default(); + let buf = black_box(buf); + let mut last: Vec = Vec::new(); + + println!("cycle-tracker-report-start: borsh_loop"); + for _ in 0..iterations { + let mut slice: &[u8] = black_box(&buf[..]); + last = as MeteredBorshDeserialize>::deserialize_from_slice( + black_box(&mut slice), + &mut meter, + ) + .expect("UnlimitedGasMeter never errors"); + let _ = black_box(&last); + } + println!("cycle-tracker-report-end: borsh_loop"); + + sp1_zkvm::io::commit(&last); +} diff --git a/crates/bench/sp1-microbenches/guest-storage/Cargo.lock b/crates/bench/sp1-microbenches/guest-storage/Cargo.lock new file mode 100644 index 0000000000..b3bc118dd1 --- /dev/null +++ b/crates/bench/sp1-microbenches/guest-storage/Cargo.lock @@ -0,0 +1,2825 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addchain" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e33f6a175ec6a9e0aca777567f9ff7c3deefc255660df887e7fa3585e9801d8" +dependencies = [ + "num-bigint 0.3.3", + "num-integer", + "num-traits", +] + +[[package]] +name = "alloy-primitives" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +dependencies = [ + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "paste", + "rand 0.9.4", + "ruint", + "rustc-hash", + "serde", + "sha3", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f2b5fa7b76b498158ec1079dc0ea842c5b622b6b3f675005fddd889f2c9a7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "git+https://github.com/sp1-patches/curve25519-dalek?tag=patch-4.1.3-sp1-6.0.0#ade3f90a82efc7c58c4ae1ecb4d48bbe673c2a8f" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "sp1-lib", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "git+https://github.com/sp1-patches/curve25519-dalek?tag=patch-4.1.3-sp1-6.0.0#ade3f90a82efc7c58c4ae1ecb4d48bbe673c2a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elf" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "byteorder", + "ff_derive", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f10d12652036b0e99197587c6ba87a8fc3031986499973c030d8b44fcc151b60" +dependencies = [ + "addchain", + "num-bigint 0.3.3", + "num-integer", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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", + "wasip2", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nearly-linear" +version = "0.3.0" + +[[package]] +name = "nmt-rs" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d9149cb486570ac43944740ac8ea83d309d44d6a2cd2cd856606f43e40c6429" +dependencies = [ + "borsh", + "bytes", + "serde", + "sha2", +] + +[[package]] +name = "nomt-core" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b43245e3ed2b32eaf8c062506ec16a4ff3f08e0373c29c2b0b202e8c325409" +dependencies = [ + "arrayvec", + "bitvec", + "borsh", + "digest 0.10.7", + "ruint", + "serde", +] + +[[package]] +name = "num-bigint" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p3-bn254-fr" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2077757c7cb514202ccb5368f521f23f5709c720599e6545c683c66e0a52d2d8" +dependencies = [ + "ff", + "num-bigint 0.4.6", + "p3-field", + "p3-poseidon2", + "p3-symmetric", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "p3-challenger" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a908924d43e4cfb93fb41c8346cac211b70314385a9037e9241f5b7f3eaf77" +dependencies = [ + "p3-field", + "p3-maybe-rayon", + "p3-symmetric", + "p3-util", + "serde", + "tracing", +] + +[[package]] +name = "p3-dft" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be6408b10a2c27eb13a7d5580c546c2179a8dc7dbc10a990657311891f9b41c0" +dependencies = [ + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", + "tracing", +] + +[[package]] +name = "p3-field" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc75969ca3ac847f43e632ab979d59ff7a68f9eac8dbf8edcbba47fc2e1d3aa" +dependencies = [ + "itertools 0.12.1", + "num-bigint 0.4.6", + "num-traits", + "p3-util", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "p3-koala-bear" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9683cd0ef68100df7c62490533047bcf19c04c4a0fa1efc9d7c1e03e31f6b3" +dependencies = [ + "cfg-if", + "num-bigint 0.4.6", + "p3-field", + "p3-mds", + "p3-poseidon2", + "p3-symmetric", + "rand 0.8.6", + "rustc_version", + "serde", +] + +[[package]] +name = "p3-matrix" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c3f150ceb90e09539413bf481e618d05ee19210b4e467d2902eb82d2e15281" +dependencies = [ + "itertools 0.12.1", + "p3-field", + "p3-maybe-rayon", + "p3-util", + "rand 0.8.6", + "serde", + "tracing", +] + +[[package]] +name = "p3-maybe-rayon" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0641952b42da45e1dfa2d4a2a3163e330f944ad9740942f35026c0a71a605f1" + +[[package]] +name = "p3-mds" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4a5f250e174dcfca5cbeac6ad75713924e7e7320e0a335e3c50b8b1f4fe8ec" +dependencies = [ + "itertools 0.12.1", + "p3-dft", + "p3-field", + "p3-matrix", + "p3-symmetric", + "p3-util", + "rand 0.8.6", +] + +[[package]] +name = "p3-poseidon2" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "522986377b2164c5f94f2dae88e0e0a3d169cc6239202ef4aeb4322d60feffd0" +dependencies = [ + "gcd", + "p3-field", + "p3-mds", + "p3-symmetric", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "p3-symmetric" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9047ce85c086a9b3f118e10078f10636f7bfeed5da871a04da0b61400af8793a" +dependencies = [ + "itertools 0.12.1", + "p3-field", + "serde", +] + +[[package]] +name = "p3-util" +version = "0.4.3-succinct" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff962f8eaa5f36e0447cee7c241f6b4b475fadf3ee61f154327a26bb4e009ba" +dependencies = [ + "serde", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettier-please" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32db37eb2b0ec0af154e9c1b33425902d8cd9481e35167c4e9ffb28fec3916bb" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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", + "serde", +] + +[[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 = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "ruint" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" +dependencies = [ + "proptest", + "rand 0.8.6", + "rand 0.9.4", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "git+https://github.com/sp1-patches/RustCrypto-hashes?tag=patch-sha2-0.10.9-sp1-6.0.0#e48b656ebc806117554bb33c2f8687e4637e37ff" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slop-algebra" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8abf7cfad18c0580576e8adc01f7fa27b1cb19432e451e82950c9a445a7cfc" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "serde", +] + +[[package]] +name = "slop-bn254" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c327e0927fabf9c0ae9a7b0333027dc04431e5981ab80d7bbe994d6c4ce35fc1" +dependencies = [ + "ff", + "p3-bn254-fr", + "serde", + "slop-algebra", + "slop-challenger", + "slop-poseidon2", + "slop-symmetric", +] + +[[package]] +name = "slop-challenger" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c263e731bb694d4465eedae7ecd6faf1f277198e751f3c209a1c4186d80d1b6b" +dependencies = [ + "futures", + "p3-challenger", + "serde", + "slop-algebra", + "slop-symmetric", +] + +[[package]] +name = "slop-koala-bear" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a8cdc74f04d13e738b4628312b16dfec6a2e547bde697c8bdcf556884c6e91f" +dependencies = [ + "lazy_static", + "p3-koala-bear", + "serde", + "slop-algebra", + "slop-challenger", + "slop-poseidon2", + "slop-symmetric", +] + +[[package]] +name = "slop-poseidon2" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aedfc3bf87cf2694bd108c039d663c346c7bb807491a7db6701eb9dff5c6e5d" +dependencies = [ + "p3-poseidon2", +] + +[[package]] +name = "slop-primitives" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30e6e332c3cb103541bed9f5f477b769df1b5fb6076b5e2386569c94b1475dc" +dependencies = [ + "slop-algebra", +] + +[[package]] +name = "slop-symmetric" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb1de854325a8c36a1bdfee5514e1d4c9f39290ae19eeaecb21ca6ee88d96d6" +dependencies = [ + "p3-symmetric", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "sov-db-types" +version = "0.3.0" +dependencies = [ + "borsh", + "derivative", + "digest 0.10.7", + "hex", + "serde", + "serde_with", + "sov-universal-wallet", +] + +[[package]] +name = "sov-metrics" +version = "0.3.0" +dependencies = [ + "anyhow", + "derive-new", + "hex", + "serde", +] + +[[package]] +name = "sov-microbench-guest-storage" +version = "0.3.0" +dependencies = [ + "nomt-core", + "sov-mock-da", + "sov-mock-zkvm", + "sov-modules-api", + "sp1-zkvm", +] + +[[package]] +name = "sov-mock-da" +version = "0.3.0" +dependencies = [ + "anyhow", + "async-trait", + "borsh", + "bytes", + "derive_more", + "hex", + "schemars 1.2.1", + "serde", + "serde_json", + "sha2", + "sov-rollup-interface", + "sov-universal-wallet", + "tracing", + "url", +] + +[[package]] +name = "sov-mock-zkvm" +version = "0.3.0" +dependencies = [ + "anyhow", + "bincode", + "borsh", + "ed25519-dalek", + "hex", + "schemars 1.2.1", + "serde", + "sha2", + "sov-rollup-interface", + "sov-universal-wallet", + "thiserror", +] + +[[package]] +name = "sov-modules-api" +version = "0.3.0" +dependencies = [ + "anyhow", + "bech32", + "borsh", + "bs58", + "derivative", + "derive_more", + "digest 0.10.7", + "hex", + "nearly-linear", + "schemars 1.2.1", + "serde", + "serde_json", + "sha2", + "sov-metrics", + "sov-modules-macros", + "sov-rollup-interface", + "sov-state", + "sov-universal-wallet", + "strum", + "thiserror", + "toml", + "tracing", + "unwrap-infallible", +] + +[[package]] +name = "sov-modules-macros" +version = "0.3.0" +dependencies = [ + "anyhow", + "bech32", + "blake2", + "convert_case 0.6.0", + "darling 0.21.3", + "derive_more", + "hex", + "prettier-please", + "proc-macro2", + "quote", + "serde", + "sov-universal-wallet-macro-helpers", + "syn 2.0.117", + "toml", +] + +[[package]] +name = "sov-rollup-interface" +version = "0.3.0" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "borsh", + "bytes", + "derive_more", + "digest 0.10.7", + "hex", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "sov-universal-wallet", + "thiserror", +] + +[[package]] +name = "sov-state" +version = "0.3.0" +dependencies = [ + "anyhow", + "bcs", + "borsh", + "derivative", + "derive_more", + "hex", + "nomt-core", + "serde", + "serde-big-array", + "sov-db-types", + "sov-metrics", + "sov-rollup-interface", + "sov-universal-wallet", +] + +[[package]] +name = "sov-universal-wallet" +version = "0.4.1" +dependencies = [ + "alloy-primitives", + "arrayvec", + "bech32", + "borsh", + "bs58", + "hex", + "nmt-rs", + "once_cell", + "schemars 1.2.1", + "serde", + "serde_json", + "sha2", + "sov-universal-wallet-macros", + "thiserror", +] + +[[package]] +name = "sov-universal-wallet-macro-helpers" +version = "0.4.1" +dependencies = [ + "bech32", + "borsh", + "bs58", + "darling 0.21.3", + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "sov-universal-wallet-macros" +version = "0.4.1" +dependencies = [ + "sov-universal-wallet-macro-helpers", + "syn 2.0.117", +] + +[[package]] +name = "sp1-lib" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5f56efe1d2a980d0f46083863ef4fdf715ed70cc32668c9e5725af145b8d9" +dependencies = [ + "bincode", + "elliptic-curve", + "serde", + "sp1-primitives", +] + +[[package]] +name = "sp1-primitives" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ad00052921b993af682403b378c8fe23c40382f9790f093c8fac0f30433c5e" +dependencies = [ + "bincode", + "blake3", + "elf", + "hex", + "itertools 0.14.0", + "lazy_static", + "num-bigint 0.4.6", + "serde", + "sha2", + "slop-algebra", + "slop-bn254", + "slop-challenger", + "slop-koala-bear", + "slop-poseidon2", + "slop-primitives", + "slop-symmetric", +] + +[[package]] +name = "sp1-zkvm" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5732e5c4cf9e607f224a0b1a0fd90515897f731eaba33939bf9d5dcdecf4f8" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "libm", + "rand 0.8.6", + "sha2", + "sp1-lib", + "sp1-primitives", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64 0.13.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[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" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unwrap-infallible" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151ac09978d3c2862c4e39b557f4eceee2cc72150bc4cb4f16abf061b6e381fb" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[patch.unused]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "git+https://github.com/sp1-patches/curve25519-dalek-ng?tag=patch-4.1.1-sp1-6.0.0#5854f8bab8842ff6232469b0d93ae4160e4a503d" + +[[patch.unused]] +name = "ed25519-consensus" +version = "2.1.0" +source = "git+https://github.com/sp1-patches/ed25519-consensus?rev=78f83b21767077f602f30148e513a52e844abbb6#78f83b21767077f602f30148e513a52e844abbb6" + +[[patch.unused]] +name = "tiny-keccak" +version = "2.0.2" +source = "git+https://github.com/sp1-patches/tiny-keccak?tag=patch-2.0.2-sp1-6.0.0#957430a459f7a2332ab5bab4a12f9b473bb95c87" diff --git a/crates/bench/sp1-microbenches/guest-storage/Cargo.toml b/crates/bench/sp1-microbenches/guest-storage/Cargo.toml new file mode 100644 index 0000000000..8a170df54e --- /dev/null +++ b/crates/bench/sp1-microbenches/guest-storage/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "sov-microbench-guest-storage" +version = "0.3.0" +license = "MIT OR Apache-2.0" +edition = "2021" +resolver = "2" + +[workspace] + +[dependencies] +sp1-zkvm = { version = "6.2.2" } +sov-modules-api = { path = "../../../module-system/sov-modules-api", default-features = false } +sov-mock-da = { path = "../../../adapters/mock-da", default-features = false } +sov-mock-zkvm = { path = "../../../adapters/mock-zkvm", default-features = false } +nomt-core = { version = "1.0.4", default-features = false } + +[patch.crates-io] +sha2-v0-10-9 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha2", tag = "patch-sha2-0.10.9-sp1-6.0.0" } +curve25519-dalek = { git = "https://github.com/sp1-patches/curve25519-dalek", tag = "patch-4.1.3-sp1-6.0.0" } +curve25519-dalek-ng = { git = "https://github.com/sp1-patches/curve25519-dalek-ng", tag = "patch-4.1.1-sp1-6.0.0" } +ed25519-consensus = { git = "https://github.com/sp1-patches/ed25519-consensus", rev = "78f83b21767077f602f30148e513a52e844abbb6" } +tiny-keccak = { git = "https://github.com/sp1-patches/tiny-keccak", tag = "patch-2.0.2-sp1-6.0.0" } + +[profile.release] +debug = 1 +lto = true + +[profile.release.build-override] +opt-level = 3 diff --git a/crates/bench/sp1-microbenches/guest-storage/src/main.rs b/crates/bench/sp1-microbenches/guest-storage/src/main.rs new file mode 100644 index 0000000000..60f6f36b7a --- /dev/null +++ b/crates/bench/sp1-microbenches/guest-storage/src/main.rs @@ -0,0 +1,128 @@ +#![no_main] + +sp1_zkvm::entrypoint!(main); + +use core::hint::black_box; + +use nomt_core::hasher::{BinaryHasher, NodeHasher}; +use nomt_core::proof::{ + verify_multi_proof, verify_multi_proof_update, MultiProof, PathProof, PathProofTerminal, +}; +use nomt_core::trie::{InternalData, KeyPath, LeafData, Node, ValueHash}; +use sov_mock_da::MockDaSpec; +use sov_mock_zkvm::MockZkvm; +use sov_modules_api::default_spec::DefaultSpec; +use sov_modules_api::execution_mode::Zk; +use sov_modules_api::{Spec, Storage}; + +type MicrobenchSpec = DefaultSpec; +// Resolve the hasher off the spec's Storage — the same `NomtVerifierStorage` the prover +// verifies with — so the bench can't drift from production's `BinaryHasher`. +type Hasher = BinaryHasher<<::Storage as Storage>::Hasher>; + +// Mode flags must match cmd/storage.rs. +const MODE_READ: u8 = 0; +const MODE_WRITE: u8 = 1; + +pub fn main() { + let mode: u8 = sp1_zkvm::io::read(); + let depth: u32 = sp1_zkvm::io::read(); + let iterations: u32 = sp1_zkvm::io::read(); + + let (proof, root, key_path) = build_single_path_proof(depth as usize); + + match mode { + MODE_READ => run_read(&proof, root, iterations), + MODE_WRITE => run_write(&proof, root, key_path, iterations), + other => panic!("unknown storage bench mode {other}"), + } +} + +/// Verifies an inclusion proof for one key at the given trie depth — the per-access cost charged +/// via `bias_to_charge_for_access`. +fn run_read(proof: &MultiProof, root: Node, iterations: u32) { + println!("cycle-tracker-report-start: storage_loop"); + for _ in 0..iterations { + let verified = verify_multi_proof::(black_box(proof), black_box(root)) + .expect("hand-built proof must verify"); + black_box(&verified); + } + println!("cycle-tracker-report-end: storage_loop"); + + sp1_zkvm::io::commit(&root); +} + +/// Verifies a value update for one key — the extra cost a write pays via `BIAS_STORAGE_UPDATE`, +/// on top of the access cost measured by [`run_read`]. +fn run_write(proof: &MultiProof, root: Node, key_path: KeyPath, iterations: u32) { + let verified = + verify_multi_proof::(proof, root).expect("hand-built proof must verify"); + let new_value: ValueHash = fill(0x03); + + println!("cycle-tracker-report-start: storage_loop"); + let mut last = root; + for _ in 0..iterations { + let ops = vec![(key_path, Some(black_box(new_value)))]; + last = verify_multi_proof_update::(black_box(&verified), black_box(ops)) + .expect("hand-built update must verify"); + black_box(&last); + } + println!("cycle-tracker-report-end: storage_loop"); + + sp1_zkvm::io::commit(&last); +} + +/// Builds a valid single-key inclusion proof of exactly `depth` siblings, plus the root it proves +/// against. Construction is iteration-independent, so the two-iteration differencing cancels it. +fn build_single_path_proof(depth: usize) -> (MultiProof, Node, KeyPath) { + let key_path: KeyPath = fill(0x01); + let value_hash: ValueHash = fill(0x02); + let leaf = LeafData { + key_path, + value_hash, + }; + let siblings: Vec = (0..depth).map(|d| fill(0x40u8.wrapping_add(d as u8))).collect(); + + let root = compute_root::(&leaf, &siblings); + + let proof = MultiProof::from_path_proofs(vec![PathProof { + terminal: PathProofTerminal::Leaf(leaf), + siblings, + }]); + (proof, root, key_path) +} + +/// Folds the leaf up through its siblings to the root, mirroring nomt-core's (non-exported) +/// `hash_path`: siblings are ascending by depth and consumed from the bottom up. +fn compute_root(leaf: &LeafData, siblings: &[Node]) -> Node { + let mut node = H::hash_leaf(leaf); + for depth in (0..siblings.len()).rev() { + let sibling = siblings[depth]; + let internal = if bit_at(&leaf.key_path, depth) { + InternalData { + left: sibling, + right: node, + } + } else { + InternalData { + left: node, + right: sibling, + } + }; + node = H::hash_internal(&internal); + } + node +} + +/// Bit at index `i` of a key path, most-significant-bit first (matching nomt's `Msb0` paths). +fn bit_at(key: &KeyPath, i: usize) -> bool { + (key[i / 8] >> (7 - (i % 8))) & 1 == 1 +} + +fn fill(seed: u8) -> [u8; 32] { + let mut bytes = [0u8; 32]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = seed.wrapping_add(i as u8).wrapping_mul(0xAB); + } + bytes +} diff --git a/crates/bench/sp1-microbenches/src/cmd/borsh.rs b/crates/bench/sp1-microbenches/src/cmd/borsh.rs new file mode 100644 index 0000000000..66dff56fcf --- /dev/null +++ b/crates/bench/sp1-microbenches/src/cmd/borsh.rs @@ -0,0 +1,237 @@ +use anyhow::Context; +use clap::Args; +use sp1_sdk::blocking::{Prover, ProverClient, SP1Stdin}; + +use crate::{load_guest_elf, round_at_least_one, BenchResult}; +use sov_gas_tools::fit::{fit_linear, LinearFit}; + +const GUEST_ELF_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/guest-borsh/target/elf-compilation/riscv64im-succinct-zkvm-elf/release/sov-microbench-guest-borsh", +); + +const DEFAULT_ITERATIONS: u32 = 100; +const DEFAULT_BASELINE_ITERATIONS: u32 = 10; +const READER_BYTES_SIZES: &[u32] = &[1, 32, 64, 128, 256, 512, 1024, 4096, 16384, 65536]; +const READER_COUNT_SIZES: &[u32] = &[1, 2, 4, 8, 16, 32, 64, 128, 256, 512]; +const DECODE_VEC_SIZES: &[u32] = &[0, 1, 32, 64, 128, 256, 512, 1024, 4096, 16384, 65536]; + +// Mode flags must match guest-borsh/src/main.rs. +const MODE_READER_BYTES: u8 = 0; +const MODE_READER_COUNT: u8 = 1; +const MODE_DECODE_VEC: u8 = 2; + +#[derive(Args, Debug)] +pub struct BorshArgs { + #[arg(long, default_value_t = DEFAULT_ITERATIONS)] + pub iterations: u32, + #[arg(long, default_value_t = DEFAULT_BASELINE_ITERATIONS)] + pub baseline_iterations: u32, +} + +/// Runs the three borsh sweeps in order; each constant nets out the earlier sweeps' costs. +pub fn run(args: BorshArgs) -> anyhow::Result<()> { + let BorshArgs { + iterations, + baseline_iterations, + } = args; + + println!("\n========== STEP 1: reader-bytes =========="); + let fit_bytes = run_sweep( + "reader-bytes", + READER_BYTES_SIZES, + MODE_READER_BYTES, + iterations, + baseline_iterations, + "bytes", + |stdin, n_bytes| { + stdin.write(&n_bytes); + n_bytes + }, + )?; + print_fit(&fit_bytes, "byte"); + let per_byte_read = fit_bytes.per_byte; + + println!("\n========== STEP 2: reader-count =========="); + let fit_reads = run_sweep( + "reader-count", + READER_COUNT_SIZES, + MODE_READER_COUNT, + iterations, + baseline_iterations, + "reads", + |stdin, n_reads| { + stdin.write(&n_reads); + n_reads + }, + )?; + print_fit(&fit_reads, "read"); + let per_read_bias = fit_reads.per_byte - per_byte_read; + + println!("\n========== STEP 3: decode-vec =========="); + let fit_decode = run_sweep( + "decode-vec", + DECODE_VEC_SIZES, + MODE_DECODE_VEC, + iterations, + baseline_iterations, + "bytes", + |stdin, payload| { + let data: Vec = (0..payload).map(|i| (i as u8).wrapping_mul(0xAB)).collect(); + let buf: Vec = borsh::to_vec(&data).expect("borsh::to_vec of Vec"); + let buf_len = u32::try_from(buf.len()).expect("buf len fits in u32"); + stdin.write_vec(buf); + buf_len + }, + )?; + print_fit(&fit_decode, "byte"); + // x is buf_len (4-byte prefix included), so the slope already prices the prefix bytes; the + // intercept only carries the entry cost + the 2 fixed read biases. + let bias_borsh_deserialization = fit_decode.bias - 2.0 * per_read_bias; + + println!("\n========== SUMMARY =========="); + println!("Raw fitted values (prover gas):"); + println!(" per_byte_read = {per_byte_read:.4}"); + println!(" per_read_bias = {per_read_bias:.4} (per_read slope - per_byte_read)"); + println!(" bias_borsh_deserialization = {bias_borsh_deserialization:.2} (decode intercept - 2·per_read_bias)"); + println!( + " Sanity: decode-vec slope = {:.4} vs per_byte_read = {per_byte_read:.4}", + fit_decode.per_byte + ); + + println!( + "\nSuggested constants.toml values (full value charged to a single dimension, rounded ≥1):" + ); + let pbr = round_at_least_one(per_byte_read); + let prb = round_at_least_one(per_read_bias); + let bbd = round_at_least_one(bias_borsh_deserialization); + println!(" BORSH_PER_BYTE_READ = [{pbr}, 0]"); + println!(" BORSH_PER_READ_BIAS = [{prb}, 0]"); + println!(" BIAS_BORSH_DESERIALIZATION = [{bbd}, 0]"); + + Ok(()) +} + +/// Runs the high/low-iteration sweep, differences out per-execution setup, fits a line, and +/// prints the tables. `write_payload` writes each point's stdin payload and returns its input size. +fn run_sweep( + name: &str, + sizes: &[u32], + mode: u8, + iterations: u32, + baseline_iterations: u32, + x_label: &str, + mut write_payload: W, +) -> anyhow::Result +where + W: FnMut(&mut SP1Stdin, u32) -> u32, +{ + anyhow::ensure!( + iterations > baseline_iterations, + "--iterations must be greater than --baseline-iterations" + ); + + let elf = load_guest_elf(GUEST_ELF_PATH)?; + let client = ProverClient::from_env(); + + let mut results_high = Vec::with_capacity(sizes.len()); + let mut results_low = Vec::with_capacity(sizes.len()); + for &n in sizes { + for (iter_count, results) in [ + (iterations, &mut results_high), + (baseline_iterations, &mut results_low), + ] { + println!("[run] borsh {name} {x_label}={n} iterations={iter_count}"); + let mut stdin = SP1Stdin::new(); + stdin.write(&mode); + stdin.write(&iter_count); + let input_size = write_payload(&mut stdin, n); + let report = + execute_and_collect(&client, elf.clone(), stdin, input_size, iter_count) + .with_context(|| format!("{name} failed at {x_label}={n} iter={iter_count}"))?; + results.push(report); + } + } + + let (sizes_f, per_iter_gas) = + differential(&results_high, &results_low, iterations, baseline_iterations); + let fit = fit_linear(&sizes_f, &per_iter_gas)?; + + print_raw_table("high-iter", &results_high, x_label); + print_raw_table("low-iter (baseline)", &results_low, x_label); + print_differential_table(&sizes_f, &per_iter_gas, x_label); + + Ok(fit) +} + +fn execute_and_collect( + client: &impl Prover, + elf: sp1_sdk::blocking::Elf, + stdin: SP1Stdin, + input_size: u32, + iterations: u32, +) -> anyhow::Result { + let (_pv, report) = client.execute(elf, stdin).run()?; + let prover_gas = report + .gas() + .context("prover gas not available; ProverClient may have disabled gas calculation")?; + let total_cycles = report.total_instruction_count(); + let region_cycles = report.cycle_tracker.get("borsh_loop").copied().unwrap_or(0); + Ok(BenchResult { + input_size, + iterations, + prover_gas, + total_cycles, + region_cycles, + }) +} + +fn differential( + results_high: &[BenchResult], + results_low: &[BenchResult], + iterations: u32, + baseline_iterations: u32, +) -> (Vec, Vec) { + let iter_delta = f64::from(iterations - baseline_iterations); + let sizes: Vec = results_high + .iter() + .map(|r| f64::from(r.input_size)) + .collect(); + let per_iter_gas: Vec = results_high + .iter() + .zip(results_low.iter()) + .map(|(h, l)| (h.prover_gas as f64 - l.prover_gas as f64) / iter_delta) + .collect(); + (sizes, per_iter_gas) +} + +fn print_raw_table(label: &str, results: &[BenchResult], x_label: &str) { + println!("\n=== raw measurements ({label}) ==="); + println!( + "{:>6} {:>6} {:>18} {:>14} {:>14}", + x_label, "iters", "prover gas (total)", "total cycles", "region cycles", + ); + for r in results { + println!( + "{:>6} {:>6} {:>18} {:>14} {:>14}", + r.input_size, r.iterations, r.prover_gas, r.total_cycles, r.region_cycles, + ); + } +} + +fn print_differential_table(sizes: &[f64], per_iter_gas: &[f64], x_label: &str) { + println!("\n=== differential (clean per-iter prover gas, setup cancelled) ==="); + println!("{:>6} {:>20}", x_label, "per_iter_gas (clean)"); + for (s, g) in sizes.iter().zip(per_iter_gas.iter()) { + println!("{:>6.0} {:>20.4}", s, g); + } +} + +fn print_fit(fit: &LinearFit, unit: &str) { + println!("\n=== linear fit (clean prover gas per call) ==="); + println!("Model: gas_per_call = bias + per_{unit} * input_size"); + println!(" bias = {:.2} prover gas / call", fit.bias); + println!(" per_{unit} = {:.4} prover gas / {unit}", fit.per_byte); + println!(" R² = {:.6}", fit.r_squared); + println!(" max residual = {:.2} prover gas", fit.max_residual); +} diff --git a/crates/bench/sp1-microbenches/src/cmd/ed25519.rs b/crates/bench/sp1-microbenches/src/cmd/ed25519.rs index 42c655c743..29b3ac067e 100644 --- a/crates/bench/sp1-microbenches/src/cmd/ed25519.rs +++ b/crates/bench/sp1-microbenches/src/cmd/ed25519.rs @@ -5,7 +5,7 @@ use sov_sp1_adapter::crypto::private_key::SP1PrivateKey; use sp1_sdk::blocking::{Prover, ProverClient, SP1Stdin}; use crate::fit_prover_gas_per_byte; -use crate::{load_guest_elf, BenchResult}; +use crate::{load_guest_elf, round_at_least_one, BenchResult}; const GUEST_ELF_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), @@ -57,11 +57,6 @@ pub fn run(args: Ed25519Args) -> anyhow::Result<()> { .get("verify_loop") .copied() .unwrap_or(0); - let invocations = report - .invocation_tracker - .get("verify_loop") - .copied() - .unwrap_or(0); results.push(BenchResult { input_size: size, @@ -69,7 +64,6 @@ pub fn run(args: Ed25519Args) -> anyhow::Result<()> { prover_gas, total_cycles, region_cycles, - invocations, }); } @@ -106,14 +100,14 @@ pub fn run(args: Ed25519Args) -> anyhow::Result<()> { println!(" R² = {:.6}", gas_fit.r_squared); println!(" max residual = {:.2} prover gas", gas_fit.max_residual); - println!("\n=== suggested constants.toml values (raw SP1 prover gas, 1:1) ==="); + println!("\n=== suggested constants.toml values (full value charged to a single dimension, rounded ≥1) ==="); println!( - " DEFAULT_FIXED_GAS_TO_CHARGE_PER_SIGNATURE_VERIFICATION[1] ≈ {}", - gas_fit.bias.round() as i64 + " DEFAULT_FIXED_GAS_TO_CHARGE_PER_SIGNATURE_VERIFICATION = [{}, 0]", + round_at_least_one(gas_fit.bias) ); println!( - " DEFAULT_GAS_TO_CHARGE_PER_BYTE_SIGNATURE_VERIFICATION[1] ≈ {}", - gas_fit.per_byte.round() as i64 + " DEFAULT_GAS_TO_CHARGE_PER_BYTE_SIGNATURE_VERIFICATION = [{}, 0]", + round_at_least_one(gas_fit.per_byte) ); Ok(()) diff --git a/crates/bench/sp1-microbenches/src/cmd/mod.rs b/crates/bench/sp1-microbenches/src/cmd/mod.rs index 942075cfaa..2587219fb9 100644 --- a/crates/bench/sp1-microbenches/src/cmd/mod.rs +++ b/crates/bench/sp1-microbenches/src/cmd/mod.rs @@ -1,3 +1,5 @@ +pub mod borsh; pub mod celestia; pub mod ed25519; pub mod sha256; +pub mod storage; diff --git a/crates/bench/sp1-microbenches/src/cmd/sha256.rs b/crates/bench/sp1-microbenches/src/cmd/sha256.rs index 1c8c48fde1..047eb6f06c 100644 --- a/crates/bench/sp1-microbenches/src/cmd/sha256.rs +++ b/crates/bench/sp1-microbenches/src/cmd/sha256.rs @@ -3,7 +3,7 @@ use clap::Args; use sp1_sdk::blocking::{Prover, ProverClient, SP1Stdin}; use crate::fit_prover_gas_per_byte; -use crate::{load_guest_elf, BenchResult}; +use crate::{load_guest_elf, round_at_least_one, BenchResult}; const GUEST_ELF_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), @@ -41,11 +41,6 @@ pub fn run(args: Sha256Args) -> anyhow::Result<()> { .context("prover gas not available; ProverClient may have disabled gas calculation")?; let total_cycles = report.total_instruction_count(); let region_cycles = report.cycle_tracker.get("hash_loop").copied().unwrap_or(0); - let invocations = report - .invocation_tracker - .get("hash_loop") - .copied() - .unwrap_or(0); results.push(BenchResult { input_size: size, @@ -53,7 +48,6 @@ pub fn run(args: Sha256Args) -> anyhow::Result<()> { prover_gas, total_cycles, region_cycles, - invocations, }); } @@ -90,14 +84,14 @@ pub fn run(args: Sha256Args) -> anyhow::Result<()> { println!(" R² = {:.6}", gas_fit.r_squared); println!(" max residual = {:.2} prover gas", gas_fit.max_residual); - println!("\n=== suggested constants.toml values (raw SP1 prover gas, 1:1) ==="); + println!("\n=== suggested constants.toml values (full value charged to a single dimension, rounded ≥1) ==="); println!( - " GAS_TO_CHARGE_HASH_UPDATE[1] ≈ {}", - gas_fit.bias.round() as i64 + " GAS_TO_CHARGE_HASH_UPDATE = [{}, 0]", + round_at_least_one(gas_fit.bias) ); println!( - " GAS_TO_CHARGE_PER_BYTE_HASH_UPDATE[1] ≈ {}", - gas_fit.per_byte.round() as i64 + " GAS_TO_CHARGE_PER_BYTE_HASH_UPDATE = [{}, 0]", + round_at_least_one(gas_fit.per_byte) ); Ok(()) diff --git a/crates/bench/sp1-microbenches/src/cmd/storage.rs b/crates/bench/sp1-microbenches/src/cmd/storage.rs new file mode 100644 index 0000000000..d5568bee16 --- /dev/null +++ b/crates/bench/sp1-microbenches/src/cmd/storage.rs @@ -0,0 +1,207 @@ +use anyhow::Context; +use clap::Args; +use sp1_sdk::blocking::{Prover, ProverClient, SP1Stdin}; + +use crate::{load_guest_elf, round_at_least_one, BenchResult}; +use sov_gas_tools::fit::{fit_linear, LinearFit}; + +const GUEST_ELF_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/guest-storage/target/elf-compilation/riscv64im-succinct-zkvm-elf/release/sov-microbench-guest-storage", +); + +const DEFAULT_ITERATIONS: u32 = 100; +const DEFAULT_BASELINE_ITERATIONS: u32 = 10; +const DEPTHS: &[u32] = &[1, 2, 4, 8, 16, 32, 64, 128, 256]; + +/// Worst-case trie depth we price every access at. Honest accesses are shallower (~log2 of state +/// size); forcing depth D requires ~2^D hash grinding, so D=64 is infeasible to exceed while only +/// over-charging typical accesses ~2x. See the storage-gas-calibration memory for the rationale. +const TARGET_DEPTH: f64 = 64.0; + +// Mode flags must match guest-storage/src/main.rs. +const MODE_READ: u8 = 0; +const MODE_WRITE: u8 = 1; + +#[derive(Args, Debug)] +pub struct StorageArgs { + #[arg(long, default_value_t = DEFAULT_ITERATIONS)] + pub iterations: u32, + #[arg(long, default_value_t = DEFAULT_BASELINE_ITERATIONS)] + pub baseline_iterations: u32, +} + +/// Sweeps NOMT proof verification over trie depth and prices each access at `TARGET_DEPTH`. +pub fn run(args: StorageArgs) -> anyhow::Result<()> { + let StorageArgs { + iterations, + baseline_iterations, + } = args; + + println!("\n========== STEP 1: read (verify_multi_proof) =========="); + let fit_read = run_sweep("read", MODE_READ, iterations, baseline_iterations)?; + print_fit(&fit_read); + + println!("\n========== STEP 2: write (verify_multi_proof_update) =========="); + let fit_write = run_sweep("write", MODE_WRITE, iterations, baseline_iterations)?; + print_fit(&fit_write); + + let access = fit_read.bias + fit_read.per_byte * TARGET_DEPTH; + let update = fit_write.bias + fit_write.per_byte * TARGET_DEPTH; + + println!("\n========== SUMMARY =========="); + println!("Raw fitted values (prover gas), cost = bias + per_sibling * depth:"); + println!( + " read: bias = {:.2}, per_sibling = {:.4}", + fit_read.bias, fit_read.per_byte + ); + println!( + " write: bias = {:.2}, per_sibling = {:.4}", + fit_write.bias, fit_write.per_byte + ); + println!("Cost at TARGET_DEPTH = {TARGET_DEPTH:.0}:"); + println!(" access (verify) = {access:.2}"); + println!(" update (verify_update) = {update:.2}"); + + println!( + "\nSuggested constants.toml values (full value charged to a single dimension, rounded ≥1):" + ); + println!( + " GAS_TO_CHARGE_PER_STORAGE_ACCESS = [{}, 0]", + round_at_least_one(access) + ); + println!( + " BIAS_STORAGE_UPDATE = [{}, 0]", + round_at_least_one(update) + ); + println!( + "\nNot proof-driven (NOMT proof cost is depth-driven, values hashed to 32 bytes; value\n\ + hashing already billed via the hash constants). Leave near-zero unless a value-size\n\ + materialization sweep says otherwise:" + ); + println!(" GAS_TO_CHARGE_PER_READ = [1, 0]"); + println!(" GAS_TO_CHARGE_PER_BYTE_READ = [1, 0]"); + println!(" GAS_TO_CHARGE_PER_BYTE_STORAGE_UPDATE = [1, 0]"); + + Ok(()) +} + +/// Runs the high/low-iteration depth sweep, differences out per-execution setup, and fits a line. +fn run_sweep( + name: &str, + mode: u8, + iterations: u32, + baseline_iterations: u32, +) -> anyhow::Result { + anyhow::ensure!( + iterations > baseline_iterations, + "--iterations must be greater than --baseline-iterations" + ); + + let elf = load_guest_elf(GUEST_ELF_PATH)?; + let client = ProverClient::from_env(); + + let mut results_high = Vec::with_capacity(DEPTHS.len()); + let mut results_low = Vec::with_capacity(DEPTHS.len()); + for &depth in DEPTHS { + for (iter_count, results) in [ + (iterations, &mut results_high), + (baseline_iterations, &mut results_low), + ] { + println!("[run] storage {name} depth={depth} iterations={iter_count}"); + let mut stdin = SP1Stdin::new(); + stdin.write(&mode); + stdin.write(&depth); + stdin.write(&iter_count); + let report = execute_and_collect(&client, elf.clone(), stdin, depth, iter_count) + .with_context(|| format!("{name} failed at depth={depth} iter={iter_count}"))?; + results.push(report); + } + } + + let (depths_f, per_iter_gas) = + differential(&results_high, &results_low, iterations, baseline_iterations); + let fit = fit_linear(&depths_f, &per_iter_gas)?; + + print_raw_table("high-iter", &results_high); + print_raw_table("low-iter (baseline)", &results_low); + print_differential_table(&depths_f, &per_iter_gas); + + Ok(fit) +} + +fn execute_and_collect( + client: &impl Prover, + elf: sp1_sdk::blocking::Elf, + stdin: SP1Stdin, + depth: u32, + iterations: u32, +) -> anyhow::Result { + let (_pv, report) = client.execute(elf, stdin).run()?; + let prover_gas = report + .gas() + .context("prover gas not available; ProverClient may have disabled gas calculation")?; + let total_cycles = report.total_instruction_count(); + let region_cycles = report + .cycle_tracker + .get("storage_loop") + .copied() + .unwrap_or(0); + Ok(BenchResult { + input_size: depth, + iterations, + prover_gas, + total_cycles, + region_cycles, + }) +} + +fn differential( + results_high: &[BenchResult], + results_low: &[BenchResult], + iterations: u32, + baseline_iterations: u32, +) -> (Vec, Vec) { + let iter_delta = f64::from(iterations - baseline_iterations); + let depths: Vec = results_high + .iter() + .map(|r| f64::from(r.input_size)) + .collect(); + let per_iter_gas: Vec = results_high + .iter() + .zip(results_low.iter()) + .map(|(h, l)| (h.prover_gas as f64 - l.prover_gas as f64) / iter_delta) + .collect(); + (depths, per_iter_gas) +} + +fn print_raw_table(label: &str, results: &[BenchResult]) { + println!("\n=== raw measurements ({label}) ==="); + println!( + "{:>6} {:>6} {:>18} {:>14} {:>14}", + "depth", "iters", "prover gas (total)", "total cycles", "region cycles", + ); + for r in results { + println!( + "{:>6} {:>6} {:>18} {:>14} {:>14}", + r.input_size, r.iterations, r.prover_gas, r.total_cycles, r.region_cycles, + ); + } +} + +fn print_differential_table(depths: &[f64], per_iter_gas: &[f64]) { + println!("\n=== differential (clean per-iter prover gas, setup cancelled) ==="); + println!("{:>6} {:>20}", "depth", "per_iter_gas (clean)"); + for (d, g) in depths.iter().zip(per_iter_gas.iter()) { + println!("{:>6.0} {:>20.4}", d, g); + } +} + +fn print_fit(fit: &LinearFit) { + println!("\n=== linear fit (clean prover gas per call) ==="); + println!("Model: gas_per_call = bias + per_sibling * depth"); + println!(" bias = {:.2} prover gas / call", fit.bias); + println!(" per_sibling = {:.4} prover gas / sibling", fit.per_byte); + println!(" R² = {:.6}", fit.r_squared); + println!(" max residual = {:.2} prover gas", fit.max_residual); +} diff --git a/crates/bench/sp1-microbenches/src/lib.rs b/crates/bench/sp1-microbenches/src/lib.rs index 35519ee53e..e4efa4f2f2 100644 --- a/crates/bench/sp1-microbenches/src/lib.rs +++ b/crates/bench/sp1-microbenches/src/lib.rs @@ -1,5 +1,4 @@ -// Host-only benchmark harness; the workspace `clippy::float_arithmetic` deny exists to prevent -// native/zkVM divergence, which doesn't apply here. +// Host-only harness; the workspace `clippy::float_arithmetic` deny targets zkVM code, not this. #![allow(clippy::float_arithmetic)] pub mod cmd; @@ -17,7 +16,6 @@ pub struct BenchResult { pub prover_gas: u64, pub total_cycles: u64, pub region_cycles: u64, - pub invocations: u64, } impl BenchResult { @@ -45,3 +43,13 @@ pub fn load_guest_elf(path: &str) -> anyhow::Result { } Ok(Elf::from(bytes)) } + +/// Rounds a cost for the single metered dimension, never flooring a positive cost to zero. +pub fn round_at_least_one(total: f64) -> u64 { + let rounded = total.round() as u64; + if total > 0.0 && rounded == 0 { + 1 + } else { + rounded + } +} diff --git a/crates/bench/sp1-microbenches/src/main.rs b/crates/bench/sp1-microbenches/src/main.rs index 9cff45711d..3ced12610a 100644 --- a/crates/bench/sp1-microbenches/src/main.rs +++ b/crates/bench/sp1-microbenches/src/main.rs @@ -1,5 +1,5 @@ use clap::{Parser, Subcommand}; -use sp1_microbenches::cmd::{celestia, ed25519, sha256}; +use sp1_microbenches::cmd::{borsh, celestia, ed25519, sha256, storage}; #[derive(Parser, Debug)] #[command( @@ -19,6 +19,10 @@ enum BenchCmd { Ed25519(ed25519::Ed25519Args), /// Run the Celestia verifier prover-gas bench. Celestia(celestia::CelestiaArgs), + /// Run the borsh deserialization prover-gas sweeps. + Borsh(borsh::BorshArgs), + /// Run the NOMT storage proof-verification prover-gas sweep. + Storage(storage::StorageArgs), } impl BenchCmd { @@ -27,6 +31,8 @@ impl BenchCmd { BenchCmd::Sha256(args) => sha256::run(args), BenchCmd::Ed25519(args) => ed25519::run(args), BenchCmd::Celestia(args) => celestia::run(args), + BenchCmd::Borsh(args) => borsh::run(args), + BenchCmd::Storage(args) => storage::run(args), } } } diff --git a/crates/full-node/full-node-configs/src/sequencer.rs b/crates/full-node/full-node-configs/src/sequencer.rs index 8d7ea128d0..e7a3a327b5 100644 --- a/crates/full-node/full-node-configs/src/sequencer.rs +++ b/crates/full-node/full-node-configs/src/sequencer.rs @@ -2,7 +2,6 @@ use std::{net::IpAddr, num::NonZero}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use sov_rollup_interface::common::RollupHeight; /// See [`SequencerConfig::sequencer_kind_config`]. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -356,24 +355,6 @@ pub struct SovRateLimiterConfig { #[serde(deserialize_with = "deserialize_ip_custom_limits")] #[schemars(with = "Vec<(IpAddrOrNet, Limits)>")] pub ip_custom_limits: Vec<(ipnet::IpNet, Limits)>, - /// Rate limiting on gas is currently disabled, so this param has no impact on runtime behavior. - /// - /// This height is used to statically compute the gas limit for the rate limiter. (If this value is less than or equal to CHANGE_GAS_LIMIT_AFTER_HEIGHT in constants.toml, - /// the gas limit will *always* be computed using the initial gas limit for rate limiting. If it is greater, the gas limit will be computed using the updated gas limit.) - /// Even after rate limiting based on gas is enabled, you can safely change this param at any time since it only impacts off-chain code. - #[serde( - default = "default_height_for_gas_limit_computation", - skip_serializing_if = "height_is_max" - )] - pub height_for_gas_limit_computation: RollupHeight, -} - -fn default_height_for_gas_limit_computation() -> RollupHeight { - RollupHeight::MAX -} - -fn height_is_max(height: &RollupHeight) -> bool { - *height == RollupHeight::MAX } /// Schema-only mirror of the wire form accepted by [`deserialize_ip_custom_limits`]: a bare IP diff --git a/crates/full-node/sov-api-spec/openapi-v3.yaml b/crates/full-node/sov-api-spec/openapi-v3.yaml index d0ab3ba33a..dd9db9dc1c 100644 --- a/crates/full-node/sov-api-spec/openapi-v3.yaml +++ b/crates/full-node/sov-api-spec/openapi-v3.yaml @@ -1000,53 +1000,6 @@ components: items: type: integer format: uint64 - PartialTransaction: - type: object - properties: - sender_pub_key: - type: string - format: byte - generation: - type: integer - format: uint64 - gas_price: - $ref: "#/components/schemas/GasPrice" - sequencer: - type: string - format: byte - sequencer_rollup_address: - type: string - format: byte - encoded_call_message: - type: string - format: byte - details: - $ref: "#/components/schemas/TxDetails" - required: - - sender_pub_key - - generation - - encoded_call_message - - details - TxDetails: - type: object - properties: - max_priority_fee_bips: - type: integer - format: uint64 - max_fee: - type: string - description: A decimal string representation of a u128 value - pattern: ^\d+$ - example: "340282366920938463463374607431768211455" - gas_limit: - $ref: "#/components/schemas/GasUnit" - chain_id: - type: integer - format: uint64 - required: - - max_priority_fee_bips - - max_fee - - chain_id SimulateExecutionResponse: type: object properties: @@ -1218,6 +1171,10 @@ components: type: object description: Optional uniqueness data for the transaction additionalProperties: true + address_override: + type: string + nullable: true + description: Optional address override for execution; null uses default routing required: - sender - call diff --git a/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs b/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs index 967e537754..138723105e 100644 --- a/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs +++ b/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs @@ -15,32 +15,25 @@ type TestSpec = EvmTestSpec; #[tokio::test(flavor = "multi_thread")] #[ignore = "Account abstraction for the EVM is disabled"] async fn test_evm_account_abstraction() { - let (test_rollup, test_client, chain_id) = setup_with_simple_storage(0, EVM_EXTENSION).await; + let (test_rollup, test_client, _chain_id) = setup_with_simple_storage(0, EVM_EXTENSION).await; // Before executing the evm checks we need to insert the credentials in the `Accounts`. - send_insert_credentials(&test_client, test_client.address(), chain_id).await; + send_insert_credentials(&test_client, test_client.address()).await; // Execute the evm tests. execute_evm_tests(&test_client).await.unwrap(); test_rollup.rollup_task.abort(); } -async fn send_insert_credentials( - test_client: &SimpleStorageClient, - from_addr: Address, - chain_id: u64, -) { - let tx = create_insert_credentials(from_addr, chain_id); +async fn send_insert_credentials(test_client: &SimpleStorageClient, from_addr: Address) { + let tx = create_insert_credentials(from_addr); test_client .send_transaction_and_wait_slot(&tx) .await .unwrap(); } -fn create_insert_credentials( - from_addr: Address, - chain_id: u64, -) -> Transaction, TestSpec> { +fn create_insert_credentials(from_addr: Address) -> Transaction, TestSpec> { let nonce = 0; let key_and_address = read_private_key::("tx_signer_private_key.json"); let key = key_and_address.private_key; @@ -62,11 +55,12 @@ fn create_insert_credentials( &chain_hash, UnsignedTransaction::new( msg, - chain_id, + chain_hash, max_priority_fee_bips, max_fee, UniquenessData::Nonce(nonce), gas_limit, + None, ), ) } diff --git a/crates/full-node/sov-ethereum/tests/integration/evm/evm_fee_history.rs b/crates/full-node/sov-ethereum/tests/integration/evm/evm_fee_history.rs index 06cb2bea42..a2dbaca2f6 100644 --- a/crates/full-node/sov-ethereum/tests/integration/evm/evm_fee_history.rs +++ b/crates/full-node/sov-ethereum/tests/integration/evm/evm_fee_history.rs @@ -7,7 +7,7 @@ use alloy::network::TransactionBuilder; use alloy::signers::local::PrivateKeySigner; -use alloy_primitives::{Address, B256}; +use alloy_primitives::{Address, B256, U256}; use alloy_provider::{DynProvider, Provider}; use alloy_rpc_types_eth::{ BlockNumberOrTag, BlockTransactions, TransactionReceipt, TransactionRequest, @@ -16,16 +16,17 @@ use serde::Deserialize; use sov_cli::NodeClient; use sov_eth_client::SimpleStorageClient; -use sov_modules_api::{GasPrice, GasUnit}; +use sov_evm::{ChainSpecUpdate, EvmRuntimeConfigUpdate}; +use sov_modules_api::{CryptoSpec, GasPrice, GasUnit, Spec}; use sov_test_utils::test_rollup::TestRollup; use crate::common::{ - alloy_client, create_simple_storage_client, deploy_contract_check, + alloy_client, alloy_client_with_signer, create_simple_storage_client, deploy_contract_check, estimate_gas_and_check_affordability, set_value_check, setup_test_rollup, - setup_test_rollup_with_ideal_lag, EVM_EXTENSION, HIGH_MAX_FEE_PER_GAS, + setup_test_rollup_with_admin_key, setup_test_rollup_with_ideal_lag, EVM_EXTENSION, HIGH_PRIORITY_FEE_PER_GAS, MAX_FEE_PER_GAS, SENDER_PRIV_KEY, }; -use crate::runtime::EvmBlueprint; +use crate::runtime::{EvmBlueprint, EvmTestSpec}; /// Mirror of the EVM genesis values used by `default_genesis()` (see /// `crate::common::genesis`). Inlined so the test does not depend on @@ -225,48 +226,6 @@ async fn send_high_fee_set_value( .map_err(|err| anyhow::anyhow!(err.to_string())) } -fn override_rollup_gas_limit_transition( - initial_gas_limit: u64, - updated_gas_limit: u64, - change_after_height: u64, -) { - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_INITIAL_GAS_LIMIT", - format!("[{initial_gas_limit},{initial_gas_limit}]"), - ); - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_UPDATED_GAS_LIMIT", - format!("[{updated_gas_limit},{updated_gas_limit}]"), - ); - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_CHANGE_GAS_LIMIT_AFTER_HEIGHT", - change_after_height.to_string(), - ); -} - -async fn send_high_fee_transfer( - client: &DynProvider, - nonce: u64, -) -> anyhow::Result { - send_high_fee_transfer_with_gas_limit(client, nonce, 1_000_000).await -} - -async fn send_high_fee_transfer_with_gas_limit( - client: &DynProvider, - nonce: u64, - gas_limit: u64, -) -> anyhow::Result { - let tx = TransactionRequest::default() - .with_to(Address::ZERO) - .with_nonce(nonce) - .with_value(alloy_primitives::U256::ZERO) - .with_gas_limit(gas_limit) - .with_max_fee_per_gas(HIGH_MAX_FEE_PER_GAS) - .with_max_priority_fee_per_gas(HIGH_PRIORITY_FEE_PER_GAS); - let pending = client.send_transaction(tx).await?; - Ok(pending.get_receipt().await?) -} - // ==================== Test Setup Helpers ==================== async fn setup_fee_history_test(wait_blocks: u64) -> (TestRollup, DynProvider) { @@ -1350,134 +1309,6 @@ async fn test_fee_history_block_with_tx_nonzero_ratio() -> anyhow::Result<()> { Ok(()) } -/// Regression: each gas_used_ratio entry must use that block's gas limit across a gas-limit transition. -#[tokio::test(flavor = "multi_thread")] -async fn test_fee_history_gas_used_ratio_uses_per_block_limit_across_transition( -) -> anyhow::Result<()> { - const INITIAL_GAS_LIMIT: u64 = 100_000_000_000; - const UPDATED_GAS_LIMIT: u64 = 50_000_000_000; - const CHANGE_GAS_LIMIT_AFTER_HEIGHT: u64 = 10; - - override_rollup_gas_limit_transition( - INITIAL_GAS_LIMIT, - UPDATED_GAS_LIMIT, - CHANGE_GAS_LIMIT_AFTER_HEIGHT, - ); - - let rollup = setup_test_rollup(0, EVM_EXTENSION).await; - let client = alloy_client(rollup.http_addr); - - let target_pre_boundary_height = CHANGE_GAS_LIMIT_AFTER_HEIGHT.saturating_sub(2); - let current_height = rollup.height().await.get(); - assert!( - current_height <= target_pre_boundary_height, - "test precondition failed: startup height {current_height} already exceeded target pre-boundary height {target_pre_boundary_height}" - ); - rollup.wait_for_height(target_pre_boundary_height).await; - - let signer: PrivateKeySigner = SENDER_PRIV_KEY.parse()?; - let mut nonce = client.get_transaction_count(signer.address()).await?; - let mut tx_blocks = Vec::new(); - - // Send transactions in blocks on both sides of the fee boundary (so that eth_feeHistory will have nonzero ratios) - for _ in 0..6 { - let receipt = send_high_fee_transfer(&client, nonce).await?; - rollup.wait_for_rollup_height_advance_by(1).await; - nonce = nonce.saturating_add(1); - tx_blocks.push( - receipt - .block_number - .expect("transfer receipt should include block number"), - ); - - let has_pre_change = tx_blocks - .iter() - .any(|&block| block <= CHANGE_GAS_LIMIT_AFTER_HEIGHT); - let has_post_change = tx_blocks - .iter() - .any(|&block| block > CHANGE_GAS_LIMIT_AFTER_HEIGHT); - if has_pre_change && has_post_change { - break; - } - } - - let pre_change_block = tx_blocks - .iter() - .copied() - .filter(|&block| block <= CHANGE_GAS_LIMIT_AFTER_HEIGHT) - .max() - .expect("expected at least one tx-bearing block at or before the change height"); - let post_change_block = tx_blocks - .iter() - .copied() - .find(|&block| block > CHANGE_GAS_LIMIT_AFTER_HEIGHT) - .expect("expected at least one tx-bearing block after the change height"); - - let block_count = post_change_block - .saturating_sub(pre_change_block) - .saturating_add(1); - let fee_history = client - .get_fee_history( - block_count, - BlockNumberOrTag::Number(post_change_block), - &[], - ) - .await?; - - assert_eq!( - fee_history.oldest_block, pre_change_block, - "feeHistory range should start at the last tx-bearing block before the gas-limit change" - ); - - let pre_change_idx = 0usize; - let post_change_idx: usize = post_change_block - .saturating_sub(pre_change_block) - .try_into() - .expect("block distance should fit in usize"); - - let pre_change_gas_used = total_gas_used_from_receipts(&client, pre_change_block).await?; - let post_change_gas_used = total_gas_used_from_receipts(&client, post_change_block).await?; - assert!( - pre_change_gas_used > 0, - "pre-change block should contain a transaction" - ); - assert!( - post_change_gas_used > 0, - "post-change block should contain a transaction" - ); - - let observed_pre_change_ratio: f64 = fee_history.gas_used_ratio[pre_change_idx]; - let observed_post_change_ratio: f64 = fee_history.gas_used_ratio[post_change_idx]; - assert!( - observed_post_change_ratio > observed_pre_change_ratio, - "post-change gas_used_ratio should increase when the gas limit is reduced" - ); - - let observed_ratio_change = observed_pre_change_ratio / observed_post_change_ratio; - let expected_ratio_change = (pre_change_gas_used as f64 / post_change_gas_used as f64) - * (UPDATED_GAS_LIMIT as f64 / INITIAL_GAS_LIMIT as f64); - assert_float_eq( - observed_ratio_change, - expected_ratio_change, - "gas_used_ratio change across gas-limit transition", - ); - assert!( - observed_ratio_change < 0.6, - "gas_used_ratio before/after should reflect the gas-limit drop, got {observed_ratio_change}" - ); - - // `wrong_shared_denominator_ratio_change` is the ratio of gas_used across the two blocks. If the gas limit hadn't changed, - // the rateio of `fee_ratio_before / fee_ratio_after` would be equal to this value. We want to assert that it *has* changed - let wrong_shared_denominator_ratio_change = - pre_change_gas_used as f64 / post_change_gas_used as f64; - assert!( - (observed_ratio_change - wrong_shared_denominator_ratio_change).abs() > 1e-12, // Float not equals check - "gas_used_ratio change should include the gas-limit transition, not just the gas-used change" - ); - - Ok(()) -} - /// TC30: Multiple transactions across blocks show distinct ratios /// /// This test verifies that blocks with transactions show non-zero gas_used_ratio. @@ -2058,3 +1889,124 @@ async fn test_fee_history_heavy_gas_usage() -> anyhow::Result<()> { Ok(()) } + +/// Regression: `eth_feeHistory` must compute each block's `gasUsedRatio` against *that block's* +/// stored gas limit, not the current runtime constant. We drive a gas-limit change through the EVM +/// admin config update (not the removed `CHANGE_GAS_LIMIT_AFTER_HEIGHT` fork) and assert that every +/// block's ratio equals `gas_used / eth_getBlockByNumber(block).gasLimit` across the change. +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_history_gas_used_ratio_uses_per_block_limit() -> anyhow::Result<()> { + // Clearly different from the genesis EVM block gas limit and above MIN_BLOCK_GAS_LIMIT (5M). + const NEW_BLOCK_GAS_LIMIT: u64 = 0x1_2345_6700; // 4_886_718_208 + + let (rollup, admin_key) = setup_test_rollup_with_admin_key(0, EVM_EXTENSION).await; + let client = alloy_client_with_signer(rollup.http_addr, SENDER_PRIV_KEY); + rollup.wait_for_sequencer_ready().await.unwrap(); + + // 1. A transaction under the original (genesis) gas limit. + let pre_block = send_transfer_get_block(&client).await?; + let pre_limit = get_block_gas_limit(&client, pre_block).await?; + assert_ne!( + pre_limit, NEW_BLOCK_GAS_LIMIT, + "test precondition: the new limit must differ from the genesis limit" + ); + + // 2. Change the EVM block gas limit via an admin runtime-config update. + update_block_gas_limit(&rollup, &admin_key, NEW_BLOCK_GAS_LIMIT).await?; + + // Wait until freshly produced blocks reflect the new limit. + let mut applied = false; + for _ in 0..30 { + rollup.wait_for_rollup_height_advance_by(1).await; + let latest = client.get_block_number().await?; + if get_block_gas_limit(&client, latest).await? == NEW_BLOCK_GAS_LIMIT { + applied = true; + break; + } + } + assert!(applied, "EVM block gas limit update did not take effect"); + + // 3. A transaction under the updated gas limit (non-zero gas_used so the denominator matters). + let post_block = send_transfer_get_block(&client).await?; + assert_eq!( + get_block_gas_limit(&client, post_block).await?, + NEW_BLOCK_GAS_LIMIT + ); + + // 4. Query fee history spanning both blocks; every ratio must use the block's own limit. + let block_count = post_block - pre_block + 1; + let fee_history = client + .get_fee_history(block_count, BlockNumberOrTag::Number(post_block), &[]) + .await?; + let oldest = fee_history.oldest_block; + assert!( + oldest <= pre_block, + "fee history (oldest {oldest}) should cover the pre-update block {pre_block}" + ); + + let (mut saw_pre, mut saw_post) = (false, false); + for (i, ratio) in fee_history.gas_used_ratio.iter().enumerate() { + let n = oldest + i as u64; + let limit = get_block_gas_limit(&client, n).await?; + let gas_used = total_gas_used_from_receipts(&client, n).await?; + let expected = gas_used as f64 / limit as f64; + assert_float_eq( + *ratio, + expected, + &format!("gas_used_ratio for block {n} must use that block's own gas limit ({limit})"), + ); + saw_pre |= limit == pre_limit; + saw_post |= limit == NEW_BLOCK_GAS_LIMIT; + } + assert!( + saw_pre && saw_post, + "fee history range must span the gas-limit change to be a meaningful regression test" + ); + + Ok(()) +} + +/// Sends a zero-value transfer and returns the block number it landed in. +async fn send_transfer_get_block(client: &DynProvider) -> anyhow::Result { + let signer: PrivateKeySigner = SENDER_PRIV_KEY.parse()?; + let nonce = client.get_transaction_count(signer.address()).await?; + let tx = TransactionRequest::default() + .with_to(Address::ZERO) + .with_nonce(nonce) + .with_value(U256::ZERO) + .with_max_fee_per_gas(MAX_FEE_PER_GAS) + .with_max_priority_fee_per_gas(HIGH_PRIORITY_FEE_PER_GAS) + .with_gas_limit(100_000); + let receipt = client.send_transaction(tx).await?.get_receipt().await?; + receipt + .block_number + .ok_or_else(|| anyhow::anyhow!("transfer receipt should include a block number")) +} + +/// Reads a block's stored gas limit (the value `eth_getBlockByNumber` reports). +async fn get_block_gas_limit(client: &DynProvider, block_number: u64) -> anyhow::Result { + let block = client + .get_block_by_number(BlockNumberOrTag::Number(block_number)) + .await? + .ok_or_else(|| anyhow::anyhow!("block {block_number} should exist"))?; + Ok(block.header.gas_limit) +} + +/// Changes the EVM block gas limit via an admin `UpdateRuntimeConfig` call. +async fn update_block_gas_limit( + rollup: &TestRollup, + admin_key: &<::CryptoSpec as CryptoSpec>::PrivateKey, + new_block_gas_limit: u64, +) -> anyhow::Result<()> { + let update = EvmRuntimeConfigUpdate:: { + new_hardfork: None, + new_contract_creation_policy: None, + chain_spec_update: Some(ChainSpecUpdate { + new_limit_contract_code_size: None, + new_block_gas_limit: Some(new_block_gas_limit), + new_tx_gas_limit: None, + }), + new_admin: None, + }; + super::send_evm_runtime_config_update(rollup, admin_key, update).await +} diff --git a/crates/full-node/sov-ethereum/tests/integration/evm/evm_max_fee_validation.rs b/crates/full-node/sov-ethereum/tests/integration/evm/evm_max_fee_validation.rs index 17cfb1e65b..6cf69d89c8 100644 --- a/crates/full-node/sov-ethereum/tests/integration/evm/evm_max_fee_validation.rs +++ b/crates/full-node/sov-ethereum/tests/integration/evm/evm_max_fee_validation.rs @@ -5,16 +5,14 @@ use alloy::signers::local::PrivateKeySigner; use alloy_primitives::{Address, U256}; use alloy_provider::DynProvider; use alloy_rpc_types_eth::TransactionInput; -use sov_evm::{CallMessage, EvmRuntimeConfigUpdate}; -use sov_modules_api::transaction::Transaction; +use sov_evm::EvmRuntimeConfigUpdate; use sov_modules_api::{CryptoSpec, Spec}; -use sov_test_utils::default_test_signed_transaction_with_nonce; use sov_test_utils::test_rollup::TestRollup; use crate::common::{ alloy_client_with_signer, setup_test_rollup_with_admin_key, EVM_EXTENSION, SENDER_PRIV_KEY, }; -use crate::runtime::{EvmBlueprint, EvmTestSpec, TestRuntime, TestRuntimeCall}; +use crate::runtime::{EvmBlueprint, EvmTestSpec}; const GAS_LIMIT: u64 = 100_000; @@ -174,19 +172,5 @@ async fn disable_max_fee_check( admin_key: &AdminPrivateKey, ) -> anyhow::Result<()> { let update = EvmRuntimeConfigUpdate::::empty(); - let msg = TestRuntimeCall::::Evm(CallMessage::UpdateRuntimeConfig(update)); - - let chain_hash = - as sov_modules_stf_blueprint::Runtime>::CHAIN_HASH; - - let tx: Transaction, EvmTestSpec> = - default_test_signed_transaction_with_nonce(admin_key, &msg, 0, &chain_hash); - - rollup - .client - .client - .send_tx_to_sequencer_with_retry(&tx) - .await?; - - Ok(()) + super::send_evm_runtime_config_update(rollup, admin_key, update).await } diff --git a/crates/full-node/sov-ethereum/tests/integration/evm/evm_rate_limit.rs b/crates/full-node/sov-ethereum/tests/integration/evm/evm_rate_limit.rs index aee8945c15..391056d01d 100644 --- a/crates/full-node/sov-ethereum/tests/integration/evm/evm_rate_limit.rs +++ b/crates/full-node/sov-ethereum/tests/integration/evm/evm_rate_limit.rs @@ -9,7 +9,6 @@ use alloy_provider::DynProvider; use alloy_rpc_types_eth::TransactionInput; use reqwest::header::{HeaderMap, HeaderValue}; use sov_full_node_configs::sequencer::Limits; -use sov_rollup_interface::common::RollupHeight; use sov_sequencer::SovRateLimiterConfig; use crate::common::{ @@ -38,7 +37,6 @@ async fn evm_test_rate_limit() -> anyhow::Result<()> { max_nb_of_concurrent_users_in_rate_limiter: 1000, max_requests_per_second: 1, address_custom_limits: Vec::default(), - height_for_gas_limit_computation: RollupHeight::GENESIS, ip_custom_limits: Vec::default(), }; diff --git a/crates/full-node/sov-ethereum/tests/integration/evm/evm_sequencing_data_pruning.rs b/crates/full-node/sov-ethereum/tests/integration/evm/evm_sequencing_data_pruning.rs index 46d56111fe..eafc3f798c 100644 --- a/crates/full-node/sov-ethereum/tests/integration/evm/evm_sequencing_data_pruning.rs +++ b/crates/full-node/sov-ethereum/tests/integration/evm/evm_sequencing_data_pruning.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::convert::Infallible; use std::marker::PhantomData; use std::str::FromStr; use std::sync::{Arc, LazyLock, Mutex}; @@ -8,7 +7,6 @@ use std::time::Duration; use alloy_primitives::{Address, Bytes, TxHash, U256}; use borsh::{BorshDeserialize, BorshSerialize}; use sov_address::{EthereumAddress, FromVmAddress, MultiAddress}; -use sov_blob_storage::PreferredBatchData; use sov_evm::precompiles::{ EvmPrecompile, EvmPrecompileEnv, PrecompileError, PrecompileOutput, PrecompileResult, }; @@ -19,28 +17,16 @@ use sov_evm::{ use sov_evm_test_utils::{PrecompileTester, SolCall}; use sov_mock_da::BlockProducingConfig; use sov_modules_api::capabilities::{ - AuthorizationData, GasEnforcer, Guard, HasCapabilities, HasKernel, ProofProcessor, - SequencerAuthorization, SequencerRemuneration, SequencingDataHandler, TransactionAuthenticator, - TransactionAuthorizer, + Guard, HasCapabilities, HasSequencingData, SequencingDataFormat, TransactionAuthenticator, }; use sov_modules_api::sov_universal_wallet::schema::UniversalWallet; -use sov_modules_api::transaction::{ - AuthenticatedTransactionData, ProverReward, RemainingFunds, SequencerReward, Transaction, -}; -use sov_modules_api::{ - AggregatedProofPublicData, Amount, BlobReaderTrait, Context, DaSpec, ExecutionContext, Gas, - GetGasPrice, InfallibleStateAccessor, InvalidProofError, OperatingMode, RawTx, Rewards, - SequencerType, SovAttestation, SovStateTransitionPublicData, Spec, StateAccessor, StateReader, - StateWriter, Storage, TxState, VersionReader, -}; -use sov_modules_stf_blueprint::{GenesisParams, Runtime as RuntimeTrait}; +use sov_modules_api::transaction::Transaction; +use sov_modules_api::{Amount, RawTx, Spec, TxState}; +use sov_modules_stf_blueprint::GenesisParams; use sov_paymaster::Paymaster; -use sov_rollup_interface::common::SlotNumber; use sov_rollup_interface::node::da::DaService; -use sov_rollup_interface::optimistic::{SerializedAttestation, SerializedChallenge}; -use sov_rollup_interface::zk::aggregated_proof::SerializedAggregatedProof; +use sov_rollup_interface::TxHash as SovTxHash; use sov_sequencer::SequencerKindConfig; -use sov_state::{Kernel, User}; use sov_stf_runner::processes::RollupProverConfig; use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; use sov_test_utils::runtime::StandardProvenRollupCapabilities; @@ -65,9 +51,29 @@ static ORACLE_DATA: LazyLock> = #[derive(Clone, Debug, Default, PartialEq, Eq, BorshDeserialize, BorshSerialize)] pub struct OracleSequencingData(BTreeMap); -impl sov_modules_api::capabilities::SequencingDataTrait for OracleSequencingData { - fn get_maybe_timestamp(self) -> Option { - None +impl SequencingDataFormat for OracleSequencingData { + type Key = u64; + type Value = u64; + + fn get(bytes: &[u8], key: &Self::Key) -> anyhow::Result> { + Ok(Self::try_from_slice(bytes)?.0.get(key).copied()) + } + + fn prune_to_used_keys( + bytes: sov_modules_api::Bytes, + used_keys: &BTreeSet, + ) -> anyhow::Result> { + let mut data = Self::try_from_slice(&bytes)?; + data.0.retain(|key, _| used_keys.contains(key)); + Ok(Some(borsh::to_vec(&data)?.into())) + } + + fn create() -> Option { + Some( + borsh::to_vec(&*ORACLE_DATA.lock().expect("oracle data mutex was poisoned")) + .expect("oracle data serialization should be infallible") + .into(), + ) } } @@ -99,15 +105,10 @@ impl EvmPrecompile for OraclePrecompile { return Err(PrecompileError::OutOfGas); } - let sequencing_data = { - let sequencing_data = env - .sov_context - .and_then(|ctx| ctx.sequencing_data().as_ref()) - .ok_or_else(|| PrecompileError::State("missing sequencing data".to_string()))?; - OracleSequencingData::try_from_slice(sequencing_data).map_err(|err| { - PrecompileError::State(format!("failed to deserialize sequencing data: {err}")) - })? - }; + let sequencing_data = env + .sov_context + .map(|ctx| ctx.sequencing_data_view::()) + .ok_or_else(|| PrecompileError::State("missing sequencing data".to_string()))?; let mut output = Vec::with_capacity(input.len()); for raw_key in input.chunks_exact(32) { @@ -118,14 +119,14 @@ impl EvmPrecompile for OraclePrecompile { )); } let key = raw_key.to::(); - let value = *sequencing_data.0.get(&key).ok_or_else(|| { - PrecompileError::InvalidInput(format!("missing oracle key {key}")) - })?; - - // This crate doesn't have a `"native"` feature, but in a real precompile - // implementation, key recording is native-only. - // #[cfg(feature = "native")] - record_used_oracle_key(env, key)?; + let value = sequencing_data + .get(&key) + .map_err(|err| { + PrecompileError::State(format!("failed to read sequencing data: {err}")) + })? + .ok_or_else(|| { + PrecompileError::InvalidInput(format!("missing oracle key {key}")) + })?; output.extend_from_slice(&U256::from(value).to_be_bytes::<32>()); } @@ -137,30 +138,6 @@ impl EvmPrecompile for OraclePrecompile { } } -fn record_used_oracle_key>( - env: &EvmPrecompileEnv<'_, S, ST>, - key: u64, -) -> Result<(), PrecompileError> { - env.sov_context - .expect("sov context was checked above") - .sequencing_scratchpad() - .with_value(|scratchpad| { - let mut used_keys = match scratchpad.as_deref() { - Some(bytes) => Vec::::try_from_slice(bytes).map_err(|err| { - PrecompileError::State(format!("failed to deserialize used oracle keys: {err}")) - })?, - None => Vec::new(), - }; - used_keys.push(key); - *scratchpad = Some( - borsh::to_vec(&used_keys) - .expect("u64 vector serialization is infallible") - .into(), - ); - Ok(()) - }) -} - sov_evm::generate_precompile_set! { pub struct OraclePrecompiles { oracle: OraclePrecompile, @@ -194,291 +171,38 @@ where type S = EvmTestSpec; type RT = PruningRuntime; type PruningBlueprint = RtAgnosticBlueprintWithApis; -type StandardCapabilities<'a, S> = StandardProvenRollupCapabilities<'a, S, &'a mut Paymaster>; - -pub struct PruningCapabilities<'a, S: Spec> { - standard: StandardCapabilities<'a, S>, -} - -impl SequencingDataHandler for PruningCapabilities<'_, S> { - type SequencingData = OracleSequencingData; - - fn handle_sequencing_data( - &mut self, - _data: Self::SequencingData, - _context: &Context, - _state: &mut impl TxState, - ) -> anyhow::Result<()> { - Ok(()) - } - - fn create_sequencing_data(&self) -> Self::SequencingData { - ORACLE_DATA - .lock() - .expect("oracle data mutex was poisoned") - .clone() - } - - fn finalize_sequencing_data( - &mut self, - mut data: Self::SequencingData, - scratchpad: Option, - ) -> Self::SequencingData { - let Some(scratchpad) = scratchpad else { - return data; - }; - let Ok(used_keys) = Vec::::try_from_slice(&scratchpad) else { - return data; - }; - let used_keys = used_keys.into_iter().collect::>(); - data.0.retain(|key, _| used_keys.contains(key)); - data - } -} - -impl GasEnforcer for PruningCapabilities<'_, S> { - fn try_reserve_gas( - &mut self, - tx: &AuthenticatedTransactionData, - gas_price: ::Price, - ctx: &mut Context, - state: &mut impl StateAccessor, - ) -> anyhow::Result<()> { - self.standard.try_reserve_gas(tx, gas_price, ctx, state) - } - - fn try_reserve_gas_for_proof( - &mut self, - tx: &AuthenticatedTransactionData, - gas_price: ::Price, - sender: &S::Address, - state: &mut impl StateAccessor, - ) -> anyhow::Result<()> { - self.standard - .try_reserve_gas_for_proof(tx, gas_price, sender, state) - } - - fn reward_prover( - &mut self, - prover_rewards: &ProverReward, - operating_mode: OperatingMode, - state: &mut impl InfallibleStateAccessor, - ) { - self.standard - .reward_prover(prover_rewards, operating_mode, state); - } - - fn refund_remaining_gas( - &mut self, - recipient: &S::Address, - remaining_funds: &RemainingFunds, - state: &mut impl InfallibleStateAccessor, - ) { - self.standard - .refund_remaining_gas(recipient, remaining_funds, state); - } - - fn reward_prover_from_sequencer_balance( - &mut self, - amount: Amount, - sequencer: &S::Address, - operating_mode: OperatingMode, - state: &mut impl InfallibleStateAccessor, - ) -> anyhow::Result<()> { - self.standard - .reward_prover_from_sequencer_balance(amount, sequencer, operating_mode, state) - } - - fn return_escrowed_funds_to_sequencer< - Accessor: StateReader - + StateWriter - + StateWriter - + StateReader - + VersionReader, - >( - &mut self, - bond_amount: Amount, - reward: Rewards, - sequencer: &::Address, - state: &mut Accessor, - ) { - self.standard - .return_escrowed_funds_to_sequencer(bond_amount, reward, sequencer, state); - } -} - -impl SequencerAuthorization for PruningCapabilities<'_, S> { - fn is_preferred_sequencer( - &self, - sequencer: &::Address, - state: &mut impl InfallibleStateAccessor, - ) -> bool { - self.standard.is_preferred_sequencer(sequencer, state) - } -} - -impl TransactionAuthorizer for PruningCapabilities<'_, S> { - fn resolve_context( - &mut self, - auth_data: &AuthorizationData, - sequencer: &::Address, - sequencer_rollup_address: S::Address, - state: &mut impl StateAccessor, - sequencing_data: Option, - execution_context: ExecutionContext, - sequencer_type: SequencerType, - ) -> anyhow::Result> { - self.standard.resolve_context( - auth_data, - sequencer, - sequencer_rollup_address, - state, - sequencing_data, - execution_context, - sequencer_type, - ) - } - - fn resolve_unregistered_context( - &mut self, - auth_data: &AuthorizationData, - sequencer: &::Address, - state: &mut impl StateAccessor, - execution_context: ExecutionContext, - ) -> anyhow::Result> { - self.standard - .resolve_unregistered_context(auth_data, sequencer, state, execution_context) - } - - fn check_uniqueness( - &self, - auth_data: &AuthorizationData, - context: &Context, - execution_context: &ExecutionContext, - state: &mut impl StateAccessor, - ) -> anyhow::Result<()> { - self.standard - .check_uniqueness(auth_data, context, execution_context, state) - } - - fn mark_tx_attempted( - &mut self, - auth_data: &AuthorizationData, - sequencer: &::Address, - state: &mut impl StateAccessor, - ) -> anyhow::Result<()> { - self.standard.mark_tx_attempted(auth_data, sequencer, state) - } -} - -impl<'a, S: Spec> ProofProcessor for PruningCapabilities<'a, S> { - type BondingProofService> = - as ProofProcessor>::BondingProofService; - - fn create_bonding_proof_service>( - &self, - attester_address: S::Address, - storage_receiver: tokio::sync::watch::Receiver, - ) -> Self::BondingProofService { - self.standard - .create_bonding_proof_service::(attester_address, storage_receiver) - } - - fn process_aggregated_proof + GetGasPrice>( - &mut self, - proof: SerializedAggregatedProof, - prover_address: &S::Address, - execution_context: ExecutionContext, - state: &mut ST, - ) -> Result< - ( - AggregatedProofPublicData::Root>, - SerializedAggregatedProof, - ), - InvalidProofError, - > { - self.standard - .process_aggregated_proof(proof, prover_address, execution_context, state) - } - - fn process_attestation + GetGasPrice>( - &mut self, - proof: SerializedAttestation, - prover_address: &S::Address, - state: &mut ST, - ) -> Result, InvalidProofError> { - self.standard - .process_attestation(proof, prover_address, state) - } - - fn process_challenge + GetGasPrice>( - &mut self, - proof: SerializedChallenge, - rollup_height: SlotNumber, - prover_address: &S::Address, - state: &mut ST, - ) -> Result, InvalidProofError> { - self.standard - .process_challenge(proof, rollup_height, prover_address, state) - } -} - -impl SequencerRemuneration for PruningCapabilities<'_, S> { - fn reward_sequencer_or_refund< - Accessor: StateReader - + StateWriter - + StateWriter - + StateReader, - >( - &mut self, - sequencer: &::Address, - sequencer_rollup_address: &S::Address, - reward: SequencerReward, - state: &mut Accessor, - ) { - self.standard.reward_sequencer_or_refund( - sequencer, - sequencer_rollup_address, - reward, - state, - ); - } - - fn preferred_sequencer( - &self, - state: &mut impl InfallibleStateAccessor, - ) -> Option<::Address> { - self.standard.preferred_sequencer(state) - } -} impl HasCapabilities for PruningRuntime where S::Address: FromVmAddress, { type Capabilities<'a> - = PruningCapabilities<'a, S> + = StandardProvenRollupCapabilities<'a, S, &'a mut Paymaster> where Self: 'a; - type SequencingData = OracleSequencingData; fn capabilities(&mut self) -> Guard> { - Guard::new(PruningCapabilities { - standard: StandardProvenRollupCapabilities { - bank: &mut self.bank, - gas_payer: &mut self.paymaster, - sequencer_registry: &mut self.sequencer_registry, - accounts: &mut self.accounts, - uniqueness: &mut self.uniqueness, - chain_state: &mut self.chain_state, - operator_incentives: &mut self.operator_incentives, - prover_incentives: &mut self.prover_incentives, - attester_incentives: &mut self.attester_incentives, - }, + Guard::new(StandardProvenRollupCapabilities { + bank: &mut self.bank, + gas_payer: &mut self.paymaster, + sequencer_registry: &mut self.sequencer_registry, + accounts: &mut self.accounts, + uniqueness: &mut self.uniqueness, + chain_state: &mut self.chain_state, + operator_incentives: &mut self.operator_incentives, + prover_incentives: &mut self.prover_incentives, + attester_incentives: &mut self.attester_incentives, }) } } +impl HasSequencingData for PruningRuntime +where + S::Address: FromVmAddress, +{ + type SequencingData = OracleSequencingData; +} + #[tokio::test(flavor = "multi_thread")] async fn evm_precompile_can_prune_sequencing_data_and_replay_from_da() -> anyhow::Result<()> { let test_rollup = setup_pruning_rollup().await; @@ -491,17 +215,17 @@ async fn evm_precompile_can_prune_sequencing_data_and_replay_from_da() -> anyhow let oracle_entries = BTreeMap::from([(11, 1_100), (22, 2_200), (33, 3_300)]); set_oracle_data(oracle_entries.clone()); - let mut expected_by_hash: BTreeMap<[u8; 32], BTreeSet> = BTreeMap::new(); + let mut expected_by_hash: BTreeMap> = BTreeMap::new(); for &key in oracle_entries.keys() { let keys = [key]; let tx_hash = assert_precompile_values(&evm_client, tester_address, &keys, &oracle_entries).await?; - expected_by_hash.insert(tx_hash_bytes(tx_hash), keys.into_iter().collect()); + expected_by_hash.insert(SovTxHash::from(tx_hash.0), keys.into_iter().collect()); } for keys in [vec![11, 22], vec![33, 11, 22]] { let tx_hash = assert_precompile_values(&evm_client, tester_address, &keys, &oracle_entries).await?; - expected_by_hash.insert(tx_hash_bytes(tx_hash), keys.into_iter().collect()); + expected_by_hash.insert(SovTxHash::from(tx_hash.0), keys.into_iter().collect()); } let last_checked_height = test_rollup.da_service.get_head_block_header().await?.height; @@ -520,7 +244,7 @@ async fn evm_precompile_can_prune_sequencing_data_and_replay_from_da() -> anyhow for tx_hash in expected_by_hash.keys() { let receipt = evm_client - .wait_for_finalized_receipt(TxHash::from(*tx_hash)) + .wait_for_finalized_receipt(TxHash::from(tx_hash.0)) .await; assert!(receipt.status(), "oracle assertion tx should be finalized"); } @@ -533,7 +257,7 @@ async fn evm_precompile_can_prune_sequencing_data_and_replay_from_da() -> anyhow for tx_hash in expected_by_hash.keys() { let receipt = resynced_client - .wait_for_finalized_receipt(TxHash::from(*tx_hash)) + .wait_for_finalized_receipt(TxHash::from(tx_hash.0)) .await; assert!( receipt.status(), @@ -673,57 +397,42 @@ async fn assert_precompile_values( async fn wait_for_pruned_txs_on_da( test_rollup: &TestRollup, - expected_by_hash: &BTreeMap<[u8; 32], BTreeSet>, + expected_by_hash: &BTreeMap>, oracle_entries: &BTreeMap, - mut last_checked_height: u64, + last_checked_height: u64, ) -> anyhow::Result<()> { - tokio::time::timeout(Duration::from_secs(20), async { - let mut found = BTreeMap::new(); - loop { - test_rollup.da_service.produce_block_now().await?; - let head_height = test_rollup.da_service.get_head_block_header().await?.height; - for height in last_checked_height + 1..=head_height { - let mut block = test_rollup.da_service.get_block_at(height).await?; - for blob in block.batch_blobs.iter_mut() { - let batch = PreferredBatchData::try_from_slice(blob.full_data())?; - for tx in batch.data.iter() { - let tx_hash = >::Auth::compute_tx_hash(tx)?; - let tx_hash = <[u8; 32]>::from(tx_hash); - let Some(expected_keys) = expected_by_hash.get(&tx_hash) else { - continue; - }; - let sequencing_data = tx - .sequencing_data - .as_ref() - .expect("oracle tx should include sequencing data"); - let sequencing_data = - OracleSequencingData::try_from_slice(sequencing_data)?; - let actual_keys = - sequencing_data.0.keys().copied().collect::>(); - assert_eq!( - actual_keys, *expected_keys, - "published tx should retain only the used oracle keys" - ); - for expected_key in expected_keys { - assert_eq!( - sequencing_data.0.get(expected_key), - oracle_entries.get(expected_key), - "published tx should retain the used oracle value" - ); - } - found.insert(tx_hash, expected_keys.clone()); - } - } - } - if found.len() == expected_by_hash.len() { - return anyhow::Ok(()); - } - last_checked_height = head_height; - tokio::task::yield_now().await; + let published = test_rollup + .wait_for_txs_on_da( + &expected_by_hash.keys().copied().collect(), + last_checked_height, + Duration::from_secs(20), + ) + .await?; + + for (tx_hash, expected_keys) in expected_by_hash { + let sequencing_data = published[tx_hash] + .sequencing_data + .as_ref() + .expect("oracle tx should include sequencing data"); + let envelope = sov_modules_api::SequencingData::decode(sequencing_data)?; + let data = envelope + .unrecorded_data() + .expect("oracle tx should retain its data payload"); + let sequencing_data = OracleSequencingData::try_from_slice(data)?; + let actual_keys = sequencing_data.0.keys().copied().collect::>(); + assert_eq!( + actual_keys, *expected_keys, + "published tx should retain only the used oracle keys" + ); + for expected_key in expected_keys { + assert_eq!( + sequencing_data.0.get(expected_key), + oracle_entries.get(expected_key), + "published tx should retain the used oracle value" + ); } - }) - .await - .expect("timed out waiting for oracle txs to be published") + } + Ok(()) } async fn restart_from_scratch_on_same_da( @@ -750,9 +459,3 @@ fn u256_words(values: impl IntoIterator) -> Bytes { } Bytes::from(bytes) } - -fn tx_hash_bytes(tx_hash: TxHash) -> [u8; 32] { - let mut bytes = [0; 32]; - bytes.copy_from_slice(tx_hash.as_slice()); - bytes -} diff --git a/crates/full-node/sov-ethereum/tests/integration/evm/mod.rs b/crates/full-node/sov-ethereum/tests/integration/evm/mod.rs index f2b773dbc4..ae6eda72a4 100644 --- a/crates/full-node/sov-ethereum/tests/integration/evm/mod.rs +++ b/crates/full-node/sov-ethereum/tests/integration/evm/mod.rs @@ -1,3 +1,11 @@ +use sov_evm::{CallMessage, EvmRuntimeConfigUpdate}; +use sov_modules_api::transaction::Transaction; +use sov_modules_api::{CryptoSpec, Spec}; +use sov_test_utils::default_test_signed_transaction_with_nonce; +use sov_test_utils::test_rollup::TestRollup; + +use crate::runtime::{EvmBlueprint, EvmTestSpec, TestRuntime, TestRuntimeCall}; + mod evm_account_abstraction; mod evm_balances; mod evm_basefee; @@ -31,3 +39,25 @@ mod evm_tracing; mod evm_tx; mod evm_tx_type; mod evm_ws_watch; + +/// Signs an EVM `UpdateRuntimeConfig` admin call with `admin_key` and sends it to the sequencer. +/// +/// Shared by the EVM integration tests that drive runtime-config changes (e.g. disabling the +/// max-fee check or updating the block gas limit); they differ only in the `update` payload. +async fn send_evm_runtime_config_update( + rollup: &TestRollup, + admin_key: &<::CryptoSpec as CryptoSpec>::PrivateKey, + update: EvmRuntimeConfigUpdate, +) -> anyhow::Result<()> { + let msg = TestRuntimeCall::::Evm(CallMessage::UpdateRuntimeConfig(update)); + let chain_hash = + as sov_modules_stf_blueprint::Runtime>::CHAIN_HASH; + let tx: Transaction, EvmTestSpec> = + default_test_signed_transaction_with_nonce(admin_key, &msg, 0, &chain_hash); + rollup + .client + .client + .send_tx_to_sequencer_with_retry(&tx) + .await?; + Ok(()) +} diff --git a/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs b/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs index 2e5fd9b8c9..d914a80392 100644 --- a/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs +++ b/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs @@ -14,14 +14,13 @@ use sov_modules_api::capabilities::{ AuthorizationData, ChainState, TransactionAuthorizer, UniquenessData, }; use sov_modules_api::common::Amount; -use sov_modules_api::macros::config_value; use sov_modules_api::prelude::anyhow; use sov_modules_api::sov_universal_wallet::schema::{RollupRoots, SchemaError}; use sov_modules_api::transaction::{Credentials, PriorityFeeBips, TxDetails}; use sov_modules_api::{ get_runtime_schema, AuthenticatedTransactionData, CredentialId, DaSpec, ErrorContext, - EventModuleName, FullyBakedTx, Gas, GasArray, HDTimestamp, HexHash, HexString, Runtime, - SequencerType, Spec, StateCheckpoint, StateProvider as _, WorkingSet, + EventModuleName, FullyBakedTx, Gas, GasArray, HexHash, HexString, Runtime, SequencerType, Spec, + StateCheckpoint, StateProvider as _, WorkingSet, }; use sov_modules_stf_blueprint::{apply_tx, get_gas_used, ApplyTxResult}; use sov_rest_utils::{json_obj, preconfigured_router_layers, ErrorObject}; @@ -271,7 +270,7 @@ impl> SovereignSimulate { .transpose() .map_err(|e| SimulateError::InvalidInput(format!("{e:?}")))?; Ok(TxDetails { - chain_id: config_value!("CHAIN_ID"), + chain_hash_fragment: 0, max_priority_fee_bips: partial .max_priority_fee_bips .unwrap_or(PriorityFeeBips::ZERO), @@ -314,6 +313,14 @@ impl> SovereignSimulate { let credential_id = CredentialId::from_str(¶ms.sender).map_err(|e| { SimulateError::InvalidInput(format!("failed to parse sender credential id: {e}")) })?; + let address_override = params + .address_override + .as_deref() + .map(S::Address::from_str) + .transpose() + .map_err(|e| { + SimulateError::InvalidInput(format!("failed to parse address override: {e:?}")) + })?; let uniqueness = match params.uniqueness { Some(uniqueness) => uniqueness, None => { @@ -326,10 +333,12 @@ impl> SovereignSimulate { Ok(AuthorizationData { tx_hash: NULL_TX_HASH, + non_malleable_hash: NULL_TX_HASH, uniqueness, credential_id, default_address: credential_id.into(), credentials: Credentials::new(credential_id), + address_override, }) } @@ -412,6 +421,8 @@ pub struct SimulateParameters { /// Optional uniqueness data for the transaction. /// If not provided a valid uniqueness will be used. pub uniqueness: Option, + /// Optional address override for execution; null uses default routing. + pub address_override: Option, } impl> SimulateEndpoint for SovereignSimulate { @@ -442,8 +453,8 @@ impl> SimulateEndpoint for SovereignSimulate { AuthenticatedTransactionData(state.tx_details(params.tx_details.unwrap_or_default())?); let mut scratchpad = accessor.to_tx_scratchpad(); - // Create sequencing metadata for simulation so modules can access timestamp data - let sequencing_metadata = borsh::to_vec(&HDTimestamp::now()).ok().map(Into::into); + let sequencing_metadata = + Some(sov_modules_api::capabilities::new_tx_sequencing_data::()); let context = runtime .transaction_authorizer() .resolve_context( diff --git a/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs b/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs index b9d5fe16bb..110613581c 100644 --- a/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs +++ b/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs @@ -1,10 +1,15 @@ use sov_api_spec::types; use sov_bank::{config_gas_token_id, Bank}; use sov_modules_api::prelude::tokio::{self}; -use sov_modules_api::{Amount, Gas, GasArray, GasSpec, Spec}; +use sov_modules_api::sov_universal_wallet::schema::RollupRoots; +use sov_modules_api::{ + get_runtime_schema, Amount, CredentialId, Gas, GasArray, GasSpec, Runtime, Spec, +}; use sov_rest_utils::json_obj; use sov_rollup_interface::node::SyncStatus; -use sov_test_utils::{AsUser, TestUser, TransactionTestCase}; +use sov_test_utils::{ + default_test_tx_details, AsUser, TestUser, TransactionTestCase, TransactionType, +}; use crate::{TestData, RT, S}; @@ -167,6 +172,132 @@ async fn test_simulation_success() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn test_simulation_success_with_address_override() { + let mut data = TestData::setup().await; + let delegated_credential: CredentialId = [9u8; 32].into(); + let registration_call = json_obj!({ + "accounts": { + "insert_credential_id": delegated_credential, + }, + }); + let schema = get_runtime_schema::().unwrap(); + let registration_call_bytes = schema + .json_to_borsh( + schema + .rollup_expected_index(RollupRoots::RuntimeCall) + .unwrap(), + &serde_json::to_string(®istration_call).unwrap(), + ) + .unwrap(); + + data.runner.execute_transaction(TransactionTestCase { + input: TransactionType::Plain { + message: RT::decode_call(®istration_call_bytes).unwrap(), + key: data.user.private_key().clone(), + details: default_test_tx_details::(), + }, + assert: Box::new(move |result, _state| { + assert!( + result.tx_receipt.is_successful(), + "The credential registration should have succeeded" + ); + }), + }); + data.send_storage(); + + let address_override = data.user.address(); + let receiver = TestUser::::generate_with_default_balance().address(); + let call = sov_bank::CallMessage::::Transfer { + to: receiver, + coins: sov_bank::Coins { + amount: Amount::new(1000), + token_id: config_gas_token_id(), + }, + }; + let params = json_obj!({ + "sender": delegated_credential.to_string(), + "address_override": address_override.to_string(), + "call": { + "bank": call, + }, + }); + let client = reqwest::Client::new(); + + let response = client + .post(format!("http://{}/rollup/simulate", data.axum_addr)) + .json(¶ms) + .send() + .await + .unwrap(); + let actual = response.json::().await.unwrap(); + + assert_eq!(actual["outcome"], "success"); + assert_eq!( + actual["events"][0], + serde_json::json!({ + "key": "Bank/TokenTransferred", + "module": "Bank", + "value": { + "token_transferred": { + "from": { + "user": address_override + }, + "to": { + "user": receiver + }, + "coins": { + "amount": "1000", + "token_id": "token_1nyl0e0yweragfsatygt24zmd8jrr2vqtvdfptzjhxkguz2xxx3vs0y07u7" + } + } + } + }) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulation_create_synthetic_address_with_credential_sender() { + let data = TestData::setup().await; + let sender = data.user.credential_id().to_string(); + let salt = Vec::from([7u8; 32]); + let params = json_obj!({ + "sender": sender, + "call": { + "accounts": { + "create_synthetic_address": { + "salt": salt, + }, + }, + }, + }); + let client = reqwest::Client::new(); + + let response = client + .post(format!("http://{}/rollup/simulate", data.axum_addr)) + .json(¶ms) + .send() + .await + .unwrap(); + let actual = response.json::().await.unwrap(); + + assert_eq!(actual["outcome"], "success"); + assert_eq!(actual["events"].as_array().unwrap().len(), 1); + assert_eq!( + actual["events"][0]["key"], + "Accounts/SyntheticAddressCreated" + ); + assert_eq!(actual["events"][0]["module"], "Accounts"); + assert_eq!( + actual["events"][0]["value"]["synthetic_address_created"]["creator"], + data.user.address().to_string() + ); + assert_eq!( + actual["events"][0]["value"]["synthetic_address_created"]["credential"], + sender + ); +} + #[tokio::test(flavor = "multi_thread")] async fn test_simulation_fail() { let data = TestData::setup().await; diff --git a/crates/full-node/sov-sequencer/src/common.rs b/crates/full-node/sov-sequencer/src/common.rs index 37e6c66d62..e23a16c409 100644 --- a/crates/full-node/sov-sequencer/src/common.rs +++ b/crates/full-node/sov-sequencer/src/common.rs @@ -587,6 +587,8 @@ pub fn pre_exec_err_to_accept_tx_err(err: PreExecError) -> ErrorObject { pub enum AcceptTxErrorCode { /// The transaction's `maxFeePerGas` was below the rollup base fee. InsufficientMaxFeePerGas, + /// The transaction's chain hash fragment did not match the current runtime schema. + InvalidChainHashFragment, } /// Structured details attached to `accept_tx` failures. @@ -607,6 +609,9 @@ impl AcceptTxErrorDetails { AuthenticationError::FatalError(FatalError::InsufficientMaxFeePerGas { .. }, _) => { Some(AcceptTxErrorCode::InsufficientMaxFeePerGas) } + AuthenticationError::FatalError(FatalError::InvalidChainHashFragment { .. }, _) => { + Some(AcceptTxErrorCode::InvalidChainHashFragment) + } _ => None, }; diff --git a/crates/full-node/sov-sequencer/src/preferred/async_batch.rs b/crates/full-node/sov-sequencer/src/preferred/async_batch.rs index ad02997640..b7560b47db 100644 --- a/crates/full-node/sov-sequencer/src/preferred/async_batch.rs +++ b/crates/full-node/sov-sequencer/src/preferred/async_batch.rs @@ -159,7 +159,7 @@ pub(crate) struct ExecutedTxResponse { pub(crate) receipt: TransactionReceipt, pub(crate) tx_changes: TxChangeSet, pub(crate) remaining_slot_gas: ::Gas, - pub(crate) sequencing_scratchpad: Option, + pub(crate) sequencing_scratchpad: sov_modules_api::SequencingScratchpadContents, } /// The channel responsible for notifying an async tx submitter of the txs result diff --git a/crates/full-node/sov-sequencer/src/preferred/block_executor.rs b/crates/full-node/sov-sequencer/src/preferred/block_executor.rs index cebbc375cc..e7d7fb5a02 100644 --- a/crates/full-node/sov-sequencer/src/preferred/block_executor.rs +++ b/crates/full-node/sov-sequencer/src/preferred/block_executor.rs @@ -7,14 +7,15 @@ use anyhow::Context; use axum::http::StatusCode; use borsh::BorshDeserialize; use sov_blob_storage::PreferredProofData; +use sov_modules_api::capabilities::prune_sequencing_data; use sov_modules_api::capabilities::{ - get_maybe_timestamp_from_sequencing_data, BlobSelector, BlobSelectorOutput, ChainState, - FatalError, HasCapabilities, RollupHeight, SequencingDataHandler, TransactionAuthenticator, + get_timestamp_from_sequencing_data, BlobSelector, BlobSelectorOutput, ChainState, FatalError, + RollupHeight, TransactionAuthenticator, }; use sov_modules_api::macros::config_value; use sov_modules_api::{ - call_message_repr, Amount, BlobDataWithId, ChangeSet, DaSpec, ExecutionContext, FullyBakedTx, - Gas, GasSpec, HexString, KernelStateAccessor, NoOpControlFlow, RejectReason, Runtime, + call_message_repr, BlobDataWithId, ChangeSet, DaSpec, ExecutionContext, FullyBakedTx, Gas, + GasSpec, HexString, KernelStateAccessor, NoOpControlFlow, RejectReason, Runtime, RuntimeEventProcessor, RuntimeEventResponse, SelectedBlob, Spec, StateCheckpoint, TransactionReceipt, TxChangeSet, TxHash, VersionReader, VisibleSlotNumber, }; @@ -293,14 +294,17 @@ impl> RollupBlockExecutor { baked_tx: FullyBakedTxWithMaybeChangeSet, ) -> Result<(AcceptedTxWithBudgetInfo, TxChangeSet), RollupBlockExecutorErrorWithBudget> { - // Extract the timestamp from the sequencing data. We do this even though we could pass the timestamp directly from the place where it is generated - // for symmetry with the replicas. Replicas have to extract the timestamp from the sequencing data, but they can only do so if the runtime is using the standard - // sequencing data handler. Doing it the same way here ensures that the replica and the master agree on the timestamp in all cases. - let timestamp = get_maybe_timestamp_from_sequencing_data::(&baked_tx.tx, true); let result = self.apply_tx_to_in_progress_batch_inner(baked_tx).await; match result { Ok((receipt, tx, remaining_slot_gas, execution_time_micros, tx_changes)) => { + // Extract the HDTimestamp from the finalized (pruned) tx body, rather than from + // the pre-pruning bytes or the locally generated value, so the preferred + // sequencer and replicas derive confirmation metadata from the same published + // transaction bytes. If pruning removed the timestamp, the confirmation must + // not report one either, since nodes deriving it from the stored body cannot + // reproduce it. + let timestamp = get_timestamp_from_sequencing_data(&tx, true); let accepted_tx = self.process_tx_receipt(receipt, tx, timestamp); if let Some(writer) = self.startup_transaction_cache_writer.as_mut() { writer.insert(accepted_tx.clone()).await; @@ -688,20 +692,19 @@ impl> RollupBlockExecutor { fn finalize_tx_sequencing_data( tx: &mut FullyBakedTx, - scratchpad: Option, + scratchpad: sov_modules_api::SequencingScratchpadContents, ) { - let Some(data) = tx.sequencing_data.as_ref() else { - return; - }; - let Ok(decoded) = >::SequencingData::try_from_slice(data) else { - tracing::warn!("Failed to deserialize sequencing data while finalizing transaction"); + // data is `Bytes` so cloning is cheap + let Some(data) = tx.sequencing_data.clone() else { return; }; - let mut runtime = Rt::default(); - let mut handler = runtime.sequencing_data_handler(); - let finalized = handler.finalize_sequencing_data(decoded, scratchpad); - tx.set_sequencing_metadata(&finalized); + match prune_sequencing_data::(data, scratchpad) { + Ok(finalized) => tx.sequencing_data = finalized, + Err(error) => { + tracing::warn!(%error, "Failed to prune sequencing data; keeping original data"); + } + } } fn update_kernel_with_user_state_root(&mut self) { @@ -965,13 +968,7 @@ where let standard_gas_escrow = S::max_tx_check_costs() .checked_value(next_gas_price) .expect("Gas price overflow! This is a bug, please report it."); - let needed_gas_escrow_for_preferred_sequencer = - // The blob sender decides what the gas limit should be *before* we call `increment_rollup_height`. Use the same height - if old_rollup_height > ::change_gas_limit_after_height() { - Amount::ZERO - } else { - standard_gas_escrow - }; + let needed_gas_escrow_for_preferred_sequencer = standard_gas_escrow; kernel.escrow_funds_for_preferred_sequencer(needed_gas_escrow_for_preferred_sequencer, &mut accessor).expect("Failed to escrow funds for the preferred sequencer. The sequencer is too low on funds, which could cause soft confirmations to be invalidated. Increase your bond and restart the sequencer."); let blob_selector_output = { diff --git a/crates/full-node/sov-sequencer/src/preferred/mod.rs b/crates/full-node/sov-sequencer/src/preferred/mod.rs index 8c82c06b9d..40cde18047 100644 --- a/crates/full-node/sov-sequencer/src/preferred/mod.rs +++ b/crates/full-node/sov-sequencer/src/preferred/mod.rs @@ -22,7 +22,9 @@ use crate::preferred::block_executor::RollupBlockExecutorConfig; use crate::preferred::cache_warm_up_executor::CacheWarmUpExecutor; use crate::preferred::rate_limiter::IpAndCredentialId; use crate::preferred::replica::replica_sync_task::ReplicaSyncTask; -use crate::preferred::rpc_errors::{cant_fit_tx, rate_limit, replica_mode, shut_down}; +use crate::preferred::rpc_errors::{ + cant_fit_tx, rate_limit, replica_mode, shut_down, tx_with_sequencing_data_too_big, +}; use async_trait::async_trait; use axum::http::StatusCode; use batch_size_tracker::BatchSizeTracker; @@ -522,6 +524,15 @@ where max_batch_size, tx_len, } => return Err(cant_fit_tx(current_batch_size, max_batch_size, tx_len)), + DoNewTxError::TxWithSequencingDataTooBig { + total_payload_len, + max_payload_len, + } => { + return Err(tx_with_sequencing_data_too_big( + total_payload_len, + max_payload_len, + )) + } DoNewTxError::ExecutorError(err) => { return Err(RollupBlockExecutorError::into_http_error(err)); } diff --git a/crates/full-node/sov-sequencer/src/preferred/rate_limiter/mod.rs b/crates/full-node/sov-sequencer/src/preferred/rate_limiter/mod.rs index 6cc83d2a6c..572b64727c 100644 --- a/crates/full-node/sov-sequencer/src/preferred/rate_limiter/mod.rs +++ b/crates/full-node/sov-sequencer/src/preferred/rate_limiter/mod.rs @@ -1,14 +1,13 @@ mod limiter; mod resource; -use crate::preferred::sync_sequencer_state::comfortable_gas_limit_for_height; +use crate::preferred::sync_sequencer_state::comfortable_gas_limit; pub(crate) use limiter::ResourceUsed; use limiter::*; use resource::*; use sov_full_node_configs::sequencer::{Limits, SovRateLimiterConfig}; use sov_modules_api::BasicAddress; use sov_modules_api::{CredentialId, Spec}; -use sov_rollup_interface::common::RollupHeight; use std::collections::HashMap; use std::hash::Hash; use std::{ @@ -141,9 +140,8 @@ fn calculate_limits( max_requests_per_second: u64, batch_execution_time_limit: Duration, max_batch_size_bytes: usize, - height_for_gas_limit_computation: RollupHeight, ) -> RateLimiterConfig { - let max_gas = comfortable_gas_limit_for_height::(height_for_gas_limit_computation); + let max_gas = comfortable_gas_limit::(); let batch_execution_time_limit_millis = batch_execution_time_limit .as_millis() .try_into() @@ -203,7 +201,6 @@ fn to_limiter_config_map( max_requests_per_second: u64, batch_execution_time_limit: Duration, max_batch_size_bytes: usize, - height_for_gas_limit_computation: RollupHeight, v: Vec<(K, Limits)>, ) -> HashMap> { v.into_iter() @@ -215,7 +212,6 @@ fn to_limiter_config_map( max_requests_per_second, batch_execution_time_limit, max_batch_size_bytes, - height_for_gas_limit_computation, ), ) }) @@ -254,14 +250,12 @@ fn limits( sov_config.max_requests_per_second, batch_execution_time_limit, max_batch_size_bytes, - sov_config.height_for_gas_limit_computation, ); let addrs = to_limiter_config_map::( sov_config.max_requests_per_second, batch_execution_time_limit, max_batch_size_bytes, - sov_config.height_for_gas_limit_computation, sov_config.address_custom_limits, ); @@ -277,7 +271,6 @@ fn limits( sov_config.max_requests_per_second, batch_execution_time_limit, max_batch_size_bytes, - sov_config.height_for_gas_limit_computation, ip_custom_limits, ); @@ -527,7 +520,6 @@ mod tests { 10000, Duration::from_millis(max_batch_exec_time), 6000000, - RollupHeight::GENESIS, ); let max_allowed_resources_per_key = rate_limiter_config.max_allowed_resources; @@ -556,7 +548,6 @@ mod tests { 1000, Duration::from_millis(6000), 6_000_000, - RollupHeight::GENESIS, ); assert_eq!(config.max_allowed_resources.inner.milli_req_counter, 60_000); } @@ -573,7 +564,6 @@ mod tests { 10, Duration::from_millis(10), 6_000_000, - RollupHeight::GENESIS, ); assert_eq!(config.max_allowed_resources.inner.milli_req_counter, 1); } @@ -590,7 +580,6 @@ mod tests { 1000, Duration::from_millis(100), 6_000_000, - RollupHeight::GENESIS, ); assert_eq!(config.max_allowed_resources.inner.milli_req_counter, 0); } @@ -607,7 +596,6 @@ mod tests { 0, Duration::from_millis(100), 6_000_000, - RollupHeight::GENESIS, ); assert_eq!(config.max_allowed_resources.inner.milli_req_counter, 0); } @@ -812,7 +800,6 @@ mod tests { resources_per_bucket: 5, refill_rate: 1, }, - height_for_gas_limit_computation: RollupHeight::GENESIS, address_custom_limits: Vec::default(), ip_custom_limits: vec![ ( @@ -846,7 +833,6 @@ mod tests { max_requests_per_second: 1000, max_nb_of_concurrent_users_in_rate_limiter: 1000, default_limits: limits, - height_for_gas_limit_computation: RollupHeight::GENESIS, address_custom_limits: Vec::default(), ip_custom_limits: vec![ ("10.0.0.0/24".parse().unwrap(), limits), @@ -931,7 +917,6 @@ mod tests { resources_per_bucket: 5, refill_rate: 0, }, - height_for_gas_limit_computation: RollupHeight::GENESIS, address_custom_limits: Vec::default(), ip_custom_limits: vec![( "10.0.0.5/24".parse().unwrap(), @@ -962,7 +947,6 @@ mod tests { resources_per_bucket: 5, refill_rate: 0, }, - height_for_gas_limit_computation: RollupHeight::GENESIS, address_custom_limits: Vec::default(), ip_custom_limits: vec![( "::ffff:10.0.0.42/120".parse().unwrap(), @@ -1003,7 +987,6 @@ mod tests { max_requests_per_second: 1000, max_nb_of_concurrent_users_in_rate_limiter: 1000, default_limits: loose, - height_for_gas_limit_computation: RollupHeight::GENESIS, address_custom_limits: vec![(limited_addr, tight)], ip_custom_limits: vec![(subnet, tight)], }; diff --git a/crates/full-node/sov-sequencer/src/preferred/rpc_errors.rs b/crates/full-node/sov-sequencer/src/preferred/rpc_errors.rs index 9af2c053da..48fec09da6 100644 --- a/crates/full-node/sov-sequencer/src/preferred/rpc_errors.rs +++ b/crates/full-node/sov-sequencer/src/preferred/rpc_errors.rs @@ -24,6 +24,22 @@ pub(crate) fn cant_fit_tx( } } +pub(crate) fn tx_with_sequencing_data_too_big( + total_payload_len: usize, + max_payload_len: usize, +) -> ErrorObject { + ErrorObject { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: + "Transaction combined with its sequencing data exceeds the maximum transaction size" + .to_string(), + details: json_obj!({ + "total_payload_size": total_payload_len, + "max_allowed_size": max_payload_len, + }), + } +} + pub(crate) fn rate_limit(err: ResourceLimitExceededError) -> ErrorObject { ErrorObject { status: StatusCode::SERVICE_UNAVAILABLE, diff --git a/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/inner.rs b/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/inner.rs index 80048b02a8..3c052142bc 100644 --- a/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/inner.rs +++ b/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/inner.rs @@ -17,7 +17,7 @@ use crate::preferred::sync_sequencer_state::EventReceiverStartNotifier; use crate::preferred::AcceptedTx; use crate::preferred::BatchSizeTracker; use crate::preferred::RollupBlockExecutorConfig; -use crate::preferred::{comfortable_gas_limit_for_height, PreferredBlobToReplay}; +use crate::preferred::{comfortable_gas_limit, PreferredBlobToReplay}; use crate::preferred::{ current_visible_slot_number_according_to_node, get_next_sequence_number_according_to_node, is_lagging_less_than_ideal_amount, next_visible_slot_number_increase, BatchCreationError, @@ -68,6 +68,10 @@ pub(crate) enum DoNewTxError { max_batch_size: usize, tx_len: usize, }, + TxWithSequencingDataTooBig { + total_payload_len: usize, + max_payload_len: usize, + }, ExecutorError(RollupBlockExecutorError), Shutdown, } @@ -410,19 +414,18 @@ where &mut self, remaining_slot_gas: ::Gas, ) { - let rollup_height = self.executor.checkpoint.rollup_height_to_access(); // Check if we're close to the gas limit and close the batch if we are. - // We want to close when gas used is at least 95% of the initial gas limit. - let initial_gas_limit = ::gas_limit_for_height(rollup_height); - let comfortable_gas_limit = comfortable_gas_limit_for_height::(rollup_height); + // We want to close when gas used is at least 95% of the block gas limit. + let block_gas_limit = ::block_gas_limit(); + let comfortable_limit = comfortable_gas_limit::(); - let gas_used = initial_gas_limit + let gas_used = block_gas_limit .checked_sub(remaining_slot_gas) - .expect("remaining_lot_gas is always smaller than initial_gas_limit"); + .expect("remaining_slot_gas is always smaller than block_gas_limit"); - let close_to_gas_limit = comfortable_gas_limit.dim_is_less_or_eq(gas_used); + let close_to_gas_limit = comfortable_limit.dim_is_less_or_eq(gas_used); if close_to_gas_limit { - tracing::debug!(%comfortable_gas_limit, %gas_used, "Closing and publishing current batch because we're close to the gas limit"); + tracing::debug!(%comfortable_limit, %gas_used, "Closing and publishing current batch because we're close to the gas limit"); self.close_current_batch().await; } @@ -801,6 +804,22 @@ where ); } + // `MAX_FULLY_BAKED_TX_SIZE` is enforced by `FullyBakedTx`'s deserializers, so a tx that + // exceeds it once sequencing data is attached would execute and soft-confirm here, yet + // produce a blob that every node (including our own blob sender) rejects. Reject it up + // front instead. Note that pruning can only shrink the sequencing data, so this check + // is conservative. + let total_payload_len = baked_tx.payload_len(); + if total_payload_len > sov_rollup_interface::stf::MAX_FULLY_BAKED_TX_SIZE { + return ( + Err(DoNewTxError::TxWithSequencingDataTooBig { + total_payload_len, + max_payload_len: sov_rollup_interface::stf::MAX_FULLY_BAKED_TX_SIZE, + }), + request_used, + ); + } + let baked_tx = cache_warm_up_executor.send_tx(baked_tx.clone(), sequence_number); let apply_tx_res = executor.apply_tx_to_in_progress_batch(baked_tx).await; diff --git a/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/mod.rs b/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/mod.rs index 507ea0d499..b2974079e1 100644 --- a/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/mod.rs +++ b/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/mod.rs @@ -314,16 +314,13 @@ impl InitialStatus { } /// These two constants are used to calculate the comfortable gas limit. -/// Currently, this is 95% of the initial gas limit. After the comfortable limit is reached, +/// Currently, this is 95% of the block gas limit. After the comfortable limit is reached, /// the sequencer will close and publish the current batch. const COMFORTABLE_GAS_LIMIT_MULTIPLIER: u64 = 19; const COMFORTABLE_GAS_LIMIT_DIVISOR: u64 = 20; -pub(crate) fn comfortable_gas_limit_for_height( - height: RollupHeight, -) -> ::Gas { - let gas_limit = ::gas_limit_for_height(height); - gas_limit +pub(crate) fn comfortable_gas_limit() -> ::Gas { + ::block_gas_limit() .scalar_division(COMFORTABLE_GAS_LIMIT_DIVISOR) .checked_scalar_product(COMFORTABLE_GAS_LIMIT_MULTIPLIER).unwrap_or_else(|| { panic!( diff --git a/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/sync_state.rs b/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/sync_state.rs index ef2427df2f..a36a94e368 100644 --- a/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/sync_state.rs +++ b/crates/full-node/sov-sequencer/src/preferred/sync_sequencer_state/sync_state.rs @@ -31,7 +31,7 @@ use crate::{ }; use sov_blob_sender::{new_blob_id, BlobInternalId}; use sov_blob_storage::SequenceNumber; -use sov_modules_api::capabilities::{RollupHeight, SequencingDataHandler}; +use sov_modules_api::capabilities::RollupHeight; use sov_modules_api::state::{ApiStateAccessor, ConcurrentStateCheckpoint}; use sov_modules_api::{FullyBakedTx, HexString, Runtime, Spec, StateCheckpoint, VersionReader}; use sov_rollup_full_node_interface::StateUpdateInfo; @@ -1144,10 +1144,6 @@ where ip_and_credential: IpAndCredentialId, reason: &'static str, ) -> Result>>, AcceptTxError> { - let sequencing_data = self - .runtime - .sequencing_data_handler() - .create_sequencing_data(); let load_based_accept_probability = if self.use_pi_rate_limiter { self.get_acceptance_probability(&baked_tx) } else { @@ -1208,9 +1204,15 @@ where } let mut baked_tx = baked_tx; - // Important: we read the sequencing data from the baked tx inside apply_tx_to_in_progress_batch (which is called from do_new_tx) - // so this must not be moved without updating do_new_tx. See the comment in apply_tx_to_in_progress_batch for more details. - baked_tx.set_sequencing_metadata(&sequencing_data); + // Important: the sequencing data must be attached before `do_new_tx`. Execution inside + // `apply_tx_to_in_progress_batch` reads it from the baked tx (time-oracle update, + // `handle_sequencing_data` hook, and pruning), and `do_new_tx`'s up-front + // `MAX_FULLY_BAKED_TX_SIZE` check must account for its length, or an accepted tx could + // produce a blob that every node's deserializer rejects. + // This runs after all the reject paths above so rejected transactions don't pay for + // sequencing data creation. + baked_tx.sequencing_data = + Some(sov_modules_api::capabilities::new_tx_sequencing_data::()); let (res, resource_used) = inner.do_new_tx(tx_hash, baked_tx).await; // Do not use `?` or return early here. We must always call `rate_limiter.update` diff --git a/crates/full-node/sov-sequencer/src/preferred/transaction_subscriptions.rs b/crates/full-node/sov-sequencer/src/preferred/transaction_subscriptions.rs index 39fc5fd0c2..1d8d14d7a9 100644 --- a/crates/full-node/sov-sequencer/src/preferred/transaction_subscriptions.rs +++ b/crates/full-node/sov-sequencer/src/preferred/transaction_subscriptions.rs @@ -7,7 +7,7 @@ use sov_modules_api::capabilities::TransactionAuthenticator; use futures::task::Poll; use futures::{Future, FutureExt, Stream, StreamExt}; use sov_db::ledger_db::LedgerDb; -use sov_modules_api::capabilities::get_maybe_timestamp_from_sequencing_data; +use sov_modules_api::capabilities::get_timestamp_from_sequencing_data; use sov_modules_api::{HexString, Runtime, RuntimeEventResponse, Spec, TxHash}; use sov_rollup_interface::node::ledger_api::{EventIdentifier, LedgerStateProvider, QueryMode}; use tokio::sync::{broadcast, RwLock}; @@ -257,7 +257,7 @@ impl> TransactionCache { let maybe_timestamp = tx .body .as_ref() - .and_then(|body| get_maybe_timestamp_from_sequencing_data::(body, false)); + .and_then(|body| get_timestamp_from_sequencing_data(body, false)); Ok(Some(AcceptedTx { tx: tx.body.unwrap_or_default(), @@ -464,9 +464,10 @@ impl> AcceptedTxStream { .flatten() .enumerate() .map(|(idx, tx)| { - let timestamp_nanos = tx.body.as_ref().and_then(|body| { - get_maybe_timestamp_from_sequencing_data::(body, false) - }); + let timestamp_nanos = tx + .body + .as_ref() + .and_then(|body| get_timestamp_from_sequencing_data(body, false)); let tx_body = tx .body .and_then(|body| Rt::Auth::decode_serialized_tx(&body).ok()) diff --git a/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs b/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs index 27b47c27d0..98d43df5e7 100644 --- a/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs +++ b/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs @@ -1,7 +1,7 @@ //! Integration tests for the preferred sequencer that use [`RollupBuilder`] and //! thus test sequencer + node interactions. -use crate::utils::{encode_call, tx_set_value_nonce, EventEmitterModule}; +use crate::utils::{encode_call, EventEmitterModule}; use crate::utils::{ generate_paymaster_tx, generate_txs, new_test_rollup, new_test_rollup_with_pruning, pause_update_state, tempdir_inside_codebase_dir, tx_set_value_with_gas, @@ -892,8 +892,9 @@ impl Default for TestState { Self { value_by_slot_number: Default::default(), _current_slot_number: Default::default(), - // initialize to a higher generation so that "invalid generation" actions are always possible - next_generation: config_value!("PAST_TRANSACTION_GENERATIONS") + 10, + // Start at the pruning boundary so that "too old" generation actions + // are possible without exceeding the forward-generation cap during setup. + next_generation: config_value!("PAST_TRANSACTION_GENERATIONS"), current_value: Default::default(), } } @@ -1117,7 +1118,7 @@ async fn sequencer_filled_up_block() { .map(|x| (x * 10) / 9) .collect::>(); std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_INITIAL_GAS_LIMIT", + "SOV_TEST_CONST_OVERRIDE_BLOCK_GAS_LIMIT", format!("{gas_limit_array:?}"), ); // Set very high initial balance for the admin. @@ -1533,7 +1534,7 @@ async fn seq_out_of_gas_for_pre_checks() { let gas_limit = gas_limit.scalar_division(2); let gas_limit_array = gas_limit.as_ref(); std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_INITIAL_GAS_LIMIT", + "SOV_TEST_CONST_OVERRIDE_BLOCK_GAS_LIMIT", format!("{gas_limit_array:?}"), ); let price_array = config_value!("INITIAL_BASE_FEE_PER_GAS"); @@ -2597,183 +2598,6 @@ async fn seq_many_invalid_txs() { .expect("Rollup shutdown properly"); } -/// Run a test gas limit update. -/// -/// This test runs the rollup for ~20 blocks with the gas limit update scheduled for block 12. -/// We update the limit to a tiny value (50_000) and send expensive txs so that we can observe the gas dynamics -/// after the update. -/// -/// A big part of this test is simply running the upgrade with state root assertions enabled (they're enabled by default in test_rollup). -/// That assertion would catch the most likely issues with node-sequencer disagreement, and the successful execution of the test guarantees -/// that we haven't hit any panics. The assertions we run here are just extra sanity checks. -#[tokio::test(flavor = "multi_thread")] -async fn test_gas_limit_update() { - // 1. Configure gas price update timing and params - const UPDATED_GAS_LIMIT: [u64; 2] = [50_000, 50_000]; - const CHANGE_GAS_LIMIT_AFTER_HEIGHT: u32 = 12; - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_UPDATED_GAS_LIMIT", - format!("{:?}", UPDATED_GAS_LIMIT), - ); - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_CHANGE_GAS_LIMIT_AFTER_HEIGHT", - CHANGE_GAS_LIMIT_AFTER_HEIGHT.to_string(), - ); - - // 2. Boilerplate test setup - let (test_rollup, admin) = create_test_rollup( - 0, - TEST_MAX_BATCH_SIZE, - TEST_BLOB_PROCESSING_TIMEOUT, - MAX_BATCH_EXECUTION_TIME_MILLIS, - TEST_FINALIZATION_BLOCKS, - BlockProducingConfig::Manual, - ) - .await; - test_rollup.produce_enough_finalized_slots().await; - test_rollup.wait_for_sequencer_ready().await.unwrap(); - - // 3. Setup - initialize empty arrays/values for tracking data. Subscribe to slot notifications - let mut sequencer_receipts = vec![]; - let mut ledger_receipts = vec![]; - let mut rollup_height; - let mut nonce = 0; - let mut max_base_fee_per_gas_observed_after_update = 0; - let mut slot_subscription = test_rollup - .client - .client - .subscribe_slots_with_children(IncludeChildren::new(true)) - .await - .unwrap(); - - // 4. Main loop Submit a tx and save the receipt. Then, force close the batch and produce a block. - for i in 0..20_u64 { - // 4.1 Submit a tx and save the receipt. Use the retrying client so that transient - // `WaitingOnBlobSender` 503s from blob-sender backpressure are retried with backoff - // instead of failing the test outright (one historical Mode-C failure mode). - let receipt = test_rollup - .api_client() - .send_raw_tx_to_sequencer_with_retry(&tx_set_value_nonce::>( - &admin.private_key, - nonce, - i, - Some(GasUnit::from_dimensions([33_000, 33_000])), - )) - .await; - - if let Err(e) = &receipt { - panic!("Tx {i} failed with error: {e}"); - } else { - nonce += 1; - sequencer_receipts.push(receipt.unwrap()); - } - - // 4.2 Force close the batch and produce a block. - let _ = test_rollup.force_close_batch().await; - test_rollup.tenderly_produce_blocks(1).await.unwrap(); - let slot = slot_subscription.next().await.unwrap().unwrap(); - for batch in slot.batches { - for tx in batch.txs { - ledger_receipts.push(tx); - } - } - - // 4.3 Query the rollup hight. After the transition, save the largest base fee value that we observe. - rollup_height = test_rollup.height().await.get(); - if rollup_height > CHANGE_GAS_LIMIT_AFTER_HEIGHT as u64 { - let base_fee_per_gas = test_rollup - .client - .http_get("/rollup/base-fee-per-gas/latest") - .await - .unwrap(); - // Parse the integer price from a string like "[10, 10]" - let current_base_fee = base_fee_per_gas - .trim() - .trim_start_matches(r#"{"base_fee_per_gas":[""#) - .split('"') - .next() - .unwrap() - .parse::() - .unwrap_or_else(|_| panic!("Failed to parse base fee per gas: {base_fee_per_gas}")); - if current_base_fee > max_base_fee_per_gas_observed_after_update { - max_base_fee_per_gas_observed_after_update = current_base_fee; - } - } - - // 4.4 Once we've observed several blocks after the transition, break the loop. - if rollup_height.saturating_sub(5) > CHANGE_GAS_LIMIT_AFTER_HEIGHT as u64 { - break; - } - } - - // 5. Drain the blob-sender pipeline: produce DA blocks until every sequencer-accepted tx has - // appeared in a slot, or we've produced an unreasonable number of blocks. Under load, the - // `force_close_batch + produce_block` pattern in the loop can produce empty DA blocks while - // the just-closed batch is still on its way to DA, so the tx appears in a later block. - // Observe the base fee along the way too — when previously-pending batches finally land, the - // gas_used in those rollup blocks pushes the base fee above 7, and we may only see that rise - // during the drain (not the main loop). - let expected_tx_count = sequencer_receipts.len(); - let mut drain_blocks = 0u32; - while ledger_receipts.len() < expected_tx_count { - test_rollup.da_service.produce_block_now().await.unwrap(); - drain_blocks += 1; - let slot = slot_subscription.next().await.unwrap().unwrap(); - for batch in slot.batches { - for tx in batch.txs { - ledger_receipts.push(tx); - } - } - let drain_height = test_rollup.height().await.get(); - if drain_height > CHANGE_GAS_LIMIT_AFTER_HEIGHT as u64 { - let base_fee_per_gas = test_rollup - .client - .http_get("/rollup/base-fee-per-gas/latest") - .await - .unwrap(); - let current_base_fee = base_fee_per_gas - .trim() - .trim_start_matches(r#"{"base_fee_per_gas":[""#) - .split('"') - .next() - .unwrap() - .parse::() - .unwrap_or_else(|_| panic!("Failed to parse base fee per gas: {base_fee_per_gas}")); - if current_base_fee > max_base_fee_per_gas_observed_after_update { - max_base_fee_per_gas_observed_after_update = current_base_fee; - } - } - if drain_blocks > 60 { - panic!( - "Produced 60 drain blocks but only collected {} of {} expected txs in the ledger", - ledger_receipts.len(), - expected_tx_count - ); - } - } - - // 6. Assertions - // 6.1 Check that the gas price rose after the update. This verifies that our attempted change really did take effect. - - // Before we update the gas limit, the gas price falls to the minimum possible value (7). After the update, assuming all went well, - // the price should start rising rapidly since our blocks are always full. Sanity check that. - assert!( - max_base_fee_per_gas_observed_after_update > 7, - "Max observed base fee per gas after update must be greater than 7" - ); - - // 6.2 Sanity check that the ledger DB agrees with the sequencer on the tx results. - assert_eq!(sequencer_receipts.len(), ledger_receipts.len()); - for (sequencer_receipt, ledger_receipt) in sequencer_receipts - .into_iter() - .zip(ledger_receipts.into_iter()) - { - let sequencer_receipt = sequencer_receipt.as_ref().receipt.as_ref().unwrap(); - assert_eq!(sequencer_receipt.result, ledger_receipt.receipt.result); - assert_eq!(sequencer_receipt.data, ledger_receipt.receipt.data); - } -} - /// Ensure that we use the correct visible slot number when replaying transactions after a call to `update_state` in the sequencer. /// The key thing that this test does is to execute the same transaction 3 times - once in the sequencer via `accept_tx`, once via `update_state` /// and once in the node. Everything else is implementation details. diff --git a/crates/full-node/sov-sequencer/tests/integration/rate_limits.rs b/crates/full-node/sov-sequencer/tests/integration/rate_limits.rs index d8baed17c9..044a7edafd 100644 --- a/crates/full-node/sov-sequencer/tests/integration/rate_limits.rs +++ b/crates/full-node/sov-sequencer/tests/integration/rate_limits.rs @@ -13,7 +13,6 @@ use sov_modules_api::PrivateKey; use sov_modules_api::RawTx; use sov_modules_api::Spec; use sov_modules_stf_blueprint::Runtime; -use sov_rollup_interface::common::RollupHeight; use sov_sequencer::rest_api::AcceptTx; use sov_sequencer::SequencerKindConfig; use sov_test_utils::generate_operator_runtime_with_kernel; @@ -119,7 +118,6 @@ async fn test_rate_limiting() { max_requests_per_second: 1_000_000, address_custom_limits: Vec::default(), ip_custom_limits: Vec::default(), - height_for_gas_limit_computation: RollupHeight::GENESIS, }; let (test_rollup, admin) = create_test_rollup(genesis, sov_config).await; @@ -172,7 +170,6 @@ async fn test_zero_limit_address() { let sov_config = SovRateLimiterConfig { max_nb_of_concurrent_users_in_rate_limiter: 1000, max_requests_per_second: 1_000_000, - height_for_gas_limit_computation: RollupHeight::GENESIS, default_limits: Limits { resources_per_bucket: 5, refill_rate: 100, @@ -215,7 +212,6 @@ async fn test_correct_ip() { resources_per_bucket: 5, refill_rate: 0, }, - height_for_gas_limit_computation: RollupHeight::GENESIS, address_custom_limits: Vec::default(), ip_custom_limits: Vec::default(), }; @@ -281,7 +277,6 @@ async fn test_zero_limit_ip() { let genesis = Genesis::new(); let sov_config = SovRateLimiterConfig { - height_for_gas_limit_computation: RollupHeight::GENESIS, max_requests_per_second: 1000, max_nb_of_concurrent_users_in_rate_limiter: 1000, default_limits: Limits { @@ -345,7 +340,6 @@ async fn assert_subnet_bucket_is_shared(subnet: &str, ip_a: &str, ip_b: &str) { resources_per_bucket: 5, refill_rate: 0, }, - height_for_gas_limit_computation: RollupHeight::GENESIS, address_custom_limits: Vec::default(), ip_custom_limits: vec![( subnet.parse().unwrap(), diff --git a/crates/full-node/sov-sequencer/tests/integration/sequencing_metadata.rs b/crates/full-node/sov-sequencer/tests/integration/sequencing_metadata.rs index cb7154fc27..07767d7ad7 100644 --- a/crates/full-node/sov-sequencer/tests/integration/sequencing_metadata.rs +++ b/crates/full-node/sov-sequencer/tests/integration/sequencing_metadata.rs @@ -1,32 +1,25 @@ -use std::str::FromStr; +use std::collections::BTreeSet; use std::time::Duration; -use borsh::{to_vec, BorshDeserialize}; -use sov_blob_storage::PreferredBatchData; +use borsh::to_vec; +use sov_api_spec::types::TxReceiptResult; use sov_mock_da::BlockProducingConfig; -use sov_modules_api::capabilities::{ - Guard, HasCapabilities, SequencingDataHandler, TransactionAuthenticator, -}; -use sov_modules_api::{ - BlobReaderTrait, Context, EncodeCall, HDTimestamp, RawTx, Runtime, Spec, TxState, -}; +use sov_modules_api::capabilities::TransactionAuthenticator; +use sov_modules_api::{EncodeCall, FullyBakedTx, HDTimestamp, RawTx, Runtime, SequencingData}; use sov_modules_stf_blueprint::GenesisParams; use sov_rollup_interface::node::da::DaService; -use sov_test_modules::sequencing_data::{SequencingDataTester, SCRATCHPAD_TIMESTAMP_NANOS}; +use sov_test_modules::sequencing_data::{CallMessage, SequencingDataTester}; use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; -use sov_test_utils::runtime::StandardProvenRollupCapabilities; use sov_test_utils::test_rollup::TestRollup; use sov_test_utils::RtAgnosticBlueprint; use sov_test_utils::{ - default_test_signed_transaction, generate_runtime_without_capabilities, TestSpec, TestUser, + default_test_signed_transaction, generate_runtime, TestSpec, TestUser, TEST_BLOB_PROCESSING_TIMEOUT, TEST_FINALIZATION_BLOCKS, TEST_MAX_BATCH_SIZE, }; use crate::utils::{new_test_rollup, tempdir_inside_codebase_dir, MAX_BATCH_EXECUTION_TIME_MILLIS}; -const INITIAL_TIMESTAMP_NANOS: u128 = 1_893_456_000_000_000_000; - -generate_runtime_without_capabilities!( +generate_runtime!( name: TestRuntime, modules: [sequencing_data_tester: SequencingDataTester], operating_mode: sov_modules_api::runtime::OperatingMode::Optimistic, @@ -41,74 +34,6 @@ type S = TestSpec; type RT = TestRuntime; type TestBlueprint = RtAgnosticBlueprint; -struct TestSequencingDataHandler<'a, S: Spec> { - chain_state: &'a mut sov_test_utils::runtime::ChainState, -} - -impl SequencingDataHandler for TestSequencingDataHandler<'_, S> { - type SequencingData = HDTimestamp; - - fn handle_sequencing_data( - &mut self, - data: Self::SequencingData, - context: &Context, - state: &mut impl TxState, - ) -> anyhow::Result<()> { - if !context.sequencer_is_preferred() { - return Ok(()); - } - - self.chain_state - .update_oracle_time_from_sequencing_data(data, state) - } - - fn create_sequencing_data(&self) -> Self::SequencingData { - timestamp_from_nanos(INITIAL_TIMESTAMP_NANOS) - } - - fn finalize_sequencing_data( - &mut self, - data: Self::SequencingData, - scratchpad: Option, - ) -> Self::SequencingData { - scratchpad - .as_deref() - .and_then(|bytes| timestamp_bytes_to_nanos(bytes).ok()) - .map(timestamp_from_nanos) - .unwrap_or(data) - } -} - -impl HasCapabilities for TestRuntime { - type Capabilities<'a> - = StandardProvenRollupCapabilities<'a, S> - where - Self: 'a; - type SequencingData = HDTimestamp; - - fn capabilities(&mut self) -> Guard> { - Guard::new(StandardProvenRollupCapabilities { - bank: &mut self.bank, - gas_payer: (), - sequencer_registry: &mut self.sequencer_registry, - accounts: &mut self.accounts, - uniqueness: &mut self.uniqueness, - chain_state: &mut self.chain_state, - operator_incentives: &mut self.operator_incentives, - prover_incentives: &mut self.prover_incentives, - attester_incentives: &mut self.attester_incentives, - }) - } - - fn sequencing_data_handler( - &mut self, - ) -> impl SequencingDataHandler { - TestSequencingDataHandler { - chain_state: &mut self.chain_state, - } - } -} - fn create_genesis_params() -> (GenesisParams>, TestUser) { let genesis_config = HighLevelOptimisticGenesisConfig::generate().add_accounts_with_default_balance(1); @@ -160,7 +85,62 @@ async fn sequencer_sets_timestamp_before_execution() { test_rollup.produce_enough_finalized_slots().await; test_rollup.wait_for_sequencer_ready().await.unwrap(); - let call = >>::to_decodable(()); + let call = >>::to_decodable( + CallMessage::AssertTimestampIsReasonable, + ); + let before = HDTimestamp::now(); + let (published_tx, receipt) = submit_and_publish_tx(&test_rollup, &admin, call).await; + let after = HDTimestamp::now(); + + assert_eq!( + receipt, + TxReceiptResult::Successful, + "the module should observe the sequencing timestamp through the transaction context" + ); + + let timestamp = decode_sequencing_data(&published_tx) + .timestamp + .expect("published transaction should include the sequencer timestamp"); + assert!( + before <= timestamp && timestamp <= after, + "published timestamp {timestamp} should have been generated during tx acceptance, between {before} and {after}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn sequencer_publishes_timestamp_even_when_unused() { + let (test_rollup, admin) = create_test_rollup().await; + test_rollup.produce_enough_finalized_slots().await; + test_rollup.wait_for_sequencer_ready().await.unwrap(); + + let call = >>::to_decodable(CallMessage::Noop); + let (published_tx, _receipt) = submit_and_publish_tx(&test_rollup, &admin, call).await; + + let sequencing_data = decode_sequencing_data(&published_tx); + assert!( + sequencing_data.timestamp.is_some(), + "the sequencer timestamp must be published even when execution never reads it" + ); + assert_eq!( + sequencing_data.unrecorded_data(), + None, + "a runtime without format-specific sequencing data must not publish a data payload" + ); +} + +fn decode_sequencing_data(tx: &FullyBakedTx) -> SequencingData { + let bytes = tx + .sequencing_data + .as_ref() + .expect("published transaction should include sequencing data"); + SequencingData::decode(bytes).expect("published sequencing data should decode") +} + +async fn submit_and_publish_tx( + test_rollup: &TestRollup, + admin: &TestUser, + call: ::Decodable, +) -> (FullyBakedTx, TxReceiptResult) { let tx = default_test_signed_transaction::(&admin.private_key, &call, 0, &RT::CHAIN_HASH); let raw_tx = RawTx::new(to_vec(&tx).unwrap()); @@ -174,13 +154,20 @@ async fn sequencer_sets_timestamp_before_execution() { "test must submit a tx without sequencing metadata" ); - let accepted_tx = test_rollup + let tx_hash = >::Auth::compute_tx_hash(&baked_tx) + .expect("submitted transaction hash should compute"); + + let response = test_rollup .api_client() .send_raw_tx_to_sequencer(&raw_tx) .await - .expect("sequencer execution should insert sequencing metadata"); - let accepted_tx_hash = accepted_tx.as_ref().id.to_string(); - let mut last_checked_height = test_rollup + .expect("sequencer should accept the transaction"); + let receipt = response + .into_inner() + .receipt + .expect("sequencer confirmation should include a receipt") + .result; + let last_checked_height = test_rollup .da_service .get_head_block_header() .await @@ -188,50 +175,18 @@ async fn sequencer_sets_timestamp_before_execution() { .height; test_rollup.force_close_batch().await.unwrap(); - let published_tx = tokio::time::timeout(Duration::from_secs(15), async { - loop { - test_rollup.da_service.produce_block_now().await.unwrap(); - let head_height = test_rollup - .da_service - .get_head_block_header() - .await - .unwrap() - .height; - for height in last_checked_height + 1..=head_height { - let mut block = test_rollup.da_service.get_block_at(height).await.unwrap(); - for blob in block.batch_blobs.iter_mut() { - let batch = PreferredBatchData::try_from_slice(blob.full_data()) - .expect("preferred batch blob should decode"); - if let Some(tx) = batch.data.iter().find_map(|tx| { - let hash = >::Auth::compute_tx_hash(tx) - .expect("published transaction hash should compute"); - (hash.to_string() == accepted_tx_hash).then(|| tx.clone()) - }) { - return tx; - } - } - } - last_checked_height = head_height; - tokio::task::yield_now().await; - } - }) - .await - .expect("timed out waiting for the submitted tx to be published"); - let sequencing_data = published_tx - .sequencing_data - .expect("published transaction should include finalized sequencing metadata"); - assert_eq!( - timestamp_bytes_to_nanos(&sequencing_data).unwrap(), - SCRATCHPAD_TIMESTAMP_NANOS, - "sequencer should finalize metadata using the execution scratchpad" - ); -} - -fn timestamp_from_nanos(nanos: u128) -> HDTimestamp { - HDTimestamp::from_str(&nanos.to_string()).expect("u128 timestamp should parse") -} - -fn timestamp_bytes_to_nanos(bytes: &[u8]) -> anyhow::Result { - let bytes = <[u8; 16]>::try_from(bytes)?; - Ok(u128::from_le_bytes(bytes)) + let mut published = test_rollup + .wait_for_txs_on_da( + &BTreeSet::from([tx_hash]), + last_checked_height, + Duration::from_secs(15), + ) + .await + .expect("published batch blobs should decode"); + ( + published + .remove(&tx_hash) + .expect("the submitted tx should be published"), + receipt, + ) } diff --git a/crates/full-node/sov-sequencer/tests/integration/setup_mode.rs b/crates/full-node/sov-sequencer/tests/integration/setup_mode.rs index 54dd57e4c7..f8e22f6f13 100644 --- a/crates/full-node/sov-sequencer/tests/integration/setup_mode.rs +++ b/crates/full-node/sov-sequencer/tests/integration/setup_mode.rs @@ -336,7 +336,7 @@ fn encode_zero_gas_tx( max_fee: Amount::ZERO, max_priority_fee_bips: PriorityFeeBips::ZERO, gas_limit: None, - chain_id: config_value!("CHAIN_ID"), + chain_hash_fragment: 0, }; let tx = test_signed_transaction::, TestSpec>( key, diff --git a/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs b/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs index d494ea6297..b9d78b2b90 100644 --- a/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs +++ b/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs @@ -99,7 +99,7 @@ async fn test_mixed_nonce_and_generation_transactions() { let construct_tx = |uniqueness: UniquenessData| { let unsigned_tx = - UnsignedTransaction::new_with_details(msg.clone(), uniqueness, details.clone()); + UnsignedTransaction::new_with_details(msg.clone(), uniqueness, details.clone(), None); Transaction::::new_signed_tx( &test_user.private_key, &RT::CHAIN_HASH, diff --git a/crates/full-node/sov-sequencer/tests/integration/utils.rs b/crates/full-node/sov-sequencer/tests/integration/utils.rs index 5aadf10140..67dcd59ab1 100644 --- a/crates/full-node/sov-sequencer/tests/integration/utils.rs +++ b/crates/full-node/sov-sequencer/tests/integration/utils.rs @@ -142,7 +142,7 @@ pub fn generate_paymaster_tx + EncodeCall::sign_and_serialize( >>::to_decodable(message), diff --git a/crates/fuzz/fuzz_targets/accounts_call_random.rs b/crates/fuzz/fuzz_targets/accounts_call_random.rs index 0b5190eff0..881b915f5a 100644 --- a/crates/fuzz/fuzz_targets/accounts_call_random.rs +++ b/crates/fuzz/fuzz_targets/accounts_call_random.rs @@ -9,9 +9,10 @@ use sov_test_utils::storage::SimpleStorageManager; use sov_test_utils::TestStorageSpec; type S = sov_test_utils::TestSpec; +type FuzzInput<'a> = (&'a [u8], Vec<(Context, CallMessage)>); // Check arbitrary, random calls -fuzz_target!(|input: (&[u8], Vec<(Context, CallMessage)>)| { +fuzz_target!(|input: FuzzInput| { let storage_manager = SimpleStorageManager::::new(); let storage = storage_manager.create_storage(); let mut state = StateCheckpoint::new(storage, &MockKernel::::default()); diff --git a/crates/fuzz/fuzz_targets/accounts_parse_call_message.rs b/crates/fuzz/fuzz_targets/accounts_parse_call_message.rs index 267724873b..de15cfe2e4 100644 --- a/crates/fuzz/fuzz_targets/accounts_parse_call_message.rs +++ b/crates/fuzz/fuzz_targets/accounts_parse_call_message.rs @@ -3,8 +3,10 @@ use libfuzzer_sys::fuzz_target; use sov_accounts::CallMessage; -fuzz_target!(|input: CallMessage| { +type S = sov_test_utils::TestSpec; + +fuzz_target!(|input: CallMessage| { let json = serde_json::to_vec(&input).unwrap(); - let msg = serde_json::from_slice::(&json).unwrap(); + let msg = serde_json::from_slice::>(&json).unwrap(); assert_eq!(input, msg); }); diff --git a/crates/fuzz/fuzz_targets/accounts_parse_call_message_random.rs b/crates/fuzz/fuzz_targets/accounts_parse_call_message_random.rs index d49dbf8cf6..05b4c428bd 100644 --- a/crates/fuzz/fuzz_targets/accounts_parse_call_message_random.rs +++ b/crates/fuzz/fuzz_targets/accounts_parse_call_message_random.rs @@ -3,6 +3,8 @@ use libfuzzer_sys::fuzz_target; use sov_accounts::CallMessage; +type S = sov_test_utils::TestSpec; + fuzz_target!(|input: &[u8]| { - serde_json::from_slice::(input).ok(); + serde_json::from_slice::>(input).ok(); }); diff --git a/crates/module-system/hyperlane/src/igp/metadata.rs b/crates/module-system/hyperlane/src/igp/metadata.rs index 23de1a0da5..4135f150bd 100644 --- a/crates/module-system/hyperlane/src/igp/metadata.rs +++ b/crates/module-system/hyperlane/src/igp/metadata.rs @@ -2,7 +2,9 @@ use std::io::{Error, ErrorKind, Result}; use sov_bank::Amount; -/// Metadata with fields for IGP, we skip first fields since we don't need them. +/// IGP metadata following Hyperlane's `StandardHookMetadata` layout. We only +/// retain the gas limit; `variant` is validated on deserialization and the other +/// fields are advisory and ignored. /// /// See /// @@ -14,48 +16,49 @@ use sov_bank::Amount; pub struct IGPMetadata { /// Gas limit. /// - /// NOTE: in Hyperlane they use u256 but we only write/read last 16 bytes since sovereign sdk - /// uses u128 for amount. + /// NOTE: Hyperlane encodes this as a u256, but Sovereign SDK uses u128 for + /// amounts, so values exceeding `u128::MAX` are rejected during deserialization. pub gas_limit: Amount, } +/// Reads the next `N` bytes from `buf`, advancing it past them. +fn read_field(buf: &mut &[u8]) -> Result<[u8; N]> { + let (field, rest) = buf + .split_at_checked(N) + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "IGPMetadata is too short"))?; + *buf = rest; + Ok(field.try_into().expect("slice length checked above")) +} + impl IGPMetadata { + /// The only metadata format version we support, matching Hyperlane's + /// `StandardHookMetadata.VARIANT`. + const EXPECTED_VARIANT: u16 = 1; + pub(crate) fn deserialize(buf: &[u8]) -> Result { - const MIN_LENGTH: usize = 66; - const GAS_LIMIT_OFFSET: usize = 34; + let mut cursor = buf; - if buf.len() < MIN_LENGTH { + // variant (0:2): reject unknown versions so a future format isn't misparsed. + let variant = u16::from_be_bytes(read_field(&mut cursor)?); + if variant != Self::EXPECTED_VARIANT { return Err(Error::new( ErrorKind::InvalidData, format!( - "Expected at least {MIN_LENGTH} bytes for IGPMetadata, got {}", - buf.len() + "Unsupported IGPMetadata variant: expected {}, got {variant}", + Self::EXPECTED_VARIANT ), )); } - // Extract gas_limit from position 34-66 - // We only need the last 16 bytes for u128 - // So check if first 16 are zeroes - let first_half = &buf[GAS_LIMIT_OFFSET..GAS_LIMIT_OFFSET + 16]; - if !first_half.iter().all(|&b| b == 0) { - return Err(Error::new( - ErrorKind::InvalidData, - "Gas limit exceeds u128 maximum", - )); - } - - let gas_limit = match buf[GAS_LIMIT_OFFSET + 16..GAS_LIMIT_OFFSET + 32].try_into() { - Ok(bytes) => bytes, - Err(_) => { - return Err(Error::new( - ErrorKind::InvalidData, - "Failed to convert gas limit bytes to array", - )) - } - }; - let gas_limit = u128::from_be_bytes(gas_limit); + // msg.value (2:34): advisory, the gas paymaster doesn't act on it. + read_field::<32>(&mut cursor)?; + // gas limit (34:66): Hyperlane uses a u256 but our amounts are u128, so + // anything beyond u128::MAX is rejected. + let gas_limit = ruint::Uint::<256, 4>::from_be_bytes(read_field::<32>(&mut cursor)?); + let gas_limit: u128 = gas_limit + .try_into() + .map_err(|_| Error::new(ErrorKind::InvalidData, "Gas limit exceeds u128 maximum"))?; if gas_limit == 0 { return Err(Error::new( ErrorKind::InvalidData, @@ -63,6 +66,9 @@ impl IGPMetadata { )); } + // refund address (66:86) and trailing custom metadata are advisory; we + // charge exactly the required gas and never refund, so they're ignored. + Ok(Self { gas_limit: Amount(gas_limit), }) @@ -77,38 +83,37 @@ mod tests { use super::IGPMetadata; + /// Builds a valid `StandardHookMetadata` buffer (variant 1, zero msg.value, + /// zero refund address) with the given gas limit. + fn valid_buf(gas_limit: u128) -> Vec { + let mut buf = vec![0_u8; 86]; + // variant = 1 + buf[0..2].copy_from_slice(&1u16.to_be_bytes()); + // gas limit lives in the lower 16 bytes of the (34:66) field + buf[34 + 16..34 + 32].copy_from_slice(&gas_limit.to_be_bytes()); + buf + } + #[test] fn igp_metadata_deserialize() { - let metadata = IGPMetadata { - gas_limit: Amount(14235043), - }; - let mut buf = vec![0_u8; 86]; - let gas_limit_bytes = metadata.gas_limit.0.to_be_bytes(); - buf[34 + 16..34 + 32].copy_from_slice(&gas_limit_bytes); + let buf = valid_buf(14235043); let decoded = IGPMetadata::deserialize(&buf).expect("should deserialize"); - assert_eq!(decoded.gas_limit, metadata.gas_limit); + assert_eq!(decoded.gas_limit, Amount(14235043)); } #[test] fn igp_metadata_deserialize_max_u128() { - let metadata = IGPMetadata { - gas_limit: Amount(u128::MAX), - }; - let mut buf = vec![0_u8; 86]; - let gas_limit_bytes = metadata.gas_limit.0.to_be_bytes(); - buf[34 + 16..34 + 32].copy_from_slice(&gas_limit_bytes); + let buf = valid_buf(u128::MAX); let decoded = IGPMetadata::deserialize(&buf).expect("should deserialize"); - assert_eq!(decoded.gas_limit, metadata.gas_limit); + assert_eq!(decoded.gas_limit, Amount(u128::MAX)); } #[test] fn igp_metadata_deserialize_exceeds_u128() { // This simulates a U256 value that's too large for u128 - let mut buf = vec![0_u8; 86]; + let mut buf = valid_buf(u128::MAX); // Set the first byte of the gas limit to non-zero buf[34] = 1; - // Fill the rest with valid data - buf[34 + 16..34 + 32].copy_from_slice(&u128::MAX.to_be_bytes()); let result = IGPMetadata::deserialize(&buf); assert!(result.is_err()); @@ -120,7 +125,7 @@ mod tests { #[test] fn igp_metadata_deserialize_zero_gas_limit() { - let buf = vec![0_u8; 86]; + let buf = valid_buf(0); let result = IGPMetadata::deserialize(&buf); assert!(result.is_err()); @@ -132,13 +137,60 @@ mod tests { #[test] fn igp_metadata_deserialize_buffer_too_small() { - let buf = vec![0_u8; 65]; // MIN_LENGTH is 66 + // Truncated partway through the gas limit field. + let buf = valid_buf(14235043); + let result = IGPMetadata::deserialize(&buf[..65]); + assert!(result.is_err()); + if let Err(e) = result { + assert_eq!(e.kind(), ErrorKind::InvalidData); + assert_eq!(e.to_string(), "IGPMetadata is too short"); + } + } + + #[test] + fn igp_metadata_deserialize_unsupported_variant() { + let mut buf = valid_buf(14235043); + // variant = 2 is not a format we understand + buf[0..2].copy_from_slice(&2u16.to_be_bytes()); let result = IGPMetadata::deserialize(&buf); assert!(result.is_err()); if let Err(e) = result { assert_eq!(e.kind(), ErrorKind::InvalidData); - assert!(e.to_string().contains("Expected at least 66 bytes")); + assert_eq!( + e.to_string(), + "Unsupported IGPMetadata variant: expected 1, got 2" + ); } } + + #[test] + fn igp_metadata_deserialize_ignores_nonzero_msg_value() { + let mut buf = valid_buf(14235043); + // A non-zero msg.value (2:34) must not affect parsing. + buf[33] = 0x42; + + let decoded = IGPMetadata::deserialize(&buf).expect("non-zero msg.value should be ignored"); + assert_eq!(decoded.gas_limit, Amount(14235043)); + } + + #[test] + fn igp_metadata_deserialize_ignores_nonzero_refund_address() { + let mut buf = valid_buf(14235043); + // A non-zero refund address (66:86) is the common case and must be accepted. + buf[66] = 0xab; + + let decoded = + IGPMetadata::deserialize(&buf).expect("non-zero refund address should be ignored"); + assert_eq!(decoded.gas_limit, Amount(14235043)); + } + + #[test] + fn igp_metadata_deserialize_refund_address_omitted() { + // Exactly 66 bytes: the refund address field is omitted, which is fine. + let buf = valid_buf(14235043); + let decoded = IGPMetadata::deserialize(&buf[..66]) + .expect("should deserialize without refund address"); + assert_eq!(decoded.gas_limit, Amount(14235043)); + } } diff --git a/crates/module-system/hyperlane/tests/integration/warp/bridged_gas_token.rs b/crates/module-system/hyperlane/tests/integration/warp/bridged_gas_token.rs index ef8159ea65..17fb1ec7ca 100644 --- a/crates/module-system/hyperlane/tests/integration/warp/bridged_gas_token.rs +++ b/crates/module-system/hyperlane/tests/integration/warp/bridged_gas_token.rs @@ -1,5 +1,4 @@ use sov_hyperlane_integration::warp::{Admin, StoredTokenKind, TokenKind, WarpRouteId}; -use sov_modules_api::macros::config_value; use sov_modules_api::prelude::UnwrapInfallible; use sov_modules_api::transaction::{PriorityFeeBips, TxDetails}; use sov_test_utils::{ @@ -104,7 +103,7 @@ fn register_relayer_gasless(runner: &mut TestRunner, relayer: &TestUser) -> RawTx { } fn generation() -> u64 { - static GENERATION: AtomicU64 = AtomicU64::new(0); - GENERATION.fetch_add(1, Ordering::Relaxed) + // The dockerized Hyperlane agent uses Unix milliseconds for generations. + // Keep test-created transactions in the same range for shared credentials. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after the Unix epoch"); + + now.as_secs() + .saturating_mul(1_000) + .saturating_add(u64::from(now.subsec_millis())) } async fn anvil_rpc_value(port: u16, method: &str, params: Value) -> Value { diff --git a/crates/module-system/module-implementations/extern/hyperlane-solana-register/README.md b/crates/module-system/module-implementations/extern/hyperlane-solana-register/README.md index 6ddd90a2c2..f1d2f7551a 100644 --- a/crates/module-system/module-implementations/extern/hyperlane-solana-register/README.md +++ b/crates/module-system/module-implementations/extern/hyperlane-solana-register/README.md @@ -62,7 +62,6 @@ The `SolanaRegistration` module is a Sovereign SDK module that implements the - **Selective Message Handling**: Only processes messages from the configured Solana domain and trusted program ID - **Account Linking**: Associates Solana embedded wallets with rollup addresses via the `sov-accounts` module -- **Duplicate Prevention**: Rejects attempts to register an embedded wallet that's already linked to a different address - **Admin Controls**: Allows configuration updates via admin-only calls - **Fallback to Warp**: Non-Solana messages are forwarded to the underlying `Warp` module @@ -132,7 +131,6 @@ pub enum Event { The module defines several error types: -- `AlreadyRegistered`: The embedded public key is already linked to a different address - `InvalidBodyLength`: The message body doesn't contain exactly 64 bytes - `ExtractPubKey`: Failed to parse public keys from the message body - `AdminNotFound`: Admin address not configured @@ -146,9 +144,8 @@ See `src/lib.rs:196-213` for the `handle` implementation: 2. Verify the sender matches the trusted Solana program ID 3. Extract the two 32-byte public keys from the message body 4. Convert the payer public key to a rollup address -5. Use `sov-accounts` to resolve or create the address-credential mapping -6. Reject if the embedded wallet is already linked to a different address -7. Emit a `UserRegistered` event +5. Authorize the embedded credential to act as the payer's address by recording `(payer, embedded)` in `sov-accounts` +6. Emit a `UserRegistered` event --- @@ -372,4 +369,3 @@ cargo test ``` Program tests are located in `solana/program-tests/src/tests.rs`. - diff --git a/crates/module-system/module-implementations/extern/hyperlane-solana-register/src/lib.rs b/crates/module-system/module-implementations/extern/hyperlane-solana-register/src/lib.rs index d5c20fa675..a37a6b8445 100644 --- a/crates/module-system/module-implementations/extern/hyperlane-solana-register/src/lib.rs +++ b/crates/module-system/module-implementations/extern/hyperlane-solana-register/src/lib.rs @@ -53,11 +53,6 @@ where pub enum SolanaRegistrationError { #[error("Core module error: {0}")] CoreModuleError(#[from] CoreModuleError), - #[error("Embedded pubkey already registered to different address. Attempted: {attempted_address}, Registered: {registered_address}")] - AlreadyRegistered { - attempted_address: String, - registered_address: String, - }, #[error("Invalid body length. Expected {expected}, found {found}")] InvalidBodyLength { expected: usize, found: usize }, #[error("Failed to extract public key from body")] @@ -297,26 +292,19 @@ where let (user_pubkey, embedded_pubkey) = self.unpack_body(body.as_ref())?; let credential_id = CredentialId::from(embedded_pubkey); let address = S::Address::try_from(&user_pubkey).map_err(CoreModuleError::from)?; - let resolved_address = self - .accounts - .resolve_sender_address(&address, &credential_id, state) + + self.accounts + .authorize_credential(&address, &credential_id, state) .map_err(CoreModuleError::state_write)?; - if address != resolved_address { - Err(SolanaRegistrationError::AlreadyRegistered { - attempted_address: address.to_string(), - registered_address: resolved_address.to_string(), - }) - } else { - self.emit_event( - state, - Event::UserRegistered { - address, - credential_id, - }, - ); - Ok(()) - } + self.emit_event( + state, + Event::UserRegistered { + address, + credential_id, + }, + ); + Ok(()) } pub fn admin( diff --git a/crates/module-system/module-implementations/extern/hyperlane-solana-register/tests/integration/registration.rs b/crates/module-system/module-implementations/extern/hyperlane-solana-register/tests/integration/registration.rs index 189340d28a..d155be963f 100644 --- a/crates/module-system/module-implementations/extern/hyperlane-solana-register/tests/integration/registration.rs +++ b/crates/module-system/module-implementations/extern/hyperlane-solana-register/tests/integration/registration.rs @@ -4,6 +4,7 @@ use sov_hyperlane_integration::{CallMessage, Ism, Recipient}; use sov_hyperlane_register_module::{ CallMessage as RegistrationCallMessage, SolanaDeployment, SolanaRegistration, }; +use sov_modules_api::prelude::UnwrapInfallible; use sov_modules_api::{Base58Address, CredentialId, HexString, SafeVec, Spec}; use sov_test_utils::{AsUser, TransactionTestCase}; @@ -23,16 +24,20 @@ fn test_user_is_registered_correctly() { let route_id = register_basic_warp_route(&mut runner, &admin); let payer = [1u8; 32]; + let payer_addr = ::Address::from(payer); let embedded = [2u8; 32]; + let credential = CredentialId::from(embedded); let body = [payer, embedded].concat(); let valid_message = make_valid_message(0, route_id, HexString::new(body)); let message = HexString::new(SafeVec::try_from(valid_message.encode().0).unwrap()); - let credential = CredentialId::from(embedded); - // Sanity check, ensure account definently doesnt already exist runner.query_state(|state| { - let account = sov_accounts::Accounts::default().get_account(credential, state); - assert!(matches!(account, sov_accounts::Response::AccountEmpty)); + assert!( + !sov_accounts::Accounts::::default() + .is_explicitly_authorized(&payer_addr, &credential, state) + .unwrap_infallible(), + "Embedded credential should not yet be authorized for the payer address" + ); }); runner.execute_transaction(TransactionTestCase { @@ -41,17 +46,18 @@ fn test_user_is_registered_correctly() { message, }), assert: Box::new(move |result, _| { + let receipt = &result.tx_receipt; assert!( result.tx_receipt.is_successful(), - "Recipient was not registered successfully" + "Recipient was not registered successfully: {receipt:?}" ); assert_eq!( result.events.last().unwrap(), &TestRuntimeEvent::SolanaRegister( sov_hyperlane_register_module::Event::UserRegistered { - address: ::Address::from(payer), - credential_id: CredentialId::from(embedded), + address: payer_addr, + credential_id: credential, } ) ); @@ -59,16 +65,17 @@ fn test_user_is_registered_correctly() { }); runner.query_state(|state| { - let account = sov_accounts::Accounts::default().get_account(credential, state); - assert!(matches!( - account, - sov_accounts::Response::AccountExists { .. } - )); + assert!( + sov_accounts::Accounts::::default() + .is_explicitly_authorized(&payer_addr, &credential, state) + .unwrap_infallible(), + "Embedded credential should be authorized for the payer address after registration" + ); }); } #[test] -fn test_errors_if_user_already_registered() { +fn test_two_payers_registering_same_embedded_both_succeed() { let SetupParams { mut runner, admin, @@ -77,47 +84,64 @@ fn test_errors_if_user_already_registered() { } = setup(); let route_id = register_basic_warp_route(&mut runner, &admin); - let payer = [1u8; 32]; + let payer_a = [1u8; 32]; + let payer_a_addr = ::Address::from(payer_a); + let payer_b = [3u8; 32]; + let payer_b_addr = ::Address::from(payer_b); let embedded = [2u8; 32]; - let body = [payer, embedded].concat(); - let valid_message = make_valid_message(0, route_id, HexString::new(body)); - let message = HexString::new(SafeVec::try_from(valid_message.encode().0).unwrap()); + let credential = CredentialId::from(embedded); + // First registration: (payer_a, embedded). + let body_a = [payer_a, embedded].concat(); + let msg_a = make_valid_message(0, route_id, HexString::new(body_a)); + let envelope_a = HexString::new(SafeVec::try_from(msg_a.encode().0).unwrap()); runner.execute_transaction(TransactionTestCase { input: user.create_plain_message::>(CallMessage::Process { metadata: HexString::new(SafeVec::new()), - message: message.clone(), + message: envelope_a, }), assert: Box::new(move |result, _| { + let receipt = &result.tx_receipt; assert!( result.tx_receipt.is_successful(), - "Recipient was not registered successfully" + "First registration should succeed: {receipt:?}" ); }), }); - // payer is different so will try to register to different address - let payer = [3u8; 32]; - let embedded = [2u8; 32]; - let body = [payer, embedded].concat(); - let valid_message = make_valid_message(1, route_id, HexString::new(body)); - let message = HexString::new(SafeVec::try_from(valid_message.encode().0).unwrap()); - + // Same embedded credential, different payer — many-to-many authorization is allowed. + let body_b = [payer_b, embedded].concat(); + let msg_b = make_valid_message(1, route_id, HexString::new(body_b)); + let envelope_b = HexString::new(SafeVec::try_from(msg_b.encode().0).unwrap()); runner.execute_transaction(TransactionTestCase { input: user.create_plain_message::>(CallMessage::Process { metadata: HexString::new(SafeVec::new()), - message, + message: envelope_b, }), - assert: Box::new(move |result, _| match result.tx_receipt { - sov_rollup_interface::stf::TxEffect::Reverted(contents) => { - assert_eq!( - contents.reason.to_string(), - "Embedded pubkey already registered to different address. Attempted: CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8, Registered: 4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi".to_string() - ); - } - _ => panic!("Registration should have reverted: {:?}", result.tx_receipt), + assert: Box::new(move |result, _| { + let receipt = &result.tx_receipt; + assert!( + result.tx_receipt.is_successful(), + "Second registration with same embedded but different payer should also succeed: {receipt:?}" + ); }), }); + + runner.query_state(|state| { + let accounts = sov_accounts::Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&payer_a_addr, &credential, state) + .unwrap_infallible(), + "First (payer_a, embedded) authorization should be recorded" + ); + assert!( + accounts + .is_explicitly_authorized(&payer_b_addr, &credential, state) + .unwrap_infallible(), + "Second (payer_b, embedded) authorization should also be recorded" + ); + }); } #[test] diff --git a/crates/module-system/module-implementations/integration-tests/tests/kernel_interactions/gas_price_soft_confirmations.rs b/crates/module-system/module-implementations/integration-tests/tests/kernel_interactions/gas_price_soft_confirmations.rs index 47d0bdf96a..fe5a8fe69b 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/kernel_interactions/gas_price_soft_confirmations.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/kernel_interactions/gas_price_soft_confirmations.rs @@ -2,7 +2,7 @@ use sov_chain_state::ChainState; use sov_modules_api::macros::config_value; use sov_modules_api::prelude::UnwrapInfallible; use sov_modules_api::GasSpec; -use sov_rollup_interface::common::{IntoSlotNumber, RollupHeight}; +use sov_rollup_interface::common::IntoSlotNumber; use sov_test_utils::generate_optimistic_runtime_with_kernel; use crate::kernel_interactions::{HighLevelOptimisticGenesisConfig, TestRunner, S}; @@ -95,7 +95,6 @@ fn test_gas_price_soft_confirmations() { .unwrap() .gas_info() .clone(), - RollupHeight::GENESIS, 1, ), "The gas price should be the one for the slot after the last visible slot" diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs index e9629fc31c..d304782a1d 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs @@ -19,7 +19,7 @@ use sov_test_utils::runtime::{config_gas_token_id, Payable, TestRunner}; type S = sov_test_utils::TestSpec; -use sov_modules_api::transaction::{Transaction, TxDetails, UnsignedTransaction}; +use sov_modules_api::transaction::{Transaction, TxDetails, UnsignedTransaction, Version0}; use sov_modules_api::{PrivateKey, RawTx}; use sov_test_utils::{EncodeCall, TestUser, TEST_DEFAULT_MAX_FEE}; use sov_value_setter::ValueSetter; @@ -116,15 +116,6 @@ fn default_rewards() -> Rewards { } pub(crate) fn reset_constants() { - env::set_var( - "SOV_TEST_CONST_OVERRIDE_DEFAULT_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION", - "[1, 1]", - ); - env::set_var( - "SOV_TEST_CONST_OVERRIDE_MAX_ALLOWED_DATA_SIZE_RETURNED_BY_BLOB_STORAGE", - "10000000", - ); - env::set_var( "SOV_TEST_CONST_OVERRIDE_MAX_ALLOWED_DATA_SIZE_RETURNED_BY_BLOB_STORAGE", "10000000", @@ -233,16 +224,16 @@ pub fn create_tx_bad_sig>( nonce: u64, max_priority_fee_bips: PriorityFeeBips, signer: &TestUser, - chain_id: u64, message: RT::Decodable, ) -> Transaction { let utx = UnsignedTransaction::::new( message, - chain_id, + RT::CHAIN_HASH, max_priority_fee_bips, TEST_DEFAULT_MAX_FEE, UniquenessData::Nonce(nonce), None, + None, ); let signed_tx = Transaction::::new_signed_tx(&signer.private_key, &RT::CHAIN_HASH, utx); @@ -251,18 +242,19 @@ pub fn create_tx_bad_sig>( let bad_signature = signer.private_key.sign(&[1, 2, 3]); match signed_tx { - Transaction::V0(inner) => Transaction::new_with_details_v0( - inner.pub_key, - inner.runtime_call, - bad_signature, - inner.uniqueness, - TxDetails { + Transaction::V0(inner) => Transaction::V0(Version0 { + signature: bad_signature, + pub_key: inner.pub_key, + runtime_call: inner.runtime_call, + uniqueness: inner.uniqueness, + details: TxDetails { max_priority_fee_bips, max_fee: Amount::new(200_000), gas_limit: None, - chain_id, + chain_hash_fragment: inner.details.chain_hash_fragment, }, - ), + address_override: inner.address_override, + }), Transaction::V1(_inner) => { todo!("Bad signature generation for multisig transactions is not yet supported"); } @@ -272,17 +264,17 @@ pub fn create_tx_bad_sig>( pub fn create_tx_bad_sender>( nonce: u64, max_priority_fee_bips: PriorityFeeBips, - chain_id: u64, message: RT::Decodable, chain_hash: &[u8; 32], ) -> Transaction { let utx = UnsignedTransaction::new( message, - chain_id, + *chain_hash, max_priority_fee_bips, Amount::new(200_000), UniquenessData::Nonce(nonce), None, + None, ); let signer = TestUser::::generate(Amount::ZERO); @@ -293,19 +285,20 @@ pub fn create_tx_valid>( generation: u64, max_priority_fee_bips: PriorityFeeBips, signer: &TestUser, - chain_id: u64, + chain_hash: &[u8; 32], message: RT::Decodable, ) -> Transaction { let utx = UnsignedTransaction::new( message, - chain_id, + *chain_hash, max_priority_fee_bips, TEST_DEFAULT_MAX_FEE, UniquenessData::Generation(generation), None, + None, ); - Transaction::::new_signed_tx(signer.private_key(), &RT::CHAIN_HASH, utx) + Transaction::::new_signed_tx(signer.private_key(), chain_hash, utx) } // Transaction with zero gas limit. @@ -313,16 +306,16 @@ pub fn create_tx_out_of_gas>( nonce: u64, max_priority_fee_bips: PriorityFeeBips, signer: &TestUser, - chain_id: u64, message: RT::Decodable, ) -> Transaction { let utx = UnsignedTransaction::new( message, - chain_id, + RT::CHAIN_HASH, max_priority_fee_bips, Amount::new(200_000), UniquenessData::Nonce(nonce), Some(<::Gas as Gas>::zero()), + None, ); Transaction::::new_signed_tx(signer.private_key(), &RT::CHAIN_HASH, utx) @@ -349,7 +342,7 @@ pub fn create_txs + EncodeCall>>( generation, max_priority_fee_bips, admin, - config_value!("CHAIN_ID"), + &RT::CHAIN_HASH, encode_message::(None), ); txs.push(encode(tx)); @@ -363,18 +356,20 @@ pub fn create_txs + EncodeCall>>( 0, max_priority_fee_bips, admin, - config_value!("CHAIN_ID"), + &RT::CHAIN_HASH, encode_message::(None), ); txs.push(encode(tx)); } } TxStatus::BadChainId => { + let mut bad_chain_hash = RT::CHAIN_HASH; + bad_chain_hash[0] ^= 1; let tx = create_tx_valid::( generation, max_priority_fee_bips, admin, - config_value!("CHAIN_ID") + 1, + &bad_chain_hash, encode_message::(None), ); txs.push(encode(tx)); @@ -385,7 +380,6 @@ pub fn create_txs + EncodeCall>>( generation, max_priority_fee_bips, admin, - config_value!("CHAIN_ID"), encode_message::(None), ); txs.push(encode(tx)); @@ -395,7 +389,6 @@ pub fn create_txs + EncodeCall>>( generation, max_priority_fee_bips, admin, - config_value!("CHAIN_ID"), encode_message::(None), ); txs.push(encode(tx)); @@ -406,7 +399,7 @@ pub fn create_txs + EncodeCall>>( 0, max_priority_fee_bips, not_admin, - config_value!("CHAIN_ID"), + &RT::CHAIN_HASH, encode_message::(None), ); txs.push(encode(tx)); @@ -419,7 +412,6 @@ pub fn create_txs + EncodeCall>>( let tx = create_tx_bad_sender::( 0, max_priority_fee_bips, - config_value!("CHAIN_ID"), encode_message::(None), &RT::CHAIN_HASH, ); diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs index 65d6015f74..b3b7e00600 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs @@ -1,4 +1,3 @@ -use sov_accounts::{Accounts, CallMessage as AccountsCallMessage}; use sov_address::{EthereumAddress, EvmCryptoSpec}; use sov_eip712_auth::{ Eip712Authenticator, Eip712AuthenticatorInput, Eip712AuthenticatorTrait, SchemaProvider, @@ -8,9 +7,9 @@ use sov_mock_zkvm::MockZkvm; use sov_modules_api::capabilities::{TransactionAuthenticator, UniquenessData}; use sov_modules_api::configurable_spec::ConfigurableSpec; use sov_modules_api::execution_mode::Native; -use sov_modules_api::macros::config_value; -use sov_modules_api::transaction::PubKeyAndSignature; +use sov_modules_api::prelude::UnwrapInfallible; use sov_modules_api::transaction::TxDetails; +use sov_modules_api::transaction::{chain_hash_fragment, PubKeyAndSignature}; use sov_modules_api::transaction::{PriorityFeeBips, Transaction, UnsignedTransaction}; use sov_modules_api::CryptoSpec; use sov_modules_api::Multisig; @@ -20,7 +19,6 @@ use sov_rollup_interface::da::RelevantBlobs; use sov_rollup_interface::stf::{TxEffect, TxReceiptContents}; use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; use sov_test_utils::runtime::{TestRunner, ValueSetter}; -use sov_test_utils::TransactionTestCase; use sov_test_utils::{generate_runtime, EncodeCall, TestStorage, TestUser, TEST_DEFAULT_MAX_FEE}; use sov_value_setter::CallMessage; @@ -133,13 +131,27 @@ fn setup() -> (TestRunner, TestUser) { } pub fn create_utx>(message: RT::Decodable) -> UnsignedTransaction { + create_utx_with_generation::(message, 0, None) +} + +pub fn create_utx_with_generation>( + message: RT::Decodable, + generation: u64, + address_override: Option, +) -> UnsignedTransaction { + let chain_hash = TestSchemaProvider::get_schema().chain_hash().unwrap(); let details = TxDetails { max_priority_fee_bips: PriorityFeeBips::ZERO, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: None, - chain_id: config_value!("CHAIN_ID"), + chain_hash_fragment: chain_hash_fragment(&chain_hash), }; - UnsignedTransaction::new_with_details(message, UniquenessData::Generation(0), details) + UnsignedTransaction::new_with_details( + message, + UniquenessData::Generation(generation), + details, + address_override, + ) } pub fn sign_utx_in_place>( @@ -150,13 +162,38 @@ pub fn sign_utx_in_place>( let transaction_type_index = schema .rollup_expected_index( - sov_modules_api::sov_universal_wallet::schema::RollupRoots::UnsignedTransaction, + sov_modules_api::sov_universal_wallet::schema::RollupRoots::TransactionSigningPayload, + ) + .unwrap(); + + let signing_payload_bytes = utx.to_signing_bytes_v0(schema.chain_hash().unwrap()); + let eip712_signing_data = schema + .eip712_signing_digest(transaction_type_index, &signing_payload_bytes) + .expect("Failed to calculate EIP712 hash"); + + private_key.sign(&eip712_signing_data) +} + +/// Signs a V1 (multisig) unsigned transaction with the given private key using EIP712. +/// The credential_address is computed from the provided multisig. +pub fn sign_utx_v1_in_place>( + utx: &UnsignedTransaction, + multisig: &Multisig<::PublicKey>, + private_key: &::PrivateKey, +) -> ::Signature { + let schema = TestSchemaProvider::get_schema(); + + let transaction_type_index = schema + .rollup_expected_index( + sov_modules_api::sov_universal_wallet::schema::RollupRoots::TransactionSigningPayload, ) .unwrap(); - let utx_bytes = borsh::to_vec(&utx).expect("Failed to serialize unsigned transaction"); + let signing_payload_bytes = utx + .to_signing_bytes_v1(multisig, schema.chain_hash().unwrap()) + .expect("Chain hash fragment should match the schema chain hash"); let eip712_signing_data = schema - .eip712_signing_digest(transaction_type_index, &utx_bytes) + .eip712_signing_digest(transaction_type_index, &signing_payload_bytes) .expect("Failed to calculate EIP712 hash"); private_key.sign(&eip712_signing_data) @@ -178,11 +215,10 @@ pub fn create_tx>( sign_utx::(utx, signer) } -pub fn encode_message + EncodeCall>>() -> RT::Decodable { - let msg = CallMessage::SetValue { - value: 0, - gas: None, - }; +pub fn encode_message + EncodeCall>>( + value: u32, +) -> RT::Decodable { + let msg = CallMessage::SetValue { value, gas: None }; RT::to_decodable(msg) } @@ -239,7 +275,7 @@ fn execute_tx_maybe_malformed( #[test] fn correct_signature_is_accepted() { let (mut runner, admin) = setup(); - let call = encode_message::<_, RT>(); + let call = encode_message::<_, RT>(1); let tx = create_tx::<_, RT>(call, &admin); let receipt = execute_tx(&mut runner, tx); @@ -251,7 +287,7 @@ fn correct_signature_is_accepted() { #[test] fn duplicate_tx_is_rejected() { let (mut runner, admin) = setup(); - let call = encode_message::<_, RT>(); + let call = encode_message::<_, RT>(0); let tx = create_tx::<_, RT>(call, &admin); let tx_clone = tx.clone(); @@ -266,46 +302,65 @@ fn duplicate_tx_is_rejected() { assert!( error .to_string() - .contains("148 trailing bytes after transaction deserialization"), - "Expected error to contain '148 trailing bytes after transaction deserialization', got: {error}" + .contains("149 trailing bytes after transaction deserialization"), + "Expected error to contain '149 trailing bytes after transaction deserialization', got: {error}" ); } #[test] fn test_multisig_signature_verification() { - use sov_test_utils::AsUser; - let (mut runner, admin) = setup(); - - // First, create and register a multisig + // `address_override` authorization correctness (positive + negative paths) is + // covered in sov-accounts/tests/integration/main.rs::test_v1_address_override_*. + // This test focuses on signature verification mechanics only. let multisig_keys = [ TestPrivateKey::generate(), TestPrivateKey::generate(), TestPrivateKey::generate(), ]; - - // Create the multisig and register it let multisig = Multisig::new(2, multisig_keys.iter().map(|k| k.pub_key()).collect()); let multisig_credential_id = multisig.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); - runner.execute_transaction(TransactionTestCase { - input: admin.create_plain_message::>( - AccountsCallMessage::InsertCredentialId(multisig_credential_id), - ), - assert: Box::new(move |result, _state| { - assert!(result.tx_receipt.is_successful()); - }), + + // Alice is a regular funded user; she pays gas for the multisig's transactions. + // The multisig acts on her behalf via an `address_override` authorized in genesis. + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = HighLevelOptimisticGenesisConfig::generate() + .add_accounts_with_default_balance(1) + .add_accounts(vec![alice]); + let module_config = sov_value_setter::ValueSetterConfig { + admin: alice_address, + }; + let mut genesis = + GenesisConfig::from_minimal_config(genesis_config.clone().into(), module_config); + genesis.accounts.accounts.push(sov_accounts::AccountData { + credential_id: multisig_credential_id, + address: alice_address, }); + let mut runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); - // Create a multisig transaction - let utx = create_utx::(encode_message::<_, RT>()); - let mut signatures = Vec::new(); - for key in multisig_keys.iter() { - signatures.push(sign_utx_in_place(&utx, key)); - } // Generate a signature from a random private key that's not part of the multisig. We'll use this in some of the test cases. let random_private_key = TestPrivateKey::generate(); - let random_signature = sign_utx_in_place(&utx, &random_private_key); - let tx = utx.to_multisig_tx(multisig); + let address_override = Some(alice_address); + let make_multisig_tx = |generation, value| { + let utx = create_utx_with_generation::( + encode_message::<_, RT>(value), + generation, + address_override, + ); + let signatures = multisig_keys + .iter() + .map(|key| sign_utx_v1_in_place(&utx, &multisig, key)) + .collect::>(); + let random_signature = sign_utx_v1_in_place(&utx, &multisig, &random_private_key); + + ( + utx.to_multisig_tx(multisig.clone()), + signatures, + random_signature, + ) + }; // Helper functions to assert the expected behavior of the transaction let assert_tx_success = |tx: Transaction, runner: &mut TestRunner| { @@ -326,30 +381,45 @@ fn test_multisig_signature_verification() { "Expected error to contain {reason}, got: {error}" ); }; + let assert_stored_value = |runner: &mut TestRunner, expected: u32| { + runner.query_state(|state| { + let runtime = RT::default(); + assert_eq!( + runtime.value_setter.value.get(state).unwrap_infallible(), + Some(expected), + ); + }); + }; - // A transaction with three valid signatures should succeed + // A transaction with three valid signatures should succeed and write `value = 3`. let tx_with_three_sigs = { - let mut tx = tx.clone(); + let (mut tx, signatures, _) = make_multisig_tx(0, 3); for (key, signature) in multisig_keys.iter().zip(signatures.iter()) { tx.add_signature(signature.clone(), key.pub_key()).unwrap(); } Transaction::::from(tx) }; assert_tx_success(tx_with_three_sigs, &mut runner); + assert_stored_value(&mut runner, 3); - // A transaction with only two valid signatures should succeed + // A transaction with only two valid signatures should succeed and write `value = 2`. let tx_with_two_sigs = { - let mut tx = tx.clone(); + let (mut tx, signatures, _) = make_multisig_tx(1, 2); for (key, signature) in multisig_keys.iter().zip(signatures.iter().take(2)) { tx.add_signature(signature.clone(), key.pub_key()).unwrap(); } Transaction::::from(tx) }; assert_tx_success(tx_with_two_sigs, &mut runner); + assert_stored_value(&mut runner, 2); + + // From here on, every case is expected to be skipped — `value` should remain at 2. + // Each case uses a distinct sentinel for `SetValue { value }` so that a regression + // letting any case slip through points to which one leaked. // A transaction with only one valid signature should be skipped, since this is a 2/3 multisig. let tx_with_one_sig = { - let mut tx = tx.clone(); + let (mut tx, signatures, _) = make_multisig_tx(2, 99); for (key, signature) in multisig_keys.iter().zip(signatures.iter().take(1)) { tx.add_signature(signature.clone(), key.pub_key()).unwrap(); } @@ -360,31 +430,30 @@ fn test_multisig_signature_verification() { &mut runner, "Not enough valid signatures. Required: 2, Got: 1", ); + assert_stored_value(&mut runner, 2); // A transaction where one of the signatures isn't from this account should be skipped, since this changes the computed credential ID. let tx_with_random_sig = { - let mut tx = tx.clone(); + let (mut tx, signatures, random_signature) = make_multisig_tx(3, 98); tx.add_signature(signatures[0].clone(), multisig_keys[0].pub_key()) .unwrap(); tx.signatures .try_push(PubKeyAndSignature { - signature: random_signature.clone(), + signature: random_signature, pub_key: random_private_key.pub_key(), }) .unwrap(); Transaction::::from(tx) }; - // Since the random signature is not part of the multisig, this changes the computed credential ID yielding a gas error. If we were to add a paymaster, - // The tx would succeed on a different account. In that case, this test case would need refinement to distinguish between the two cases. - assert_tx_skipped( - tx_with_random_sig, - &mut runner, - "Insufficient balance to pay for the transaction gas", - ); + // The random key changes the credential_address so it no longer matches the signed bytes, + // so signature verification fails. + assert_tx_skipped(tx_with_random_sig, &mut runner, "signature error"); + assert_stored_value(&mut runner, 2); - // A transaction with a duplicate signature should be skipped + // A transaction with a duplicate signature should be skipped — the duplicate changes + // the credential_address, causing signature verification failure. let tx_with_duplicate_sig = { - let mut tx = tx.clone(); + let (mut tx, signatures, _) = make_multisig_tx(4, 97); tx.add_signature(signatures[0].clone(), multisig_keys[0].pub_key()) .unwrap(); tx.signatures @@ -395,15 +464,12 @@ fn test_multisig_signature_verification() { .unwrap(); Transaction::::from(tx) }; - assert_tx_skipped( - tx_with_duplicate_sig, - &mut runner, - "is not part of the multisig or has already signed", - ); + assert_tx_skipped(tx_with_duplicate_sig, &mut runner, "signature error"); + assert_stored_value(&mut runner, 2); - // A transaction with a bad signature should be skipped + // A transaction with a bad signature should be skipped. let tx_with_bad_sig = { - let mut tx = tx.clone(); + let (mut tx, signatures, _) = make_multisig_tx(5, 96); tx.add_signature(signatures[0].clone(), multisig_keys[0].pub_key()) .unwrap(); tx.add_signature( @@ -414,4 +480,5 @@ fn test_multisig_signature_verification() { Transaction::::from(tx) }; assert_tx_skipped(tx_with_bad_sig, &mut runner, "signature error"); + assert_stored_value(&mut runner, 2); } diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/registered.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/registered.rs index da8a973198..2ea3ff4a04 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/registered.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/registered.rs @@ -33,7 +33,7 @@ fn check_txs(tx_statuses: Vec) { runner.config.sequencer_da_address, ); - let seq_burn_gas = ::gas_to_charge_per_byte_borsh_deserialization() + let seq_burn_gas = ::gas_to_charge_per_byte_borsh_read() .checked_scalar_product(mock_blob.total_len() as u64) .unwrap(); diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs index 5e73df837c..e698bcb374 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use sov_bank::Bank; use sov_modules_api::capabilities::{TransactionAuthenticator, UniquenessData}; -use sov_modules_api::transaction::{Transaction, UnsignedTransaction}; +use sov_modules_api::transaction::{Transaction, UnsignedTransaction, Version0}; use sov_modules_api::{Amount, EncodeCall, FullyBakedTx, PrivateKey, RawTx, Runtime}; use sov_test_utils::generators::bank::BankMessageGenerator; use sov_test_utils::generators::sequencer_registry::SequencerRegistryMessageGenerator; @@ -45,15 +45,16 @@ pub fn simulate_da_with_revert_msg(admin: TestPrivateKey) -> Vec { pub fn simulate_da_with_bad_sig(key: TestPrivateKey) -> Vec { let bank_generator: BankMessageGenerator = BankMessageGenerator::with_minter(key.clone()); let create_token_message = bank_generator.create_default_messages().remove(0); - let tx = Transaction::, S>::new_with_details_v0( - create_token_message.sender_key.pub_key(), - as EncodeCall>>::to_decodable(create_token_message.content), - // Use the signature of an empty message - key.sign(&[]), - UniquenessData::Generation(create_token_message.generation), - create_token_message.details, - ); - // Overwrite the signature with the signature of the empty message + let tx = Transaction::, S>::V0(Version0 { + signature: key.sign(&[]), + pub_key: create_token_message.sender_key.pub_key(), + runtime_call: as EncodeCall>>::to_decodable( + create_token_message.content, + ), + uniqueness: UniquenessData::Generation(create_token_message.generation), + details: create_token_message.details, + address_override: None, + }); vec![encode_with_auth(tx)] } @@ -86,6 +87,7 @@ pub fn simulate_da_with_bad_serialization(key: TestPrivateKey) -> Vec, priority_fee_bips: PriorityFeeBips) { runner.config.sequencer_da_address, ); // The gas amount burned by the sequencer to submit the blob. See #2491 - let seq_burn_gas = ::gas_to_charge_per_byte_borsh_deserialization() + let seq_burn_gas = ::gas_to_charge_per_byte_borsh_read() .checked_scalar_product(mock_blob.total_len() as u64) .unwrap(); @@ -203,7 +202,7 @@ fn non_existing_seq_da_tests() { #[test] fn sequencer_run_out_of_gas() { env::set_var( - "SOV_TEST_CONST_OVERRIDE_DEFAULT_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION", + "SOV_TEST_CONST_OVERRIDE_BORSH_PER_BYTE_READ", "[100000, 100000]", ); @@ -216,7 +215,7 @@ fn sequencer_run_out_of_gas() { #[test] fn slot_out_of_gas_tests() { env::set_var( - "SOV_TEST_CONST_OVERRIDE_INITIAL_GAS_LIMIT", + "SOV_TEST_CONST_OVERRIDE_BLOCK_GAS_LIMIT", "[10000000000, 10000000000]", ); let priority_fee_bips = PriorityFeeBips::from_percentage(5); @@ -235,7 +234,7 @@ fn slot_out_of_gas_tests() { 10, priority_fee_bips, &actors.admin_account, - config_value!("CHAIN_ID"), + & as sov_modules_api::Runtime>::CHAIN_HASH, encode_message::>(Some(gas)), ); diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/unregistered.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/unregistered.rs index 0a50be7890..18ad701c7d 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/unregistered.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/unregistered.rs @@ -5,7 +5,6 @@ use sov_attester_incentives::AttesterIncentives; use sov_bank::IntoPayable; use sov_mock_da::{MockAddress, MockBlob}; use sov_modules_api::capabilities::TransactionAuthenticator; -use sov_modules_api::macros::config_value; use sov_modules_api::transaction::{PriorityFeeBips, Transaction, UnsignedTransaction}; use sov_modules_api::{ Amount, ApiStateAccessor, DaSpec, FullyBakedTx, Gas, GasArray, ModuleInfo, RawTx, Rewards, @@ -246,16 +245,16 @@ mod helpers { fn create_tx_bad_sender( nonce: u64, max_priority_fee_bips: PriorityFeeBips, - chain_id: u64, message: IntegTestRuntimeCall, ) -> Transaction, S> { let utx = UnsignedTransaction::new( message, - chain_id, + IntegTestRuntime::::CHAIN_HASH, max_priority_fee_bips, Amount::new(200_000), UniquenessData::Nonce(nonce), None, + None, ); let signer = TestUser::::generate(Amount::ZERO); @@ -272,7 +271,6 @@ mod helpers { max_priority_fee_bips: PriorityFeeBips, signer: &TestUser, da_address: <::Da as DaSpec>::Address, - chain_id: u64, ) -> Transaction, S> { // Here, we attempt to bond more funds than are available for a given user, causing the transaction to be reverted. let encoded_message = encode_message( @@ -285,11 +283,12 @@ mod helpers { let utx = UnsignedTransaction::new( encoded_message, - chain_id, + IntegTestRuntime::::CHAIN_HASH, max_priority_fee_bips, TEST_DEFAULT_MAX_FEE, UniquenessData::Nonce(nonce), None, + None, ); Transaction::, S>::new_signed_tx( @@ -323,22 +322,25 @@ mod helpers { 0, max_priority_fee_bips, &potential_seq.user, - config_value!("CHAIN_ID"), + &IntegTestRuntime::::CHAIN_HASH, encode_message(potential_seq.da_address, BOND_AMOUNT), )), TxStatus::BadGeneration => panic!("Unregistered blobs send one transaction per user, any generation number is valid for a user's first transaction"), - TxStatus::BadChainId => encode_tx(create_tx_valid::>( - 0, - max_priority_fee_bips, - &potential_seq.user, - config_value!("CHAIN_ID") + 1, - encode_message(potential_seq.da_address, BOND_AMOUNT), - )), + TxStatus::BadChainId => { + let mut bad_chain_hash = IntegTestRuntime::::CHAIN_HASH; + bad_chain_hash[0] ^= 1; + encode_tx(create_tx_valid::>( + 0, + max_priority_fee_bips, + &potential_seq.user, + &bad_chain_hash, + encode_message(potential_seq.da_address, BOND_AMOUNT), + )) + } TxStatus::BadSignature => encode_tx(create_tx_bad_sig( 0, max_priority_fee_bips, &potential_seq.user, - config_value!("CHAIN_ID"), encode_message(potential_seq.da_address, BOND_AMOUNT), )), TxStatus::BadSerialization => as Runtime>::Auth::encode_with_standard_auth(RawTx { @@ -348,7 +350,6 @@ mod helpers { 0, max_priority_fee_bips, &potential_seq.user, - config_value!("CHAIN_ID"), encode_message(potential_seq.da_address, BOND_AMOUNT), )), TxStatus::Reverted => encode_tx(create_tx_reverted( @@ -356,12 +357,10 @@ mod helpers { max_priority_fee_bips, &potential_seq.user, potential_seq.da_address, - config_value!("CHAIN_ID"), )), TxStatus::SignerDoesNotExist => encode_tx(create_tx_bad_sender( 0, max_priority_fee_bips, - config_value!("CHAIN_ID"), encode_message(potential_seq.da_address, BOND_AMOUNT), )), }; diff --git a/crates/module-system/module-implementations/sov-accounts/Cargo.toml b/crates/module-system/module-implementations/sov-accounts/Cargo.toml index 2622868de9..f064da1d7d 100644 --- a/crates/module-system/module-implementations/sov-accounts/Cargo.toml +++ b/crates/module-system/module-implementations/sov-accounts/Cargo.toml @@ -21,12 +21,15 @@ serde = { workspace = true } serde_with = { workspace = true } sov-modules-api = { workspace = true } +sov-chain-state = { workspace = true } sov-state = { workspace = true } arbitrary = { workspace = true, optional = true } [dev-dependencies] +sov-rollup-interface = { workspace = true, features = ["native"] } sov-accounts = { path = ".", features = ["native"] } +sov-bank = { workspace = true } tempfile = { workspace = true } strum = { workspace = true } sov-modules-api = { workspace = true, features = ["test-utils"] } @@ -40,10 +43,15 @@ arbitrary = [ "sov-modules-api/arbitrary", "sov-state/arbitrary", "sov-test-utils/arbitrary", + "sov-bank/arbitrary", + "sov-rollup-interface/arbitrary" ] native = [ "sov-accounts/native", + "sov-chain-state/native", "sov-modules-api/native", "sov-state/native", + "sov-bank/native", + "sov-rollup-interface/native" ] test-utils = [] diff --git a/crates/module-system/module-implementations/sov-accounts/README.md b/crates/module-system/module-implementations/sov-accounts/README.md index 08b73c9c37..31a977656f 100644 --- a/crates/module-system/module-implementations/sov-accounts/README.md +++ b/crates/module-system/module-implementations/sov-accounts/README.md @@ -1,15 +1,194 @@ # `sov-accounts` module -The `sov-accounts` module is responsible for managing accounts on the rollup. +The `sov-accounts` module resolves transaction credentials to rollup +addresses and records which credentials may act for which addresses. +### The `sov-accounts` module offers the following functionality -### The `sov-accounts` module offers the following functionality: +1. A credential has a deterministic canonical address, computed as `credential_id.into::()`. + This relation is stateless: using the canonical address does not require an account entry to be written. -1. When a sender sends their first message, the `sov-accounts` module will create a new address by deriving it from the sender's credential. - The module will then add a mapping between the credential id and the address to its state. For all subsequent messages that include the sender's credential, - the module will retrieve the sender's address from the mapping and pass it along with the original message to an intended module. +2. It is possible to authorize another credential for the caller's address using + the `CallMessage::InsertCredentialId(..)` message. + This writes an `account_owners` authorization. -1. It is possible to add new credential to a given address using the `CallMessage::InsertCredentialId(..)` message. +3. It is possible to explicitly authorize a credential for the caller's own + address with `CallMessage::AddCredentialToAddress { address, credential }`, + revoke such an authorization with + `CallMessage::RemoveCredentialFromAddress { address, credential }`, and + atomically swap one credential for another with + `CallMessage::RotateCredentialOnAddress { address, old_credential, new_credential }`. + All three calls require `message.address == context.sender()`: callers can + only modify credentials on the address they are currently signing as. The + V1 signing path's `target_address` field lets a caller signing with a + credential authorized for multiple addresses select which one to act as. + There is no orphan guard on remove — revoking the last credential leaves + the address unspendable via `account_owners`. -1. It is possible to query the `sov-accounts` module using the `get_account` method and get the account corresponding to the given credential id. +4. It is possible to create a new *synthetic* address — an address whose + authorization lives purely in `account_owners` and which has no + naturally-corresponding private key — with + `CallMessage::CreateSyntheticAddress { salt }`. The address is derived + by hashing `(domain || visible_slot_hash || sender_addr || + sender_credential || salt)` with `S::CryptoSpec::Hasher`, converting + the resulting 32 bytes to `CredentialId`, and then to `S::Address`. + The caller's current credential is auto-authorized for it. Different + callers, salts, and visible slots produce different addresses; + replaying the same tuple in the same slot is an idempotent no-op. + `visible_slot_hash` is part of the derivation by design: an attacker + who later compromises the caller's private key cannot reconstruct + the same synthetic address off-chain, because the slot hash only + becomes known once chain progress commits to it and is unforgeable + without participating in consensus. This makes the call + *bind-then-use*, not *counterfactual*: unlike CREATE2 or ERC-4337, + the address cannot be predicted and prefunded ahead of the + `CreateSyntheticAddress` transaction. Callers must wait for + finalization and read the derived address from the emitted + `SyntheticAddressCreated` event before routing assets or permissions + to it. + + The resulting synthetic address is operated on through the same + `AddCredentialToAddress` / `RemoveCredentialFromAddress` / + `RotateCredentialOnAddress` calls as any other address. + + +## Credential and Address Relations + +### Stateless canonical address + +```text +canonical(credential_id) = credential_id.into::() +``` + +This is a deterministic, stateless derivation. It requires no state write. For +specs whose `S::Address` is a simple hash-derived address, an unauthorized +credential can still act as `canonical(credential_id)` thanks to the canonical +fallback in `is_authorized_for`. For composite address specs (e.g. +`MultiAddress`), `canonical(credential_id)` is only one of several +possible "natural" addresses for a credential — see the worked example below. + +### Authenticator-declared default address + +```text +default_address = authenticator(credential_id) +``` + +This is the address the authenticator chooses to admit the transaction as, +absent an explicit `address_override`. It is **not stateless** and **not +required to equal** `canonical(credential_id)`. The authenticator is trusted +to have verified the credential→default_address binding before producing this +value; the on-chain admit-path treats `default_address` as authoritative +unless `account_owners[(default_address, credential_id)] = false` is +explicitly recorded. + +### Account-credential authorization map + +```text +account_owners[(address, credential_id)] = true | false +``` + +This state map records authorization overrides. `true` grants authorization; +`false` explicitly revokes authorization for that pair, including the +stateless canonical fallback. The key is the exact `(address, credential_id)` +pair, so this relation does not provide a credential-only lookup by itself. +New `InsertCredentialId` / `AddCredentialToAddress` calls write `true`; +`RemoveCredentialFromAddress` writes `false`. + +### Three predicates + +`Accounts` exposes three authorization predicates with deliberately different +fallback semantics. Each answers a different question: + +| Function | No-entry fallback | Question answered | Used by | +|---|---|---|---| +| `is_explicitly_authorized(addr, cred)` | `false` | "Is this pair explicitly granted?" | On-chain admit-path when `address_override = Some(_)` | +| `is_default_address_authorized(addr, cred)` | `true` | "Would the chain admit a tx if an authenticator declared this address as default?" | On-chain admit-path when `address_override = None` | +| `is_authorized_for(addr, cred)` | `canonical(cred) == addr` | "Is this credential authorized to act as this address under the canonical-fallback view?" | Internal credential lifecycle checks (revoke, rotate, conflict) | + +The REST endpoint `GET /authorizations/{address}/{credential_id}` returns all +three as `admit_as_override`, `admit_as_default`, and `authorized` +(deprecated — kept for backward compatibility). + +### Worked example: EVM authenticator divergence + +Consider a rollup whose spec is `MultiAddress` and a +transaction signed by EVM `signer = 0xAa...Bb` with `credential_id = C = +keccak256(signer_pubkey)`: + +- `canonical(C) = C.into::() = MultiAddress::Standard(...)` + via the blanket `impl From for MultiAddress` + in `sov-address/src/lib.rs`. +- `default_address = S::Address::from_vm_address(EthereumAddress(0xAa...Bb)) = + MultiAddress::Vm(0xAa...Bb)` set by `sov-evm/src/authenticate.rs` in + `extract_evm_authorization_data`. + +The two are different enum discriminants, so `default_address != canonical(C)` +for every EVM-signed transaction. Querying +`GET /authorizations/{default_address}/{C}` with no prior `account_owners` +entry returns: + +| Field | Value | Why | +|---|---|---| +| `admit_as_override` | `false` | No explicit entry | +| `admit_as_default` | `true` | No entry → chain trusts authenticator | +| `authorized` (deprecated) | `false` | No entry AND `canonical(C) != default_address` (different `MultiAddress` variants) | + +The chain admits these transactions (via the `admit_as_default` path). +Consumers that inspect authorization via the REST endpoint must read +`admit_as_default` — not the deprecated `authorized` field — or they will +incorrectly report EVM signers as unauthorized for addresses the chain +actually authorizes. + +## Upgrade procedure for chains with legacy `accounts` entries + +The module used to have a separate `accounts` mapping with different semantics, now deprecated and unused. +Chains whose genesis was before the `accounts` deprecation need to have a migration run at the upgrade height +(including whenever resyncing from genesis). + +The migration ships as a CLI binary in `examples/demo-rollup`: + +```sh +# Inspect what would change without committing. +cargo run --features migration-script \ + --bin legacy-accounts-migrate -- \ + --rollup-config-path /path/to/rollup_config.toml \ + --dry-run + +# Commit the migration in-place at the current head version. +cargo run --features migration-script \ + --bin legacy-accounts-migrate -- \ + --rollup-config-path /path/to/rollup_config.toml +``` + +The binary requires a stopped node (the storage manager opens the DB exclusively). +The reported `pre_state_root` and `post_state_root` are written in JSON; verify the +post-root matches what the rollup loads on restart. + +### Behavior change: canonical-address authorization is no longer suppressed + +A pre-migration `accounts[C] = A` row was authoritative: `is_authorized_for(X, C)` returned strictly +`A == X` and bypassed the canonical-address fallback. The refactored `is_authorized_for` returns +`true` whenever `X == credential_id.into::()` or `account_owners[(X, C)] = true`, with +no suppression. + +The migration converts each `accounts[C] = A` row into `account_owners[(A, C)] = true`. For any +legacy row where `A != canonical(credential_id)`, the credential gains authorization to act as +`canonical(credential_id)` after the migration, in addition to keeping authorization for `A`. The +canonical fallback is computed, not stored, so it cannot be revoked through `account_owners`. + +Worked example: suppose pre-migration `accounts[C] = A` with `A = 0x1111…` and +`canonical(C) = C.into::() = 0x2222…`. Before the migration, `C` could sign only as +`0x1111…`. After the migration, `account_owners[(0x1111…, C)] = true` is written, and the +unsuppressed canonical fallback means `C` can now also sign as `0x2222…` — two addresses, one +credential. + +Operators should treat each migrated entry as also implicitly authorizing +`credential_id.into::()`. If that address holds assets or permissions whose security +relied on the legacy exclusive semantic, retire the credential before deploying the new binary. + +The migration requires NOMT prefix iteration; JMT-backed deployments are not supported. + +For non-demo rollups, copy `examples/demo-rollup/src/migrations/legacy_accounts.rs` +and swap in your own runtime/spec types — the migration logic itself lives in +`sov_accounts::migrations` and is reusable. diff --git a/crates/module-system/module-implementations/sov-accounts/src/call.rs b/crates/module-system/module-implementations/sov-accounts/src/call.rs index 6b23a9ece5..f0572e0e87 100644 --- a/crates/module-system/module-implementations/sov-accounts/src/call.rs +++ b/crates/module-system/module-implementations/sov-accounts/src/call.rs @@ -1,22 +1,100 @@ -use anyhow::bail; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Context as _}; use schemars::JsonSchema; +use sov_modules_api::digest::Digest; use sov_modules_api::macros::{serialize, UniversalWallet}; -use sov_modules_api::{Context, CredentialId, Spec, StateReader, TxState}; +use sov_modules_api::{ + Context, CredentialId, CryptoSpec, EventEmitter, Multisig, PublicKey, Spec, StateReader, + TxState, +}; use sov_state::namespaces::User; -use crate::{Account, Accounts}; +use crate::{AccountOwnerKey, Accounts, Event}; + +/// Domain separation prefix for the [`CallMessage::CreateSyntheticAddress`] +/// address derivation. Bumping the version invalidates all previously +/// derived synthetic addresses, so do not change without a chain upgrade. +const SYNTHETIC_ADDRESS_DOMAIN: &[u8] = b"sov_accounts::synthetic_address::v1"; /// Represents the available call messages for interacting with the sov-accounts module. #[derive(Debug, PartialEq, Eq, Clone, JsonSchema, UniversalWallet)] #[serialize(Borsh, Serde)] +#[schemars(bound = "S::Address: ::schemars::JsonSchema", rename = "CallMessage")] #[serde(rename_all = "snake_case")] -pub enum CallMessage { - /// Inserts a new credential id for the corresponding Account. +pub enum CallMessage { + /// Authorizes `credential_id` as a signer for the caller's address. + /// Fails if the credential is already authorized for the caller's address. InsertCredentialId( - /// The new credential id. + /// The credential id being authorized. CredentialId, ), + + /// Authorizes `credential` to sign transactions that execute as `address`. + /// The caller must currently be signing as `address`. Fails if the tuple + /// is already authorized. + AddCredentialToAddress { + /// The address whose credential set is being extended. Must equal + /// `context.sender()`. + address: S::Address, + /// The credential being authorized for `address`. + credential: CredentialId, + }, + + /// Revokes `credential` from `address`. The caller must currently be + /// signing as `address`. No orphan guard: revoking the last credential + /// succeeds and leaves `address` unspendable via this map. + RemoveCredentialFromAddress { + /// The address whose credential set is being reduced. Must equal + /// `context.sender()`. + address: S::Address, + /// The credential being revoked from `address`. + credential: CredentialId, + }, + + /// Atomically swaps `old_credential` for `new_credential` on `address`. + /// Functionally equivalent to a `RemoveCredentialFromAddress` followed by + /// an `AddCredentialToAddress`, collapsed into a single call so the + /// caller does not have to authorize two transactions during a rotation. + /// The caller must currently be signing as `address`. + RotateCredentialOnAddress { + /// The address whose credential set is being rotated. Must equal + /// `context.sender()`. + address: S::Address, + /// The credential being revoked from `address`. Must be currently + /// authorized. + old_credential: CredentialId, + /// The credential being authorized for `address`. Must not already + /// be authorized. + new_credential: CredentialId, + }, + + /// Creates a new *synthetic* address — an address whose authorization + /// lives purely in `account_owners` and which has no + /// naturally-corresponding private key — and auto-authorizes the + /// caller's current credential for it. + /// + /// The address is derived deterministically by hashing + /// `(domain || visible_slot_hash || sender_addr || sender_credential || salt)` + /// with `S::CryptoSpec::Hasher`, then converting the resulting 32 bytes + /// to `S::Address` via `CredentialId.into()`. Different callers, salts, + /// and visible slots produce different addresses; replaying the same + /// tuple in the same slot is an idempotent no-op. + /// + /// `visible_slot_hash` is part of the derivation by design: an + /// attacker who later compromises the caller's private key cannot + /// reconstruct the same synthetic address off-chain, because the slot + /// hash only becomes known once chain progress commits to it and is + /// unforgeable without participating in consensus. This makes the + /// call *bind-then-use*, not *counterfactual*: unlike CREATE2 or + /// ERC-4337, the address cannot be predicted and prefunded ahead of + /// the `CreateSyntheticAddress` transaction. Callers must wait for + /// finalization and read the derived address from the + /// `SyntheticAddressCreated` event before routing assets or + /// permissions to it. + CreateSyntheticAddress { + /// Caller-supplied salt that allows the same caller to derive + /// multiple distinct synthetic addresses in the same slot. + salt: [u8; 32], + }, } impl Accounts { @@ -25,35 +103,246 @@ impl Accounts { new_credential_id: CredentialId, context: &Context, state: &mut impl TxState, - ) -> Result<()> { - if !self.enable_custom_account_mappings.get(state)?.expect( - "`enable_custom_account_mappings` should not be None; it must be set at genesis.", - ) { - bail!("Custom account mappings are disabled"); + ) -> anyhow::Result<()> { + self.ensure_custom_account_mappings_enabled(state)?; + + self.ensure_credential_not_authorized(context.sender(), &new_credential_id, state)?; + + self.authorize_credential(context.sender(), &new_credential_id, state)?; + self.emit_event( + state, + Event::CredentialAdded { + address: *context.sender(), + credential: new_credential_id, + authorizer_address: *context.sender(), + }, + ); + Ok(()) + } + + pub(crate) fn add_credential_to_address( + &mut self, + address: S::Address, + credential: CredentialId, + context: &Context, + state: &mut impl TxState, + ) -> anyhow::Result<()> { + self.ensure_custom_account_mappings_enabled(state)?; + self.ensure_caller_owns(&address, context)?; + self.ensure_credential_not_authorized(&address, &credential, state)?; + + self.authorize_credential(&address, &credential, state)?; + self.emit_event( + state, + Event::CredentialAdded { + address, + credential, + authorizer_address: *context.sender(), + }, + ); + Ok(()) + } + + pub(crate) fn remove_credential_from_address( + &mut self, + address: S::Address, + credential: CredentialId, + context: &Context, + state: &mut impl TxState, + ) -> anyhow::Result<()> { + self.ensure_custom_account_mappings_enabled(state)?; + self.ensure_caller_owns(&address, context)?; + + anyhow::ensure!( + self.is_authorized_for(&address, &credential, state) + .context("Failed to check credential authorization")?, + "CredentialId is not authorized for this address" + ); + + self.revoke_credential(&address, &credential, state)?; + self.emit_event( + state, + Event::CredentialRemoved { + address, + credential, + authorizer_address: *context.sender(), + }, + ); + Ok(()) + } + + pub(crate) fn rotate_credential_on_address( + &mut self, + address: S::Address, + old_credential: CredentialId, + new_credential: CredentialId, + context: &Context, + state: &mut impl TxState, + ) -> anyhow::Result<()> { + self.ensure_custom_account_mappings_enabled(state)?; + self.ensure_caller_owns(&address, context)?; + + anyhow::ensure!( + self.is_authorized_for(&address, &old_credential, state) + .context("Failed to check old credential authorization")?, + "old_credential is not authorized for this address" + ); + self.ensure_credential_not_authorized(&address, &new_credential, state)?; + + self.revoke_credential(&address, &old_credential, state)?; + self.authorize_credential(&address, &new_credential, state)?; + self.emit_event( + state, + Event::CredentialRotated { + address, + old_credential, + new_credential, + authorizer_address: *context.sender(), + }, + ); + Ok(()) + } + + pub(crate) fn create_synthetic_address( + &mut self, + salt: [u8; 32], + context: &Context, + state: &mut impl TxState, + ) -> anyhow::Result<()> { + self.ensure_custom_account_mappings_enabled(state)?; + + let caller_credential = self.caller_credential_id(context)?; + // `chain_state` writes the genesis slot in its `genesis` step and updates + // `slots` at the start of every slot via `synchronize_chain`. By the time + // a user transaction executes there is always a visible slot, so the + // `None` branch below would indicate that `chain_state` was not wired + // into the runtime — we surface that as a tx-level error rather than + // panicking so a misconfigured runtime cannot crash the batch. + let visible_slot = self + .chain_state + .latest_visible_slot(state) + .map_err(|err| anyhow!("Failed to read the visible DA slot: {err:?}"))? + .ok_or_else(|| anyhow!("Visible DA slot hash is unavailable"))?; + + let mut hasher = ::Hasher::new(); + hasher.update(SYNTHETIC_ADDRESS_DOMAIN); + hasher.update(visible_slot.slot_hash().as_ref()); + hasher.update(context.sender().as_ref()); + let caller_credential_bytes: &[u8] = caller_credential.0.as_ref(); + hasher.update(caller_credential_bytes); + hasher.update(salt); + let synthetic_credential = CredentialId::from_bytes(hasher.finalize().into()); + let new_address: S::Address = synthetic_credential.into(); + + // Replays in the same visible slot must stay a no-op even if the + // creator explicitly revoked or rotated this credential away. The + // `.is_none()` check (not `.unwrap_or(false)`) is load-bearing: it + // relies on the `account_owners` invariant documented on the field + // in `lib.rs` — `None` means "never touched", so `Some(false)` from + // `revoke_credential` continues to skip re-authorization here. + if self + .account_owners + .get(&AccountOwnerKey::new(new_address, caller_credential), state) + .context("Failed to read synthetic-address authorization state")? + .is_none() + { + self.authorize_credential(&new_address, &caller_credential, state)?; + self.emit_event( + state, + Event::SyntheticAddressCreated { + address: new_address, + creator: *context.sender(), + credential: caller_credential, + }, + ); + } + Ok(()) + } + + /// Resolves the credential id of the current caller. Supports callers that + /// already carry a pre-authenticated `CredentialId`, plus the V0 + /// (single-signer) and V1 multisig paths; any other credential type + /// (e.g. EVM `Address`, Solana `pub_key`) cannot create a synthetic address + /// through this call. + pub(crate) fn caller_credential_id( + &self, + context: &Context, + ) -> anyhow::Result { + if let Some(credential_id) = context.get_sender_credential::() { + return Ok(*credential_id); } - self.exit_if_credential_exists(&new_credential_id, state)?; + if let Some(public_key) = + context.get_sender_credential::<::PublicKey>() + { + return Ok(public_key.credential_id()); + } + + if let Some(multisig) = + context.get_sender_credential::::PublicKey>>() + { + return Ok(multisig.credential_id::<::Hasher>()); + } + + bail!( + "CreateSyntheticAddress: caller credential is neither a CryptoSpec::PublicKey nor a \ + Multisig, which are the only credential types supported by this call" + ) + } + + fn revoke_credential( + &mut self, + address: &S::Address, + credential: &CredentialId, + state: &mut impl TxState, + ) -> anyhow::Result<()> { + let key = AccountOwnerKey::new(*address, *credential); + // Write `false` explicitly so the canonical-address fallback in + // `is_authorized_for` cannot re-authorize the tuple. + self.account_owners.set(&key, &false, state)?; + Ok(()) + } - // Insert the new credential id -> account mapping - let account = Account { - addr: *context.sender(), - }; - self.accounts.set(&new_credential_id, &account, state)?; + fn ensure_custom_account_mappings_enabled( + &self, + state: &mut impl StateReader, + ) -> anyhow::Result<()> { + if !self + .enable_custom_account_mappings + .get(state) + .context("Failed to read enable_custom_account_mappings")? + .expect( + "`enable_custom_account_mappings` should not be None; it must be set at genesis.", + ) + { + bail!("Custom account mappings are disabled"); + } + Ok(()) + } + /// Enforces that the caller is signing as `address`. The upstream + /// authorization path has already verified the caller controls a + /// credential authorized for `context.sender()`. + fn ensure_caller_owns(&self, address: &S::Address, context: &Context) -> anyhow::Result<()> { + let sender = context.sender(); + anyhow::ensure!( + sender == address, + "Caller {sender} is not authorized to modify credentials for address {address}" + ); Ok(()) } - fn exit_if_credential_exists( + fn ensure_credential_not_authorized( &self, - new_credential_id: &CredentialId, + address: &S::Address, + credential: &CredentialId, state: &mut impl StateReader, - ) -> Result<()> { + ) -> anyhow::Result<()> { anyhow::ensure!( - self.accounts - .get(new_credential_id, state) - .map_err(|err| anyhow!("Error raised while getting account: {err:?}"))? - .is_none(), - "New CredentialId already exists" + !self + .is_authorized_for(address, credential, state) + .context("Failed to check credential authorization")?, + "CredentialId already authorized for this address" ); Ok(()) } diff --git a/crates/module-system/module-implementations/sov-accounts/src/capabilities.rs b/crates/module-system/module-implementations/sov-accounts/src/capabilities.rs index 8d84fc04d4..99560ebec3 100644 --- a/crates/module-system/module-implementations/sov-accounts/src/capabilities.rs +++ b/crates/module-system/module-implementations/sov-accounts/src/capabilities.rs @@ -1,45 +1,125 @@ -use sov_modules_api::{CredentialId, Spec, StateAccessor, StateReader, StateWriter}; +use sov_modules_api::{CredentialId, Spec, StateReader, StateWriter}; use sov_state::User; -use crate::{Account, Accounts}; +use crate::{AccountOwnerKey, Accounts}; impl Accounts { - /// Resolve the sender's public key to an address. - /// If the sender is not registered, but a fallback address if provided, immediately registers - /// the credential to the fallback and then returns it. - pub fn resolve_sender_address( + /// Authorizes `credential_id` to sign as `address`. + pub fn authorize_credential>( &mut self, - default_address: &S::Address, + address: &S::Address, credential_id: &CredentialId, state: &mut ST, - ) -> Result>::Error> { - let maybe_address = self.accounts.get(credential_id, state)?.map(|a| a.addr); + ) -> Result<(), >::Error> { + self.account_owners.set( + &AccountOwnerKey::new(*address, *credential_id), + &true, + state, + ) + } - match maybe_address { - Some(address) => Ok(address), - None => { - // 1. Add the credential -> account mapping - let new_account = Account { - addr: *default_address, - }; - self.accounts.set(credential_id, &new_account, state)?; + /// Returns `true` only if `(address, credential_id)` has an explicit + /// `true` entry in `account_owners`. + /// + /// This is the strict semantic used by the on-chain admit-path when a + /// transaction sets `address_override = Some(_)`. The REST endpoint + /// `/authorizations/{address}/{credential_id}` exposes this value as the + /// `admit_as_override` field of `AuthorizationResponse` (in `query`, + /// native feature only). + /// + /// See also: + /// - [`Self::is_default_address_authorized`] — the permissive semantic + /// used when `address_override = None`. + /// - [`Self::is_authorized_for`] — the canonical-fallback semantic used + /// for internal credential lifecycle checks (revoke, rotate, conflict). + pub fn is_explicitly_authorized>( + &self, + address: &S::Address, + credential_id: &CredentialId, + state: &mut ST, + ) -> Result { + Ok(self + .account_owners + .get(&AccountOwnerKey::new(*address, *credential_id), state)? + .unwrap_or(false)) + } - Ok(*default_address) - } - } + /// Returns `true` if the authenticator-selected default address may be used + /// with `credential_id`. + /// + /// Lookup order: + /// 1. If `account_owners[(address, credential_id)]` has an explicit + /// entry, that value is authoritative — including `false`, which + /// is how `revoke_credential` denies the default path. + /// 2. Otherwise, returns `true`: the authenticator's declared default + /// address is trusted for the `address_override = None` path. + /// + /// This is the permissive semantic used by the on-chain admit-path when a + /// transaction has `address_override = None` — the chain trusts that the + /// authenticator has already verified the credential→default_address + /// binding before resolution. The REST endpoint + /// `/authorizations/{address}/{credential_id}` exposes this value as the + /// `admit_as_default` field of `AuthorizationResponse` (in `query`, + /// native feature only). + /// + /// Note: `default_address` may differ from `canonical(credential_id)`. + /// The EVM authenticator, for example, sets `default_address` to the + /// `MultiAddress::Vm` variant while `canonical(credential_id)` produces + /// the `Standard` variant. Callers MUST NOT assume equality. + /// + /// See also: + /// - [`Self::is_explicitly_authorized`] — the strict semantic used when + /// `address_override = Some(_)`. + /// - [`Self::is_authorized_for`] — the canonical-fallback semantic used + /// for internal credential lifecycle checks (revoke, rotate, conflict). + pub fn is_default_address_authorized>( + &self, + address: &S::Address, + credential_id: &CredentialId, + state: &mut ST, + ) -> Result { + Ok(self + .account_owners + .get(&AccountOwnerKey::new(*address, *credential_id), state)? + .unwrap_or(true)) } - /// Resolve the sender's public key to an address. - pub fn resolve_sender_address_read_only>( + /// Returns `true` if `credential_id` is authorized to act as `address` + /// under the **canonical-fallback** semantic. + /// + /// Lookup order: + /// 1. If `account_owners[(address, credential_id)]` has an explicit + /// entry, that value is authoritative — including `false`, which + /// is how `revoke_credential` denies the canonical fallback. + /// 2. Otherwise, returns `true` iff `address` is the canonical address + /// of `credential_id` (i.e. `credential_id.into() == address`). + /// + /// This semantic is used for **internal credential lifecycle checks** in + /// the `call` module (revocation, rotation, conflict detection). It is + /// *not* used by the on-chain admit-path. The REST endpoint + /// `/authorizations/{address}/{credential_id}` exposes this value as the + /// (deprecated) `authorized` field of `AuthorizationResponse` (in `query`, + /// native feature only); new REST consumers should prefer + /// `admit_as_override` and `admit_as_default` instead, which mirror the + /// admit-path directly. + /// + /// See also: + /// - [`Self::is_explicitly_authorized`] — matches admit-path override. + /// - [`Self::is_default_address_authorized`] — matches admit-path default. + pub fn is_authorized_for>( &self, - default_address: &S::Address, + address: &S::Address, credential_id: &CredentialId, state: &mut ST, - ) -> Result { - let maybe_address = self.accounts.get(credential_id, state)?.map(|a| a.addr); - match maybe_address { - Some(address) => Ok(address), - None => Ok(*default_address), + ) -> Result { + if let Some(is_authorized) = self + .account_owners + .get(&AccountOwnerKey::new(*address, *credential_id), state)? + { + return Ok(is_authorized); } + + let canonical_address: S::Address = (*credential_id).into(); + Ok(canonical_address == *address) } } diff --git a/crates/module-system/module-implementations/sov-accounts/src/fuzz.rs b/crates/module-system/module-implementations/sov-accounts/src/fuzz.rs index b00b8422e9..c39ea9f686 100644 --- a/crates/module-system/module-implementations/sov-accounts/src/fuzz.rs +++ b/crates/module-system/module-implementations/sov-accounts/src/fuzz.rs @@ -3,9 +3,31 @@ use sov_modules_api::{CryptoSpec, DaSpec, Module, Spec, StateCheckpoint}; use crate::{Account, AccountConfig, AccountData, Accounts, CallMessage}; -impl<'a> Arbitrary<'a> for CallMessage { +impl<'a, S> Arbitrary<'a> for CallMessage +where + S: Spec, + S::Address: Arbitrary<'a>, +{ fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { - Ok(Self::InsertCredentialId(u.arbitrary()?)) + match u.int_in_range(0..=4)? { + 0 => Ok(Self::InsertCredentialId(u.arbitrary()?)), + 1 => Ok(Self::AddCredentialToAddress { + address: u.arbitrary()?, + credential: u.arbitrary()?, + }), + 2 => Ok(Self::RemoveCredentialFromAddress { + address: u.arbitrary()?, + credential: u.arbitrary()?, + }), + 3 => Ok(Self::RotateCredentialOnAddress { + address: u.arbitrary()?, + old_credential: u.arbitrary()?, + new_credential: u.arbitrary()?, + }), + _ => Ok(Self::CreateSyntheticAddress { + salt: u.arbitrary()?, + }), + } } } @@ -51,7 +73,7 @@ where ::BlockHeader: Default, ::PublicKey: Arbitrary<'a>, { - /// Creates an arbitrary set of accounts and stores it under `state`. + /// Creates arbitrary genesis credential/address authorizations under `state`. pub fn arbitrary_workset( u: &mut Unstructured<'a>, state: &mut StateCheckpoint, diff --git a/crates/module-system/module-implementations/sov-accounts/src/genesis.rs b/crates/module-system/module-implementations/sov-accounts/src/genesis.rs index 5bf6eefaec..38435aec04 100644 --- a/crates/module-system/module-implementations/sov-accounts/src/genesis.rs +++ b/crates/module-system/module-implementations/sov-accounts/src/genesis.rs @@ -1,20 +1,39 @@ -use anyhow::{bail, Result}; +use anyhow::bail; use schemars::JsonSchema; use serde_with::{serde_as, DisplayFromStr}; use sov_modules_api::prelude::*; -use sov_modules_api::{CredentialId, GenesisState}; +use sov_modules_api::{CredentialId, CryptoSpec, GenesisState}; -use crate::{Account, Accounts}; +use crate::{AccountOwnerKey, Accounts}; -/// Account data for the genesis. +/// Best-effort guard-rail: returns `true` if `address` cannot represent a valid +/// public key under `S::CryptoSpec`. A `false` result does **not** prove the +/// address is non-synthetic — see below for the limits. +/// +/// For 32-byte address specs (e.g., Solana-style `Base58Address` with ed25519) this +/// rejects addresses whose bytes are on the curve — addresses that correspond to a +/// real keypair and therefore have a natural off-chain owner. +/// +/// For address schemes shorter than the public-key size (default 28-byte `Address`, +/// 20-byte `EthereumAddress`), `TryFrom` errors on size and this check is a no-op: +/// those schemes lose information through truncation/hashing and cannot be checked +/// from the address bytes alone. Closing that gap requires a different address scheme. +fn is_synthetic_address(address: &S::Address) -> bool { + <::PublicKey as TryFrom>>::try_from( + address.as_ref().to_vec(), + ) + .is_err() +} + +/// Credential/address authorization data for genesis. #[serde_as] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AccountData
{ - /// Credential ID of the account. + /// Credential ID to authorize. #[serde_as(as = "DisplayFromStr")] pub credential_id: CredentialId, - /// Address of the account. + /// Address the credential may act as. pub address: Address, } @@ -23,9 +42,9 @@ pub struct AccountData
{ #[serde(deny_unknown_fields)] #[schemars(bound = "S: ::sov_modules_api::Spec", rename = "AccountConfig")] pub struct AccountConfig { - /// Accounts to initialize the rollup. + /// Credential/address authorizations to initialize. pub accounts: Vec>, - /// Enable custom `CredentailId` => `Account` mapping. + /// Enable configured credential authorizations and `InsertCredentialId`. #[serde(default = "default_true")] pub enable_custom_account_mappings: bool, } @@ -39,25 +58,38 @@ impl Accounts { &mut self, config: &::Config, state: &mut impl GenesisState, - ) -> Result<()> { + ) -> anyhow::Result<()> { self.enable_custom_account_mappings .set(&config.enable_custom_account_mappings, state)?; if !config.enable_custom_account_mappings { if !config.accounts.is_empty() { - bail!("Custom account mapping is disabled, but accounts are provided") + bail!( + "Custom account mapping is disabled, but accounts are provided: {:?}", + config.accounts + ) } return Ok(()); } for acc in &config.accounts { - if self.accounts.get(&acc.credential_id, state)?.is_some() { - bail!("Account already exists") + let key = AccountOwnerKey::new(acc.address, acc.credential_id); + if self.account_owners.get(&key, state)?.is_some() { + bail!( + "Authorization already exists for address {} and credential {}", + acc.address, + acc.credential_id + ) } - - let new_account = Account { addr: acc.address }; - - self.accounts.set(&acc.credential_id, &new_account, state)?; + if !is_synthetic_address::(&acc.address) { + bail!( + "Genesis address {} is a valid public key on the configured curve. \ + Only addresses with no natural keypair owner may be registered at genesis.", + acc.address, + ) + } + // TODO: We can write canonical address, needlessly... + self.authorize_credential(&acc.address, &acc.credential_id, state)?; } Ok(()) @@ -68,11 +100,29 @@ impl Accounts { mod tests { use std::str::FromStr; - use sov_modules_api::PublicKey; + use sov_modules_api::capabilities::mocks::MockKernel; + use sov_modules_api::{CredentialId, CryptoSpec, PublicKey, StateCheckpoint}; + use sov_test_utils::storage::SimpleStorageManager; use sov_test_utils::{TestPublicKey, TestSpec}; use super::*; + fn fresh_state() -> StateCheckpoint { + let kernel = MockKernel::::default(); + let storage = SimpleStorageManager::new().create_storage(); + StateCheckpoint::::new(storage, &kernel) + } + + fn account_data( + credential_id_hex: &str, + address_str: &str, + ) -> AccountData<::Address> { + AccountData { + credential_id: CredentialId::from_str(credential_id_hex).unwrap(), + address: ::Address::from_str(address_str).unwrap(), + } + } + #[test] fn test_config_serialization() { let pub_key = &TestPublicKey::from_str( @@ -92,11 +142,200 @@ mod tests { let data = r#" { - "accounts":[{"credential_id":"0x1cd4e2d9d5943e6f3d12589d31feee6bb6c11e7b8cd996a393623e207da72cbf","address":"sov1rn2w9kw4jslx70gjtzwnrlhwdwmvz8nm3nvedgunvglzqp593hk"}], + "accounts":[ + { + "credential_id":"0x1cd4e2d9d5943e6f3d12589d31feee6bb6c11e7b8cd996a393623e207da72cbf", + "address":"sov1rn2w9kw4jslx70gjtzwnrlhwdwmvz8nm3nvedgunvglzqp593hk" + } + ], "enable_custom_account_mappings":false }"#; let parsed_config: AccountConfig = serde_json::from_str(data).unwrap(); assert_eq!(parsed_config, config); } + + #[test] + fn init_module_disabled_empty_accounts_succeeds() { + let mut state = fresh_state(); + let config = AccountConfig:: { + accounts: Vec::new(), + enable_custom_account_mappings: false, + }; + let mut gs = state.to_genesis_state_accessor::>(&config); + let mut accounts = Accounts::::default(); + + accounts.init_module(&config, &mut gs).unwrap(); + + assert_eq!( + accounts + .enable_custom_account_mappings + .get(&mut gs) + .unwrap(), + Some(false), + ); + } + + #[test] + fn init_module_disabled_with_accounts_errors() { + let mut state = fresh_state(); + let config = AccountConfig:: { + accounts: vec![account_data( + "0x1111111111111111111111111111111111111111111111111111111111111111", + "sov1rn2w9kw4jslx70gjtzwnrlhwdwmvz8nm3nvedgunvglzqp593hk", + )], + enable_custom_account_mappings: false, + }; + let mut gs = state.to_genesis_state_accessor::>(&config); + let mut accounts = Accounts::::default(); + + let err = accounts.init_module(&config, &mut gs).unwrap_err(); + assert!( + err.to_string() + .contains("Custom account mapping is disabled"), + "unexpected error: {err}" + ); + } + + #[test] + fn init_module_enabled_authorizes_each_account() { + let mut state = fresh_state(); + // Two distinct addresses, plus a second credential authorized for the + // first address — exercising the "multiple credentials per address" + // path that distinguishes `account_owners` from a plain credential map. + let entries = vec![ + account_data( + "0x1111111111111111111111111111111111111111111111111111111111111111", + "sov1rn2w9kw4jslx70gjtzwnrlhwdwmvz8nm3nvedgunvglzqp593hk", + ), + account_data( + "0x2222222222222222222222222222222222222222222222222222222222222222", + "sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf", + ), + account_data( + "0x3333333333333333333333333333333333333333333333333333333333333333", + "sov1rn2w9kw4jslx70gjtzwnrlhwdwmvz8nm3nvedgunvglzqp593hk", + ), + ]; + let config = AccountConfig:: { + accounts: entries.clone(), + enable_custom_account_mappings: true, + }; + let mut gs = state.to_genesis_state_accessor::>(&config); + let mut accounts = Accounts::::default(); + + accounts.init_module(&config, &mut gs).unwrap(); + + assert_eq!( + accounts + .enable_custom_account_mappings + .get(&mut gs) + .unwrap(), + Some(true), + ); + for acc in entries { + let key = AccountOwnerKey::new(acc.address, acc.credential_id); + assert_eq!( + accounts.account_owners.get(&key, &mut gs).unwrap(), + Some(true), + "authorization missing for {}/{}", + acc.address, + acc.credential_id, + ); + } + } + + #[test] + fn init_module_enabled_duplicate_account_errors() { + let mut state = fresh_state(); + let duplicate = account_data( + "0x1111111111111111111111111111111111111111111111111111111111111111", + "sov1rn2w9kw4jslx70gjtzwnrlhwdwmvz8nm3nvedgunvglzqp593hk", + ); + let config = AccountConfig:: { + accounts: vec![duplicate.clone(), duplicate], + enable_custom_account_mappings: true, + }; + let mut gs = state.to_genesis_state_accessor::>(&config); + let mut accounts = Accounts::::default(); + + let err = accounts.init_module(&config, &mut gs).unwrap_err(); + assert!( + err.to_string().contains("already exists"), + "unexpected error: {err}" + ); + } + + #[test] + fn is_synthetic_address_is_no_op_for_28_byte_addresses() { + // For the default 28-byte `sov1...` Address with 32-byte ed25519 pubkeys, + // TryFrom always errors on size, so every 28-byte address is treated as + // synthetic. This is an honest no-op gate — the address scheme has lost + // information through truncation and cannot be checked from bytes alone. + let entry = account_data( + "0x3333333333333333333333333333333333333333333333333333333333333333", + "sov1rn2w9kw4jslx70gjtzwnrlhwdwmvz8nm3nvedgunvglzqp593hk", + ); + assert_eq!(entry.address.as_ref().len(), 28); + assert!(is_synthetic_address::(&entry.address)); + } + + mod base58_spec { + use sov_modules_api::configurable_spec::ConfigurableSpec; + use sov_modules_api::execution_mode::Native; + use sov_modules_api::{Base58Address, PrivateKey}; + use sov_test_utils::{MockDaSpec, MockZkvm, MockZkvmCryptoSpec, TestStorage}; + + use super::*; + + // A 32-byte address spec (Solana-style) so the synthetic check has teeth. + // For this spec, address.as_ref() is the same 32 bytes as an ed25519 pubkey. + type SolanaLikeSpec = ConfigurableSpec< + MockDaSpec, + MockZkvm, + MockZkvm, + Base58Address, + Native, + MockZkvmCryptoSpec, + TestStorage, + >; + + type SolanaCryptoSpec = ::CryptoSpec; + type SolanaPrivateKey = ::PrivateKey; + + fn synthetic_base58_address() -> Base58Address { + // SHA256 of an arbitrary label. ~50% of random 32-byte strings are on the + // ed25519 curve, so if the first seed lands on-curve we walk a counter + // until we find an off-curve one. In practice we exit on iteration 0 or 1. + use sov_modules_api::digest::Digest; + type Hasher = ::Hasher; + for counter in 0u32..32 { + let mut hasher = Hasher::new(); + hasher.update(b"sov_accounts::test::synthetic"); + hasher.update(counter.to_le_bytes()); + let bytes: [u8; 32] = hasher.finalize().into(); + let addr = Base58Address::from(bytes); + if is_synthetic_address::(&addr) { + return addr; + } + } + panic!("could not find an off-curve hash output in 32 tries"); + } + + #[test] + fn is_synthetic_address_rejects_natural_pubkey() { + // A real ed25519 keypair: its 32-byte pubkey IS the address bytes, and is + // by construction on the curve. The predicate must say "not synthetic". + let priv_key = SolanaPrivateKey::generate(); + let credential_id = priv_key.pub_key().credential_id(); + let natural_address = Base58Address::from(credential_id); + assert!(!is_synthetic_address::(&natural_address)); + } + + #[test] + fn is_synthetic_address_accepts_hash_derived() { + let synth = synthetic_base58_address(); + assert!(is_synthetic_address::(&synth)); + } + } } diff --git a/crates/module-system/module-implementations/sov-accounts/src/lib.rs b/crates/module-system/module-implementations/sov-accounts/src/lib.rs index 6bfe8bbda0..987be682d2 100644 --- a/crates/module-system/module-implementations/sov-accounts/src/lib.rs +++ b/crates/module-system/module-implementations/sov-accounts/src/lib.rs @@ -1,4 +1,7 @@ #![deny(missing_docs)] +// Tombstone field `_accounts` makes the `ModuleInfo`-derived +// `_prefix__accounts` accessor double-underscored. +#![allow(non_snake_case)] #![doc = include_str!("../README.md")] mod call; mod capabilities; @@ -7,18 +10,21 @@ mod fuzz; mod genesis; pub use genesis::*; #[cfg(feature = "native")] +pub mod migrations; +#[cfg(feature = "native")] mod query; #[cfg(feature = "native")] pub use query::*; #[cfg(test)] mod tests; pub use call::CallMessage; +use sov_modules_api::macros::serialize; use sov_modules_api::{ Context, CredentialId, DaSpec, GenesisState, Module, ModuleId, ModuleInfo, ModuleRestApi, Spec, StateMap, StateValue, TxState, }; -/// An account on the rollup. +/// Stored address for a legacy/custom credential-indexed account mapping. #[derive( borsh::BorshDeserialize, borsh::BorshSerialize, @@ -30,25 +36,157 @@ use sov_modules_api::{ Clone, )] pub struct Account { - /// The address of the account. + /// The mapped address. pub addr: S::Address, } -/// A module responsible for managing accounts on the rollup. +/// Events emitted by the [`Accounts`] module. +#[derive(Debug, PartialEq, Eq, Clone, schemars::JsonSchema)] +#[serialize(Borsh, Serde)] +#[serde(bound = "S: Spec", rename_all = "snake_case")] +#[schemars(bound = "S::Address: ::schemars::JsonSchema", rename = "Event")] +pub enum Event { + /// Emitted by [`CallMessage::CreateSyntheticAddress`] when a new + /// synthetic address — an address with no naturally-corresponding + /// private key — is created and the caller's credential is + /// auto-authorized for it. Consumers should treat this event as the + /// canonical source of the derived address: because the derivation + /// is bound to the visible slot hash, it can only be reproduced + /// after the creation tx is finalized. + SyntheticAddressCreated { + /// The newly created address. + address: S::Address, + /// The address that submitted the creation transaction. + creator: S::Address, + /// The credential authorized to control the new address. + credential: CredentialId, + }, + /// Emitted by [`CallMessage::InsertCredentialId`] and + /// [`CallMessage::AddCredentialToAddress`] when a new credential is + /// authorized for an address. + CredentialAdded { + /// The address whose credential set was extended. + address: S::Address, + /// The newly authorized credential. + credential: CredentialId, + /// The address that submitted the authorizing transaction. Today + /// equals `address` (handlers require `context.sender() == address`); + /// recorded separately for audit. + authorizer_address: S::Address, + }, + /// Emitted by [`CallMessage::RemoveCredentialFromAddress`] when a + /// credential is revoked from an address. + CredentialRemoved { + /// The address whose credential set was reduced. + address: S::Address, + /// The revoked credential. + credential: CredentialId, + /// The address that submitted the revoking transaction. Today + /// equals `address` (handlers require `context.sender() == address`); + /// recorded separately for audit. + authorizer_address: S::Address, + }, + /// Emitted by [`CallMessage::RotateCredentialOnAddress`] when a + /// credential is atomically swapped for another on an address. + CredentialRotated { + /// The address whose credential set was rotated. + address: S::Address, + /// The revoked credential. + old_credential: CredentialId, + /// The newly authorized credential. + new_credential: CredentialId, + /// The address that submitted the rotating transaction. Today + /// equals `address` (handlers require `context.sender() == address`); + /// recorded separately for audit. + authorizer_address: S::Address, + }, +} + +/// Composite key for [`Accounts::account_owners`]. +#[derive( + borsh::BorshDeserialize, borsh::BorshSerialize, Debug, Clone, Copy, PartialEq, Eq, Hash, +)] +pub(crate) struct AccountOwnerKey { + address: S::Address, + credential_id: CredentialId, +} + +impl AccountOwnerKey { + pub(crate) fn new(address: S::Address, credential_id: CredentialId) -> Self { + Self { + address, + credential_id, + } + } +} + +// `Display` / `FromStr` exist only to satisfy `StateMap`'s trait bound; on-chain +// keys are Borsh-serialized. +impl std::fmt::Display for AccountOwnerKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.address, self.credential_id) + } +} + +impl std::str::FromStr for AccountOwnerKey { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + let (addr_str, cred_str) = s + .rsplit_once('/') + .ok_or_else(|| anyhow::anyhow!("invalid AccountOwnerKey: missing '/' separator"))?; + Ok(Self { + address: ::from_str(addr_str) + .map_err(|e| anyhow::Error::from_boxed(e.into()))?, + credential_id: cred_str.parse()?, + }) + } +} + +/// A module responsible for resolving credentials to addresses and recording +/// credential authorizations. #[derive(Clone, ModuleInfo, ModuleRestApi)] -#[cfg_attr(feature = "arbitrary", derive(Debug))] pub struct Accounts { /// The ID of the sov-accounts module. #[id] pub id: ModuleId, - /// Mapping from a credential to its corresponding account. + /// Chain-state module, used to read the visible DA slot hash when + /// deriving synthetic addresses. + #[module] + pub(crate) chain_state: sov_chain_state::ChainState, + + /// Tombstone for the legacy `credential_id -> address` routing index. + /// + /// **Do not read or write outside [`crate::migrations`].** The field is + /// retained only to preserve the `#[state]` field discriminant ordering + /// derived by the `ModuleInfo` macro (this is the first state field, so + /// removing it would shift the discriminants of every following field + /// and corrupt their on-disk data). Existing entries are migrated to + /// [`Self::account_owners`] by + /// [`crate::migrations::apply_legacy_account_migration`] and the source + /// rows are deleted. The leading underscore signals to readers that this + /// field is intentionally unused. #[state] - pub(crate) accounts: StateMap>, + pub(crate) _accounts: StateMap>, - /// If this field is false, `CallMessage::InsertCredentialId` messages will be rejected. + /// If this field is false, configured genesis authorizations and + /// `CallMessage::InsertCredentialId` messages will be rejected. #[state] enable_custom_account_mappings: StateValue, + + /// Authorization overrides. `Some(true)` means `credential_id` may sign as + /// `address`; `Some(false)` explicitly revokes fallback authorization for + /// the pair. + /// + /// **Invariant:** entries are written *only* by [`Self::authorize_credential`] + /// (writing `true`) and by [`crate::call::Accounts::revoke_credential`] + /// (writing `false`). The absence of an entry — `None` — therefore reliably + /// means "no call has ever touched this `(address, credential)` pair", which + /// is what [`crate::call::Accounts::create_synthetic_address`] relies on to + /// keep replay-after-revoke a no-op. Adding a new writer that does not + /// preserve this convention breaks that guarantee. + #[state] + pub(crate) account_owners: StateMap, bool>, } impl Module for Accounts { @@ -56,9 +194,9 @@ impl Module for Accounts { type Config = AccountConfig; - type CallMessage = call::CallMessage; + type CallMessage = call::CallMessage; - type Event = (); + type Event = Event; type Error = anyhow::Error; @@ -79,7 +217,29 @@ impl Module for Accounts { ) -> Result<(), Self::Error> { match msg { call::CallMessage::InsertCredentialId(new_credential_id) => { - Ok(self.insert_credential_id(new_credential_id, context, state)?) + self.insert_credential_id(new_credential_id, context, state) + } + call::CallMessage::AddCredentialToAddress { + address, + credential, + } => self.add_credential_to_address(address, credential, context, state), + call::CallMessage::RemoveCredentialFromAddress { + address, + credential, + } => self.remove_credential_from_address(address, credential, context, state), + call::CallMessage::RotateCredentialOnAddress { + address, + old_credential, + new_credential, + } => self.rotate_credential_on_address( + address, + old_credential, + new_credential, + context, + state, + ), + call::CallMessage::CreateSyntheticAddress { salt } => { + self.create_synthetic_address(salt, context, state) } } } diff --git a/crates/module-system/module-implementations/sov-accounts/src/migrations.rs b/crates/module-system/module-implementations/sov-accounts/src/migrations.rs new file mode 100644 index 0000000000..a7fb97900c --- /dev/null +++ b/crates/module-system/module-implementations/sov-accounts/src/migrations.rs @@ -0,0 +1,224 @@ +//! One-time data migration from the legacy +//! [`Accounts::accounts`](crate::Accounts::accounts) credential→account index +//! to the [`Accounts::account_owners`](crate::Accounts::account_owners) +//! authorization set. +//! +//! Offline only: requires backend prefix iteration via +//! [`NativeStorage::maybe_iter_user_values_with_prefix`], which is supported by +//! NOMT but not by JMT. Run before deploying a binary that has dropped the +//! layer-1 `accounts` reads. +//! +//! Two-phase API: +//! 1. [`collect_legacy_account_entries`] reads all entries from `storage` +//! while it is still borrowable. +//! 2. [`apply_legacy_account_migration`] writes the entries to +//! `account_owners` and deletes them from `accounts` via a +//! [`sov_modules_api::StateCheckpoint`] (which consumes storage on +//! construction, so collection has to happen first). + +use anyhow::Context; +use sov_modules_api::{CredentialId, Spec, StateWriter}; +use sov_state::namespaces::User; +use sov_state::NativeStorage; + +use crate::{Account, AccountOwnerKey, Accounts}; + +/// Summary of a single legacy-accounts migration run. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MigrationReport { + /// Number of `accounts` entries copied to `account_owners` and deleted + /// from the source map. + pub entries_migrated: u64, +} + +/// Reads every `(credential_id, Account)` pair currently stored in the +/// legacy [`Accounts::accounts`](crate::Accounts::accounts) map. +/// +/// # Errors +/// +/// - The backend does not support prefix iteration (e.g. JMT). NOMT does. +/// - A stored value cannot be borsh-decoded as [`Account`]. +#[allow(deprecated)] +pub fn collect_legacy_account_entries( + accounts: &Accounts, + storage: &Storage, +) -> anyhow::Result)>> +where + S: Spec, + Storage: NativeStorage, +{ + let raw_entries: Vec<(CredentialId, Vec)> = accounts + ._accounts + .iter_raw(storage) + .context("failed to start prefix iteration over legacy accounts map")? + .ok_or_else(|| { + anyhow::anyhow!( + "storage backend does not support prefix iteration; \ + legacy-accounts migration requires NOMT" + ) + })? + .collect::>>()?; + + raw_entries + .into_iter() + .map(|(credential_id, value_bytes)| { + let account: Account = borsh::from_slice(&value_bytes) + .with_context(|| format!("failed to decode legacy Account for {credential_id}"))?; + Ok((credential_id, account)) + }) + .collect() +} + +/// Writes every entry from `entries` to +/// [`Accounts::account_owners`](crate::Accounts::account_owners) as +/// `(addr, credential_id) -> true` and deletes the corresponding +/// [`Accounts::accounts`](crate::Accounts::accounts) row. +/// +/// Idempotent: passing an empty slice (or running a second time after the +/// first run cleared the source) returns `entries_migrated: 0` without error. +/// +/// # Errors +/// +/// - The `writer` returns an error from `set`/`delete`. +#[allow(deprecated)] +pub fn apply_legacy_account_migration( + accounts: &mut Accounts, + entries: &[(CredentialId, Account)], + writer: &mut Writer, +) -> anyhow::Result +where + S: Spec, + Writer: StateWriter, +{ + for (credential_id, account) in entries { + let owner_key = AccountOwnerKey::new(account.addr, *credential_id); + accounts.account_owners.set(&owner_key, &true, writer)?; + accounts._accounts.delete(credential_id, writer)?; + } + + Ok(MigrationReport { + entries_migrated: entries.len() as u64, + }) +} + +#[cfg(test)] +mod tests { + use sov_modules_api::Spec; + use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; + use sov_test_utils::runtime::TestRunner; + use sov_test_utils::{generate_optimistic_runtime, TestSpec}; + + use super::*; + use crate::Accounts; + + type S = TestSpec; + generate_optimistic_runtime!(MigrationTestRuntime <=); + type RT = MigrationTestRuntime; + + fn setup_runner() -> TestRunner { + let genesis_config = HighLevelOptimisticGenesisConfig::generate(); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()) + } + + fn cred(byte: u8) -> CredentialId { + [byte; 32].into() + } + + fn addr(byte: u8) -> ::Address { + let mut bytes = [0u8; 28]; + bytes[0] = byte; + ::Address::from(bytes) + } + + /// Migrating a populated `accounts` map writes `account_owners` entries + /// for every pair and clears the source rows. + #[test] + #[allow(deprecated)] + fn apply_migration_moves_entries_to_owners() { + let mut runner = setup_runner(); + let cred_1 = cred(1); + let cred_2 = cred(2); + let addr_1 = addr(0xAA); + let addr_2 = addr(0xBB); + + runner.__apply_to_state(|state| { + let mut accounts = Accounts::::default(); + + accounts + ._accounts + .set(&cred_1, &Account { addr: addr_1 }, state) + .unwrap(); + accounts + ._accounts + .set(&cred_2, &Account { addr: addr_2 }, state) + .unwrap(); + + assert_eq!( + accounts._accounts.get(&cred_1, state).unwrap(), + Some(Account { addr: addr_1 }) + ); + assert_eq!( + accounts._accounts.get(&cred_2, state).unwrap(), + Some(Account { addr: addr_2 }) + ); + + let entries = vec![ + (cred_1, Account { addr: addr_1 }), + (cred_2, Account { addr: addr_2 }), + ]; + let report = apply_legacy_account_migration(&mut accounts, &entries, state).unwrap(); + assert_eq!(report.entries_migrated, 2); + + assert!(accounts._accounts.get(&cred_1, state).unwrap().is_none()); + assert!(accounts._accounts.get(&cred_2, state).unwrap().is_none()); + assert!(accounts + .is_explicitly_authorized(&addr_1, &cred_1, state) + .unwrap()); + assert!(accounts + .is_explicitly_authorized(&addr_2, &cred_2, state) + .unwrap()); + }); + } + + /// Empty input is a valid no-op. + #[test] + fn apply_migration_empty_input() { + let mut runner = setup_runner(); + runner.__apply_to_state(|state| { + let mut accounts = Accounts::::default(); + let report = apply_legacy_account_migration(&mut accounts, &[], state).unwrap(); + assert_eq!(report.entries_migrated, 0); + }); + } + + /// Re-applying the same entries after they've already been migrated + /// re-writes the same `account_owners` entries (still `true`) and + /// no-op-deletes the (already empty) source rows; the post-state is + /// indistinguishable from a single application. + #[test] + #[allow(deprecated)] + fn apply_migration_is_safe_to_repeat() { + let mut runner = setup_runner(); + let cred_1 = cred(7); + let addr_1 = addr(0xCC); + let entries = vec![(cred_1, Account { addr: addr_1 })]; + + runner.__apply_to_state(|state| { + let mut accounts = Accounts::::default(); + + accounts + ._accounts + .set(&cred_1, &Account { addr: addr_1 }, state) + .unwrap(); + + apply_legacy_account_migration(&mut accounts, &entries, state).unwrap(); + apply_legacy_account_migration(&mut accounts, &entries, state).unwrap(); + + assert!(accounts._accounts.get(&cred_1, state).unwrap().is_none()); + assert!(accounts + .is_explicitly_authorized(&addr_1, &cred_1, state) + .unwrap()); + }); + } +} diff --git a/crates/module-system/module-implementations/sov-accounts/src/query.rs b/crates/module-system/module-implementations/sov-accounts/src/query.rs index 15ae9c039d..02f10148c1 100644 --- a/crates/module-system/module-implementations/sov-accounts/src/query.rs +++ b/crates/module-system/module-implementations/sov-accounts/src/query.rs @@ -1,35 +1,163 @@ -//! Defines queries exposed by the accounts module, along with the relevant types -use sov_modules_api::prelude::UnwrapInfallible; +//! Read-only REST endpoints for inspecting `sov-accounts` state. + +use std::str::FromStr; + +use axum::routing::get; +use sov_modules_api::prelude::{axum, UnwrapInfallible}; +use sov_modules_api::rest::utils::{errors, ApiResult, Path}; +use sov_modules_api::rest::{ApiState, HasCustomRestApi}; use sov_modules_api::{ApiStateAccessor, CredentialId, Spec}; -use crate::{Account, Accounts}; +use crate::Accounts; -/// This is the response returned from the accounts_getAccount endpoint. +/// Response of `GET /authorizations/{address}/{credential_id}`. +/// +/// Exposes the three distinct authorization predicates from +/// [`crate::Accounts`]. Prefer `admit_as_override` and `admit_as_default` +/// over the legacy `authorized` field — the new fields mirror the on-chain +/// admit-path directly, while `authorized` collapses two distinct semantics +/// into a single boolean and can disagree with what the chain actually does +/// for authenticators (such as EVM) whose `default_address` is not the +/// credential's canonical address. #[derive(Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, Clone)] -#[serde( - bound = "Addr: serde::Serialize + serde::de::DeserializeOwned", - rename_all = "snake_case" -)] -pub enum Response { - /// The account corresponding to the given credential id exists. - AccountExists { - /// The address of the account, - addr: Addr, - }, - /// The account corresponding to the credential id does not exist. - AccountEmpty, +pub struct AuthorizationResponse { + /// `true` iff `credential_id` is authorized to act as `address` under + /// the canonical-fallback semantic of [`Accounts::is_authorized_for`]. + /// + /// Equivalent to `admit_as_override || (no_entry && canonical(credential_id) == address)`. + /// + /// Retained for backward compatibility. Prefer `admit_as_override` / + /// `admit_as_default` for new consumers; they correspond directly to + /// what the on-chain admit-path checks. + #[deprecated( + note = "prefer admit_as_override (admit-path override semantic) and admit_as_default (admit-path default semantic); see field docs" + )] + pub authorized: bool, + + /// `true` iff the on-chain admit-path would accept a transaction + /// submitted with `address_override = Some(this_address)` and this + /// credential. Matches [`Accounts::is_explicitly_authorized`]: there + /// must be an explicit `true` entry in `account_owners`. + pub admit_as_override: bool, + + /// `true` iff the on-chain admit-path would accept a transaction + /// submitted with `address_override = None` whose authenticator declares + /// this address as `default_address` for this credential. Matches + /// [`Accounts::is_default_address_authorized`]. + /// + /// NOTE: returns `true` for any `(address, credential)` pair with no + /// `account_owners` entry — the chain trusts the authenticator to have + /// verified the credential→default_address binding before resolution. + /// Callers should interpret this field as "would the chain admit, IF the + /// authenticator declares this address as default", not as a standalone + /// authorization signal. + pub admit_as_default: bool, } impl Accounts { - /// Get the account corresponding to the given credential id. - pub fn get_account( - &self, - credential_id: CredentialId, - state: &mut ApiStateAccessor, - ) -> Response { - match self.accounts.get(&credential_id, state).unwrap_infallible() { - Some(Account { addr }) => Response::AccountExists { addr }, - None => Response::AccountEmpty, - } + async fn route_is_authorized( + state: ApiState, + mut accessor: ApiStateAccessor, + Path((address_str, credential_id_str)): Path<(String, String)>, + ) -> ApiResult { + let address = ::from_str(&address_str).map_err(|_| { + errors::bad_request_400( + &format!("invalid address `{address_str}`"), + "address parse failed", + ) + })?; + let credential_id = CredentialId::from_str(&credential_id_str).map_err(|e| { + errors::bad_request_400( + &format!("invalid credential_id `{credential_id_str}`"), + e.to_string(), + ) + })?; + + let authorized = state + .is_authorized_for(&address, &credential_id, &mut accessor) + .unwrap_infallible(); + let admit_as_override = state + .is_explicitly_authorized(&address, &credential_id, &mut accessor) + .unwrap_infallible(); + let admit_as_default = state + .is_default_address_authorized(&address, &credential_id, &mut accessor) + .unwrap_infallible(); + #[allow(deprecated)] + let response = AuthorizationResponse { + authorized, + admit_as_override, + admit_as_default, + }; + Ok(response.into()) + } +} + +impl HasCustomRestApi for Accounts { + type Spec = S; + + fn custom_rest_api(&self, state: ApiState) -> axum::Router<()> { + axum::Router::new() + .route( + "/authorizations/{address}/{credential_id}", + get(Self::route_is_authorized), + ) + .with_state(state.with(self.clone())) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use sov_modules_api::capabilities::mocks::MockKernel; + use sov_modules_api::rest::utils::Path; + use sov_modules_api::{ConcurrentStateCheckpoint, StateCheckpoint}; + use sov_test_utils::storage::SimpleStorageManager; + use sov_test_utils::TestSpec; + + use super::*; + + type S = TestSpec; + + #[test] + fn route_includes_canonical_fallback() { + let kernel = Arc::new(MockKernel::::default()); + let storage = SimpleStorageManager::new().create_storage(); + let checkpoint = Arc::new(ConcurrentStateCheckpoint::from_state_checkpoint( + StateCheckpoint::::new(storage, kernel.as_ref()), + )); + let (_sender, receiver) = sov_modules_api::prelude::tokio::sync::watch::channel(checkpoint); + + let accounts = Accounts::::default(); + let state = ApiState::build(Arc::new(()), receiver, kernel, None, Default::default()) + .with(accounts); + let accessor = state.default_api_state_accessor(); + + let credential_id = CredentialId::from([7u8; 32]); + let address = ::Address::from(credential_id); + + let response = sov_modules_api::prelude::tokio::runtime::Runtime::new() + .unwrap() + .block_on(Accounts::::route_is_authorized( + state, + accessor, + Path((address.to_string(), credential_id.to_string())), + )) + .unwrap(); + + #[allow(deprecated)] + let authorized = response.0.authorized; + assert!( + authorized, + "canonical (address == credential_id.into()) pair should be authorized under is_authorized_for semantic" + ); + assert!( + !response.0.admit_as_override, + "no explicit account_owners entry exists — admit_as_override must be false" + ); + assert!( + response.0.admit_as_default, + "no explicit account_owners entry exists — admit_as_default must be true (chain trusts authenticator)" + ); } } diff --git a/crates/module-system/module-implementations/sov-accounts/src/tests.rs b/crates/module-system/module-implementations/sov-accounts/src/tests.rs index 8db2d82b8f..6e5aad7d60 100644 --- a/crates/module-system/module-implementations/sov-accounts/src/tests.rs +++ b/crates/module-system/module-implementations/sov-accounts/src/tests.rs @@ -1,68 +1,118 @@ use sov_modules_api::prelude::*; use sov_modules_api::sov_universal_wallet::schema::Schema; +use sov_modules_api::Spec; +use sov_test_utils::TestSpec; -use crate::query::Response; -use crate::CallMessage; - -type S = sov_test_utils::TestSpec; +use crate::{Accounts, CallMessage}; #[test] -fn test_response_serialization() { - let addr: Vec = (1..=28).collect(); - let mut addr_array = [0u8; 28]; - addr_array.copy_from_slice(&addr); - let response = Response::AccountExists::<::Address> { - addr: ::Address::from(addr_array), - }; +fn test_display_accounts_call() { + type S = TestSpec; + + #[derive(Debug, Clone, PartialEq, borsh::BorshSerialize, UniversalWallet)] + enum RuntimeCall { + Accounts(CallMessage), + } - let json = serde_json::to_string(&response).unwrap(); + let insert_msg = RuntimeCall::Accounts(CallMessage::InsertCredentialId([1; 32].into())); + let schema = Schema::of_single_type::().unwrap(); assert_eq!( - json, - r#"{"account_exists":{"addr":"sov1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3crhxalf"}}"# + r#"Accounts.InsertCredentialId(0x0101010101010101010101010101010101010101010101010101010101010101)"#, + schema + .display(0, &borsh::to_vec(&insert_msg).unwrap()) + .unwrap(), ); -} -#[test] -fn test_response_deserialization() { - let json = - r#"{"account_exists":{"addr":"sov1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3crhxalf"}}"#; - let response: Response<::Address> = serde_json::from_str(json).unwrap(); + let addr_bytes: [u8; 28] = core::array::from_fn(|i| (i + 1) as u8); + let address = ::Address::from(addr_bytes); - let expected_addr: Vec = (1..=28).collect(); - let mut addr_array = [0u8; 28]; - addr_array.copy_from_slice(&expected_addr); - let expected_response = Response::AccountExists::<::Address> { - addr: ::Address::from(addr_array), - }; + let add_msg = RuntimeCall::Accounts(CallMessage::AddCredentialToAddress { + address, + credential: [2; 32].into(), + }); + let rendered = schema + .display(0, &borsh::to_vec(&add_msg).unwrap()) + .unwrap(); + assert!( + rendered.starts_with("Accounts.AddCredentialToAddress"), + "unexpected render: {rendered}" + ); + assert!( + rendered.contains("0x0202020202020202020202020202020202020202020202020202020202020202"), + "render missing credential: {rendered}" + ); - assert_eq!(response, expected_response); -} + let remove_msg = RuntimeCall::Accounts(CallMessage::RemoveCredentialFromAddress { + address, + credential: [3; 32].into(), + }); + let rendered = schema + .display(0, &borsh::to_vec(&remove_msg).unwrap()) + .unwrap(); + assert!( + rendered.starts_with("Accounts.RemoveCredentialFromAddress"), + "unexpected render: {rendered}" + ); + assert!( + rendered.contains("0x0303030303030303030303030303030303030303030303030303030303030303"), + "render missing credential: {rendered}" + ); -#[test] -fn test_response_deserialization_on_wrong_hrp() { - let json = r#"{"account_exists":{"addr":"hax1qypqx68ju0l"}}"#; - let response: Result::Address>, serde_json::Error> = - serde_json::from_str(json); - match response { - Ok(response) => panic!("Expected error, got {response:?}"), - Err(err) => { - assert_eq!(err.to_string(), "Wrong HRP: hax at line 1 column 44"); - } - } + let rotate_msg = RuntimeCall::Accounts(CallMessage::RotateCredentialOnAddress { + address, + old_credential: [4; 32].into(), + new_credential: [5; 32].into(), + }); + let rendered = schema + .display(0, &borsh::to_vec(&rotate_msg).unwrap()) + .unwrap(); + assert!( + rendered.starts_with("Accounts.RotateCredentialOnAddress"), + "unexpected render: {rendered}" + ); + assert!( + rendered.contains("0x0404040404040404040404040404040404040404040404040404040404040404"), + "render missing old_credential: {rendered}" + ); + assert!( + rendered.contains("0x0505050505050505050505050505050505050505050505050505050505050505"), + "render missing new_credential: {rendered}" + ); } +/// `caller_credential_id` must reject any sender credential that is not a +/// `CryptoSpec::PublicKey` or `Multisig`. We exercise the rejection path by +/// handing `Context::new` an empty `Credentials` bag, which is the same shape +/// the function sees if a future authenticator stores some unknown third +/// credential type. #[test] -fn test_display_accounts_call() { - #[derive(Debug, Clone, PartialEq, borsh::BorshSerialize, UniversalWallet)] - enum RuntimeCall { - Accounts(CallMessage), - } +fn test_create_synthetic_address_rejects_unsupported_credential() { + use sov_modules_api::transaction::Credentials; + use sov_modules_api::{Context, ExecutionContext, SequencerType}; - let msg = RuntimeCall::Accounts(CallMessage::InsertCredentialId([1; 32].into())); + type S = TestSpec; - let schema = Schema::of_single_type::().unwrap(); - assert_eq!( - r#"Accounts.InsertCredentialId(0x0101010101010101010101010101010101010101010101010101010101010101)"#, - schema.display(0, &borsh::to_vec(&msg).unwrap()).unwrap(), + let sender_bytes: [u8; 28] = core::array::from_fn(|i| (i + 1) as u8); + let sender = ::Address::from(sender_bytes); + let da_address = <::Da as sov_modules_api::DaSpec>::Address::default(); + + let ctx = Context::::new( + sender, + Credentials::default(), + sender, + da_address, + None, + ExecutionContext::Node, + SequencerType::Preferred, + ); + + let accounts = Accounts::::default(); + let err = accounts + .caller_credential_id(&ctx) + .expect_err("empty credentials should not resolve to a credential id"); + let msg = err.to_string(); + assert!( + msg.contains("CreateSyntheticAddress"), + "error should attribute to the CreateSyntheticAddress call message, got: {msg}" ); } diff --git a/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs b/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs index c23bf2806a..5ab4179a4a 100644 --- a/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs +++ b/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs @@ -1,11 +1,17 @@ -use sov_accounts::{Accounts, CallMessage, Response}; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use borsh::BorshDeserialize; +use sov_accounts::{AccountData, Accounts, CallMessage, Event}; +use sov_modules_api::digest::Digest; use sov_modules_api::transaction::{UnsignedTransaction, Version1}; use sov_modules_api::{ - CryptoSpec, PrivateKey, PublicKey, RawTx, Runtime, SkippedTxContents, Spec, TxEffect, + Amount, CredentialId, CryptoSpec, PrivateKey, PublicKey, RawTx, Runtime, SkippedTxContents, + Spec, StoredEvent, TxEffect, }; use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; use sov_test_utils::runtime::TestRunner; -use sov_test_utils::{default_test_tx_details, TransactionType}; +use sov_test_utils::{default_test_tx_details, BatchTestCase, BatchType, TransactionType}; use sov_test_utils::{ generate_optimistic_runtime, AsUser, TestPrivateKey, TestUser, TransactionTestCase, }; @@ -25,8 +31,10 @@ struct TestData { non_registered_account: TestUser, } -/// We setup genesis with three accounts, two of which are registered at genesis. -fn setup() -> (TestData, TestRunner) { +/// We set up genesis with three accounts, two of which have custom credentials +/// authorized at genesis. Returns the genesis so callers can extend it +/// (e.g. push extra `account_owners` mappings) before building the runner. +fn setup_genesis() -> (TestData, GenesisConfig) { let genesis_config = HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![ TestUser::generate_with_default_balance().add_credential_id([0u8; 32].into()), TestUser::generate_with_default_balance().add_credential_id([1u8; 32].into()), @@ -39,18 +47,22 @@ fn setup() -> (TestData, TestRunner) { let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); - let runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); - ( TestData { account_1: user_1, account_2: user_2, non_registered_account: user_3, }, - runner, + genesis, ) } +fn setup() -> (TestData, TestRunner) { + let (data, genesis) = setup_genesis(); + let runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + (data, runner) +} + fn setup_with_disable_custom_account_mappings() -> (TestUser, TestRunner) { let mut genesis_config = HighLevelOptimisticGenesisConfig::generate(); let user = TestUser::generate_with_default_balance(); @@ -73,16 +85,13 @@ fn test_config_account() { runner, ) = setup(); - // The account is registered at genesis. + // The account is registered at genesis: its credential is authorized for + // the user's address via `account_owners`. runner.query_visible_state(|state| { let accounts = Accounts::::default(); - let response = accounts.get_account(user.credential_id(), state); - assert_eq!( - response, - Response::AccountExists { - addr: user.address() - } - ); + assert!(accounts + .is_explicitly_authorized(&user.address(), &user.credential_id(), state) + .unwrap()); }); } @@ -107,65 +116,100 @@ fn test_update_account() { let accounts = Accounts::::default(); - // New account with the new public key and an old address is created. - assert_eq!( - accounts.get_account(new_credential, state), - Response::AccountExists { - addr: user.address() - } - ); - // Account corresponding to the old credential still exists. - assert_eq!( - accounts.get_account(user.credential_id(), state), - Response::AccountExists { - addr: user.address() - } - ); - + // The new credential is authorized for the user's address. + assert!(accounts + .is_explicitly_authorized(&user.address(), &new_credential, state) + .unwrap()); assert_ne!(new_credential, user.credential_id()); }), }); } +/// A credential already authorized for an address cannot be inserted twice. #[test] -fn test_update_account_fails() { +fn test_insert_existing_credential_fails() { let ( TestData { - account_1, - account_2, + non_registered_account: sender, .. }, mut runner, ) = setup(); + let new_credential = TestPrivateKey::generate().pub_key().credential_id(); + runner.execute_transaction(TransactionTestCase { - input: account_1.create_plain_message::>(CallMessage::InsertCredentialId( - account_2.credential_id(), + input: sender.create_plain_message::>(CallMessage::InsertCredentialId( + new_credential, )), assert: Box::new(move |result, _state| { - if let TxEffect::Reverted(contents) = result.tx_receipt { + assert!(result.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: sender.create_plain_message::>(CallMessage::InsertCredentialId( + new_credential, + )), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { assert_eq!( contents.reason.to_string(), - "New CredentialId already exists" + "CredentialId already authorized for this address" ); } + _ => panic!("Expected reverted transaction for existing credential"), }), }); } -/// Tests the multisig functionality of the Accounts module. +/// A credential already authorized canonically for the sender's address cannot +/// be inserted through `InsertCredentialId`. #[test] -fn test_setup_multisig_and_act() { - use sov_modules_api::Multisig; +fn test_insert_canonical_credential_rejected() { let ( TestData { - non_registered_account: user, + non_registered_account: sender, .. }, mut runner, ) = setup(); + let canonical_credential = sender.credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: sender.create_plain_message::>(CallMessage::InsertCredentialId( + canonical_credential, + )), + assert: Box::new(move |result, state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "CredentialId already authorized for this address" + ); + let accounts = Accounts::::default(); + assert!( + !accounts + .is_explicitly_authorized(&sender.address(), &canonical_credential, state) + .unwrap(), + "canonical duplicate rejection must not materialize an explicit entry" + ); + } + other => { + panic!("Expected reverted transaction for canonical credential, got {other:?}") + } + }), + }); +} + +/// Tests the multisig functionality of the Accounts module. +/// +/// Seeds genesis with a `TestUser` whose custom `credential_id` matches the +/// multisig, so the multisig's canonical address is funded before any tx is +/// submitted. This keeps the focus on signature-level invariants. +#[test] +fn test_setup_multisig_and_act() { + use sov_modules_api::Multisig; - // First, create and register a multisig let multisig_keys = [ TestPrivateKey::generate(), TestPrivateKey::generate(), @@ -174,33 +218,16 @@ fn test_setup_multisig_and_act() { let multisig = Multisig::new(2, multisig_keys.iter().map(|k| k.pub_key()).collect()); let multisig_credential_id = multisig.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); - runner.execute_transaction(TransactionTestCase { - input: user.create_plain_message::>(CallMessage::InsertCredentialId( - multisig_credential_id, - )), - assert: Box::new(move |result, state| { - assert!(result.tx_receipt.is_successful()); - - let accounts = Accounts::::default(); - // New account with the new public key and an old address is created. - assert_eq!( - accounts.get_account(multisig_credential_id, state), - Response::AccountExists { - addr: user.address() - } - ); - // Account corresponding to the old credential still exists. - assert_eq!( - accounts.get_account(user.credential_id(), state), - Response::AccountExists { - addr: user.address() - } - ); + // Build a funded `TestUser` whose address is the multisig's canonical address. + let multisig_user = + TestUser::generate_with_default_balance().add_credential_id(multisig_credential_id); - assert_ne!(multisig_credential_id, user.credential_id()); - }), - }); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); // Define utilities for... // - Generating a valid multisig (version 1) transaction @@ -215,18 +242,18 @@ fn test_setup_multisig_and_act() { )), sov_modules_api::capabilities::UniquenessData::Generation(0), default_test_tx_details::(), + None, ) .to_multisig_tx(multisig.clone()) }; - let sign = |tx: &mut Version1, S>, key: &TestPrivateKey| { + let sign = |tx: &mut Version1, key: &TestPrivateKey| { use sov_modules_api::Runtime; let chain_hash = &>::CHAIN_HASH; tx.sign(key, chain_hash).unwrap(); }; - let assert_tx_success = |tx: Version1, S>, - runner: &mut TestRunner| { + let assert_tx_success = |tx: Version1, runner: &mut TestRunner| { let tx = Transaction::::from(tx); let multisig_tx = TransactionType::::PreSigned(RawTx { data: borsh::to_vec(&tx).unwrap(), @@ -239,32 +266,31 @@ fn test_setup_multisig_and_act() { }); }; - let assert_tx_skip = |tx: Version1, S>, - runner: &mut TestRunner, - reason: &'static str| { - let tx = Transaction::::from(tx); - let multisig_tx = TransactionType::::PreSigned(RawTx { - data: borsh::to_vec(&tx).unwrap(), - }); - runner.execute_transaction(TransactionTestCase { - input: multisig_tx, - assert: Box::new(move |result, _state| { - assert!(result.tx_receipt.is_skipped()); - match result.tx_receipt { - TxEffect::Skipped(SkippedTxContents { error, .. }) => { - assert!( - error.to_string().contains(reason), - "Unexpected skip reason. Expected: {reason}, Got: {error}" - ); + let assert_tx_skip = + |tx: Version1, runner: &mut TestRunner, reason: &'static str| { + let tx = Transaction::::from(tx); + let multisig_tx = TransactionType::::PreSigned(RawTx { + data: borsh::to_vec(&tx).unwrap(), + }); + runner.execute_transaction(TransactionTestCase { + input: multisig_tx, + assert: Box::new(move |result, _state| { + assert!(result.tx_receipt.is_skipped()); + match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + assert!( + error.to_string().contains(reason), + "Unexpected skip reason. Expected: {reason}, Got: {error}" + ); + } + _ => panic!( + "Expected skipped transaction but found {:?}", + result.tx_receipt + ), } - _ => panic!( - "Expected skipped transaction but found {:?}", - result.tx_receipt - ), - } - }), - }); - }; + }), + }); + }; // A transaction with two valid signatures should succeed in our 2/3 multisig let tx_with_two_valid_signatures = { @@ -294,9 +320,7 @@ fn test_setup_multisig_and_act() { }; assert_tx_success(tx_with_other_valid_signatures, &mut runner); - // A transaction with a non-member signature should be skipped because it does not match a valid credential ID. - // Note that this error will disappear if we add the paymaster to our test runtime; the tx will succeed on a different account. In that case, - // this test will need refinement + // A transaction with a non-member signature should fail because it does not match the signed credential ID. let tx_with_non_member_signature = { let mut tx = generate_multisig_tx(); sign(&mut tx, &multisig_keys[0]); @@ -304,8 +328,9 @@ fn test_setup_multisig_and_act() { // Manually add a signature from a non-member key { let non_member_key = TestPrivateKey::generate(); - let non_member_sig = - tx.sign_without_adding(&non_member_key, &>::CHAIN_HASH); + let non_member_sig = tx + .sign_without_adding(&non_member_key, &>::CHAIN_HASH) + .unwrap(); tx.signatures .try_push(PubKeyAndSignature { signature: non_member_sig, @@ -318,8 +343,8 @@ fn test_setup_multisig_and_act() { assert_tx_skip( tx_with_non_member_signature, &mut runner, - "Impossible to reserve gas", - ); // Fails to reserve gas because the credential ID has changed + "Verification equation was not satisfied", + ); // A transaction with only one signature should be skipped because it does not meet the required threshold. let tx_with_too_few_signatures = { @@ -355,8 +380,9 @@ fn test_setup_multisig_and_act() { sign(&mut tx, &multisig_keys[1]); // Manually add a duplicate signature { - let duplicate_sig = - tx.sign_without_adding(&multisig_keys[0], &>::CHAIN_HASH); + let duplicate_sig = tx + .sign_without_adding(&multisig_keys[0], &>::CHAIN_HASH) + .unwrap(); tx.signatures .try_push(PubKeyAndSignature { signature: duplicate_sig, @@ -368,10 +394,12 @@ fn test_setup_multisig_and_act() { tx }; + // The duplicate key changes the credential_address in the signed bytes, + // so signature verification fails. assert_tx_skip( tx_with_duplicate_signature, &mut runner, - "is not part of the multisig or has already signed.", + "Verification equation was not satisfied", ); } @@ -385,13 +413,24 @@ fn test_register_new_account() { mut runner, ) = setup(); - // The account is empty at the start because it is not registered at genesis. assert_eq!(non_registered_account.custom_credential_id, None); runner.query_visible_state(|state| { let accounts = Accounts::::default(); - let response = accounts.get_account(non_registered_account.credential_id(), state); - assert_eq!(response, Response::AccountEmpty); + assert!(!accounts + .is_explicitly_authorized( + &non_registered_account.address(), + &non_registered_account.credential_id(), + state + ) + .unwrap()); + assert!(accounts + .is_authorized_for( + &non_registered_account.address(), + &non_registered_account.credential_id(), + state + ) + .unwrap()); }); let new_credential = TestPrivateKey::generate().pub_key().credential_id(); @@ -403,81 +442,45 @@ fn test_register_new_account() { assert: Box::new(move |result, state| { assert!(result.tx_receipt.is_successful()); - let accounts = Accounts::::default(); - - // New account with the new public key and an old address is created. - assert_eq!( - accounts.get_account(new_credential, state), - Response::AccountExists { - addr: non_registered_account.address() - } - ); - - // The default credential of the account exists - assert_eq!( - accounts.get_account(non_registered_account.credential_id(), state), - Response::AccountExists { - addr: non_registered_account.address() - } - ); + let (event_address, event_credential, event_authorizer) = + credential_added_event(&result.events); + assert_eq!(event_address, non_registered_account.address()); + assert_eq!(event_credential, new_credential); + assert_eq!(event_authorizer, non_registered_account.address()); - assert_ne!(new_credential, non_registered_account.credential_id()); - }), - }); -} + let accounts = Accounts::::default(); -#[test] -fn test_resolve_sender_address_with_default_address_non_registered() { - let ( - TestData { - non_registered_account, - .. - }, - runner, - ) = setup(); + assert!(accounts + .is_explicitly_authorized(&non_registered_account.address(), &new_credential, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&non_registered_account.address(), &new_credential, state) + .unwrap()); - runner.query_visible_state(|state| { - let mut accounts = Accounts::::default(); - assert_eq!( - accounts - .resolve_sender_address( + assert!(!accounts + .is_explicitly_authorized( &non_registered_account.address(), &non_registered_account.credential_id(), state ) - .unwrap(), - non_registered_account.address() - ); - }); -} - -#[test] -fn test_resolve_sender_address_registered() { - let ( - TestData { - account_1, - account_2, - .. - }, - runner, - ) = setup(); + .unwrap()); + assert!(accounts + .is_authorized_for( + &non_registered_account.address(), + &non_registered_account.credential_id(), + state + ) + .unwrap()); - runner.query_visible_state(|state| { - let mut accounts = Accounts::::default(); - - // Ensure correct (registered) address is used even if another fallback is provided - assert_eq!( - accounts - .resolve_sender_address(&account_2.address(), &account_1.credential_id(), state) - .unwrap(), - account_1.address() - ); + assert_ne!(new_credential, non_registered_account.credential_id()); + }), }); } -/// Tests what happens if one tries to resolve an address when there is more than one credential available. +/// After `InsertCredentialId` from a user, each inserted credential is +/// authorized under that user's address. #[test] -fn test_resolve_address_if_more_than_one_credential() { +fn test_authorize_multiple_credentials_for_same_address() { let ( TestData { non_registered_account, @@ -486,62 +489,34 @@ fn test_resolve_address_if_more_than_one_credential() { mut runner, ) = setup(); - let pub_key_1 = TestPrivateKey::generate().pub_key(); - let credential_1 = pub_key_1.credential_id(); - let default_address_1 = credential_1.into(); - - let pub_key_2 = TestPrivateKey::generate().pub_key(); - let credential_2 = pub_key_2.credential_id(); - let default_address_2 = credential_2.into(); + let credential_1 = TestPrivateKey::generate().pub_key().credential_id(); + let credential_2 = TestPrivateKey::generate().pub_key().credential_id(); runner.execute( non_registered_account .create_plain_message::>(CallMessage::InsertCredentialId(credential_1)), ); - runner.execute( non_registered_account .create_plain_message::>(CallMessage::InsertCredentialId(credential_2)), ); runner.query_visible_state(|state| { - let mut accounts = Accounts::::default(); - - assert_eq!( - accounts - .resolve_sender_address(&default_address_1, &credential_1, state) - .unwrap(), - non_registered_account.address() - ); - - assert_eq!( - accounts - .resolve_sender_address(&default_address_2, &credential_2, state) - .unwrap(), - non_registered_account.address() - ); - }); -} - -/// This test should verify that when a new credential is specified with an existing account's -/// address as fallback, that the credential is appended to that address. However -/// query_visible_state doesn't mutate the state so it simply verifies that the fallback address is -/// returned correctly -#[test] -fn test_resolve_with_different_default_address() { - let (TestData { account_1, .. }, runner) = setup(); - - let random_credential = TestPrivateKey::generate().pub_key().credential_id(); - - runner.query_visible_state(|state| { - let mut accounts = Accounts::::default(); - - assert_eq!( - accounts - .resolve_sender_address(&account_1.address(), &random_credential, state) - .unwrap(), - account_1.address() - ); + let accounts = Accounts::::default(); + let addr = non_registered_account.address(); + + assert!(accounts + .is_explicitly_authorized(&addr, &credential_1, state) + .unwrap()); + assert!(accounts + .is_explicitly_authorized(&addr, &credential_2, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&addr, &credential_1, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&addr, &credential_2, state) + .unwrap()); }); } @@ -566,3 +541,2488 @@ fn test_disable_custom_account_mappings() { }), }); } + +/// A multisig test fixture: keys, envelope, credential id, and a funded `TestUser` +/// registered at the multisig's default address in genesis. +struct MultisigEnv { + keys: [TestPrivateKey; 3], + multisig: sov_modules_api::Multisig<<::CryptoSpec as CryptoSpec>::PublicKey>, + credential_id: sov_modules_api::CredentialId, + user: TestUser, +} + +fn make_multisig_env() -> MultisigEnv { + let keys = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let multisig = sov_modules_api::Multisig::new(2, keys.iter().map(|k| k.pub_key()).collect()); + let credential_id = multisig.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + let user = TestUser::generate_with_default_balance().add_credential_id(credential_id); + MultisigEnv { + keys, + multisig, + credential_id, + user, + } +} + +/// Builds an unsigned V1 tx carrying an `InsertCredentialId` call. +fn make_v1_tx( + multisig: &sov_modules_api::Multisig<<::CryptoSpec as CryptoSpec>::PublicKey>, + inner_credential: sov_modules_api::CredentialId, + address_override: Option<::Address>, +) -> Version1 { + let details = default_test_tx_details::(); + UnsignedTransaction::::new( + TestAccountsRuntimeCall::Accounts(CallMessage::InsertCredentialId(inner_credential)), + >::CHAIN_HASH, + details.max_priority_fee_bips, + details.max_fee, + sov_modules_api::capabilities::UniquenessData::Generation(0), + details.gas_limit, + address_override, + ) + .to_multisig_tx(multisig.clone()) +} + +/// Builds an unsigned V1 tx carrying the given accounts call. +fn make_v1_tx_with_call( + multisig: &sov_modules_api::Multisig<<::CryptoSpec as CryptoSpec>::PublicKey>, + call: CallMessage, + address_override: Option<::Address>, + generation: u64, +) -> Version1 { + UnsignedTransaction::::new_with_details( + TestAccountsRuntimeCall::Accounts(call), + sov_modules_api::capabilities::UniquenessData::Generation(generation), + default_test_tx_details::(), + address_override, + ) + .to_multisig_tx(multisig.clone()) +} + +fn make_v0_tx( + sender: &TestUser, + inner_credential: sov_modules_api::CredentialId, + address_override: Option<::Address>, +) -> Transaction { + let utx = UnsignedTransaction::::new_with_details( + TestAccountsRuntimeCall::Accounts(CallMessage::InsertCredentialId(inner_credential)), + sov_modules_api::capabilities::UniquenessData::Generation(0), + default_test_tx_details::(), + address_override, + ); + utx.sign(sender.private_key(), &>::CHAIN_HASH) +} + +fn make_v0_tx_with_call( + sender: &TestUser, + call: CallMessage, + address_override: Option<::Address>, + generation: u64, +) -> Transaction { + let utx = UnsignedTransaction::::new_with_details( + TestAccountsRuntimeCall::Accounts(call), + sov_modules_api::capabilities::UniquenessData::Generation(generation), + default_test_tx_details::(), + address_override, + ); + utx.sign(sender.private_key(), &>::CHAIN_HASH) +} + +fn sign_v1(tx: &mut Version1, key: &TestPrivateKey) { + tx.sign(key, &>::CHAIN_HASH).unwrap(); +} + +#[test] +fn test_v1_signing_rejects_mixed_chain_hashes() { + let env = make_multisig_env(); + let mut tx = make_v1_tx(&env.multisig, [9; 32].into(), None); + let stale_chain_hash = [9; 32]; + let first_chain_hash = [1; 32]; + let other_chain_hash = [2; 32]; + tx.details.chain_hash_fragment = + sov_modules_api::transaction::chain_hash_fragment(&stale_chain_hash); + + let error = tx + .sign_without_adding(&env.keys[0], &first_chain_hash) + .expect_err("detached signing with a mismatched chain hash must fail"); + assert!(error.to_string().contains("Chain hash fragment mismatch")); + assert!(tx.signatures.is_empty()); + assert_eq!( + tx.details.chain_hash_fragment, + sov_modules_api::transaction::chain_hash_fragment(&stale_chain_hash) + ); + + tx.sign(&env.keys[0], &first_chain_hash).unwrap(); + assert_eq!( + tx.details.chain_hash_fragment, + sov_modules_api::transaction::chain_hash_fragment(&first_chain_hash) + ); + + let error = tx + .sign_without_adding(&env.keys[1], &other_chain_hash) + .expect_err("signing with a mismatched chain hash must fail"); + assert!(error.to_string().contains("Chain hash fragment mismatch")); + + let error = tx + .sign(&env.keys[1], &other_chain_hash) + .expect_err("adding a signature for a different chain hash must fail"); + assert!(error.to_string().contains("Chain hash fragment mismatch")); + assert_eq!(tx.signatures.len(), 1); + assert_eq!( + tx.details.chain_hash_fragment, + sov_modules_api::transaction::chain_hash_fragment(&first_chain_hash) + ); +} + +fn submit_v0(tx: Transaction) -> TransactionType { + TransactionType::::PreSigned(RawTx { + data: borsh::to_vec(&tx).unwrap(), + }) +} + +fn submit_v1(tx: Version1) -> TransactionType { + let tx = Transaction::::from(tx); + TransactionType::::PreSigned(RawTx { + data: borsh::to_vec(&tx).unwrap(), + }) +} + +/// Mirrors `CallMessage::CreateSyntheticAddress` derivation for test setup. +const SYNTHETIC_ADDRESS_DOMAIN: &[u8] = b"sov_accounts::synthetic_address::v1"; + +/// Destructures the single expected `SyntheticAddressCreated` event from a +/// receipt's event list. +#[track_caller] +fn synthetic_address_created_event( + events: &[TestAccountsRuntimeEvent], +) -> (::Address, ::Address, CredentialId) { + match events { + [TestAccountsRuntimeEvent::Accounts(Event::SyntheticAddressCreated { + address, + creator, + credential, + })] => (*address, *creator, *credential), + other => panic!("expected one synthetic-address event, got {other:?}"), + } +} + +#[track_caller] +fn stored_synthetic_address_created_event( + events: &[StoredEvent], +) -> (::Address, ::Address, CredentialId) { + match events { + [stored_event] => { + let event = TestAccountsRuntimeEvent::::deserialize( + &mut stored_event.value().inner().as_slice(), + ) + .expect("stored batch event should deserialize into runtime event"); + + synthetic_address_created_event(std::slice::from_ref(&event)) + } + other => panic!("expected one synthetic-address event, got {other:?}"), + } +} + +fn credential_added_event( + events: &[TestAccountsRuntimeEvent], +) -> (::Address, CredentialId, ::Address) { + match events { + [TestAccountsRuntimeEvent::Accounts(Event::CredentialAdded { + address, + credential, + authorizer_address, + })] => (*address, *credential, *authorizer_address), + other => panic!("expected one CredentialAdded event, got {other:?}"), + } +} + +fn credential_removed_event( + events: &[TestAccountsRuntimeEvent], +) -> (::Address, CredentialId, ::Address) { + match events { + [TestAccountsRuntimeEvent::Accounts(Event::CredentialRemoved { + address, + credential, + authorizer_address, + })] => (*address, *credential, *authorizer_address), + other => panic!("expected one CredentialRemoved event, got {other:?}"), + } +} + +fn credential_rotated_event( + events: &[TestAccountsRuntimeEvent], +) -> ( + ::Address, + CredentialId, + CredentialId, + ::Address, +) { + match events { + [TestAccountsRuntimeEvent::Accounts(Event::CredentialRotated { + address, + old_credential, + new_credential, + authorizer_address, + })] => ( + *address, + *old_credential, + *new_credential, + *authorizer_address, + ), + other => panic!("expected one CredentialRotated event, got {other:?}"), + } +} + +/// Executes a `CreateSyntheticAddress` transaction, asserts it succeeded and +/// emitted a matching event, and returns the newly created address. +fn execute_create_synthetic_address( + runner: &mut TestRunner, + input: TransactionType, + expected_creator: ::Address, + expected_credential: CredentialId, +) -> ::Address { + let captured: Rc::Address>>> = Rc::new(Cell::new(None)); + let captured_for_assert = Rc::clone(&captured); + + runner.execute_transaction(TransactionTestCase { + input, + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "CreateSyntheticAddress should succeed, got {:?}", + result.tx_receipt + ); + let (address, creator, credential) = synthetic_address_created_event(&result.events); + assert_eq!(creator, expected_creator); + assert_eq!(credential, expected_credential); + + let accounts = Accounts::::default(); + assert!(accounts + .is_explicitly_authorized(&address, &expected_credential, state) + .unwrap()); + + captured_for_assert.set(Some(address)); + }), + }); + + captured + .get() + .expect("CreateSyntheticAddress event should have been captured") +} + +#[track_caller] +fn derive_synthetic_address( + runner: &TestRunner, + creator: ::Address, + credential: CredentialId, + salt: [u8; 32], +) -> ::Address { + // Mirrors the visible slot hash the call will see when it executes. + // MockDA encodes block hashes as `u64_to_bytes(height + 1)` (see + // `MockBlockHeader::new`) and TestRunner advances one true slot per + // call, so the next visible-slot hash is `runner.true_slot_number() + 2` + // serialized big-endian into the first 8 bytes. Update both halves of + // this constant if either convention changes. + let next_slot_hash = { + let mut hash = [0u8; 32]; + hash[..8].copy_from_slice(&(runner.true_slot_number().get() + 2).to_be_bytes()); + hash + }; + + let mut hasher = <::CryptoSpec as CryptoSpec>::Hasher::new(); + hasher.update(SYNTHETIC_ADDRESS_DOMAIN); + hasher.update(next_slot_hash.as_ref()); + hasher.update(creator.as_ref()); + let credential_bytes: &[u8] = credential.0.as_ref(); + hasher.update(credential_bytes); + hasher.update(salt); + let synthetic_credential = CredentialId::from_bytes(hasher.finalize().into()); + synthetic_credential.into() +} + +/// V1 tx with `address_override = None` executes as the multisig's default +/// address. Evidence: the `InsertCredentialId` call writes +/// `(multisig_default, inner_credential)` to `account_owners`. +#[test] +fn test_v1_address_override_none_uses_default_resolver() { + let MultisigEnv { + keys, + multisig, + user: multisig_user, + .. + } = make_multisig_env(); + let multisig_default_address = multisig_user.address(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, None); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&multisig_default_address, &inner_credential, state) + .unwrap(), + "address_override=None should route to multisig default; the InsertCredentialId \ + call must write the new credential under that address" + ); + }), + }); +} + +/// V1 tx with `address_override = Some(X)` where `(X, multisig_credential_id)` is +/// authorized resolves context as `X`. Evidence: `InsertCredentialId` writes +/// `(X, inner_credential)` — not `(multisig_default, inner_credential)`. +#[test] +fn test_v1_address_override_some_authorized_succeeds() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + + // Alice is a regular funded user; we authorize the multisig for her address. + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, Some(alice_address)); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "V1 address_override=Some(authorized) should succeed, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&alice_address, &inner_credential, state) + .unwrap(), + "InsertCredentialId should write under address_override, not multisig default" + ); + }), + }); +} + +/// V1 tx with `address_override = Some(Y)` where `(Y, credential_id) ∉ account_owners` +/// is skipped, not reverted. No state is mutated — in particular, no auto-register +/// on the `Some` path. +#[test] +fn test_v1_address_override_some_unauthorized_skipped() { + let MultisigEnv { + keys, + multisig, + user: multisig_user, + .. + } = make_multisig_env(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + // Unowned: nothing in `account_owners` points to this address. + let unowned_address = TestUser::::generate_with_default_balance().address(); + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + + let mut tx = make_v1_tx(&multisig, inner_credential, Some(unowned_address)); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "unexpected skip reason: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + } + // No auto-register on the `Some` path: the unauthorized lookup must + // not have created an entry. + let accounts = Accounts::::default(); + assert!( + !accounts + .is_explicitly_authorized(&unowned_address, &inner_credential, state) + .unwrap(), + "unauthorized address_override path must not auto-register any tuple" + ); + }), + }); +} + +/// `address_override = None` ignores any pre-existing `(X, multisig_credential_id)` +/// mapping and routes to the multisig's default address. The mapping is untouched. +#[test] +fn test_v1_address_override_none_ignores_existing_mapping() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + let multisig_default_address = multisig_user.address(); + + // Alice has an authorization for the multisig credential, but the V1 tx below + // signs with `None` and so must not route through her address. + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, None); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "V1 address_override=None should succeed, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&multisig_default_address, &inner_credential, state) + .unwrap(), + "None must route to multisig default" + ); + assert!( + !accounts + .is_explicitly_authorized(&alice_address, &inner_credential, state) + .unwrap(), + "None must not write under alice's address" + ); + assert!( + accounts + .is_explicitly_authorized(&alice_address, &multisig_credential_id, state) + .unwrap(), + "pre-existing (alice, multisig_credential_id) authorization must be preserved" + ); + }), + }); +} + +/// `address_override` is part of the signed bytes: tampering with it after signing +/// invalidates the signature. +#[test] +fn test_v1_address_override_tamper_breaks_signature() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + // Authorize the multisig for both alice and a second, unrelated address so + // the "tampered-to" address is also in the account_owners map. This isolates + // the failure to signature verification rather than authorization. + let tampered_address = TestUser::::generate_with_default_balance().address(); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: tampered_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, Some(alice_address)); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + // Mutate after signing. + tx.address_override = Some(tampered_address); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("Verification equation was not satisfied"), + "expected signature failure after address_override tamper, got: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + }), + }); +} + +/// V0 tx with `address_override = None` executes as the signer's default address. +#[test] +fn test_v0_address_override_none_uses_default_resolver() { + let sender = TestUser::::generate_with_default_balance(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![sender.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let tx = make_v0_tx(&sender, inner_credential, None); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&sender.address(), &inner_credential, state) + .unwrap(), + "address_override=None should route to the signer's default address" + ); + }), + }); +} + +/// V0 tx with `address_override = Some(X)` where `(X, credential_id)` is +/// authorized resolves context as `X`. +#[test] +fn test_v0_address_override_some_authorized_succeeds() { + let ( + TestData { + account_2, + non_registered_account, + .. + }, + mut genesis, + ) = setup_genesis(); + let alice_address = account_2.address(); + genesis.accounts.accounts.push(AccountData { + credential_id: non_registered_account.credential_id(), + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let tx = make_v0_tx( + &non_registered_account, + inner_credential, + Some(alice_address), + ); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "V0 address_override=Some(authorized) should succeed, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&alice_address, &inner_credential, state) + .unwrap(), + "InsertCredentialId should write under address_override, not sender default" + ); + }), + }); +} + +/// V0 tx with `address_override = Some(Y)` where `(Y, credential_id) ∉ account_owners` +/// is skipped, not reverted. +#[test] +fn test_v0_address_override_some_unauthorized_skipped() { + let sender = TestUser::::generate_with_default_balance(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![sender.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let unowned_address = TestUser::::generate_with_default_balance().address(); + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let tx = make_v0_tx(&sender, inner_credential, Some(unowned_address)); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "unexpected skip reason: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + } + let accounts = Accounts::::default(); + assert!( + !accounts + .is_explicitly_authorized(&unowned_address, &inner_credential, state) + .unwrap(), + "unauthorized address_override path must not auto-register any tuple" + ); + }), + }); +} + +/// `address_override` is part of the signed bytes for V0 as well: tampering with it +/// after signing invalidates the signature. +#[test] +fn test_v0_address_override_tamper_breaks_signature() { + let sender = TestUser::::generate_with_default_balance(); + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + let tampered_address = TestUser::::generate_with_default_balance().address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![sender.clone(), alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: sender.credential_id(), + address: alice_address, + }); + genesis.accounts.accounts.push(AccountData { + credential_id: sender.credential_id(), + address: tampered_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v0_tx(&sender, inner_credential, Some(alice_address)); + let Transaction::V0(inner) = &mut tx else { + panic!("expected v0 tx"); + }; + inner.address_override = Some(tampered_address); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("Verification equation was not satisfied"), + "expected signature failure after address_override tamper, got: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + }), + }); +} + +/// An existing owner can authorize a second credential for their own address +/// via `AddCredentialToAddress` (V0 path, sender == address). +#[test] +fn test_add_credential_to_address_by_owner() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let new_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>(CallMessage::AddCredentialToAddress { + address: owner_address, + credential: new_credential, + }), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + + let (event_address, event_credential, event_authorizer) = + credential_added_event(&result.events); + assert_eq!(event_address, owner_address); + assert_eq!(event_credential, new_credential); + assert_eq!(event_authorizer, owner_address); + + let accounts = Accounts::::default(); + assert!( + accounts + .is_authorized_for(&owner_address, &new_credential, state) + .unwrap(), + "new credential should be authorized for owner's address" + ); + }), + }); +} + +/// A brand-new user (no genesis entry, no prior `account_owners` entry) can +/// authorize a credential for their own default address. The simple +/// `sender == address` rule subsumes the bootstrap case: `sender` is the +/// user's default address because the resolver returns the default for an +/// unknown credential. +#[test] +fn test_add_credential_to_address_bootstrap() { + let ( + TestData { + non_registered_account: fresh_user, + .. + }, + mut runner, + ) = setup(); + let fresh_address = fresh_user.address(); + let extra_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: fresh_user.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: fresh_address, + credential: extra_credential, + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(accounts + .is_authorized_for(&fresh_address, &extra_credential, state) + .unwrap()); + }), + }); +} + +/// A caller cannot add a credential for an address that isn't their own. +/// Under the resolver, `context.sender()` is the caller's resolved address; +/// the handler rejects if `message.address != context.sender()`. Uses two +/// users whose canonical (`hash(pubkey).into()`) addresses equal their funded +/// addresses so V0 gas reservation succeeds and the handler actually runs. +#[test] +fn test_add_credential_to_address_non_owner_rejected() { + let attacker = TestUser::::generate_with_default_balance(); + let victim = TestUser::::generate_with_default_balance(); + let victim_address = victim.address(); + let attacker_address = attacker.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![attacker.clone(), victim]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let attacker_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: attacker.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: victim_address, + credential: attacker_credential, + }, + ), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + format!( + "Caller {attacker_address} is not authorized to modify credentials for address {victim_address}" + ) + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + } + // Confirm the attacker's credential was never written under the victim. + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&victim_address, &attacker_credential, state) + .unwrap()); + }), + }); +} + +/// Adding a tuple that already exists fails cleanly. +#[test] +fn test_add_credential_duplicate_rejected() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential, + }, + )); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>(CallMessage::AddCredentialToAddress { + address: owner_address, + credential, + }), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "CredentialId already authorized for this address" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + }), + }); +} + +/// Adding a credential that is already authorized canonically for the owner's +/// address fails cleanly. +#[test] +fn test_add_credential_canonical_duplicate_rejected() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let canonical_credential = owner.credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>(CallMessage::AddCredentialToAddress { + address: owner_address, + credential: canonical_credential, + }), + assert: Box::new(move |result, state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "CredentialId already authorized for this address" + ); + let accounts = Accounts::::default(); + assert!( + !accounts + .is_explicitly_authorized(&owner_address, &canonical_credential, state) + .unwrap(), + "canonical duplicate rejection must not materialize an explicit entry" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + }), + }); +} + +/// The owner can revoke a credential from their own address. +#[test] +fn test_remove_credential_from_address_by_owner() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let kept_credential = TestPrivateKey::generate().pub_key().credential_id(); + let removed_credential = TestPrivateKey::generate().pub_key().credential_id(); + + // Authorize both credentials for the owner. + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential: kept_credential, + }, + )); + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential: removed_credential, + }, + )); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RemoveCredentialFromAddress { + address: owner_address, + credential: removed_credential, + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + + let (event_address, event_credential, event_authorizer) = + credential_removed_event(&result.events); + assert_eq!(event_address, owner_address); + assert_eq!(event_credential, removed_credential); + assert_eq!(event_authorizer, owner_address); + + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&owner_address, &removed_credential, state) + .unwrap()); + assert!( + accounts + .is_authorized_for(&owner_address, &kept_credential, state) + .unwrap(), + "unrelated authorization should survive the revocation" + ); + }), + }); +} + +/// Removing a credential from its own canonical address writes an explicit +/// denial, because there is no legacy map entry to delete for that fallback. +#[test] +fn test_remove_canonical_credential_blocks_fallback_authorization() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let owner_credential = owner.credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RemoveCredentialFromAddress { + address: owner_address, + credential: owner_credential, + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&owner_address, &owner_credential, state) + .unwrap()); + }), + }); +} + +/// A caller cannot revoke a credential from an address they don't own. Uses +/// two canonical-address users (no custom credential_id) so the V0 gas path +/// resolves correctly to the funded address. +#[test] +fn test_remove_credential_non_owner_rejected() { + let attacker = TestUser::::generate_with_default_balance(); + let victim = TestUser::::generate_with_default_balance(); + let victim_address = victim.address(); + let victim_credential = victim.credential_id(); + let attacker_address = attacker.address(); + + // Seed victim's `(address, credential)` so the attacker's revoke attempt + // targets a real tuple. Otherwise the handler would fail on the tuple + // check before the ownership check, hiding the bug we care about. + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![attacker.clone(), victim]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: victim_credential, + address: victim_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + runner.execute_transaction(TransactionTestCase { + input: attacker.create_plain_message::>( + CallMessage::RemoveCredentialFromAddress { + address: victim_address, + credential: victim_credential, + }, + ), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + format!( + "Caller {attacker_address} is not authorized to modify credentials for address {victim_address}" + ) + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + } + // Victim's authorization is unaffected. + let accounts = Accounts::::default(); + assert!(accounts + .is_authorized_for(&victim_address, &victim_credential, state) + .unwrap()); + }), + }); +} + +/// Attempting to remove a tuple that doesn't exist fails cleanly. +#[test] +fn test_remove_credential_not_authorized_rejected() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let ghost_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RemoveCredentialFromAddress { + address: owner_address, + credential: ghost_credential, + }, + ), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "CredentialId is not authorized for this address" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + }), + }); +} + +/// Removing the last credential succeeds (no orphan guard) and leaves the +/// address unspendable via `account_owners`: a subsequent V1 tx with +/// `target_address = Some(orphaned)` signed by the revoked credential is +/// skipped at the resolver. +#[test] +fn test_remove_last_credential_orphans_address() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + // Seed `(alice_address, multisig_credential_id)` so the multisig can route + // to Alice's address before we revoke. + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + // Revoke the only credential authorizing the multisig for Alice. + let mut revoke_tx = make_v1_tx_with_call( + &multisig, + CallMessage::RemoveCredentialFromAddress { + address: alice_address, + credential: multisig_credential_id, + }, + Some(alice_address), + 0, + ); + sign_v1(&mut revoke_tx, &keys[0]); + sign_v1(&mut revoke_tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(revoke_tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&alice_address, &multisig_credential_id, state) + .unwrap()); + }), + }); + + // Orphan confirmed: trying to route to Alice again is rejected at the + // resolver (skipped, not reverted). + let follow_up_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut orphan_tx = make_v1_tx_with_call( + &multisig, + CallMessage::InsertCredentialId(follow_up_credential), + Some(alice_address), + 1, + ); + sign_v1(&mut orphan_tx, &keys[0]); + sign_v1(&mut orphan_tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(orphan_tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "expected resolver skip; got: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + }), + }); +} + +/// Regression test: `is_explicitly_authorized` must return `false` once +/// `revoke_credential` has written `Some(false)`. An earlier version used +/// `.is_some()`, which collapsed `Some(false)` and `Some(true)` into `true` +/// and silently neutered revocation for the `address_override` path. +#[test] +fn test_is_explicitly_authorized_honors_revoked_false() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + // Seed `(alice_address, multisig_credential_id) = true` so the + // post-revoke entry is `Some(false)`, not `None`. + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let mut revoke_tx = make_v1_tx_with_call( + &multisig, + CallMessage::RemoveCredentialFromAddress { + address: alice_address, + credential: multisig_credential_id, + }, + Some(alice_address), + 0, + ); + sign_v1(&mut revoke_tx, &keys[0]); + sign_v1(&mut revoke_tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(revoke_tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!( + !accounts + .is_explicitly_authorized(&alice_address, &multisig_credential_id, state) + .unwrap(), + "is_explicitly_authorized must honor a stored Some(false) and report not-authorized" + ); + }), + }); +} + +/// End-to-end multisig rotation M1 → M2 while preserving the original +/// address. Exercises `AddCredentialToAddress` + `RemoveCredentialFromAddress` +/// via V1 + `target_address`. +#[test] +fn test_multisig_key_rotation() { + use sov_modules_api::Multisig; + + // M1 is the incumbent 2-of-3 multisig. M2 is the rotated key set, fully + // disjoint so credential_id_1 and credential_id_2 cannot collide. + let keys_1 = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let keys_2 = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let multisig_1 = Multisig::new(2, keys_1.iter().map(|k| k.pub_key()).collect()); + let multisig_2 = Multisig::new(2, keys_2.iter().map(|k| k.pub_key()).collect()); + let credential_id_1 = + multisig_1.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + let credential_id_2 = + multisig_2.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + + let multisig_user = + TestUser::generate_with_default_balance().add_credential_id(credential_id_1); + let target_address = multisig_user.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + // 1. M1 authorizes M2 for the shared address X. + let mut add_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::AddCredentialToAddress { + address: target_address, + credential: credential_id_2, + }, + Some(target_address), + 0, + ); + sign_v1(&mut add_tx, &keys_1[0]); + sign_v1(&mut add_tx, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(add_tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(accounts + .is_authorized_for(&target_address, &credential_id_1, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&target_address, &credential_id_2, state) + .unwrap()); + }), + }); + + // 2. M1 revokes itself from X. + let mut remove_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::RemoveCredentialFromAddress { + address: target_address, + credential: credential_id_1, + }, + Some(target_address), + 1, + ); + sign_v1(&mut remove_tx, &keys_1[0]); + sign_v1(&mut remove_tx, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(remove_tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&target_address, &credential_id_1, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&target_address, &credential_id_2, state) + .unwrap()); + }), + }); + + // 3. M2 signs a routine tx targeting X — should succeed. + let follow_up_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut m2_tx = make_v1_tx_with_call( + &multisig_2, + CallMessage::InsertCredentialId(follow_up_credential), + Some(target_address), + 0, + ); + sign_v1(&mut m2_tx, &keys_2[0]); + sign_v1(&mut m2_tx, &keys_2[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(m2_tx), + assert: Box::new(move |result, _state| { + assert!( + result.tx_receipt.is_successful(), + "M2 should now control X; got {:?}", + result.tx_receipt + ); + }), + }); + + // 4. M1 signs another tx targeting X — should be skipped (no longer + // authorized). + let orphan_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut m1_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::InsertCredentialId(orphan_credential), + Some(target_address), + 2, + ); + sign_v1(&mut m1_tx, &keys_1[0]); + sign_v1(&mut m1_tx, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(m1_tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "expected resolver skip after rotation; got: {msg}" + ); + } + other => panic!("expected skipped tx after rotation, got {other:?}"), + }), + }); +} + +/// With `enable_custom_account_mappings = false`, both new call variants are +/// rejected by the same guard that already rejects `InsertCredentialId`. +#[test] +fn test_feature_flag_gates_new_variants() { + let (user, mut runner) = setup_with_disable_custom_account_mappings(); + let user_address = user.address(); + let credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: user.create_plain_message::>(CallMessage::AddCredentialToAddress { + address: user_address, + credential, + }), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "Custom account mappings are disabled" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: user.create_plain_message::>( + CallMessage::RemoveCredentialFromAddress { + address: user_address, + credential, + }, + ), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "Custom account mappings are disabled" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + }), + }); +} + +/// `RotateCredentialOnAddress` atomically replaces an authorized credential +/// with a new one. The old tuple is deleted; the new tuple is written; a +/// separate unrelated authorization on the same address is untouched. +#[test] +fn test_rotate_credential_by_owner() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let old_credential = TestPrivateKey::generate().pub_key().credential_id(); + let new_credential = TestPrivateKey::generate().pub_key().credential_id(); + let unrelated_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential: old_credential, + }, + )); + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential: unrelated_credential, + }, + )); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RotateCredentialOnAddress { + address: owner_address, + old_credential, + new_credential, + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + + let (event_address, event_old, event_new, event_authorizer) = + credential_rotated_event(&result.events); + assert_eq!(event_address, owner_address); + assert_eq!(event_old, old_credential); + assert_eq!(event_new, new_credential); + assert_eq!(event_authorizer, owner_address); + + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&owner_address, &old_credential, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&owner_address, &new_credential, state) + .unwrap()); + assert!( + accounts + .is_authorized_for(&owner_address, &unrelated_credential, state) + .unwrap(), + "unrelated authorization must survive rotation" + ); + }), + }); +} + +/// Attempting to rotate out a credential that isn't authorized fails and +/// leaves state untouched. +#[test] +fn test_rotate_credential_old_not_authorized_rejected() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let ghost_credential = TestPrivateKey::generate().pub_key().credential_id(); + let new_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RotateCredentialOnAddress { + address: owner_address, + old_credential: ghost_credential, + new_credential, + }, + ), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "old_credential is not authorized for this address" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + } + // The revert must leave the new tuple unwritten. + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&owner_address, &new_credential, state) + .unwrap()); + }), + }); +} + +/// Attempting to rotate in a credential that is already authorized fails +/// without revoking the old credential. +#[test] +fn test_rotate_credential_new_already_authorized_rejected() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let old_credential = TestPrivateKey::generate().pub_key().credential_id(); + let new_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential: old_credential, + }, + )); + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential: new_credential, + }, + )); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RotateCredentialOnAddress { + address: owner_address, + old_credential, + new_credential, + }, + ), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "CredentialId already authorized for this address" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + } + // The old credential must still be authorized — the revert rolls + // back the delete. + let accounts = Accounts::::default(); + assert!(accounts + .is_authorized_for(&owner_address, &old_credential, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&owner_address, &new_credential, state) + .unwrap()); + }), + }); +} + +/// Attempting to rotate in the owner's canonical credential fails without +/// revoking the old credential or materializing a duplicate explicit entry. +#[test] +fn test_rotate_credential_new_canonical_already_authorized_rejected() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let owner_address = owner.address(); + let old_credential = TestPrivateKey::generate().pub_key().credential_id(); + let canonical_credential = owner.credential_id(); + + runner.execute(owner.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: owner_address, + credential: old_credential, + }, + )); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RotateCredentialOnAddress { + address: owner_address, + old_credential, + new_credential: canonical_credential, + }, + ), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "CredentialId already authorized for this address" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + } + let accounts = Accounts::::default(); + assert!(accounts + .is_authorized_for(&owner_address, &old_credential, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&owner_address, &canonical_credential, state) + .unwrap()); + assert!( + !accounts + .is_explicitly_authorized(&owner_address, &canonical_credential, state) + .unwrap(), + "canonical duplicate rejection must not materialize an explicit entry" + ); + }), + }); +} + +/// A caller cannot rotate credentials on an address they don't own. +#[test] +fn test_rotate_credential_non_owner_rejected() { + let attacker = TestUser::::generate_with_default_balance(); + let victim = TestUser::::generate_with_default_balance(); + let victim_address = victim.address(); + let victim_credential = victim.credential_id(); + let attacker_address = attacker.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![attacker.clone(), victim]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: victim_credential, + address: victim_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let attacker_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: attacker.create_plain_message::>( + CallMessage::RotateCredentialOnAddress { + address: victim_address, + old_credential: victim_credential, + new_credential: attacker_credential, + }, + ), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + format!( + "Caller {attacker_address} is not authorized to modify credentials for address {victim_address}" + ) + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + } + let accounts = Accounts::::default(); + assert!(accounts + .is_authorized_for(&victim_address, &victim_credential, state) + .unwrap()); + assert!(!accounts + .is_authorized_for(&victim_address, &attacker_credential, state) + .unwrap()); + }), + }); +} + +/// `RotateCredentialOnAddress` is gated by the same feature flag as the +/// other write variants. +#[test] +fn test_rotate_credential_feature_flag_gated() { + let (user, mut runner) = setup_with_disable_custom_account_mappings(); + let user_address = user.address(); + let old_credential = TestPrivateKey::generate().pub_key().credential_id(); + let new_credential = TestPrivateKey::generate().pub_key().credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: user.create_plain_message::>( + CallMessage::RotateCredentialOnAddress { + address: user_address, + old_credential, + new_credential, + }, + ), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "Custom account mappings are disabled" + ); + } + other => panic!("Expected reverted transaction, got {other:?}"), + }), + }); +} + +/// Multisig rotation M1 → M2 in a single atomic `RotateCredentialOnAddress` +/// call, collapsing the two-step Add+Remove dance. +#[test] +fn test_multisig_key_rotation_atomic() { + use sov_modules_api::Multisig; + + let keys_1 = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let keys_2 = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let multisig_1 = Multisig::new(2, keys_1.iter().map(|k| k.pub_key()).collect()); + let multisig_2 = Multisig::new(2, keys_2.iter().map(|k| k.pub_key()).collect()); + let credential_id_1 = + multisig_1.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + let credential_id_2 = + multisig_2.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + + let multisig_user = + TestUser::generate_with_default_balance().add_credential_id(credential_id_1); + let target_address = multisig_user.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let mut rotate_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::RotateCredentialOnAddress { + address: target_address, + old_credential: credential_id_1, + new_credential: credential_id_2, + }, + Some(target_address), + 0, + ); + sign_v1(&mut rotate_tx, &keys_1[0]); + sign_v1(&mut rotate_tx, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(rotate_tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&target_address, &credential_id_1, state) + .unwrap()); + assert!(accounts + .is_authorized_for(&target_address, &credential_id_2, state) + .unwrap()); + }), + }); + + // M1 cannot bypass the revocation by omitting target_address and falling + // back to its canonical address. + let stale_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut m1_target_none_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::InsertCredentialId(stale_credential), + None, + 1, + ); + sign_v1(&mut m1_target_none_tx, &keys_1[0]); + sign_v1(&mut m1_target_none_tx, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(m1_target_none_tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for resolved address"), + "expected resolver skip after target-less rotation fallback; got: {msg}" + ); + } + other => panic!("expected skipped tx after target-less fallback, got {other:?}"), + }), + }); + + // M2 now controls X. + let follow_up_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut m2_tx = make_v1_tx_with_call( + &multisig_2, + CallMessage::InsertCredentialId(follow_up_credential), + Some(target_address), + 0, + ); + sign_v1(&mut m2_tx, &keys_2[0]); + sign_v1(&mut m2_tx, &keys_2[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(m2_tx), + assert: Box::new(move |result, _state| { + assert!( + result.tx_receipt.is_successful(), + "M2 should now control X after atomic rotation; got {:?}", + result.tx_receipt + ); + }), + }); + + // M1 can no longer act as X. + let orphan_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut m1_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::InsertCredentialId(orphan_credential), + Some(target_address), + 1, + ); + sign_v1(&mut m1_tx, &keys_1[0]); + sign_v1(&mut m1_tx, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(m1_tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "expected resolver skip after rotation; got: {msg}" + ); + } + other => panic!("expected skipped tx after rotation, got {other:?}"), + }), + }); +} + +/// V0 single-signer happy path: the new address is created, the caller's +/// credential is auto-authorized for it, and exactly one matching event is +/// emitted. +#[test] +fn test_create_synthetic_address_happy_path() { + let user = TestUser::::generate_with_default_balance(); + let user_address = user.address(); + let user_credential = user.credential_id(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![user.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let new_address = execute_create_synthetic_address( + &mut runner, + user.create_plain_message::>(CallMessage::CreateSyntheticAddress { + salt: [7; 32], + }), + user_address, + user_credential, + ); + + // The new address is not the caller's own address, nor the canonical + // address of any unrelated credential we can sample. + assert_ne!(new_address, user_address); + assert_ne!(new_address, ::Address::from(user_credential)); + assert_ne!( + new_address, + ::Address::from(CredentialId::from([99u8; 32])) + ); +} + +/// Re-issuing the same `(caller, salt)` in the same slot is an idempotent +/// no-op: both transactions succeed, and the `account_owners` entry remains +/// authorized. +#[test] +fn test_create_synthetic_address_is_idempotent_in_same_slot() { + let user = TestUser::::generate_with_default_balance(); + let user_address = user.address(); + let user_credential = user.credential_id(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![user.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + let salt = [22; 32]; + + let tx_1 = + user.create_plain_message::>(CallMessage::CreateSyntheticAddress { salt }); + let tx_2 = + user.create_plain_message::>(CallMessage::CreateSyntheticAddress { salt }); + + runner.execute_batch(BatchTestCase { + input: BatchType::from(vec![tx_1, tx_2]), + assert: Box::new(move |result, state| { + let batch = result.batch_receipt.expect("batch should be accepted"); + assert_eq!(batch.tx_receipts.len(), 2); + assert!( + batch.tx_receipts[0].receipt.is_successful(), + "first CreateSyntheticAddress should succeed, got {:?}", + batch.tx_receipts[0].receipt + ); + assert!( + batch.tx_receipts[1].receipt.is_successful(), + "duplicate CreateSyntheticAddress in same slot should be idempotent, got {:?}", + batch.tx_receipts[1].receipt + ); + assert_eq!(batch.tx_receipts[0].events.len(), 1); + assert!( + batch.tx_receipts[1].events.is_empty(), + "same-slot replay should not emit a second creation event" + ); + + let (synthetic_address, creator, credential) = + stored_synthetic_address_created_event(&batch.tx_receipts[0].events); + assert_eq!(creator, user_address); + assert_eq!(credential, user_credential); + + let accounts = Accounts::::default(); + assert!(accounts + .is_explicitly_authorized(&synthetic_address, &user_credential, state) + .unwrap()); + }), + }); +} + +/// Revoking the creator credential from a just-created synthetic address must +/// survive a same-slot replay of the same creation tuple. +#[test] +fn test_create_synthetic_address_replay_after_revoke_in_same_slot_does_not_restore_authorization() { + let user = TestUser::::generate_with_default_balance(); + let user_address = user.address(); + let user_credential = user.credential_id(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![user.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + let salt = [23; 32]; + let synthetic_address = derive_synthetic_address(&runner, user_address, user_credential, salt); + + let create_tx = + user.create_plain_message::>(CallMessage::CreateSyntheticAddress { salt }); + // The revoke transaction sets `address_override = Some(synthetic_address)`, + // which makes the synthetic address pay its own gas. The newly-created + // synthetic address starts with zero balance, so without this transfer the + // revoke is skipped with `CannotReserveGas` before it can run. + let fund_tx = + user.create_plain_message::>(sov_bank::CallMessage::Transfer { + to: synthetic_address, + coins: sov_bank::Coins { + amount: Amount::new(1_000_000_000_000), + token_id: sov_bank::config_gas_token_id(), + }, + }); + let revoke_tx = submit_v0(make_v0_tx_with_call( + &user, + CallMessage::RemoveCredentialFromAddress { + address: synthetic_address, + credential: user_credential, + }, + Some(synthetic_address), + 1, + )); + let replay_tx = + user.create_plain_message::>(CallMessage::CreateSyntheticAddress { salt }); + + runner.execute_batch(BatchTestCase { + input: BatchType::from(vec![create_tx, fund_tx, revoke_tx, replay_tx]), + assert: Box::new(move |result, state| { + let batch = result.batch_receipt.expect("batch should be accepted"); + assert_eq!(batch.tx_receipts.len(), 4); + assert!( + batch.tx_receipts[0].receipt.is_successful(), + "first CreateSyntheticAddress should succeed, got {:?}", + batch.tx_receipts[0].receipt + ); + assert!( + batch.tx_receipts[1].receipt.is_successful(), + "funding the synthetic address should succeed, got {:?}", + batch.tx_receipts[1].receipt + ); + assert!( + batch.tx_receipts[2].receipt.is_successful(), + "revoking the creator credential from the synthetic address should succeed, got {:?}", + batch.tx_receipts[2].receipt + ); + assert!( + batch.tx_receipts[3].receipt.is_successful(), + "replayed CreateSyntheticAddress should remain a no-op after revocation, got {:?}", + batch.tx_receipts[3].receipt + ); + assert!( + batch.tx_receipts[3].events.is_empty(), + "same-slot replay after revocation should not emit a second creation event" + ); + + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&synthetic_address, &user_credential, state) + .unwrap()); + }), + }); +} + +/// Different salts from the same caller produce distinct synthetic addresses. +#[test] +fn test_create_synthetic_address_different_salt_produces_different_address() { + let user = TestUser::::generate_with_default_balance(); + let user_address = user.address(); + let user_credential = user.credential_id(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![user.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + runner.execute_batch(BatchTestCase { + input: BatchType::from(vec![ + user.create_plain_message::>(CallMessage::CreateSyntheticAddress { + salt: [1; 32], + }), + user.create_plain_message::>(CallMessage::CreateSyntheticAddress { + salt: [2; 32], + }), + ]), + assert: Box::new(move |result, state| { + let batch = result.batch_receipt.expect("batch should be accepted"); + let accounts = Accounts::::default(); + assert_eq!(batch.tx_receipts.len(), 2); + + let mut created_addresses = Vec::with_capacity(batch.tx_receipts.len()); + for tx_receipt in &batch.tx_receipts { + assert!( + tx_receipt.receipt.is_successful(), + "CreateSyntheticAddress should succeed, got {:?}", + tx_receipt.receipt + ); + + let (address, creator, credential) = + stored_synthetic_address_created_event(&tx_receipt.events); + assert_eq!(creator, user_address); + assert_eq!(credential, user_credential); + assert!(accounts + .is_explicitly_authorized(&address, &user_credential, state) + .unwrap()); + created_addresses.push(address); + } + + assert_ne!(created_addresses[0], created_addresses[1]); + }), + }); +} + +/// Two different callers using the same salt produce distinct addresses, and +/// neither caller can `AddCredentialToAddress` to the other's synthetic +/// address — the standard `ensure_caller_owns` guard applies. +#[test] +fn test_create_synthetic_address_is_creator_scoped() { + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts_with_default_balance(2); + let users = genesis_config.additional_accounts(); + let creator_1 = users[0].clone(); + let creator_2 = users[1].clone(); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let salt = [33; 32]; + let creator_1_address = creator_1.address(); + let creator_1_credential = creator_1.credential_id(); + let creator_2_address = creator_2.address(); + let creator_2_credential = creator_2.credential_id(); + let captured_addresses = Rc::new(RefCell::new((None, None))); + let captured_addresses_for_assert = Rc::clone(&captured_addresses); + + runner.execute_batch(BatchTestCase { + input: BatchType::from(vec![ + creator_1.create_plain_message::>( + CallMessage::CreateSyntheticAddress { salt }, + ), + creator_2.create_plain_message::>( + CallMessage::CreateSyntheticAddress { salt }, + ), + ]), + assert: Box::new(move |result, state| { + let batch = result.batch_receipt.expect("batch should be accepted"); + assert_eq!(batch.tx_receipts.len(), 2); + + let accounts = Accounts::::default(); + let mut created_addresses = Vec::with_capacity(batch.tx_receipts.len()); + for (tx_receipt, (expected_creator, expected_credential)) in + batch.tx_receipts.iter().zip([ + (creator_1_address, creator_1_credential), + (creator_2_address, creator_2_credential), + ]) + { + assert!( + tx_receipt.receipt.is_successful(), + "CreateSyntheticAddress should succeed, got {:?}", + tx_receipt.receipt + ); + + let (address, creator, credential) = + stored_synthetic_address_created_event(&tx_receipt.events); + assert_eq!(creator, expected_creator); + assert_eq!(credential, expected_credential); + assert!(accounts + .is_explicitly_authorized(&address, &expected_credential, state) + .unwrap()); + created_addresses.push(address); + } + + assert_ne!(created_addresses[0], created_addresses[1]); + *captured_addresses_for_assert.borrow_mut() = + (Some(created_addresses[0]), Some(created_addresses[1])); + }), + }); + + let (address_1, _address_2) = Rc::try_unwrap(captured_addresses) + .expect("capture should have no outstanding references") + .into_inner(); + let address_1 = address_1.expect("first synthetic address should have been captured"); + + // creator_2 cannot mutate creator_1's synthetic address. + let attacker_credential = TestPrivateKey::generate().pub_key().credential_id(); + runner.execute_transaction(TransactionTestCase { + input: creator_2.create_plain_message::>( + CallMessage::AddCredentialToAddress { + address: address_1, + credential: attacker_credential, + }, + ), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + let msg = contents.reason.to_string(); + assert!( + msg.contains("not authorized to modify credentials"), + "expected ensure_caller_owns rejection; got: {msg}" + ); + } + other => panic!("expected non-owner add to revert, got {other:?}"), + }), + }); +} + +/// Long-form: a multisig creates an synthetic address, funds it via bank +/// transfer, inserts a follow-up credential, adds a rotated multisig +/// credential, removes the incumbent credential, then verifies the rotated +/// multisig can act as the synthetic address while the old multisig can no +/// longer reach it. +#[test] +fn test_create_synthetic_address_can_be_used_and_rotated() { + use sov_modules_api::Multisig; + + let MultisigEnv { + keys: keys_1, + multisig: multisig_1, + credential_id: credential_id_1, + user: multisig_user, + } = make_multisig_env(); + let keys_2 = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let multisig_2 = Multisig::new(2, keys_2.iter().map(|k| k.pub_key()).collect()); + let credential_id_2 = + multisig_2.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + + let creator = multisig_user.address(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let mut create_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::CreateSyntheticAddress { salt: [11; 32] }, + None, + 0, + ); + sign_v1(&mut create_tx, &keys_1[0]); + sign_v1(&mut create_tx, &keys_1[1]); + let synthetic_address = execute_create_synthetic_address( + &mut runner, + submit_v1(create_tx), + creator, + credential_id_1, + ); + + // Sanity: the new address is not the canonical address of any sampled + // pre-existing credential. + for sampled_credential in [ + credential_id_1, + credential_id_2, + CredentialId::from([99u8; 32]), + ] { + assert_ne!( + synthetic_address, + ::Address::from(sampled_credential) + ); + } + + let mut fund_synthetic_address = UnsignedTransaction::::new_with_details( + TestAccountsRuntimeCall::Bank(sov_bank::CallMessage::Transfer { + to: synthetic_address, + coins: sov_bank::Coins { + amount: Amount::new(1_000_000_000_000), + token_id: sov_bank::config_gas_token_id(), + }, + }), + sov_modules_api::capabilities::UniquenessData::Generation(1), + default_test_tx_details::(), + None, + ) + .to_multisig_tx(multisig_1.clone()); + sign_v1(&mut fund_synthetic_address, &keys_1[0]); + sign_v1(&mut fund_synthetic_address, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(fund_synthetic_address), + assert: Box::new(move |result, _state| { + assert!( + result.tx_receipt.is_successful(), + "funding synthetic address should succeed, got {:?}", + result.tx_receipt + ); + }), + }); + + let follow_up_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut m1_follow_up = make_v1_tx_with_call( + &multisig_1, + CallMessage::InsertCredentialId(follow_up_credential), + Some(synthetic_address), + 2, + ); + sign_v1(&mut m1_follow_up, &keys_1[0]); + sign_v1(&mut m1_follow_up, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(m1_follow_up), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "incumbent credential should control synthetic address, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!(accounts + .is_explicitly_authorized(&synthetic_address, &follow_up_credential, state) + .unwrap()); + }), + }); + + let mut add_rotated = make_v1_tx_with_call( + &multisig_1, + CallMessage::AddCredentialToAddress { + address: synthetic_address, + credential: credential_id_2, + }, + Some(synthetic_address), + 3, + ); + sign_v1(&mut add_rotated, &keys_1[0]); + sign_v1(&mut add_rotated, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(add_rotated), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(accounts + .is_explicitly_authorized(&synthetic_address, &credential_id_2, state) + .unwrap()); + }), + }); + + let mut remove_incumbent = make_v1_tx_with_call( + &multisig_1, + CallMessage::RemoveCredentialFromAddress { + address: synthetic_address, + credential: credential_id_1, + }, + Some(synthetic_address), + 4, + ); + sign_v1(&mut remove_incumbent, &keys_1[0]); + sign_v1(&mut remove_incumbent, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(remove_incumbent), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!(!accounts + .is_authorized_for(&synthetic_address, &credential_id_1, state) + .unwrap()); + assert!(accounts + .is_explicitly_authorized(&synthetic_address, &credential_id_2, state) + .unwrap()); + }), + }); + + // After revocation, multisig_1 attempting to sign as the synthetic address + // is skipped by the resolver. + let stale_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut stale_tx = make_v1_tx_with_call( + &multisig_1, + CallMessage::InsertCredentialId(stale_credential), + Some(synthetic_address), + 5, + ); + sign_v1(&mut stale_tx, &keys_1[0]); + sign_v1(&mut stale_tx, &keys_1[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(stale_tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "expected resolver skip after synthetic-address rotation; got: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + }), + }); + + // The rotated multisig_2 can now act as the synthetic address. + let rotated_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut rotated_tx = make_v1_tx_with_call( + &multisig_2, + CallMessage::InsertCredentialId(rotated_credential), + Some(synthetic_address), + 0, + ); + sign_v1(&mut rotated_tx, &keys_2[0]); + sign_v1(&mut rotated_tx, &keys_2[1]); + runner.execute_transaction(TransactionTestCase { + input: submit_v1(rotated_tx), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "rotated credential should control synthetic address, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!(accounts + .is_explicitly_authorized(&synthetic_address, &rotated_credential, state) + .unwrap()); + }), + }); +} + +/// `enable_custom_account_mappings = false` rejects `CreateSyntheticAddress` +/// just like the other custom-mapping call messages. +#[test] +fn test_create_synthetic_address_rejected_when_custom_account_mappings_disabled() { + let (user, mut runner) = setup_with_disable_custom_account_mappings(); + + runner.execute_transaction(TransactionTestCase { + input: user.create_plain_message::>(CallMessage::CreateSyntheticAddress { + salt: [0; 32], + }), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Reverted(contents) => { + assert_eq!( + contents.reason.to_string(), + "Custom account mappings are disabled" + ); + } + _ => panic!("Expected reverted transaction"), + }), + }); +} + +/// For an `(address, credential_id)` pair with no `account_owners` row, +/// `is_default_address_authorized` returns `true` — the admit-path trusts the +/// authenticator's declared default via `unwrap_or(true)` — while +/// `is_authorized_for` returns `false` whenever `address` is not the canonical +/// address of `credential_id`, since its fallback is +/// `canonical_address == address`. This test pins that divergence. +/// +/// The pair is built as `(account_2.address(), account_1.credential_id())`: +/// the simplest way to construct a `(non-canonical-address, credential)` pair +/// on `TestSpec`'s single-variant Address. In production the same divergence +/// arises naturally for the EVM authenticator, where `default_address` is a +/// `MultiAddress::Vm` variant and `canonical(credential_id)` is +/// `MultiAddress::Standard`. +/// +/// Such a pair is never observed on the admit-path in production: +/// `resolve_authorized_sender` (in `sov-capabilities`) is reached only after +/// the authenticator has verified the `credential_id -> default_address` +/// binding. This test deliberately bypasses the authenticator to probe the +/// on-chain layer in isolation. +#[test] +fn test_admit_path_diverges_from_canonical_for_non_canonical_default_address() { + let ( + TestData { + account_1, + account_2, + .. + }, + runner, + ) = setup(); + + let credential = account_1.credential_id(); + let non_canonical_address = account_2.address(); + let canonical_for_credential = ::Address::from(account_1.credential_id()); + assert_ne!( + non_canonical_address, canonical_for_credential, + "test scaffold: account_2's address must differ from canonical(account_1.credential)" + ); + + runner.query_visible_state(|state| { + let accounts = Accounts::::default(); + + let explicit = accounts + .is_explicitly_authorized(&non_canonical_address, &credential, state) + .unwrap(); + let default_path = accounts + .is_default_address_authorized(&non_canonical_address, &credential, state) + .unwrap(); + let canonical_view = accounts + .is_authorized_for(&non_canonical_address, &credential, state) + .unwrap(); + + assert!( + !explicit, + "no explicit account_owners entry exists: is_explicitly_authorized must be false" + ); + assert!( + default_path, + "no explicit account_owners entry exists: is_default_address_authorized must be true (chain trusts authenticator default)" + ); + assert!( + !canonical_view, + "no explicit account_owners entry AND non-canonical address: is_authorized_for must be false" + ); + }); +} + +/// Verifies the inverse: an explicit `account_owners[(addr, cred)] = false` +/// entry (written by `RemoveCredentialFromAddress`) denies all three +/// predicates. This is the design invariant that lets revocation block the +/// admit-path even when the authenticator would otherwise have admitted the +/// transaction via the default-trust fallback. +#[test] +fn test_explicit_revoke_denies_all_three_authorization_predicates() { + let ( + TestData { + non_registered_account: owner, + .. + }, + mut runner, + ) = setup(); + let address = owner.address(); + let credential = owner.credential_id(); + + runner.execute_transaction(TransactionTestCase { + input: owner.create_plain_message::>( + CallMessage::RemoveCredentialFromAddress { + address, + credential, + }, + ), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + + let explicit = accounts + .is_explicitly_authorized(&address, &credential, state) + .unwrap(); + let default_path = accounts + .is_default_address_authorized(&address, &credential, state) + .unwrap(); + let canonical_view = accounts + .is_authorized_for(&address, &credential, state) + .unwrap(); + + assert!( + !explicit, + "explicit `false` entry: is_explicitly_authorized must be false" + ); + assert!( + !default_path, + "explicit `false` entry blocks the admit-path default-trust fallback" + ); + assert!( + !canonical_view, + "explicit `false` entry overrides the canonical fallback" + ); + }), + }); +} diff --git a/crates/module-system/module-implementations/sov-attester-incentives/tests/integration/bond_value.rs b/crates/module-system/module-implementations/sov-attester-incentives/tests/integration/bond_value.rs index cc2349e4e0..bc82e26328 100644 --- a/crates/module-system/module-implementations/sov-attester-incentives/tests/integration/bond_value.rs +++ b/crates/module-system/module-implementations/sov-attester-incentives/tests/integration/bond_value.rs @@ -52,7 +52,7 @@ impl TestRole { /// Currently, the easiest way to do this is to artificially change the gas cost of some operation in the bank module. We do that /// by modifying the runtime manually. fn test_cannot_prove_when_gas_price_is_too_high(role: TestRole) { - let gas_limit = <::Gas>::from(config_value!("INITIAL_GAS_LIMIT")); + let gas_limit = <::Gas>::from(config_value!("BLOCK_GAS_LIMIT")); let gas_target = gas_limit.scalar_division(2); let runtime = Default::default(); diff --git a/crates/module-system/module-implementations/sov-blob-storage/src/capabilities.rs b/crates/module-system/module-implementations/sov-blob-storage/src/capabilities.rs index 7bb33b6b74..354a4bef16 100644 --- a/crates/module-system/module-implementations/sov-blob-storage/src/capabilities.rs +++ b/crates/module-system/module-implementations/sov-blob-storage/src/capabilities.rs @@ -1073,10 +1073,9 @@ impl BlobStorage { state: &mut KernelStateAccessor<'_, S>, ) -> Option { if let Some((registered_sender, gas_price_for_new_block)) = charge_for_deserialization { - let funds_for_deserialization = - ::gas_to_charge_per_byte_borsh_deserialization() - .checked_scalar_product(blob.total_len() as u64)? - .checked_value(gas_price_for_new_block)?; + let funds_for_deserialization = ::gas_to_charge_per_byte_borsh_read() + .checked_scalar_product(blob.total_len() as u64)? + .checked_value(gas_price_for_new_block)?; if registered_sender.balance < funds_for_deserialization { return None; } diff --git a/crates/module-system/module-implementations/sov-blob-storage/src/validation.rs b/crates/module-system/module-implementations/sov-blob-storage/src/validation.rs index db519f43d1..23ac4afd3a 100644 --- a/crates/module-system/module-implementations/sov-blob-storage/src/validation.rs +++ b/crates/module-system/module-implementations/sov-blob-storage/src/validation.rs @@ -4,7 +4,7 @@ use sov_modules_api::digest::Digest; use sov_modules_api::prelude::UnwrapInfallible; use sov_modules_api::{ as_u32_or_panic, Amount, BatchWithId, BlobDataWithId, CryptoSpec, DaSpec, Gas, GasArray, - GasSpec, KernelStateAccessor, ModuleInfo, PrivilegedKernelAccessor, Spec, VersionReader, + GasSpec, KernelStateAccessor, ModuleInfo, PrivilegedKernelAccessor, Spec, }; use crate::{BlobStorage, Escrow, ValidatedBlob}; @@ -119,14 +119,7 @@ impl BlobStorage { let best_gas_price_estimate = self.get_new_gas_price(visible_height_increase, state); let gas_needed_for_pre_exec_checks = ::max_tx_check_costs(); - // Disable preferred sequencer escrow after the gas limit change height. - let funds_needed = if state.checkpoint.rollup_height_to_access() - > ::change_gas_limit_after_height() - { - Amount::ZERO - } else { - gas_needed_for_pre_exec_checks.checked_value(best_gas_price_estimate)? - }; + let funds_needed = gas_needed_for_pre_exec_checks.checked_value(best_gas_price_estimate)?; if funds_needed > available_balance { return None; @@ -218,12 +211,11 @@ impl BlobStorage { WORST_CASE_GAS_PRICE_INCREASE as u64 * estimated_bytes_with_key_size, )?, )? - // We also charge borsh deserialization cost because we need to deserialize the blob + // We also charge per-byte borsh decode cost because we need to deserialize the blob. .checked_combine( - ::gas_to_charge_per_byte_borsh_deserialization() - .checked_scalar_product( - WORST_CASE_GAS_PRICE_INCREASE as u64 * (estimated_bytes_to_store as u64), - )?, + ::gas_to_charge_per_byte_borsh_read().checked_scalar_product( + WORST_CASE_GAS_PRICE_INCREASE as u64 * (estimated_bytes_to_store as u64), + )?, )? .checked_value(current_gas_price)?; let tokens_needed_for_retrieval = diff --git a/crates/module-system/module-implementations/sov-blob-storage/tests/integration/unregistered_sequencer.rs b/crates/module-system/module-implementations/sov-blob-storage/tests/integration/unregistered_sequencer.rs index 9aa184756b..a696417b4d 100644 --- a/crates/module-system/module-implementations/sov-blob-storage/tests/integration/unregistered_sequencer.rs +++ b/crates/module-system/module-implementations/sov-blob-storage/tests/integration/unregistered_sequencer.rs @@ -2,7 +2,7 @@ use std::num::NonZero; use sov_blob_storage::{config_deferred_slots_count, config_unregistered_blobs_per_slot}; use sov_mock_da::{MockAddress, MockBlob}; -use sov_modules_api::{Amount, CryptoSpec, FullyBakedTx, HDTimestamp, Spec}; +use sov_modules_api::{Amount, CryptoSpec, FullyBakedTx, Spec}; use sov_modules_stf_blueprint::{BatchReceipt, Runtime}; use sov_rollup_interface::da::RelevantBlobs; use sov_sequencer_registry::SequencerRegistry; @@ -44,8 +44,8 @@ fn make_unregistered_blobs< nonces, ); let mut fully_baked_tx = FullyBakedTx::new(tx.data); - // Add sequencing metadata for integration test - fully_baked_tx.set_sequencing_metadata(&HDTimestamp::now()); + fully_baked_tx.sequencing_data = + Some(sov_modules_api::capabilities::new_tx_sequencing_data::()); MockBlob::new_with_hash(borsh::to_vec(&fully_baked_tx).unwrap(), sender.da_address) }) @@ -72,8 +72,8 @@ fn make_unregistered_blob_with_approx_size< nonces, ); let mut fully_baked_tx = FullyBakedTx::new(tx.data); - // Add sequencing metadata for integration test - fully_baked_tx.set_sequencing_metadata(&HDTimestamp::now()); + fully_baked_tx.sequencing_data = + Some(sov_modules_api::capabilities::new_tx_sequencing_data::()); MockBlob::new_with_hash(borsh::to_vec(&fully_baked_tx).unwrap(), sender.da_address) } diff --git a/crates/module-system/module-implementations/sov-chain-state/src/capabilities.rs b/crates/module-system/module-implementations/sov-chain-state/src/capabilities.rs index b62a0405d6..e3a22e8f82 100644 --- a/crates/module-system/module-implementations/sov-chain-state/src/capabilities.rs +++ b/crates/module-system/module-implementations/sov-chain-state/src/capabilities.rs @@ -289,16 +289,14 @@ impl ChainState { // We compute the base fee per gas from the previous slot if it exists let base_fee_per_gas = maybe_previous_slot - .map(|previous_slot| { - Self::compute_base_fee_per_gas(previous_slot.gas_info, leftover_rollup_height, 1) - }) + .map(|previous_slot| Self::compute_base_fee_per_gas(previous_slot.gas_info, 1)) .unwrap_or_else(|| S::initial_base_fee_per_gas()); let gas_info = BlockGasInfo::new( // TODO(@theochap): the gas limit should be updated dynamically ` ChainState { ) -> Result>, Reader::Error> { if height == RollupHeight::GENESIS { return Ok(Some(BlockGasInfo::new( - S::gas_limit_for_height(height), + S::block_gas_limit(), S::initial_base_fee_per_gas(), ))); } @@ -431,12 +429,6 @@ impl ChainState { .map(|gas_info| *gas_info.base_fee_per_gas())) } - /// Returns the slot gas limit at the specified slot height for this state accessor. - /// Note that any empty slots in between height n-1 and height n are defined to have the same gas limit as height n. - pub fn block_gas_limit_at(&self, height: RollupHeight) -> S::Gas { - S::gas_limit_for_height(height) - } - /// Returns the base fee per gas accessible at the current slot accessible from the version reader. /// This value is safe to be used in the transaction execution context. /// @@ -456,20 +448,6 @@ impl ChainState { self.base_fee_per_gas_at(state.rollup_height_to_access(), state) } - /// Returns the slot gas limit at the current slot accessible from the version reader. - pub fn block_gas_limit( - &self, - current_rollup_height: RollupHeight, - is_stale_height: bool, - ) -> S::Gas { - let height = if is_stale_height { - current_rollup_height.saturating_add(1) - } else { - current_rollup_height - }; - self.block_gas_limit_at(height) - } - /// This method is used for testing only. It sets the rollup height to zero. #[cfg(feature = "native")] pub fn test_only_set_rollup_height_for_genesis( diff --git a/crates/module-system/module-implementations/sov-chain-state/src/gas.rs b/crates/module-system/module-implementations/sov-chain-state/src/gas.rs index 27a7293d9d..158eda64a0 100644 --- a/crates/module-system/module-implementations/sov-chain-state/src/gas.rs +++ b/crates/module-system/module-implementations/sov-chain-state/src/gas.rs @@ -3,7 +3,6 @@ use std::cmp::max; use serde::{Deserialize, Serialize}; use sov_modules_api::macros::config_value; use sov_modules_api::{Amount, Gas, GasArray, GasSpec, Spec}; -use sov_rollup_interface::common::RollupHeight; use thiserror::Error; use crate::{BlockGasInfo, ChainState}; @@ -98,9 +97,9 @@ impl ChainState { gas_limit.scalar_division(Self::config_elasticity_multiplier().into()) } - /// Computes the initial gas target (genesis block) by calling [`ChainState::gas_target`] on the initial gas limit. + /// Computes the initial gas target (genesis block) by calling [`ChainState::gas_target`] on the block gas limit. pub fn initial_gas_target() -> S::Gas { - Self::gas_target(::initial_gas_limit()) + Self::gas_target(::block_gas_limit()) } } @@ -207,7 +206,6 @@ impl ChainState { /// of the multi-dimensional gas price is independently updated following EIP-1559. pub fn compute_base_fee_per_gas( mut parent_gas_info: BlockGasInfo, - parent_rollup_height: RollupHeight, slots_since_last_update: u64, ) -> ::Price { // We need to compute the base fee per gas for the slots that are not yet visible to us in state, starting from the previous rollup height. @@ -217,10 +215,7 @@ impl ChainState { // TODO(@theochap): the gas limit should be updated dynamically ` { /// The height of the first DA block. pub genesis_da_height: u64, + /// The version of Sovereign SDK consensus the rollup will run at genesis. + pub state_version: u64, + /// The admin address. This address is allowed to terminate setup mode early. #[serde(default)] pub admin: Option, @@ -48,6 +51,7 @@ impl ChainState { current_time = ?config.current_time, operating_mode = ?config.operating_mode, genesis_da_height = %config.genesis_da_height, + state_version = %config.state_version, inner_code_commitment = ?config.inner_code_commitment, outer_code_commitment = ?config.outer_code_commitment, admin = ?config.admin, @@ -89,6 +93,8 @@ impl ChainState { self.genesis_da_height .set(&config.genesis_da_height, state)?; + self.state_version.set(&config.state_version, state)?; + self.slots.set_true_current( &SlotInformation::new( genesis_slot_header.hash(), diff --git a/crates/module-system/module-implementations/sov-chain-state/src/lib.rs b/crates/module-system/module-implementations/sov-chain-state/src/lib.rs index f1d779865c..1ef2cf36ff 100644 --- a/crates/module-system/module-implementations/sov-chain-state/src/lib.rs +++ b/crates/module-system/module-implementations/sov-chain-state/src/lib.rs @@ -255,6 +255,12 @@ pub struct ChainState { /// The current time in nanoseconds, as reported by the timing oracle. #[state] oracle_time_nanos: StateValue, + + /// The global Sovereign SDK version of the rollup. + /// This value is incremented on hard forks of the Sovereign SDK. It is used to ensure that + /// versioned rollup binaries match the on-disk state corresponding to their consensus version. + #[state] + state_version: AccessoryStateValue, } impl ChainState { @@ -464,6 +470,17 @@ impl ChainState { self.genesis_da_height.get(state) } + /// Return the global on-chain state schema version. + /// + /// Existing state created before this field was introduced defaults to version 0. + #[cfg(feature = "native")] + pub fn state_version>( + &self, + state: &mut Accessor, + ) -> Result { + Ok(self.state_version.get(state)?.unwrap_or(0)) + } + /// Returns the last visible slot processed by the module. pub fn latest_visible_slot>( &self, @@ -616,13 +633,12 @@ impl ChainState { self.gas_info .get(&stale_rollup_height, state)? .unwrap_or(BlockGasInfo::new( - S::gas_limit_for_height(stale_rollup_height), + S::block_gas_limit(), S::initial_base_fee_per_gas(), )); Ok(Self::compute_base_fee_per_gas( prev_gas_info, - stale_rollup_height, provisional_visible_height_increase, )) } diff --git a/crates/module-system/module-implementations/sov-chain-state/src/tests/config.rs b/crates/module-system/module-implementations/sov-chain-state/src/tests/config.rs index 9254901f1d..c173b7db04 100644 --- a/crates/module-system/module-implementations/sov-chain-state/src/tests/config.rs +++ b/crates/module-system/module-implementations/sov-chain-state/src/tests/config.rs @@ -11,6 +11,7 @@ fn test_config_serialization() { current_time: time, operating_mode: OperatingMode::Zk, genesis_da_height: 0, + state_version: 0, inner_code_commitment: Default::default(), outer_code_commitment: Default::default(), admin: None, @@ -22,7 +23,8 @@ fn test_config_serialization() { "operating_mode": "zk", "inner_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], "outer_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], - "genesis_da_height": 0 + "genesis_da_height": 0, + "state_version": 0 }"#; let parsed_config: ChainStateConfig = serde_json::from_str(data).unwrap(); diff --git a/crates/module-system/module-implementations/sov-chain-state/src/tests/gas_elasticity_multidimensional.rs b/crates/module-system/module-implementations/sov-chain-state/src/tests/gas_elasticity_multidimensional.rs index 68eb8cfb85..4a8e8914f2 100644 --- a/crates/module-system/module-implementations/sov-chain-state/src/tests/gas_elasticity_multidimensional.rs +++ b/crates/module-system/module-implementations/sov-chain-state/src/tests/gas_elasticity_multidimensional.rs @@ -1,5 +1,4 @@ use sov_modules_api::{Amount, Gas, GasArray, GasPrice, GasSpec, Spec}; -use sov_rollup_interface::common::RollupHeight; use sov_test_utils::TestSpec; use crate::{BlockGasInfo, ChainState}; @@ -14,14 +13,12 @@ const GAS_DELTA_FRACTION: u64 = 2; /// Helper function that initializes the gas elasticity tests for the multidimensional case. It computes the new base fee per gas /// given the amount of gas used, the initial gas limit and the initial base fee per gas. fn test_helper(gas_used: &::Gas) -> <::Gas as Gas>::Price { - let mut parent_gas_info = BlockGasInfo::new( - TestSpec::initial_gas_limit(), - INITIAL_BASE_FEE_PER_GAS.into(), - ); + let mut parent_gas_info = + BlockGasInfo::new(TestSpec::block_gas_limit(), INITIAL_BASE_FEE_PER_GAS.into()); parent_gas_info.update_gas_used(*gas_used); - ChainState::::compute_base_fee_per_gas(parent_gas_info, RollupHeight::GENESIS, 1) + ChainState::::compute_base_fee_per_gas(parent_gas_info, 1) } /// Checks that the `base_fee_per_gas` does not change when the gas used is the same as the gas target. diff --git a/crates/module-system/module-implementations/sov-chain-state/tests/integration-tests/dynamic_gas_update.rs b/crates/module-system/module-implementations/sov-chain-state/tests/integration-tests/dynamic_gas_update.rs index 16a01bce1a..b7fc729e25 100644 --- a/crates/module-system/module-implementations/sov-chain-state/tests/integration-tests/dynamic_gas_update.rs +++ b/crates/module-system/module-implementations/sov-chain-state/tests/integration-tests/dynamic_gas_update.rs @@ -50,7 +50,7 @@ fn setup_dynamic_gas_update_tests() -> (TestData, TestRunner::Gas::from(config_value!("INITIAL_GAS_LIMIT")); + let gas_limit = ::Gas::from(config_value!("BLOCK_GAS_LIMIT")); let gas_target = gas_limit.scalar_division(2); let runtime = TestChainStateRuntime::::default(); diff --git a/crates/module-system/module-implementations/sov-evm/AGENTS.md b/crates/module-system/module-implementations/sov-evm/AGENTS.md index 880955ac95..f6f919130a 100644 --- a/crates/module-system/module-implementations/sov-evm/AGENTS.md +++ b/crates/module-system/module-implementations/sov-evm/AGENTS.md @@ -53,7 +53,6 @@ If you touch fee context, validate all of these together: ### 1.5 Actual-fee projection invariants -- Activation gate must stay shared via `src/sov_fee_and_gas_utils.rs:is_actual_fee_projection_height_active` for both receipt projection and RPC effective-gas-price projection. - Receipt-side gas projection must support non-uniform gas-price dimensions by computing `ceil(gas_value / gas_price[0])` with checked integer arithmetic. - Treat `GasInfo` as the source of truth: `gas_value` must equal `gas_used · gas_price`. Any divergence is a bug, not alternate fee semantics. - `gas_price[0]` is the canonical EVM gas price/base fee value. Block header `base_fee_per_gas` must represent the same value exactly (no clamping/truncation). Any mismatch is a bug. diff --git a/crates/module-system/module-implementations/sov-evm/src/authenticate.rs b/crates/module-system/module-implementations/sov-evm/src/authenticate.rs index d3904ccc79..6c4dda8e03 100644 --- a/crates/module-system/module-implementations/sov-evm/src/authenticate.rs +++ b/crates/module-system/module-implementations/sov-evm/src/authenticate.rs @@ -17,8 +17,8 @@ use sov_modules_api::capabilities::{ use sov_modules_api::macros::config_value; use sov_modules_api::runtime::capabilities::AuthenticationError; use sov_modules_api::transaction::{ - AuthenticatedTransactionAndRawHash, AuthenticatedTransactionData, Credentials, PriorityFeeBips, - TxDetails, + chain_hash_fragment, AuthenticatedTransactionAndRawHash, AuthenticatedTransactionData, + Credentials, PriorityFeeBips, TxDetails, }; use sov_modules_api::StateReader; use sov_modules_api::VersionReader; @@ -106,7 +106,7 @@ fn build_authenticated_tx_data< S: Spec, >( tx_hash: TxHash, - tx_chain_id: u64, + chain_hash_fragment: u64, gas_limit: u64, user_max_fee_per_gas: u128, state: &mut Accessor, @@ -138,7 +138,7 @@ fn build_authenticated_tx_data< ))?; let tx_details = TxDetails { - chain_id: tx_chain_id, + chain_hash_fragment, max_priority_fee_bips: PriorityFeeBips::ZERO, max_fee, gas_limit: Some(gas_limit), @@ -153,10 +153,11 @@ fn create_auth_tx_and_hash< S: Spec, >( tx: &TransactionSigned, + chain_hash: &[u8; 32], state: &mut Accessor, ) -> Result, AuthenticationError> { let tx_hash = TxHash::new(**tx.hash()); - let tx_chain_id = validate_chain_id(tx.chain_id(), tx_hash)?; + validate_chain_id(tx.chain_id(), tx_hash)?; if let Some(max_priority_fee) = tx.max_priority_fee_per_gas() { if max_priority_fee > tx.max_fee_per_gas() { return Err(AuthenticationError::FatalError( @@ -166,9 +167,11 @@ fn create_auth_tx_and_hash< } } + // EVM signatures do not commit to this fragment; it populates the shared + // authenticated transaction details shape. let authenticated_tx = build_authenticated_tx_data::<_, S>( tx_hash, - tx_chain_id, + chain_hash_fragment(chain_hash), tx.gas_limit(), tx.max_fee_per_gas(), state, @@ -219,9 +222,11 @@ where AuthorizationData { uniqueness: UniquenessData::Nonce(nonce), tx_hash, + non_malleable_hash: tx_hash, credential_id, credentials, default_address: S::Address::from_vm_address(ethereum_address), + address_override: None, } } @@ -282,13 +287,12 @@ where FatalError::Other("Missing gas price".into()), sentinel_tx_hash, ))?; - let tx_chain_id = match request.chain_id { - Some(chain_id) => validate_chain_id(Some(chain_id), sentinel_tx_hash)?, - None => config_value!("CHAIN_ID"), - }; + if let Some(chain_id) = request.chain_id { + validate_chain_id(Some(chain_id), sentinel_tx_hash)?; + } let authenticated_tx = build_authenticated_tx_data::<_, S>( sentinel_tx_hash, - tx_chain_id, + 0, gas_limit, user_max_fee_per_gas, state, @@ -309,6 +313,7 @@ pub fn authenticate< S: Spec, >( raw_tx: &[u8], + chain_hash: &[u8; 32], state: &mut Accessor, ) -> Result>, AuthenticationError> where @@ -319,7 +324,7 @@ where let (rlp, tx) = decode_evm_tx(raw_tx) .map_err(|e| fatal_deserialization_error::(raw_tx, e, state))?; - let tx_and_raw_hash = create_auth_tx_and_hash(&tx, state)?; + let tx_and_raw_hash = create_auth_tx_and_hash(&tx, chain_hash, state)?; let signer = recover_evm_signer(&tx, tx_and_raw_hash.raw_tx_hash)?; @@ -438,7 +443,7 @@ where match input { EvmAuthenticatorInput::Evm(tx) => { let (tx_and_raw_hash, auth_data, runtime_call) = - authenticate::<_, _>(&tx.data, state)?; + authenticate::<_, _>(&tx.data, &Rt::CHAIN_HASH, state)?; Ok(( tx_and_raw_hash, @@ -492,7 +497,7 @@ where { Self::Input::Evm(tx) => { let (tx_and_raw_hash, auth_data, runtime_call) = - authenticate::<_, _>(&tx.data, state)?; + authenticate::<_, _>(&tx.data, &Rt::CHAIN_HASH, state)?; Ok(( tx_and_raw_hash, auth_data, diff --git a/crates/module-system/module-implementations/sov-evm/src/lib.rs b/crates/module-system/module-implementations/sov-evm/src/lib.rs index cd6c0c3542..3e18317d33 100644 --- a/crates/module-system/module-implementations/sov-evm/src/lib.rs +++ b/crates/module-system/module-implementations/sov-evm/src/lib.rs @@ -52,6 +52,7 @@ pub use revm::primitives::hardfork::SpecId; use serde::Serialize; use sov_address::{EthereumAddress, FromVmAddress}; use sov_bank::Amount; +use sov_modules_api::macros::config_value; use sov_modules_api::{ err_detail, AccessoryStateMap, AccessoryStateValue, Context, CoreModuleError, DaSpec, ErrorContext, ErrorDetail, GenesisState, Module, ModuleId, ModuleInfo, Spec, StateMap, @@ -304,9 +305,16 @@ where state: &mut Reader, ) -> Result, E> where - Reader: StateReader, + Reader: StateReader + VersionReader, { - let enabled_custom_precompiles = self.enabled_custom_precompile_addresses(state)?; + let activation_height: u64 = config_value!("ENABLE_EVM_CUSTOM_PRECOMPILES_AT"); + // The enabled-set read is metered. Existing rollups that add their first custom precompile + // must preserve the old no-read execution path until a coordinated activation height. + let enabled_custom_precompiles = enabled_custom_precompiles_at_height( + state.rollup_height_to_access().get(), + activation_height, + || self.enabled_custom_precompile_addresses(state), + )?; Ok(precompiles::SovPrecompileProvider::new( P::default(), context, @@ -337,6 +345,44 @@ where } } +fn enabled_custom_precompiles_at_height( + rollup_height: u64, + activation_height: u64, + load_enabled: impl FnOnce() -> Result, E>, +) -> Result, E> { + if rollup_height < activation_height { + return Ok(BTreeSet::new()); + } + load_enabled() +} + +#[cfg(test)] +mod custom_precompile_activation_tests { + use std::convert::Infallible; + + use super::enabled_custom_precompiles_at_height; + + #[test] + fn enabled_set_is_not_loaded_before_activation() { + let enabled = enabled_custom_precompiles_at_height::(41, 42, || { + panic!("enabled precompile set must not be read before activation") + }) + .unwrap(); + assert!(enabled.is_empty()); + } + + #[test] + fn enabled_set_is_loaded_at_activation() { + let mut load_called = false; + enabled_custom_precompiles_at_height::(42, 42, || { + load_called = true; + Ok(Default::default()) + }) + .unwrap(); + assert!(load_called); + } +} + pub(crate) fn to_rollup_address(address: Address) -> S::Address where S::Address: FromVmAddress, diff --git a/crates/module-system/module-implementations/sov-evm/src/precompiles/sequencing_timestamp.rs b/crates/module-system/module-implementations/sov-evm/src/precompiles/sequencing_timestamp.rs index bd1be54271..02f8ab3795 100644 --- a/crates/module-system/module-implementations/sov-evm/src/precompiles/sequencing_timestamp.rs +++ b/crates/module-system/module-implementations/sov-evm/src/precompiles/sequencing_timestamp.rs @@ -1,6 +1,5 @@ use alloy_primitives::{Bytes, U256}; -use borsh::BorshDeserialize; -use sov_modules_api::{HDTimestamp, Spec, TxState}; +use sov_modules_api::{Spec, TxState}; use super::{ Address, EvmPrecompile, EvmPrecompileEnv, EvmPrecompileSet, PrecompileError, PrecompileOutput, @@ -14,7 +13,14 @@ pub const SEQUENCING_TIMESTAMP_PRECOMPILE_ADDRESS: Address = Address::new([ const SEQUENCING_TIMESTAMP_GAS: u64 = 50; -/// A built-in precompile that returns the current sequencing/oracle timestamp in nanoseconds. +/// A built-in precompile that returns the current oracle timestamp in nanoseconds. +/// +/// The SDK updates the oracle from the preferred sequencer's transaction timestamp before each +/// transaction is dispatched, so within a preferred-sequencer transaction this normally +/// reflects that transaction's own sequencing timestamp. Exceptions: the oracle never moves +/// backwards, so a regressing transaction timestamp is ignored and the previous (larger) oracle +/// value is returned; and before the oracle activation height the update is a no-op. When no +/// oracle time is set, it falls back to the DA layer time. #[derive(Clone)] pub struct SequencingTimestampPrecompile { chain_state: sov_chain_state::ChainState, @@ -71,17 +77,9 @@ fn sequencing_timestamp_precompile>( ))); } - let nanos = env - .sov_context - .and_then(|ctx| ctx.sequencing_data().as_ref()) - .and_then(|bytes| HDTimestamp::try_from_slice(bytes).ok()) - .map(|timestamp| timestamp.as_nanos()) - .map(Ok) - .unwrap_or_else(|| { - chain_state - .get_oracle_time_nanos(env.state) - .map_err(|e| PrecompileError::State(e.to_string())) - })?; + let nanos = chain_state + .get_oracle_time_nanos(env.state) + .map_err(|e| PrecompileError::State(e.to_string()))?; Ok(PrecompileOutput { gas_used: SEQUENCING_TIMESTAMP_GAS, diff --git a/crates/module-system/module-implementations/sov-evm/src/rpc/block_resolution.rs b/crates/module-system/module-implementations/sov-evm/src/rpc/block_resolution.rs index 702b155261..66a301f06f 100644 --- a/crates/module-system/module-implementations/sov-evm/src/rpc/block_resolution.rs +++ b/crates/module-system/module-implementations/sov-evm/src/rpc/block_resolution.rs @@ -171,12 +171,9 @@ where ); let fee_paid = self.receipt_fee(tx_idx, state); if let Some((receipt, _)) = self.receipt(tx_idx, state) { - if let Some(actual_effective_gas_price) = maybe_actual_effective_gas_price( - block_number, - receipt.gas_used, - fee_paid, - base_fee_per_gas, - ) { + if let Some(actual_effective_gas_price) = + maybe_actual_effective_gas_price(receipt.gas_used, fee_paid, base_fee_per_gas) + { tx_rpc.effective_gas_price = Some(actual_effective_gas_price); } } diff --git a/crates/module-system/module-implementations/sov-evm/src/rpc/estimate_gas.rs b/crates/module-system/module-implementations/sov-evm/src/rpc/estimate_gas.rs index 141c3e8542..dc4c25a419 100644 --- a/crates/module-system/module-implementations/sov-evm/src/rpc/estimate_gas.rs +++ b/crates/module-system/module-implementations/sov-evm/src/rpc/estimate_gas.rs @@ -14,7 +14,6 @@ use revm::context::result::{ExecutionResult, ResultAndState}; use revm_database_interface::TryDatabaseCommit; use sov_address::{EthereumAddress, FromVmAddress}; use sov_modules_api::capabilities::ChainState; -use sov_modules_api::capabilities::SequencingDataHandler; use sov_modules_api::capabilities::TransactionAuthenticator; use sov_modules_api::macros::config_value; use sov_modules_api::prelude::UnwrapInfallible; @@ -241,11 +240,7 @@ where let mut runtime = R::default(); let sequencing_data = if sequencer_type == SequencerType::Preferred { - Some( - borsh::to_vec(&runtime.sequencing_data_handler().create_sequencing_data()) - .map(sov_rollup_interface::Bytes::from) - .map_err(|err| format!("sequencing data serialization failed: {err}"))?, - ) + Some(sov_modules_api::capabilities::new_tx_sequencing_data::()) } else { None }; diff --git a/crates/module-system/module-implementations/sov-evm/src/rpc/fee_history.rs b/crates/module-system/module-implementations/sov-evm/src/rpc/fee_history.rs index d47fa748b2..b8377e7f7c 100644 --- a/crates/module-system/module-implementations/sov-evm/src/rpc/fee_history.rs +++ b/crates/module-system/module-implementations/sov-evm/src/rpc/fee_history.rs @@ -125,9 +125,6 @@ where let mut gas_limits = Vec::with_capacity(block_count); let mut used_pending_block = false; for n in start_block..=end_block { - // The gas limits for each block can be computed statically since they don't depend on the gas used. - gas_limits.push(S::gas_limit_for_height(RollupHeight::new(n)).as_ref()[0]); - // For all the blocks in the requested range that are already sealed, we can just take the base fee and gas used from the block. if sealed_block_numbers.contains(&n) { let block = self @@ -136,6 +133,10 @@ where .expect("Block was checked to be in range and doesn't exist. This is a bug."); base_fees.push(block.base_fee()); gas_used.push(block.gas_used()); + // Use the block's own stored gas limit (the value `eth_getBlockByNumber` reports) so + // that `gasUsedRatio` stays correct for historical blocks even if the gas limit ever + // differs from the current one. + gas_limits.push(block.header.gas_limit); continue; } @@ -167,10 +168,12 @@ where 0 }; gas_used.push(pending_gas_used); + gas_limits.push(pending_block.partial_header().gas_limit); } // Finally, eth_feeHistory always returns info for one block after the last one requested, so we need to compute the base fee for the next block. - let gas_limit: u64 = S::gas_limit_for_height(RollupHeight::new(end_block)).as_ref()[0]; + // This is the *next* (not-yet-produced) block, so the current gas limit is the right value to estimate with. + let gas_limit: u64 = S::block_gas_limit().as_ref()[0]; let actual_parent_gas_usage = gas_used .last() .expect("At least one gas used must have been collected"); diff --git a/crates/module-system/module-implementations/sov-evm/src/rpc/receipt_builder.rs b/crates/module-system/module-implementations/sov-evm/src/rpc/receipt_builder.rs index 11e8e0850a..4ce38e367d 100644 --- a/crates/module-system/module-implementations/sov-evm/src/rpc/receipt_builder.rs +++ b/crates/module-system/module-implementations/sov-evm/src/rpc/receipt_builder.rs @@ -7,15 +7,13 @@ use sov_rpc_eth_types::LogWithExecutionTimestamp; use crate::evm::primitive_types::{Receipt, TransactionSigned, TxSignedAndRecovered}; use crate::primitive_types::MaybeSealedBlock; -use crate::sov_fee_and_gas_utils::is_actual_fee_projection_height_active; pub(crate) fn maybe_actual_effective_gas_price( - block_number: u64, gas_used: u64, fee_paid: Option, base_fee_per_gas: Option, ) -> Option { - if !is_actual_fee_projection_height_active(block_number) || gas_used == 0 { + if gas_used == 0 { return None; } @@ -90,10 +88,9 @@ pub(crate) fn build_rpc_receipt( .inner() .effective_gas_price(block.maybe_partial_header().base_fee_per_gas); - // Once activated, keep zero-fee semantics from metered metadata and otherwise + // Keep zero-fee semantics from metered metadata and otherwise // report the primary gas-price dimension (EVM base fee from header). let effective_gas_price = maybe_actual_effective_gas_price( - block.number(), receipt.gas_used, fee_paid, block.maybe_partial_header().base_fee_per_gas, @@ -119,70 +116,28 @@ pub(crate) fn build_rpc_receipt( #[cfg(test)] mod tests { - use sov_modules_api::macros::config_value; - use super::*; #[test] fn maybe_actual_effective_gas_price_uses_zero_paid_fee() { - let activation_height: u64 = config_value!("EVM_RECEIPT_ACTUAL_FEE_HEIGHT"); - let active_block = activation_height - .checked_add(1) - .expect("activation height must be strictly below u64::MAX"); - assert_eq!( - maybe_actual_effective_gas_price(active_block, 21_000, Some(Amount::ZERO), Some(123)), + maybe_actual_effective_gas_price(21_000, Some(Amount::ZERO), Some(123)), Some(0) ); } - #[test] - fn maybe_actual_effective_gas_price_ignores_zero_paid_fee_before_activation() { - let activation_height: u64 = config_value!("EVM_RECEIPT_ACTUAL_FEE_HEIGHT"); - - assert_eq!( - maybe_actual_effective_gas_price( - activation_height, - 21_000, - Some(Amount::ZERO), - Some(123), - ), - None - ); - } - #[test] fn maybe_actual_effective_gas_price_uses_base_fee_for_non_zero_paid_fee() { - let activation_height: u64 = config_value!("EVM_RECEIPT_ACTUAL_FEE_HEIGHT"); - let active_block = activation_height - .checked_add(1) - .expect("activation height must be strictly below u64::MAX"); - assert_eq!( - maybe_actual_effective_gas_price( - active_block, - 21_000, - Some(Amount::new(100_000)), - Some(42), - ), + maybe_actual_effective_gas_price(21_000, Some(Amount::new(100_000)), Some(42)), Some(42) ); } #[test] fn maybe_actual_effective_gas_price_falls_back_when_base_fee_missing() { - let activation_height: u64 = config_value!("EVM_RECEIPT_ACTUAL_FEE_HEIGHT"); - let active_block = activation_height - .checked_add(1) - .expect("activation height must be strictly below u64::MAX"); - assert_eq!( - maybe_actual_effective_gas_price( - active_block, - 21_000, - Some(Amount::new(100_000)), - None, - ), + maybe_actual_effective_gas_price(21_000, Some(Amount::new(100_000)), None), None ); } diff --git a/crates/module-system/module-implementations/sov-evm/src/sov_fee_and_gas_utils.rs b/crates/module-system/module-implementations/sov-evm/src/sov_fee_and_gas_utils.rs index c5a0fc0f94..7c097cafa1 100644 --- a/crates/module-system/module-implementations/sov-evm/src/sov_fee_and_gas_utils.rs +++ b/crates/module-system/module-implementations/sov-evm/src/sov_fee_and_gas_utils.rs @@ -35,10 +35,6 @@ where S: Spec, { let tx_fee_paid = gas_info.gas_value; - if !is_actual_fee_projection_height_active(receipt.block_number) { - return Ok(None); - } - if tx_fee_paid == sov_bank::Amount::ZERO { return Ok(None); } @@ -115,13 +111,6 @@ pub(crate) fn derive_receipt_gas_used_from_actual_fee( Ok(projected_gas_used) } -/// Returns true once actual-fee projection is enabled for `block_number`. -/// Keep non-height guards in callers; only the activation-height boundary is shared here. -pub(crate) fn is_actual_fee_projection_height_active(block_number: u64) -> bool { - let apply_actual_fee_after_height: u64 = config_value!("EVM_RECEIPT_ACTUAL_FEE_HEIGHT"); - block_number > apply_actual_fee_after_height -} - /// Returns true once the EIP-1559 max-fee-per-gas check is enabled for `block_number`. /// Keep non-height guards (e.g. the admin kill-switch) in callers; only the /// activation-height boundary is shared here. @@ -148,26 +137,6 @@ mod tests { } } - #[test] - fn actual_fee_height_inactive_at_genesis_block() { - assert!(!is_actual_fee_projection_height_active(0)); - } - - #[test] - fn actual_fee_height_inactive_at_activation_height() { - let activation_height: u64 = config_value!("EVM_RECEIPT_ACTUAL_FEE_HEIGHT"); - assert!(!is_actual_fee_projection_height_active(activation_height)); - } - - #[test] - fn actual_fee_height_active_after_activation_height() { - let activation_height: u64 = config_value!("EVM_RECEIPT_ACTUAL_FEE_HEIGHT"); - let first_active_block = activation_height - .checked_add(1) - .expect("activation height must be strictly below u64::MAX"); - assert!(is_actual_fee_projection_height_active(first_active_block)); - } - #[test] fn derive_receipt_gas_used_from_actual_fee_uses_uniform_price() { let gas_info = gas_info( diff --git a/crates/module-system/module-implementations/sov-evm/tests/integration/helpers.rs b/crates/module-system/module-implementations/sov-evm/tests/integration/helpers.rs index c9731730c2..662634e0c7 100644 --- a/crates/module-system/module-implementations/sov-evm/tests/integration/helpers.rs +++ b/crates/module-system/module-implementations/sov-evm/tests/integration/helpers.rs @@ -29,14 +29,6 @@ pub(crate) fn set_max_fee_check_height(height: u64) { ); } -/// Sets the block height after which receipt effective gas price is derived from actual charged fee. -pub(crate) fn set_receipt_actual_fee_height(height: u64) { - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_EVM_RECEIPT_ACTUAL_FEE_HEIGHT", - height.to_string(), - ); -} - pub(crate) struct EvmAccount(SecretKey); impl EvmAccount { diff --git a/crates/module-system/module-implementations/sov-evm/tests/integration/rpc_basefee.rs b/crates/module-system/module-implementations/sov-evm/tests/integration/rpc_basefee.rs index 54b54a72cd..bed9b6a0b9 100644 --- a/crates/module-system/module-implementations/sov-evm/tests/integration/rpc_basefee.rs +++ b/crates/module-system/module-implementations/sov-evm/tests/integration/rpc_basefee.rs @@ -1,6 +1,4 @@ -use crate::helpers::{ - create_transfer_tx, set_max_fee_check_height, set_receipt_actual_fee_height, setup, EvmAccount, -}; +use crate::helpers::{create_transfer_tx, set_max_fee_check_height, setup, EvmAccount}; use crate::runtime::{RT, S}; use alloy_consensus::{TxEip1559, TypedTransaction}; use alloy_eips::eip1559::MIN_PROTOCOL_BASE_FEE; @@ -259,7 +257,6 @@ fn test_eth_estimate_gas_large_access_list_underestimates_executed_receipt() { const TARGET_GAS_LIMIT: u64 = 10_000_000; set_max_fee_check_height(0); - set_receipt_actual_fee_height(0); let (mut runner, account, recipient, _) = setup(); runner.execute(create_transfer_tx(0, &account, &recipient, 1).tx); @@ -316,64 +313,6 @@ fn test_eth_estimate_gas_large_access_list_underestimates_executed_receipt() { }); } -#[test] -fn test_eth_estimate_gas_historical_block_below_receipt_projection_height_skips_projection() { - const EVM_RECEIPT_ACTUAL_FEE_HEIGHT: u64 = 3; - const HISTORICAL_BLOCK: u64 = 1; - - set_max_fee_check_height(0); - set_receipt_actual_fee_height(EVM_RECEIPT_ACTUAL_FEE_HEIGHT); - - let (mut runner, account, recipient, _) = setup(); - runner.execute(create_transfer_tx(0, &account, &recipient, 1).tx); - runner.advance_slots((EVM_RECEIPT_ACTUAL_FEE_HEIGHT + 1) as usize); - - let request = TransactionRequest { - from: Some(account.address()), - to: Some(TxKind::Call(recipient.address())), - max_fee_per_gas: Some((MIN_PROTOCOL_BASE_FEE as u128) * 10), - max_priority_fee_per_gas: Some(0), - ..Default::default() - }; - - let (historical_estimate, latest_estimate) = runner.query_visible_state(|state| { - let evm = Evm::::default(); - let live_block_number = evm.block_number(state).unwrap().to::(); - assert!( - live_block_number > EVM_RECEIPT_ACTUAL_FEE_HEIGHT, - "test precondition failed: live height {live_block_number} must be above EVM_RECEIPT_ACTUAL_FEE_HEIGHT={EVM_RECEIPT_ACTUAL_FEE_HEIGHT}" - ); - - let historical_estimate = evm - .eth_estimate_gas_helper( - request.clone(), - Some(BlockId::number(HISTORICAL_BLOCK)), - None, - Some(low_base_fee_override()), - state, - ) - .unwrap() - .to::(); - let latest_estimate = evm - .eth_estimate_gas_helper( - request, - Some(BlockId::latest()), - None, - Some(low_base_fee_override()), - state, - ) - .unwrap() - .to::(); - - (historical_estimate, latest_estimate) - }); - - assert!( - latest_estimate > historical_estimate, - "historical eth_estimateGas for block {HISTORICAL_BLOCK}, which is below EVM_RECEIPT_ACTUAL_FEE_HEIGHT={EVM_RECEIPT_ACTUAL_FEE_HEIGHT}, should skip fee projection while latest uses it; historical_estimate={historical_estimate}, latest_estimate={latest_estimate}" - ); -} - #[test] fn test_eth_estimate_gas_historical_block_below_fee_check_height_keeps_same_estimate() { const EVM_MAX_FEE_CHECK_HEIGHT: u64 = 3; diff --git a/crates/module-system/module-implementations/sov-evm/tests/integration/transactions.rs b/crates/module-system/module-implementations/sov-evm/tests/integration/transactions.rs index 8eb5f455b5..b1e74f0dbc 100644 --- a/crates/module-system/module-implementations/sov-evm/tests/integration/transactions.rs +++ b/crates/module-system/module-implementations/sov-evm/tests/integration/transactions.rs @@ -68,7 +68,6 @@ fn test_simple_transfer() { #[test] fn test_receipt_fee_matches_balance_delta() { - set_receipt_actual_fee_height(0); let (mut runner, from, to, _) = setup(); let value = 1u128; let transfer = create_transfer_tx_with_fee_params( @@ -110,7 +109,6 @@ fn test_receipt_fee_matches_balance_delta() { #[test] fn test_block_receipt_fee_matches_balance_delta() { - set_receipt_actual_fee_height(0); let (mut runner, from, to, _) = setup(); let value = 1u128; let transfer = create_transfer_tx_with_fee_params( @@ -155,47 +153,6 @@ fn test_block_receipt_fee_matches_balance_delta() { }); } -#[test] -fn test_receipt_uses_eip1559_formula_before_activation_height() { - set_receipt_actual_fee_height(1_000_000); - let (mut runner, from, to, _) = setup(); - let value = 1u128; - let transfer = create_transfer_tx_with_fee_params( - 0, - &from, - &to, - value, - MIN_PROTOCOL_BASE_FEE as u128 * 2, - 0, - ); - - let evm = Evm::::default(); - runner.execute_transaction(TransactionTestCase { - input: transfer.tx, - assert: Box::new(move |_ctx, state| { - let sender_balance_after = evm.get_balance(from.address(), None, state).unwrap(); - let receipt = evm - .get_transaction_receipt(transfer.hash, state) - .unwrap() - .expect("receipt should exist"); - - let implied_fee = - U256::from(receipt.gas_used) * U256::from(receipt.effective_gas_price); - let actual_fee = U256::from(TEST_DEFAULT_USER_BALANCE.0) - .checked_sub(U256::from(value)) - .and_then(|balance_after_value| { - balance_after_value.checked_sub(sender_balance_after) - }) - .expect("sender balance should decrease by transfer value and a fee"); - - assert_ne!( - actual_fee, implied_fee, - "before activation height receipts should follow legacy EIP-1559 projection", - ); - }), - }); -} - #[test] fn test_simple_transfer_balance_larger_than_allowed() { let (mut runner, from, to, _) = setup(); diff --git a/crates/module-system/module-implementations/sov-paymaster/tests/integration/paymaster.rs b/crates/module-system/module-implementations/sov-paymaster/tests/integration/paymaster.rs index cf173ef85c..018cd00c4e 100644 --- a/crates/module-system/module-implementations/sov-paymaster/tests/integration/paymaster.rs +++ b/crates/module-system/module-implementations/sov-paymaster/tests/integration/paymaster.rs @@ -605,7 +605,7 @@ fn test_granular_policies() { max_priority_fee_bips: TEST_DEFAULT_MAX_PRIORITY_FEE, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: None, - chain_id: config_value!("CHAIN_ID"), + chain_hash_fragment: 0, }, }, assert: Box::new(|_, _| {}), @@ -627,7 +627,7 @@ fn test_granular_policies() { // This gas limit has to be high enough to cover the tx but low enough that gas_limit * gas_price // is less than the payer's balance. If we adjust the gas costs of operations too much, this value may need adjustment. gas_limit: Some([100_000, 100_000].into()), - chain_id: config_value!("CHAIN_ID"), + chain_hash_fragment: 0, }, }, assert: Box::new(|result, _state| { @@ -669,7 +669,7 @@ fn test_granular_policies() { max_priority_fee_bips: TEST_DEFAULT_MAX_PRIORITY_FEE, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: Some([u64::MAX, u64::MAX].into()), - chain_id: config_value!("CHAIN_ID"), + chain_hash_fragment: 0, }, }, assert: Box::new(|_, _| {}), diff --git a/crates/module-system/module-implementations/sov-prover-incentives/tests/integration/bond.rs b/crates/module-system/module-implementations/sov-prover-incentives/tests/integration/bond.rs index 403eefd9dd..7c3e634b49 100644 --- a/crates/module-system/module-implementations/sov-prover-incentives/tests/integration/bond.rs +++ b/crates/module-system/module-implementations/sov-prover-incentives/tests/integration/bond.rs @@ -171,7 +171,7 @@ fn test_unbonding() { /// by modifying the runtime manually. #[test] fn test_cannot_prove_when_gas_price_is_too_high() { - let gas_limit = ::Gas::from(config_value!("INITIAL_GAS_LIMIT")); + let gas_limit = ::Gas::from(config_value!("BLOCK_GAS_LIMIT")); let gas_target = gas_limit.scalar_division(2); let runtime = RT::default(); diff --git a/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/registration_mechanism.rs b/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/registration_mechanism.rs index 14e94b3246..5a0c603ef7 100644 --- a/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/registration_mechanism.rs +++ b/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/registration_mechanism.rs @@ -49,7 +49,7 @@ fn test_default_sequencer() { assert: Box::new(move |result, state| { // Assert that the sequencer has been rewarded let gas_price = state.gas_price(); - let sequencer_burn = S::gas_to_charge_per_byte_borsh_deserialization() + let sequencer_burn = S::gas_to_charge_per_byte_borsh_read() .checked_scalar_product(result.blob_info.size as u64) .unwrap() .checked_value(gas_price) diff --git a/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/reward_mechanism.rs b/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/reward_mechanism.rs index adcdb2e538..0c51ea63cb 100644 --- a/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/reward_mechanism.rs +++ b/crates/module-system/module-implementations/sov-sequencer-registry/tests/integration/reward_mechanism.rs @@ -85,7 +85,7 @@ fn reward_mechanism_test( .with_max_priority_fee_bips(max_priority_fee), assert: Box::new(move |result, state| { let gas_price = state.gas_price(); - let sequencer_burn = S::gas_to_charge_per_byte_borsh_deserialization() + let sequencer_burn = S::gas_to_charge_per_byte_borsh_read() .checked_scalar_product(result.blob_info.size as u64) .unwrap() .checked_value(gas_price) diff --git a/crates/module-system/module-implementations/sov-test-modules/src/access_pattern/mod.rs b/crates/module-system/module-implementations/sov-test-modules/src/access_pattern/mod.rs index 52a5bbb120..693b670c8f 100644 --- a/crates/module-system/module-implementations/sov-test-modules/src/access_pattern/mod.rs +++ b/crates/module-system/module-implementations/sov-test-modules/src/access_pattern/mod.rs @@ -9,10 +9,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sov_modules_api::macros::{serialize, UniversalWallet}; use sov_modules_api::{ - AccessoryStateMap, AccessoryStateValue, Context, CryptoSpec, DaSpec, GasSpec, GenesisState, - MeteredBorshDeserialize, MeteredBorshDeserializeError, MeteredHasher, MeteredSignature, Module, - ModuleId, ModuleInfo, ModuleRestApi, SafeVec, SizedSafeString, Spec, StateMap, StateValue, - StateVec, TxState, + AccessoryStateMap, AccessoryStateValue, Context, CryptoSpec, DaSpec, GenesisState, + MeteredBorshDeserialize, MeteredHasher, MeteredSignature, Module, ModuleId, ModuleInfo, + ModuleRestApi, SafeVec, SizedSafeString, Spec, StateMap, StateValue, StateVec, TxState, }; use strum::{EnumDiscriminants, EnumIs, VariantArray}; @@ -21,41 +20,6 @@ pub const MAX_VEC_LEN_BENCH: usize = 100_000; /// Max length of a string for a bench pattern call message pub const MAX_STR_LEN_BENCH: usize = 1_024; -/// A newtype struct that deserializes into a string and charges gas. -#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)] -pub struct MeteredBorshDeserializeString(pub String); - -impl MeteredBorshDeserialize for MeteredBorshDeserializeString { - fn deserialize( - buf: &mut &[u8], - meter: &mut impl sov_modules_api::GasMeter, - ) -> Result< - Self, - sov_modules_api::MeteredBorshDeserializeError<::Gas>, - > { - sov_modules_api::charge_gas_to_deserialize( - ::string_bias_borsh_deserialization(), - ::string_gas_to_charge_per_byte_borsh_deserialization(), - buf.len(), - meter, - )?; - - ::deserialize(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } - - #[cfg(feature = "native")] - fn unmetered_deserialize( - buf: &mut &[u8], - ) -> Result< - Self, - sov_modules_api::MeteredBorshDeserializeError<::Gas>, - > { - ::deserialize(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } -} - /// Call message to specify storage access patterns. #[derive(Debug, Clone, JsonSchema, EnumDiscriminants, EnumIs, Derivative, UniversalWallet)] #[serialize(Borsh, Serde)] @@ -384,21 +348,24 @@ impl AccessPattern { .iter(state)? .collect::, _>>()?; - let deserialized_string: MeteredBorshDeserializeString = - MeteredBorshDeserialize::deserialize(&mut serialized_bytes.as_ref(), state) - .with_context(|| { - "access-pattern: Impossible to deserialize the input bytes to string" - })?; + let deserialized_string: String = MeteredBorshDeserialize::deserialize_from_slice( + &mut serialized_bytes.as_ref(), + state, + ) + .with_context(|| { + "access-pattern: Impossible to deserialize the input bytes to string" + })?; - self.deserialized_bytes.set(&deserialized_string.0, state)?; + self.deserialized_bytes.set(&deserialized_string, state)?; } AccessPatternMessages::DeserializeCustomString { input } => { - let deserialized_string: MeteredBorshDeserializeString = - MeteredBorshDeserialize::deserialize(&mut input.as_ref(), state).with_context( - || "access-pattern: Impossible to deserialize the input bytes to string", - )?; + let deserialized_string: String = + MeteredBorshDeserialize::deserialize_from_slice(&mut input.as_ref(), state) + .with_context(|| { + "access-pattern: Impossible to deserialize the input bytes to string" + })?; - self.deserialized_bytes.set(&deserialized_string.0, state)?; + self.deserialized_bytes.set(&deserialized_string, state)?; } AccessPatternMessages::StoreSerializedString { input } => { self.serialized_bytes.clear(state)?; diff --git a/crates/module-system/module-implementations/sov-test-modules/src/sequencing_data.rs b/crates/module-system/module-implementations/sov-test-modules/src/sequencing_data.rs index 5c0f352146..d5525dd099 100644 --- a/crates/module-system/module-implementations/sov-test-modules/src/sequencing_data.rs +++ b/crates/module-system/module-implementations/sov-test-modules/src/sequencing_data.rs @@ -1,9 +1,28 @@ -use anyhow::{bail, ensure, Context as _}; +use anyhow::ensure; +use borsh::{BorshDeserialize, BorshSerialize}; use chrono::{TimeZone, Utc}; +use serde::{Deserialize, Serialize}; +use sov_modules_api::macros::UniversalWallet; use sov_modules_api::{Context, DaSpec, GenesisState, Module, ModuleId, ModuleInfo, Spec, TxState}; -#[cfg(feature = "native")] -pub const SCRATCHPAD_TIMESTAMP_NANOS: u128 = 1_987_654_321_000_000_000; +#[derive( + Clone, + BorshSerialize, + BorshDeserialize, + Debug, + PartialEq, + Eq, + PartialOrd, + Hash, + Serialize, + Deserialize, + schemars::JsonSchema, + UniversalWallet, +)] +pub enum CallMessage { + AssertTimestampIsReasonable, + Noop, +} #[derive(Clone, ModuleInfo)] pub struct SequencingDataTester { @@ -17,7 +36,7 @@ impl Module for SequencingDataTester { type Spec = S; type Config = (); - type CallMessage = (); + type CallMessage = CallMessage; type Event = (); type Error = anyhow::Error; @@ -32,15 +51,18 @@ impl Module for SequencingDataTester { fn call( &mut self, - _msg: Self::CallMessage, + msg: Self::CallMessage, context: &Context, _state: &mut impl TxState, ) -> Result<(), Self::Error> { - let data = context - .sequencing_data() - .as_ref() - .context("No sequencing data in context")?; - let timestamp = parse_timestamp(data)?; + if matches!(msg, CallMessage::Noop) { + return Ok(()); + } + + let timestamp = context + .sequencing_timestamp() + .ok_or_else(|| anyhow::anyhow!("No sequencing timestamp in context"))? + .as_nanos(); let reasonable_range = year_to_timestamp(2025)..year_to_timestamp(2100); ensure!( reasonable_range.contains(×tamp), @@ -48,21 +70,10 @@ impl Module for SequencingDataTester { timestamp, reasonable_range ); - #[cfg(feature = "native")] - context - .sequencing_scratchpad() - .set(SCRATCHPAD_TIMESTAMP_NANOS.to_le_bytes().to_vec().into()); Ok(()) } } -fn parse_timestamp(data: &[u8]) -> anyhow::Result { - let Ok(bytes) = data.try_into() else { - bail!("Failed to convert to [u8; 16]"); - }; - Ok(u128::from_le_bytes(bytes)) -} - fn year_to_timestamp(year: i32) -> u128 { Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0) .unwrap() diff --git a/crates/module-system/module-implementations/sov-test-modules/tests/integration/bench_pattern/mod.rs b/crates/module-system/module-implementations/sov-test-modules/tests/integration/bench_pattern/mod.rs index 757f68c5c6..c01c1f5184 100644 --- a/crates/module-system/module-implementations/sov-test-modules/tests/integration/bench_pattern/mod.rs +++ b/crates/module-system/module-implementations/sov-test-modules/tests/integration/bench_pattern/mod.rs @@ -2,7 +2,7 @@ use borsh::BorshSerialize; use sha2::Digest; use sov_modules_api::{CryptoSpec, PrivateKey, Spec}; use sov_test_modules::access_pattern::{ - AccessPattern, AccessPatternGenesisConfig, AccessPatternMessages, MeteredBorshDeserializeString, + AccessPattern, AccessPatternGenesisConfig, AccessPatternMessages, }; use sov_test_utils::runtime::genesis::zk::config::HighLevelZkGenesisConfig; use sov_test_utils::runtime::TestRunner; @@ -145,7 +145,7 @@ fn test_hashing() { fn test_deserialize() { let (mut runner, admin, _) = setup(); - let input = MeteredBorshDeserializeString("abcd".to_string()); + let input = "abcd".to_string(); let mut buf = vec![]; input.serialize(&mut buf).unwrap(); @@ -173,7 +173,7 @@ fn test_deserialize() { fn test_deserialize_with_storage_access() { let (mut runner, admin, _) = setup(); - let input = MeteredBorshDeserializeString("abcd".to_string()); + let input = "abcd".to_string(); let mut buf = vec![]; input.serialize(&mut buf).unwrap(); diff --git a/crates/module-system/module-implementations/sov-uniqueness/README.md b/crates/module-system/module-implementations/sov-uniqueness/README.md index d5096836b8..b5e1e65f89 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/README.md +++ b/crates/module-system/module-implementations/sov-uniqueness/README.md @@ -2,8 +2,12 @@ The `sov-uniqueness` module is responsible for ensuring transaction deduplication on the rollup. -The module does not expose any `CallMessage` therefore, its state can't be directly modified by the users of the rollup. Instead the nonces/generation buckets are modified via the rollup's capabilities. +The module does not expose any `CallMessage`, so users cannot directly modify its state. Instead, nonce, generation, and windowed nonce state is updated through rollup capabilities during transaction processing. -Transaction deduplication can be done in two ways: -- Nonce deduplication: Each transaction sent by a given `sov_rollup_interface::crypto::CredentialId` has a unique nonce. This is a simple way to deduplicate transaction, this mechanism is similar to what is used by blockchains such as Ethereum. It is not possible to send a transaction with the same nonce twice, and the nonce is incremented by one for each transaction. -- Generation deduplication: Each transaction sent by a given `sov_rollup_interface::crypto::CredentialId` has an associated generation number. Each generation is mapped to a bucket of transactions that deduplicate transactions by their hash. Each credential can store at most `MAX_STORED_TX_HASHES_PER_CREDENTIAL` in `PAST_TRANSACTION_GENERATIONS` generations. When a transaction land with a generation number that is higher than the highest known generation, the buckets older than `new_generation - PAST_TRANSACTION_GENERATIONS` are pruned. This mechanism allows fast and (somewhat) stateless transaction deduplication, for usage by market makers for example. Transaction buckets can be mapped to an increasing timestamp with second granularity for instance. \ No newline at end of file +Transaction deduplication can be done in three ways: + +- Nonce deduplication: Each transaction sent by a given `sov_rollup_interface::crypto::CredentialId` has a unique sequential nonce. This is similar to nonce handling in blockchains such as Ethereum. It is not possible to send a transaction with the same nonce twice, and the nonce is incremented by one for each transaction. +- Generation deduplication: Each transaction sent by a given credential has an associated generation number. Each generation is mapped to a bucket of transaction hashes. Each credential can store at most `MAX_STORED_TX_HASHES_PER_CREDENTIAL` hashes across `PAST_TRANSACTION_GENERATIONS` generations. When a transaction lands with a generation higher than the highest known generation, buckets older than `new_generation - PAST_TRANSACTION_GENERATIONS` are pruned. This mechanism allows fast and somewhat stateless deduplication; for example, transaction buckets can be mapped to an increasing timestamp with second granularity. +- Windowed nonce deduplication: Each transaction sent by a given credential has a nonce that must not have been seen before, but nonces do not need to be consecutive. The module stores a bitmap over the most recent `PAST_TRANSACTIONS_WINDOW` nonce range. Nonces below the current window are treated as already consumed, while unseen nonces inside or ahead of the window can be accepted. + +For generation and windowed nonce deduplication, fresh credentials with no stored state may submit any initial value. After that, generation transactions may not increase the latest known generation above `max(latest * 2, latest + PAST_TRANSACTION_GENERATIONS + 1)`, which still permits the minimum jump needed to prune existing buckets. Windowed nonce transactions may not increase the highest seen nonce above `max(highest * 2, PAST_TRANSACTIONS_WINDOW)`. These limits prevent accidental jumps such as submitting millisecond timestamps where second timestamps were intended, or submitting `u64::MAX`, while still allowing intentional larger migrations through a short series of intermediate transactions. Values cannot be lowered again without reintroducing replay risks. diff --git a/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs b/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs index 543f4ca06a..da5286e1b0 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs @@ -3,6 +3,34 @@ use sov_modules_api::{CredentialId, Spec, StateAccessor, StateReader, TxHash}; use sov_state::User; use crate::Uniqueness; + +fn ensure_generation_is_not_too_far_ahead( + credential_id: &CredentialId, + latest_generation: u64, + past_transaction_generations: u64, + transaction_generation: u64, +) -> anyhow::Result<()> { + // A jump to `latest_generation + PAST_TRANSACTION_GENERATIONS + 1` is the + // minimum increase that guarantees the current pruning rule can drop every + // existing bucket if the stored transaction hashes are at capacity. + let minimum_pruning_generation = latest_generation + .saturating_add(past_transaction_generations) + .saturating_add(1); + // Always allow at minimum a large enough increase to prune, in case the buckets are currently + // at capacity. This avoids an account deadlock at low generation numbers (where a doubling + // wouldn't be enough to prune). + let max_allowed_generation = latest_generation + .saturating_mul(2) + .max(minimum_pruning_generation); + + anyhow::ensure!( + transaction_generation <= max_allowed_generation, + "Bad generation for credential id: {credential_id}, latest known value is: {latest_generation}, provided value {transaction_generation} exceeds the maximum allowed value {max_allowed_generation}", + ); + + Ok(()) +} + impl Uniqueness { pub(crate) fn check_generation_uniqueness( &self, @@ -18,14 +46,23 @@ impl Uniqueness { // The "currently active" generations is the range containing the latest seen generation // and the previous PAST_TRANSACTION_GENERATIONS - let latest_generation = senders_buckets - .last_key_value() - .map_or(transaction_generation, |(k, _)| *k); + let latest_generation = senders_buckets.last_key_value().map(|(k, _)| *k); let past_transaction_generations: u64 = config_value!("PAST_TRANSACTION_GENERATIONS"); let transaction_generation_cutoff: u64 = past_transaction_generations.checked_sub(1) .ok_or( anyhow::anyhow!("PAST_TRANSACTION_GENERATIONS should be greater than 0. Please ensure you have set this value correctly"))?; + if let Some(latest_generation) = latest_generation { + ensure_generation_is_not_too_far_ahead( + credential_id, + latest_generation, + past_transaction_generations, + transaction_generation, + )?; + } + + let latest_generation = latest_generation.unwrap_or(transaction_generation); + // If we're below the current generation range, always fail // Note about the arithmetic: for a given PAST_TRANSACTION_GENERATIONS, the correct // comparison is diff --git a/crates/module-system/module-implementations/sov-uniqueness/src/window.rs b/crates/module-system/module-implementations/sov-uniqueness/src/window.rs index 761fd19240..58bd1f845a 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/src/window.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/src/window.rs @@ -94,6 +94,27 @@ impl Window { // Otherwise, check the bitmap self.bits.get_bit(offset) } + + fn highest_seen_nonce(&self) -> Option { + self.bits + .0 + .iter() + .enumerate() + .rev() + .find_map(|(byte_index, byte)| { + if *byte == 0 { + None + } else { + let highest_set_bit = 7 - byte.leading_zeros() as u64; + let offset = byte_index as u64 * 8 + highest_set_bit; + Some( + self.start_nonce + .checked_add(offset) + .expect("highest seen nonce cannot overflow. This is a bug."), + ) + } + }) + } } #[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Default, Serialize)] @@ -127,6 +148,29 @@ impl BitMap { } use crate::Uniqueness; + +fn ensure_window_nonce_is_not_too_far_ahead( + credential_id: &CredentialId, + highest_seen_nonce: u64, + nonce: u64, +) -> anyhow::Result<()> { + // The `max()` term gives a margin for comfortable nonce increases at very low nonce numbers + // for new accounts, where the raw `mul(2)` limit would be very restrictive. + // The choice of PAST_TRANSACTIONS_WINDOW is somewhat arbitrary here, but has the nice property + // that it allows any nonce within the initial window to be submitted for a new account, which + // naturally conforms to the window semantics. + let max_allowed_nonce = highest_seen_nonce + .saturating_mul(2) + .max(PAST_TRANSACTIONS_WINDOW); + + anyhow::ensure!( + nonce <= max_allowed_nonce, + "Bad window nonce for credential id: {credential_id}, latest known value is: {highest_seen_nonce}, provided value {nonce} exceeds the maximum allowed value {max_allowed_nonce}", + ); + + Ok(()) +} + impl Uniqueness { pub(crate) fn check_window_uniqueness( &self, @@ -137,6 +181,10 @@ impl Uniqueness { let window = self.window.get(credential_id, state)?.unwrap_or_default(); let start = window.start_nonce; + if let Some(highest_seen_nonce) = window.highest_seen_nonce() { + ensure_window_nonce_is_not_too_far_ahead(credential_id, highest_seen_nonce, nonce)?; + } + anyhow::ensure!( nonce >= start, "Tx outdated for credential id: {credential_id}, expected at least: {start}, but found: {nonce}"); diff --git a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs index 867c0865f4..1b405f6c6d 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs @@ -1,10 +1,11 @@ +use sov_modules_api::capabilities::UniquenessData; use sov_modules_api::macros::config_value; use sov_modules_api::{CredentialId, TxEffect}; use sov_test_utils::{BatchType, SlotInput, TransactionTestCase, TxProcessingError}; use sov_uniqueness::Uniqueness; use crate::runtime::S; -use crate::utils::{generate_value_setter_tx, setup}; +use crate::utils::{generate_value_setter_tx, generate_value_setter_uniqueness_tx, setup}; /// This test verifies that the `MAX_STORED_TX_HASHES_PER_CREDENTIAL` limit is respected and that authentication succeeds when that limit is not exceeded. #[test] @@ -32,6 +33,83 @@ fn test_max_stored_tx_hashes_per_credential() { do_max_stored_tx_hashes_per_credential_test() } +#[test] +fn generation_allows_pruning_floor_then_rejects_too_far_ahead() { + let (admin, mut runner, _) = setup(); + let past_transaction_generations: u64 = config_value!("PAST_TRANSACTION_GENERATIONS"); + let pruning_floor = past_transaction_generations.checked_add(1).unwrap(); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_tx(0, 0, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_tx(pruning_floor, 1, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_tx(u64::MAX, 2, &admin), + assert: Box::new(move |ctx, _state| { + let TxEffect::Skipped(skipped) = ctx.tx_receipt else { + panic!("Transaction should be skipped"); + }; + match skipped.error { + TxProcessingError::CheckUniquenessFailed(reason) => { + assert!(reason.contains("exceeds the maximum allowed value")); + } + _ => { + panic!( + "Transaction should be rejected because its generation is too far ahead" + ); + } + } + }), + }); +} + +#[test] +fn window_allows_window_floor_then_rejects_too_far_ahead() { + let (admin, mut runner, _) = setup(); + let window_floor: u64 = config_value!("PAST_TRANSACTIONS_WINDOW"); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_uniqueness_tx(UniquenessData::Window(0), 0, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_uniqueness_tx(UniquenessData::Window(window_floor), 1, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_uniqueness_tx(UniquenessData::Window(u64::MAX), 2, &admin), + assert: Box::new(move |ctx, _state| { + let TxEffect::Skipped(skipped) = ctx.tx_receipt else { + panic!("Transaction should be skipped"); + }; + match skipped.error { + TxProcessingError::CheckUniquenessFailed(reason) => { + assert!(reason.contains("exceeds the maximum allowed value")); + } + _ => { + panic!("Transaction should be rejected because its nonce is too far ahead"); + } + } + }), + }); +} + /// This function generates a number of transactions that will fill up the "bucket" of stored transaction hashes /// for a given account. Then it tries to send one more transaction with a current generation number and verifies that /// the tx is rejected because the bucket is full. Finally, it updates the generation number and verifies that the diff --git a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs index 5ab4fb3394..2b827536c8 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs @@ -1,12 +1,20 @@ use sov_modules_api::capabilities::UniquenessData; use sov_modules_api::macros::config_value; use sov_modules_api::prelude::UnwrapInfallible; -use sov_modules_api::{CredentialId, HexHash, TxEffect}; -use sov_test_utils::{TransactionTestCase, TxProcessingError}; +use sov_modules_api::transaction::{Transaction, UnsignedTransaction}; +use sov_modules_api::{ + CredentialId, CryptoSpec, EncodeCall, HexHash, Multisig, PrivateKey, RawTx, Runtime, Spec, + TxEffect, +}; +use sov_test_utils::{ + default_test_tx_details, TestPrivateKey, TestUser, TransactionTestCase, TransactionType, + TxProcessingError, +}; use sov_uniqueness::{Uniqueness, Window}; +use sov_value_setter::ValueSetter; -use crate::runtime::S; -use crate::utils::{generate_default_tx, setup}; +use crate::runtime::{RT, S}; +use crate::utils::{generate_default_tx, setup, setup_with_admin}; #[test] fn send_tx_works_nonce() { @@ -128,6 +136,90 @@ fn send_tx_bad_generation_duplicate() { }); } +#[test] +fn send_tx_bad_generation_duplicate_with_malleated_v1_envelope() { + let multisig_keys = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let runtime_msg = + >>::to_decodable(sov_value_setter::CallMessage::SetValue { + value: 10, + gas: None, + }); + let multisig = Multisig::new(2, multisig_keys.iter().map(|key| key.pub_key()).collect()); + let multisig_credential_id = + multisig.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + let (_, mut runner, _) = setup_with_admin( + TestUser::::generate_with_default_balance().add_credential_id(multisig_credential_id), + ); + + let mut original_tx = UnsignedTransaction::::new_with_details( + runtime_msg, + UniquenessData::Generation(0), + default_test_tx_details::(), + None, + ) + .to_multisig_tx(multisig); + original_tx + .sign(&multisig_keys[0], &RT::CHAIN_HASH) + .unwrap(); + original_tx + .sign(&multisig_keys[1], &RT::CHAIN_HASH) + .unwrap(); + + let mut malleated_tx = original_tx.clone(); + malleated_tx.unused_pub_keys.swap(0, 1); + + assert_eq!( + original_tx.to_signing_bytes(&RT::CHAIN_HASH), + malleated_tx.to_signing_bytes(&RT::CHAIN_HASH), + "The signable payload should be unchanged by V1 envelope malleation" + ); + assert_ne!( + Transaction::::from(original_tx.clone()).hash(), + Transaction::::from(malleated_tx.clone()).hash(), + "The raw transaction hash should change when the V1 envelope is malleated" + ); + + runner.execute_transaction(TransactionTestCase { + input: TransactionType::PreSigned(RawTx { + data: borsh::to_vec(&Transaction::::from(original_tx)).unwrap(), + }), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful(), "{:?}", ctx.tx_receipt); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: TransactionType::PreSigned(RawTx { + data: borsh::to_vec(&Transaction::::from(malleated_tx)).unwrap(), + }), + assert: Box::new(move |ctx, _state| { + let TxEffect::Skipped(skipped) = &ctx.tx_receipt else { + panic!( + "Expected Skipped error from uniqueness check, got {:?}", + ctx.tx_receipt + ); + }; + + match &skipped.error { + TxProcessingError::CheckUniquenessFailed(reason) => { + assert!(reason.contains("Duplicate transaction")); + } + _ => { + panic!( + "Expected uniqueness rejection, got a different error: {:?}", + skipped.error + ); + } + } + }), + }); +} + #[test] fn send_tx_bad_generation_too_old() { let (admin, mut runner, evm_account) = setup(); @@ -194,6 +286,11 @@ fn send_tx_works_window_zero_start_nonce() { fn send_tx_works_window() { let (admin, mut runner, evm_account) = setup(); let admin_credential_id: CredentialId = admin.credential_id(); + let window = config_value!("PAST_TRANSACTIONS_WINDOW"); + let first_nonce = window; + let second_nonce = window - 2; + let third_nonce = first_nonce + window - 1; + let fourth_nonce = third_nonce + 1; runner.query_visible_state(|state| { assert_eq!( @@ -206,62 +303,56 @@ fn send_tx_works_window() { }); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx(UniquenessData::Window(42), &admin, &evm_account), + input: generate_default_tx(UniquenessData::Window(first_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); + let mut dst = vec![0; window as usize / 8]; + dst[window as usize / 8 - 1] = 1; assert_eq!( Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple(( - 0, - vec![0, 0, 0, 0, 0, 1 << 2] - ))), + Some(Window::test_only_from_tuple((8, dst))), "A bit in the window should be set", ); }), }); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx(UniquenessData::Window(40), &admin, &evm_account), + input: generate_default_tx(UniquenessData::Window(second_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); + let mut dst = vec![0; window as usize / 8]; + dst[window as usize / 8 - 2] = 1 << 6; + dst[window as usize / 8 - 1] = 1; assert_eq!( Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple(( - 0, - vec![0, 0, 0, 0, 0, (1 << 2) | (1 << 0)] - ))), + Some(Window::test_only_from_tuple((8, dst))), "Two bits in the window should be set." ); }), }); - let window = config_value!("PAST_TRANSACTIONS_WINDOW"); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx( - UniquenessData::Window(40 + window - 1), - &admin, - &evm_account, - ), + input: generate_default_tx(UniquenessData::Window(third_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); let mut dst = vec![0; window as usize / 8]; - dst[0] = (1 << 2) | (1 << 0); + dst[0] = 1; dst[window as usize / 8 - 1] = 1 << 7; assert_eq!( Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple((40, dst))), + Some(Window::test_only_from_tuple((first_nonce, dst))), "Dropping bits in the window" ); }), }); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx(UniquenessData::Window(40 + window), &admin, &evm_account), + input: generate_default_tx(UniquenessData::Window(fourth_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); let mut dst = vec![0; window as usize / 8]; @@ -271,7 +362,7 @@ fn send_tx_works_window() { Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple((48, dst))), + Some(Window::test_only_from_tuple((first_nonce + 8, dst))), "Dropping bits in the window" ); }), diff --git a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs index 21cedde605..152aa4c8fa 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs @@ -13,7 +13,7 @@ use sov_evm::{ AccountData, EthereumAuthenticator, EvmChainSpec, EvmGenesisConfig, RlpEvmTransaction, SpecId, }; use sov_evm_test_utils::LegacySimpleStorage; -use sov_modules_api::capabilities::{config_chain_id, TransactionAuthenticator, UniquenessData}; +use sov_modules_api::capabilities::{TransactionAuthenticator, UniquenessData}; use sov_modules_api::macros::config_value; use sov_modules_api::transaction::{Transaction, UnsignedTransaction}; use sov_modules_api::{EncodeCall, RawTx}; @@ -104,11 +104,12 @@ pub(crate) fn generate_value_setter_uniqueness_tx( let transaction = UnsignedTransaction::new( runtime_msg, - config_chain_id(), + as Runtime>::CHAIN_HASH, TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, uniqueness, None, + None, ); let transaction = Transaction::::new_signed_tx( @@ -122,10 +123,15 @@ pub(crate) fn generate_value_setter_uniqueness_tx( } pub(crate) fn setup() -> (TestUser, TestRunner, S>, EvmAccount) { + setup_with_admin(TestUser::generate_with_default_balance()) +} + +pub(crate) fn setup_with_admin( + admin: TestUser, +) -> (TestUser, TestRunner, S>, EvmAccount) { // Generate a genesis config, then overwrite the attester key/address with ones that // we know. We leave the other values untouched. - let genesis_config = - HighLevelOptimisticGenesisConfig::generate().add_accounts_with_default_balance(1); + let genesis_config = HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![admin]); let admin = genesis_config .additional_accounts() diff --git a/crates/module-system/module-schemas/genesis-schemas/sov-accounts.json b/crates/module-system/module-schemas/genesis-schemas/sov-accounts.json index 60416486d6..8f33f17cec 100644 --- a/crates/module-system/module-schemas/genesis-schemas/sov-accounts.json +++ b/crates/module-system/module-schemas/genesis-schemas/sov-accounts.json @@ -5,14 +5,14 @@ "type": "object", "properties": { "accounts": { - "description": "Accounts to initialize the rollup.", + "description": "Credential/address authorizations to initialize.", "type": "array", "items": { "$ref": "#/$defs/AccountData" } }, "enable_custom_account_mappings": { - "description": "Enable custom `CredentailId` => `Account` mapping.", + "description": "Enable configured credential authorizations and `InsertCredentialId`.", "type": "boolean", "default": true } @@ -23,15 +23,15 @@ ], "$defs": { "AccountData": { - "description": "Account data for the genesis.", + "description": "Credential/address authorization data for genesis.", "type": "object", "properties": { "address": { - "description": "Address of the account.", + "description": "Address the credential may act as.", "$ref": "#/$defs/Address" }, "credential_id": { - "description": "Credential ID of the account.", + "description": "Credential ID to authorize.", "type": "string" } }, diff --git a/crates/module-system/module-schemas/rollup-config.json b/crates/module-system/module-schemas/rollup-config.json index 305f92a672..e3922fc25c 100644 --- a/crates/module-system/module-schemas/rollup-config.json +++ b/crates/module-system/module-schemas/rollup-config.json @@ -1080,12 +1080,6 @@ "path" ] }, - "RollupHeight": { - "description": "A rollup \"block number\". Rollup heights increase in order (1, 2, 3, ...),\nregardless of what happens on the underlying DA layer.", - "type": "integer", - "format": "uint64", - "minimum": 0 - }, "RpcAggregationConfig": { "description": "Configuration for in-process aggregation of JSON-RPC call statistics.", "type": "object", @@ -1296,10 +1290,6 @@ "description": "Default limits.", "$ref": "#/$defs/Limits" }, - "height_for_gas_limit_computation": { - "description": "Rate limiting on gas is currently disabled, so this param has no impact on runtime behavior.\n\nThis height is used to statically compute the gas limit for the rate limiter. (If this value is less than or equal to CHANGE_GAS_LIMIT_AFTER_HEIGHT in constants.toml,\nthe gas limit will *always* be computed using the initial gas limit for rate limiting. If it is greater, the gas limit will be computed using the updated gas limit.)\nEven after rate limiting based on gas is enabled, you can safely change this param at any time since it only impacts off-chain code.", - "$ref": "#/$defs/RollupHeight" - }, "ip_custom_limits": { "description": "Per-network rate limits. Accepts CIDR notation (`10.0.0.0/8`) or a bare\nIP address (`192.168.1.5`); a bare address is treated as a host network\n(`/32` for IPv4, `/128` for IPv6).", "type": "array", diff --git a/crates/module-system/module-schemas/schemas/sov-accounts.json b/crates/module-system/module-schemas/schemas/sov-accounts.json index 25b1e8afb3..dc6f9a226c 100644 --- a/crates/module-system/module-schemas/schemas/sov-accounts.json +++ b/crates/module-system/module-schemas/schemas/sov-accounts.json @@ -4,11 +4,11 @@ "description": "Represents the available call messages for interacting with the sov-accounts module.", "oneOf": [ { - "description": "Inserts a new credential id for the corresponding Account.", + "description": "Authorizes `credential_id` as a signer for the caller's address.\nFails if the credential is already authorized for the caller's address.", "type": "object", "properties": { "insert_credential_id": { - "description": "The new credential id.", + "description": "The credential id being authorized.", "$ref": "#/$defs/CredentialId" } }, @@ -16,9 +16,130 @@ "required": [ "insert_credential_id" ] + }, + { + "description": "Authorizes `credential` to sign transactions that execute as `address`.\nThe caller must currently be signing as `address`. Fails if the tuple\nis already authorized.", + "type": "object", + "properties": { + "add_credential_to_address": { + "type": "object", + "properties": { + "address": { + "description": "The address whose credential set is being extended. Must equal\n`context.sender()`.", + "$ref": "#/$defs/Address" + }, + "credential": { + "description": "The credential being authorized for `address`.", + "$ref": "#/$defs/CredentialId" + } + }, + "required": [ + "address", + "credential" + ] + } + }, + "additionalProperties": false, + "required": [ + "add_credential_to_address" + ] + }, + { + "description": "Revokes `credential` from `address`. The caller must currently be\nsigning as `address`. No orphan guard: revoking the last credential\nsucceeds and leaves `address` unspendable via this map.", + "type": "object", + "properties": { + "remove_credential_from_address": { + "type": "object", + "properties": { + "address": { + "description": "The address whose credential set is being reduced. Must equal\n`context.sender()`.", + "$ref": "#/$defs/Address" + }, + "credential": { + "description": "The credential being revoked from `address`.", + "$ref": "#/$defs/CredentialId" + } + }, + "required": [ + "address", + "credential" + ] + } + }, + "additionalProperties": false, + "required": [ + "remove_credential_from_address" + ] + }, + { + "description": "Atomically swaps `old_credential` for `new_credential` on `address`.\nFunctionally equivalent to a `RemoveCredentialFromAddress` followed by\nan `AddCredentialToAddress`, collapsed into a single call so the\ncaller does not have to authorize two transactions during a rotation.\nThe caller must currently be signing as `address`.", + "type": "object", + "properties": { + "rotate_credential_on_address": { + "type": "object", + "properties": { + "address": { + "description": "The address whose credential set is being rotated. Must equal\n`context.sender()`.", + "$ref": "#/$defs/Address" + }, + "new_credential": { + "description": "The credential being authorized for `address`. Must not already\nbe authorized.", + "$ref": "#/$defs/CredentialId" + }, + "old_credential": { + "description": "The credential being revoked from `address`. Must be currently\nauthorized.", + "$ref": "#/$defs/CredentialId" + } + }, + "required": [ + "address", + "old_credential", + "new_credential" + ] + } + }, + "additionalProperties": false, + "required": [ + "rotate_credential_on_address" + ] + }, + { + "description": "Creates a new *synthetic* address — an address whose authorization\nlives purely in `account_owners` and which has no\nnaturally-corresponding private key — and auto-authorizes the\ncaller's current credential for it.\n\nThe address is derived deterministically by hashing\n`(domain || visible_slot_hash || sender_addr || sender_credential || salt)`\nwith `S::CryptoSpec::Hasher`, then converting the resulting 32 bytes\nto `S::Address` via `CredentialId.into()`. Different callers, salts,\nand visible slots produce different addresses; replaying the same\ntuple in the same slot is an idempotent no-op.\n\n`visible_slot_hash` is part of the derivation by design: an\nattacker who later compromises the caller's private key cannot\nreconstruct the same synthetic address off-chain, because the slot\nhash only becomes known once chain progress commits to it and is\nunforgeable without participating in consensus. This makes the\ncall *bind-then-use*, not *counterfactual*: unlike CREATE2 or\nERC-4337, the address cannot be predicted and prefunded ahead of\nthe `CreateSyntheticAddress` transaction. Callers must wait for\nfinalization and read the derived address from the\n`SyntheticAddressCreated` event before routing assets or\npermissions to it.", + "type": "object", + "properties": { + "create_synthetic_address": { + "type": "object", + "properties": { + "salt": { + "description": "Caller-supplied salt that allows the same caller to derive\nmultiple distinct synthetic addresses in the same slot.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + } + }, + "required": [ + "salt" + ] + } + }, + "additionalProperties": false, + "required": [ + "create_synthetic_address" + ] } ], "$defs": { + "Address": { + "description": "Address", + "type": "string", + "pattern": "^sov1[a-zA-Z0-9]+$" + }, "CredentialId": { "description": "32 bytes in hexadecimal format, with `0x` prefix.", "type": "string", diff --git a/crates/module-system/sov-address/src/evm/address.rs b/crates/module-system/sov-address/src/evm/address.rs index 9af3b2672f..8fc5149ae6 100644 --- a/crates/module-system/sov-address/src/evm/address.rs +++ b/crates/module-system/sov-address/src/evm/address.rs @@ -155,8 +155,31 @@ mod tests { use sov_test_utils::{MockDaSpec, MockZkvm}; use super::*; + type S = ConfigurableSpec; + #[test] + fn credential_from_evm_address_stays_standard() { + let eth_addr = + EthereumAddress::from_str("0x71334bf1710D12c9f689cC819476fA589F08C64C").unwrap(); + let cred = eth_addr.as_credential_id(); + let back: MultiAddressEvm = cred.into(); + assert_eq!( + back, + MultiAddressEvm::Standard(sov_modules_api::Address::from(cred)) + ); + } + + #[test] + fn credential_from_native_hash_stays_standard() { + let cred = CredentialId::from_bytes([0x42; 32]); + let back: MultiAddressEvm = cred.into(); + assert_eq!( + back, + MultiAddressEvm::Standard(sov_modules_api::Address::from(cred)) + ); + } + #[test] fn test_serde_json_multi_address_evm_vm() { let address = MultiAddressEvm::Vm( diff --git a/crates/module-system/sov-capabilities/src/lib.rs b/crates/module-system/sov-capabilities/src/lib.rs index 4007447144..16bb11c9f3 100644 --- a/crates/module-system/sov-capabilities/src/lib.rs +++ b/crates/module-system/sov-capabilities/src/lib.rs @@ -9,19 +9,18 @@ use sov_chain_state::ChainState as ChainStateModule; use sov_modules_api::capabilities::HasKernel; use sov_modules_api::capabilities::{ AuthorizationData, GasEnforcer, ProofProcessor, SequencerAuthorization, SequencerRemuneration, - SequencingDataHandler, TransactionAuthorizer, + TransactionAuthorizer, }; use sov_modules_api::transaction::{ AuthenticatedTransactionData, ProverReward, RemainingFunds, SequencerReward, }; -use sov_modules_api::HDTimestamp; use sov_modules_api::SequencerType; use sov_modules_api::{ AggregatedProofPublicData, Amount, Context, DaSpec, Gas, GetGasPrice, InfallibleStateAccessor, InvalidProofError, ModuleInfo, OperatingMode, Rewards, SovAttestation, SovStateTransitionPublicData, Spec, StateAccessor, StateReader, StateWriter, Storage, TxState, }; -use sov_modules_api::{ExecutionContext, GasSpec, VersionReader}; +use sov_modules_api::{ExecutionContext, VersionReader}; use sov_rollup_interface::common::SlotNumber; use sov_rollup_interface::zk::aggregated_proof::SerializedAggregatedProof; use sov_rollup_interface::Bytes; @@ -44,10 +43,10 @@ pub struct StandardProvenRollupCapabilities<'a, S: Spec, GasPayer = ()> { impl<'a, S: Spec, T> StandardProvenRollupCapabilities<'a, S, T> { fn get_prover_token_holder( &'a self, - oprating_mode: OperatingMode, + operating_mode: OperatingMode, state: &mut impl InfallibleStateAccessor, ) -> TokenHolder { - let rewarded_token_holder = match oprating_mode { + let rewarded_token_holder = match operating_mode { OperatingMode::Zk => self.prover_incentives.id().to_payable().into(), OperatingMode::Optimistic => self.attester_incentives.id().to_payable().into(), OperatingMode::Operator => { @@ -139,10 +138,10 @@ where fn reward_prover( &mut self, prover_rewards: &ProverReward, - oprating_mode: OperatingMode, + operating_mode: OperatingMode, state: &mut impl InfallibleStateAccessor, ) { - let rewarded_module = self.get_prover_token_holder(oprating_mode, state); + let rewarded_module = self.get_prover_token_holder(operating_mode, state); self.bank .transfer_from( @@ -181,10 +180,10 @@ where &mut self, amount: Amount, _sequencer: &S::Address, - oprating_mode: OperatingMode, + operating_mode: OperatingMode, state: &mut impl InfallibleStateAccessor, ) -> anyhow::Result<()> { - let rewarded_prover_module = self.get_prover_token_holder(oprating_mode, state); + let rewarded_prover_module = self.get_prover_token_holder(operating_mode, state); // Transfer the penalty from the sequencer bank to the sequencer Ok(self.bank.transfer_from( self.bank.id.clone().to_payable(), @@ -207,14 +206,7 @@ where sequencer: &::Address, state: &mut Accessor, ) { - // Only the preferred sequencer is allowed to bond zero tokens. After the gas limit change height, we no longer penalize the preferred sequencer. - let mut net_amount = if bond_amount == Amount::ZERO - && state.rollup_height_to_access() > ::change_gas_limit_after_height() - { - Amount::ZERO - } else { - bond_amount.checked_sub(reward.accumulated_penalty).expect("A sequencer can never be penalized more than the amount they have escrowed, regardless of reward accumulation!") - }; + let mut net_amount = bond_amount.checked_sub(reward.accumulated_penalty).expect("A sequencer can never be penalized more than the amount they have escrowed, regardless of reward accumulation!"); net_amount = net_amount.checked_add(reward.accumulated_reward).expect("Total sequencer reward + escrow amount is greater than the max possible token supply. This is a bug in gas accounting."); self.sequencer_registry.add_to_stake( @@ -236,40 +228,6 @@ impl SequencerAuthorization for StandardProvenRollupCapabilities< } } -impl SequencingDataHandler for StandardProvenRollupCapabilities<'_, S, T> { - type SequencingData = HDTimestamp; - - fn handle_sequencing_data( - &mut self, - data: Self::SequencingData, - context: &Context, - state: &mut impl TxState, - ) -> anyhow::Result<()> { - if !context.sequencer_is_preferred() { - return Ok(()); - } - - self.chain_state - .update_oracle_time_from_sequencing_data(data, state) - } - - #[cfg(feature = "native")] - fn create_sequencing_data(&self) -> Self::SequencingData { - use std::str::FromStr; - if cfg!(debug_assertions) { - let Ok(timestamp) = std::env::var(OVERRIDE_HD_TIMESTAMPS_ENV_VAR) else { - return HDTimestamp::now(); - }; - HDTimestamp::from_str(×tamp).unwrap_or_else(|_| HDTimestamp::now()) - } else { - HDTimestamp::now() - } - } -} - -#[cfg(feature = "native")] -const OVERRIDE_HD_TIMESTAMPS_ENV_VAR: &str = "SOV_TEST_OVERRIDE_HD_TIMESTAMPS"; - impl TransactionAuthorizer for StandardProvenRollupCapabilities<'_, S, T> { /// Prevents duplicate transactions from running. fn check_uniqueness( @@ -282,7 +240,7 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' self.uniqueness.check_uniqueness( &auth_data.credential_id, auth_data.uniqueness, - auth_data.tx_hash, + auth_data.non_malleable_hash, execution_context, state, ) @@ -298,7 +256,7 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' self.uniqueness.mark_tx_attempted( &auth_data.credential_id, auth_data.uniqueness, - auth_data.tx_hash, + auth_data.non_malleable_hash, state, ) } @@ -309,17 +267,13 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' auth_data: &AuthorizationData, sequencer: &::Address, sequencer_rollup_address: S::Address, - state: &mut impl StateAccessor, + state: &mut impl StateReader, sequencing_data: Option, execution_context: ExecutionContext, sequencer_type: SequencerType, ) -> anyhow::Result> { - // This should be resolved by the sequencer registry during blob selection - let sender = self.accounts.resolve_sender_address( - &auth_data.default_address, - &auth_data.credential_id, - state, - )?; + let sender = self.resolve_authorized_sender(auth_data, state)?; + Ok(Context::new( sender, auth_data.credentials.clone(), @@ -335,19 +289,19 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' &mut self, auth_data: &AuthorizationData, sequencer: &<::Da as DaSpec>::Address, - state: &mut impl StateAccessor, + state: &mut impl StateReader, execution_context: ExecutionContext, ) -> anyhow::Result> { - let sender = self.accounts.resolve_sender_address( - &auth_data.default_address, - &auth_data.credential_id, - state, - )?; - // The tx sender & sequencer are the same entity + // On the unregistered path the sender pays its own sequencing, so the + // resolved address doubles as `sequencer_rollup_address`. When + // `address_override = Some(X)`, both fields resolve to `X` — fee debit + // and gas refund follow the override. + let address = self.resolve_authorized_sender(auth_data, state)?; + Ok(Context::new( - sender, + address, auth_data.credentials.clone(), - sender, + address, *sequencer, None, execution_context, @@ -356,6 +310,58 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' } } +impl StandardProvenRollupCapabilities<'_, S, T> { + /// Picks the sender address for a transaction. + /// + /// The two branches use deliberately different authorization predicates: + /// - `address_override = Some(_)` — requires an explicit `true` entry in + /// `account_owners` for `(addr, cred)`. Overriding the default is + /// opt-in and must be granted by a prior `InsertCredentialId` call. + /// The REST endpoint exposes this predicate as the `admit_as_override` + /// field of `sov_accounts::query::AuthorizationResponse`. + /// - `address_override = None` — trusts the authenticator's declared + /// `default_address` unless that exact `(addr, cred)` pair is + /// explicitly denied (e.g. by `revoke_credential`). The authenticator + /// is responsible for having verified the credential→default_address + /// binding before reaching this code. The REST endpoint exposes this + /// predicate as the `admit_as_default` field. + /// + /// The off-chain `sov_accounts::Accounts::is_authorized_for` (which + /// includes a canonical-fallback semantic) is *not* used here and can + /// disagree with the admit-path for authenticators (notably EVM) whose + /// `default_address` is not the credential's canonical address. + fn resolve_authorized_sender( + &mut self, + auth_data: &AuthorizationData, + state: &mut impl StateReader, + ) -> anyhow::Result { + match auth_data.address_override { + Some(address_override) => { + anyhow::ensure!( + self.accounts.is_explicitly_authorized( + &address_override, + &auth_data.credential_id, + state, + )?, + "not authorized for address override" + ); + Ok(address_override) + } + None => { + anyhow::ensure!( + self.accounts.is_default_address_authorized( + &auth_data.default_address, + &auth_data.credential_id, + state, + )?, + "not authorized for resolved address" + ); + Ok(auth_data.default_address) + } + } + } +} + impl ProofProcessor for StandardProvenRollupCapabilities<'_, S, T> { #[cfg(feature = "native")] type BondingProofService> = BondingProofServiceImpl; diff --git a/crates/module-system/sov-cli/src/lib.rs b/crates/module-system/sov-cli/src/lib.rs index 308bd41b9b..52a9b9af88 100644 --- a/crates/module-system/sov-cli/src/lib.rs +++ b/crates/module-system/sov-cli/src/lib.rs @@ -7,7 +7,9 @@ use borsh::{BorshDeserialize, BorshSerialize}; use directories::BaseDirs; use serde::{Deserialize, Serialize}; pub use sov_modules_api::clap; -use sov_modules_api::transaction::{PriorityFeeBips, TxDetails, UnsignedTransaction}; +use sov_modules_api::transaction::{ + chain_hash_fragment, PriorityFeeBips, TxDetails, UnsignedTransaction, +}; use sov_modules_api::{Amount, DispatchCall, HexHash, HexString, Spec}; use sov_node_client as node_client; @@ -58,7 +60,6 @@ where /// Creates a new [`UnsignedTransactionWithoutUniqueness`] with the given arguments. pub const fn new( tx: Tx::Decodable, - chain_id: u64, chain_hash: [u8; 32], max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, @@ -71,7 +72,7 @@ where max_priority_fee_bips, max_fee, gas_limit, - chain_id, + chain_hash_fragment: chain_hash_fragment(&chain_hash), }, } } @@ -81,11 +82,12 @@ where pub fn with_generation(&self, generation: u64) -> UnsignedTransaction { UnsignedTransaction::new( self.tx.clone(), - self.details.chain_id, + self.chain_hash.0, self.details.max_priority_fee_bips, self.details.max_fee, UniquenessData::Generation(generation), self.details.gas_limit, + None, ) } } diff --git a/crates/module-system/sov-cli/src/workflows/transactions.rs b/crates/module-system/sov-cli/src/workflows/transactions.rs index bd464797aa..edb53d0406 100644 --- a/crates/module-system/sov-cli/src/workflows/transactions.rs +++ b/crates/module-system/sov-cli/src/workflows/transactions.rs @@ -174,21 +174,18 @@ where E2: Into + Send + Sync, E3: Into + Send + Sync, { - let chain_id; let max_priority_fee_bips; let max_fee; let gas_limit; let intermediate_repr: RT::CliStringRepr = match self { TransactionLoadWorkflow::FromFile(file) => { - chain_id = file.chain_id(); max_priority_fee_bips = file.max_priority_fee_bips(); max_fee = file.max_fee(); gas_limit = file.gas_limit().map(|m| m.to_vec()); file.try_into().map_err(Into::::into)? } TransactionLoadWorkflow::FromString(json) => { - chain_id = json.chain_id(); max_priority_fee_bips = json.max_priority_fee_bips(); max_fee = json.max_fee(); gas_limit = json.gas_limit().map(|m| m.to_vec()); @@ -207,7 +204,6 @@ where Ok(UnsignedTransactionWithoutUniqueness::new( tx, - chain_id, RT::CHAIN_HASH, max_priority_fee_bips.into(), max_fee, diff --git a/crates/module-system/sov-cli/tests/integration/transactions.rs b/crates/module-system/sov-cli/tests/integration/transactions.rs index 7beda64be4..36ff8b75eb 100644 --- a/crates/module-system/sov-cli/tests/integration/transactions.rs +++ b/crates/module-system/sov-cli/tests/integration/transactions.rs @@ -67,14 +67,12 @@ fn transaction_is_serialized_correctly() { let runtime_call = RuntimeCall::Bank(call_message_from_file("requests/create_token.json")); - let chain_id = 0; let max_priority_fee_bips = TEST_DEFAULT_MAX_PRIORITY_FEE; let max_fee = TEST_DEFAULT_MAX_FEE; let gas_limit = None; let unsigned_tx = UnsignedTransactionWithoutUniqueness::new( runtime_call.clone(), - chain_id, >::CHAIN_HASH, max_priority_fee_bips, max_fee, @@ -97,15 +95,16 @@ fn transaction_is_serialized_correctly() { &chain_hash, UnsignedTransaction::new( runtime_call.clone(), - chain_id, + chain_hash, max_priority_fee_bips, max_fee, UniquenessData::Generation(initial_nonce + i as u64), gas_limit, + None, ), ); - tx.verify_signature_unmetered(&tx.serialized_with_chain_hash(&chain_hash).unwrap()) + tx.verify_signature_unmetered(&tx.to_signing_bytes(&chain_hash)) .expect("the computed signature is incorrect"); assert_eq!( @@ -193,9 +192,7 @@ fn transaction_signed_properly_from_file() { Transaction::unmetered_deserialize(&mut raw_signed_tx.as_slice()).unwrap(); signed_tx .verify_signature_unmetered( - &signed_tx - .serialized_with_chain_hash(&>::CHAIN_HASH) - .unwrap(), + &signed_tx.to_signing_bytes(&>::CHAIN_HASH), ) .unwrap(); @@ -249,9 +246,7 @@ fn transaction_signed_properly_from_json_string() { Transaction::unmetered_deserialize(&mut raw_signed_tx.as_slice()).unwrap(); signed_tx .verify_signature_unmetered( - &signed_tx - .serialized_with_chain_hash(&>::CHAIN_HASH) - .unwrap(), + &signed_tx.to_signing_bytes(&>::CHAIN_HASH), ) .unwrap(); assert_eq!(&runtime_call, signed_tx.runtime_call()); @@ -311,9 +306,7 @@ fn transaction_signed_by_account_nickname() { Transaction::unmetered_deserialize(&mut raw_signed_tx.as_slice()).unwrap(); signed_tx .verify_signature_unmetered( - &signed_tx - .serialized_with_chain_hash(&>::CHAIN_HASH) - .unwrap(), + &signed_tx.to_signing_bytes(&>::CHAIN_HASH), ) .unwrap(); @@ -371,9 +364,7 @@ fn transaction_outputs_json() { Transaction::unmetered_deserialize(&mut raw_signed_tx).unwrap(); signed_tx .verify_signature_unmetered( - &signed_tx - .serialized_with_chain_hash(&>::CHAIN_HASH) - .unwrap(), + &signed_tx.to_signing_bytes(&>::CHAIN_HASH), ) .unwrap(); } @@ -391,7 +382,6 @@ fn default_file_name_arg_for_test(path: &str) -> FileNameArg { let test_path = make_test_path(path); FileNameArg { path: test_path.to_str().unwrap().into(), - chain_id: 0, max_priority_fee_bips: 0, max_fee: Amount::ZERO, gas_limit: None, @@ -402,7 +392,6 @@ fn default_json_string_arg_for_test(path: impl AsRef) -> JsonStringArg { let test_path = make_test_path(path); JsonStringArg { json: std::fs::read_to_string(test_path).unwrap(), - chain_id: 0, max_priority_fee_bips: 0, max_fee: Amount::ZERO, gas_limit: None, diff --git a/crates/module-system/sov-eip712-auth/Cargo.toml b/crates/module-system/sov-eip712-auth/Cargo.toml index b55cb4c23b..a95135edd6 100644 --- a/crates/module-system/sov-eip712-auth/Cargo.toml +++ b/crates/module-system/sov-eip712-auth/Cargo.toml @@ -29,9 +29,13 @@ jsonrpsee = { workspace = true, features = ["jsonrpsee-types"] } # but in that case the dependency is almost certainly already present: serde_json = { workspace = true } +[dev-dependencies] +sov-eip712-auth = { path = ".", features = ["native"] } + [features] native = [ "sov-modules-api/native", "sov-state/native", - "sov-address/native" + "sov-address/native", + "sov-eip712-auth/native" ] diff --git a/crates/module-system/sov-eip712-auth/src/lib.rs b/crates/module-system/sov-eip712-auth/src/lib.rs index 62905d4bbe..95c0bcf24e 100644 --- a/crates/module-system/sov-eip712-auth/src/lib.rs +++ b/crates/module-system/sov-eip712-auth/src/lib.rs @@ -3,9 +3,9 @@ use std::sync::OnceLock; use borsh::{BorshDeserialize, BorshSerialize}; use sov_modules_api::capabilities::{ - self, calculate_hash_metered, verify_chain_id, AuthenticationError, AuthenticationOutput, - BatchFromUnregisteredSequencer, FatalError, TransactionAuthenticator, - UnregisteredAuthenticationError, + self, calculate_hash_metered, calculate_non_malleable_hash_metered, verify_chain_hash_fragment, + AuthenticationError, AuthenticationOutput, BatchFromUnregisteredSequencer, FatalError, + ReplayHashMaterial, TransactionAuthenticator, UnregisteredAuthenticationError, }; use sov_modules_api::sov_universal_wallet::schema::Schema; use sov_modules_api::transaction::{ @@ -28,13 +28,14 @@ pub use stub_evm_rpc::stub_evm_rpc; #[cfg(feature = "native")] use sov_modules_api::capabilities::{SignatureVerificationCache, DEFAULT_SIGNATURE_CACHE_SIZE}; -#[cfg(feature = "native")] -static SIGNATURE_CACHE: std::sync::LazyLock> = - std::sync::LazyLock::new(|| SignatureVerificationCache::new(DEFAULT_SIGNATURE_CACHE_SIZE)); - /// The length of an EIP712 signing hash in bytes. /// EIP712 hashes are 66 bytes: 1 byte prefix (0x19) + 1 byte version (0x01) + 32 bytes domain separator + 32 bytes struct hash. const EIP712_HASH_LENGTH: usize = 66; +type Eip712Hash = [u8; EIP712_HASH_LENGTH]; + +#[cfg(feature = "native")] +static SIGNATURE_CACHE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| SignatureVerificationCache::new(DEFAULT_SIGNATURE_CACHE_SIZE)); /// Trait for providing schema to the EIP-712 authenticator. pub trait SchemaProvider { @@ -204,7 +205,7 @@ pub fn authenticate< .map_err(|e| AuthenticationError::OutOfGas(e.to_string()))?; let mut raw_tx_slice: &[u8] = raw_tx; - let tx = match ::CryptoSpec> as MeteredBorshDeserialize>::deserialize( + let tx = match ::CryptoSpec> as MeteredBorshDeserialize>::deserialize_from_slice( &mut raw_tx_slice, state, ) { @@ -244,19 +245,26 @@ fn verify_and_decode_tx< tx: Transaction::CryptoSpec>, meter: &mut impl GasMeter, ) -> Result, AuthenticationError> { - let (auth_data, details, runtime_call) = match &tx { - Transaction::V0(tx_v0) => { - let auth_data = tx_v0.auth_data(raw_tx_hash, meter)?; - (auth_data, &tx_v0.details, &tx_v0.runtime_call) - } - Transaction::V1(tx_v1) => { - let auth_data = tx_v1.auth_data(raw_tx_hash, meter)?; - (auth_data, &tx_v1.details, &tx_v1.runtime_call) - } + let (details, runtime_call) = match &tx { + Transaction::V0(tx_v0) => (&tx_v0.details, &tx_v0.runtime_call), + Transaction::V1(tx_v1) => (&tx_v1.details, &tx_v1.runtime_call), }; - verify_chain_id(details, raw_tx_hash)?; - verify_eip712_signature::(&tx, raw_tx_hash, meter)?; + let chain_hash = chain_hash::(raw_tx_hash)?; + verify_chain_hash_fragment(details, &chain_hash, raw_tx_hash)?; + let eip712_hash = verify_eip712_signature::(&tx, &chain_hash, raw_tx_hash, meter)?; + let non_malleable_hash = calculate_non_malleable_hash_metered::<_, S>( + match &tx { + Transaction::V0(_) => ReplayHashMaterial::AlreadyNonMalleableHash(raw_tx_hash), + Transaction::V1(_) => ReplayHashMaterial::VerifiedSignatureMessage(&eip712_hash), + }, + meter, + ) + .map_err(|e| AuthenticationError::OutOfGas(e.to_string()))?; + let auth_data = match &tx { + Transaction::V0(tx_v0) => tx_v0.auth_data(raw_tx_hash, non_malleable_hash, meter)?, + Transaction::V1(tx_v1) => tx_v1.auth_data(raw_tx_hash, non_malleable_hash, meter)?, + }; let tx_and_raw_hash = AuthenticatedTransactionAndRawHash { raw_tx_hash, @@ -266,37 +274,41 @@ fn verify_and_decode_tx< Ok((tx_and_raw_hash, auth_data, runtime_call.clone())) } +fn chain_hash(raw_tx_hash: TxHash) -> Result<[u8; 32], AuthenticationError> { + // Use the schema provider to get the schema and calculate the EIP712 signing hash. + let schema = SP::get_schema(); + schema.chain_hash().map_err(|e| { + AuthenticationError::FatalError( + FatalError::SigVerificationFailed(format!( + "Failed to calculate chain hash from schema: {e}" + )), + raw_tx_hash, + ) + }) +} + fn get_eip712_hash< S: Spec, D: DispatchCall, SP: SchemaProvider, >( tx: &Transaction::CryptoSpec>, + chain_hash: &[u8; 32], raw_tx_hash: TxHash, -) -> Result<[u8; EIP712_HASH_LENGTH], AuthenticationError> { - // Convert the transaction to unsigned transaction (removes signature) - let unsigned_tx = tx.to_unsigned_transaction(); - - // Serialize the unsigned transaction - this is what should be signed - let unsigned_tx_bytes = borsh::to_vec(&unsigned_tx).map_err(|e| { - AuthenticationError::FatalError( - FatalError::SigVerificationFailed(format!( - "Failed to serialize unsigned transaction: {e}" - )), - raw_tx_hash, - ) - })?; - - // Use the schema provider to get the schema and calculate the EIP712 signing hash +) -> Result { let schema = SP::get_schema(); - let transaction_type_index = schema.rollup_expected_index(sov_modules_api::sov_universal_wallet::schema::RollupRoots::UnsignedTransaction) + // Convert the transaction to the canonical signing bytes that are transformed into + // EIP-712 typed data. + let signing_payload_bytes = tx.to_signing_bytes(chain_hash); + + let transaction_type_index = schema.rollup_expected_index(sov_modules_api::sov_universal_wallet::schema::RollupRoots::TransactionSigningPayload) .map_err(|e| AuthenticationError::FatalError( - FatalError::SigVerificationFailed(format!("Cannot verify EIP712 signature. Failed to get UnsignedTransaction type from schema: {e}")), + FatalError::SigVerificationFailed(format!("Cannot verify EIP712 signature. Failed to get TransactionSigningPayload type from schema: {e}")), raw_tx_hash, ))?; let eip712_hash = schema - .eip712_signing_digest(transaction_type_index, &unsigned_tx_bytes) + .eip712_signing_digest(transaction_type_index, &signing_payload_bytes) .map_err(|e| { AuthenticationError::FatalError( FatalError::SigVerificationFailed(format!("Failed to calculate EIP712 hash: {e}")), @@ -313,9 +325,10 @@ fn verify_eip712_signature< SP: SchemaProvider, >( tx: &Transaction::CryptoSpec>, + chain_hash: &[u8; 32], raw_tx_hash: TxHash, meter: &mut impl GasMeter, -) -> Result<(), AuthenticationError> { +) -> Result { tx.charge_gas_for_signature(EIP712_HASH_LENGTH, meter) .map_err(|e| match e { TransactionVerificationError::GasError(_) => { @@ -332,7 +345,8 @@ fn verify_eip712_signature< return known_result; } - let eip712_hash = get_eip712_hash::(tx, raw_tx_hash)?; + let eip712_hash = get_eip712_hash::(tx, chain_hash, raw_tx_hash)?; + let res = tx .verify_signature_unmetered(&eip712_hash) .map_err(|e| match e { @@ -344,6 +358,7 @@ fn verify_eip712_signature< raw_tx_hash, ), }); + let res = res.map(|()| eip712_hash); #[cfg(feature = "native")] SIGNATURE_CACHE.insert(raw_tx_hash, res.clone()); diff --git a/crates/module-system/sov-kernels/src/basic.rs b/crates/module-system/sov-kernels/src/basic.rs index 5ebd6866d5..82776bd117 100644 --- a/crates/module-system/sov-kernels/src/basic.rs +++ b/crates/module-system/sov-kernels/src/basic.rs @@ -126,6 +126,15 @@ impl sov_modules_api::capabilities::ChainState for BasicKernel<'_, S> { self.chain_state.finalize_chain_state(gas_used, state); } + fn update_oracle_time( + &mut self, + timestamp: sov_modules_api::HDTimestamp, + state: &mut impl sov_modules_api::TxState, + ) -> anyhow::Result<()> { + self.chain_state + .update_oracle_time_from_sequencing_data(timestamp, state) + } + fn base_fee_per_gas< Reader: VersionReader + StateReader @@ -150,15 +159,6 @@ impl sov_modules_api::capabilities::ChainState for BasicKernel<'_, S> { .unwrap_infallible() } - fn block_gas_limit( - &self, - current_rollup_height: RollupHeight, - is_stale_height: bool, - ) -> ::Gas { - self.chain_state - .block_gas_limit(current_rollup_height, is_stale_height) - } - fn visible_hash_for( &self, rollup_height: RollupHeight, @@ -237,6 +237,14 @@ impl sov_modules_api::capabilities::ChainState for BasicKernel<'_, S> { .unwrap_infallible() } + #[cfg(feature = "native")] + fn state_version>( + &self, + state: &mut Reader, + ) -> u64 { + self.chain_state.state_version(state).unwrap_infallible() + } + #[cfg(feature = "native")] fn test_only_set_rollup_height_for_genesis( &mut self, diff --git a/crates/module-system/sov-kernels/src/soft_confirmations.rs b/crates/module-system/sov-kernels/src/soft_confirmations.rs index 5a8bb1a167..283bb9e114 100644 --- a/crates/module-system/sov-kernels/src/soft_confirmations.rs +++ b/crates/module-system/sov-kernels/src/soft_confirmations.rs @@ -116,6 +116,15 @@ impl sov_modules_api::capabilities::ChainState for SoftConfirmationsKer self.chain_state.finalize_chain_state(gas_used, state); } + fn update_oracle_time( + &mut self, + timestamp: sov_modules_api::HDTimestamp, + state: &mut impl sov_modules_api::TxState, + ) -> anyhow::Result<()> { + self.chain_state + .update_oracle_time_from_sequencing_data(timestamp, state) + } + fn is_setup_mode_enabled< Reader: VersionReader + StateReader @@ -140,15 +149,6 @@ impl sov_modules_api::capabilities::ChainState for SoftConfirmationsKer self.chain_state.base_fee_per_gas(state).unwrap_infallible() } - fn block_gas_limit( - &self, - current_rollup_height: RollupHeight, - is_stale_height: bool, - ) -> S::Gas { - self.chain_state - .block_gas_limit(current_rollup_height, is_stale_height) - } - fn visible_hash_for( &self, rollup_height: RollupHeight, @@ -227,6 +227,14 @@ impl sov_modules_api::capabilities::ChainState for SoftConfirmationsKer .unwrap_infallible() } + #[cfg(feature = "native")] + fn state_version>( + &self, + state: &mut Reader, + ) -> u64 { + self.chain_state.state_version(state).unwrap_infallible() + } + #[cfg(feature = "native")] fn test_only_set_rollup_height_for_genesis( &mut self, diff --git a/crates/module-system/sov-modules-api/src/batch.rs b/crates/module-system/sov-modules-api/src/batch.rs index 8b5408c917..2ac15842ad 100644 --- a/crates/module-system/sov-modules-api/src/batch.rs +++ b/crates/module-system/sov-modules-api/src/batch.rs @@ -192,7 +192,7 @@ pub struct ProvisionalSequencerOutcome { pub execution_status: MaybeExecuted, /// Native scratchpad recorded while executing with the transaction's sequencing data. #[cfg(feature = "native")] - pub sequencing_scratchpad: Option, + pub sequencing_scratchpad: crate::SequencingScratchpadContents, } /// The reason a transaction was rejected by the sequencer due to insufficient funds. @@ -242,7 +242,7 @@ impl ProvisionalSequencerOutcome { penalty, execution_status: MaybeExecuted::SequencerOutOfFunds(reason), #[cfg(feature = "native")] - sequencing_scratchpad: None, + sequencing_scratchpad: Default::default(), } } @@ -253,7 +253,7 @@ impl ProvisionalSequencerOutcome { penalty, execution_status: MaybeExecuted::Executed(receipt), #[cfg(feature = "native")] - sequencing_scratchpad: None, + sequencing_scratchpad: Default::default(), } } /// A convenient constructor for provisionally rewarding the sequencer @@ -263,7 +263,7 @@ impl ProvisionalSequencerOutcome { penalty: Amount::ZERO, execution_status: MaybeExecuted::Executed(receipt), #[cfg(feature = "native")] - sequencing_scratchpad: None, + sequencing_scratchpad: Default::default(), } } } diff --git a/crates/module-system/sov-modules-api/src/cli.rs b/crates/module-system/sov-modules-api/src/cli.rs index 0d1e8aec80..220e62b54d 100644 --- a/crates/module-system/sov-modules-api/src/cli.rs +++ b/crates/module-system/sov-modules-api/src/cli.rs @@ -1,6 +1,5 @@ use std::fs; -use crate::capabilities::config_chain_id; use crate::{clap, Amount, CliWallet}; /// A trait that defines the interface for a CLI wallet. @@ -14,9 +13,6 @@ where /// A trait that defines the arguments for a CLI transaction import method. pub trait CliTxImportArg { - /// The chain ID of the transaction. - fn chain_id(&self) -> u64; - /// The priority fee to pay the sequencer, expressed as a fraction of the tokens spent on gas in basis points. /// for example, setting this value to 1 pays a tip of 1 token to the sequencer for every 10_000 tokens spent on gas. /// similarly, setting this value to 50_000 pays 5 tokens to the sequencer for every token spent on gas @@ -42,10 +38,6 @@ pub struct JsonStringArg { #[arg(long, help = "The JSON formatted transaction")] pub json: String, - /// The chain ID of the transaction. - #[arg(long, help = "The chain ID of the transaction.", default_value_t = config_chain_id())] - pub chain_id: u64, - /// the gas tip for the sequencer. #[arg( long, @@ -84,10 +76,6 @@ pub struct FileNameArg { #[arg(long, help = "The JSON formatted transaction")] pub path: String, - /// The chain ID of the transaction. - #[arg(long, help = "The chain ID of the transaction.", default_value_t = config_chain_id())] - pub chain_id: u64, - /// the gas tip for the sequencer. #[arg( long, @@ -120,10 +108,6 @@ pub struct FileNameArg { } impl CliTxImportArg for JsonStringArg { - fn chain_id(&self) -> u64 { - self.chain_id - } - fn max_priority_fee_bips(&self) -> u64 { self.max_priority_fee_bips } @@ -138,10 +122,6 @@ impl CliTxImportArg for JsonStringArg { } impl CliTxImportArg for FileNameArg { - fn chain_id(&self) -> u64 { - self.chain_id - } - fn max_priority_fee_bips(&self) -> u64 { self.max_priority_fee_bips } @@ -160,7 +140,6 @@ impl TryFrom for JsonStringArg { fn try_from(arg: FileNameArg) -> Result { let FileNameArg { path, - chain_id, max_priority_fee_bips, max_fee, gas_limit, @@ -168,7 +147,6 @@ impl TryFrom for JsonStringArg { Ok(JsonStringArg { json: fs::read_to_string(path)?, - chain_id, max_priority_fee_bips, max_fee, gas_limit, diff --git a/crates/module-system/sov-modules-api/src/gas/metered_reader.rs b/crates/module-system/sov-modules-api/src/gas/metered_reader.rs new file mode 100644 index 0000000000..b2b9a1518c --- /dev/null +++ b/crates/module-system/sov-modules-api/src/gas/metered_reader.rs @@ -0,0 +1,90 @@ +use std::io; + +use crate::gas::traits::GasMeter; +use crate::{as_u32_or_panic, GasSpec, Spec}; + +/// `io::Read` adapter that charges gas to a `GasMeter` for every byte read. +/// +/// Wraps an inner `Read` source and a meter; every `read` and `read_exact` call charges +/// `per_byte × n + per_read_bias`. A failed charge wraps the typed `GasMeteringError` +/// inside the returned `io::Error` (via [`io::Error::other`]); the caller recovers it +/// with [`io::Error::downcast`]. +pub struct MeteredReader<'a, R: io::Read, M: GasMeter> { + inner: R, + meter: &'a mut M, + per_byte: ::Gas, + per_read_bias: ::Gas, +} + +impl<'a, R: io::Read, M: GasMeter> MeteredReader<'a, R, M> { + /// Wrap `inner` in a metered reader using the per-byte and per-read constants + /// from the meter's `GasSpec`. + pub fn new(inner: R, meter: &'a mut M) -> Self { + Self::new_with_prices( + inner, + meter, + ::gas_to_charge_per_byte_borsh_read(), + ::bias_borsh_per_read(), + ) + } + + /// Construct a metered reader with explicit prices. Used for testing. + pub(crate) fn new_with_prices( + inner: R, + meter: &'a mut M, + per_byte: ::Gas, + per_read_bias: ::Gas, + ) -> Self { + Self { + inner, + meter, + per_byte, + per_read_bias, + } + } + + fn charge(&mut self, n: usize) -> io::Result<()> { + if n == 0 { + return Ok(()); + } + self.meter + .charge_gas(self.per_read_bias) + .map_err(io::Error::other)?; + self.meter + .charge_linear_gas(self.per_byte, as_u32_or_panic(n)) + .map_err(io::Error::other)?; + Ok(()) + } +} + +impl io::Read for MeteredReader<'_, R, M> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + // Charge after the inner read so partial reads / EOF / inner failures + // don't over-charge for bytes that were not delivered. + let n = self.inner.read(buf)?; + self.charge(n)?; + Ok(n) + } + + fn read_exact(&mut self, mut buf: &mut [u8]) -> io::Result<()> { + let mut consumed = 0usize; + while !buf.is_empty() { + match self.inner.read(buf) { + Ok(0) => { + let _ = self.charge(consumed); + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + Ok(n) => { + consumed += n; + buf = &mut buf[n..]; + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => { + let _ = self.charge(consumed); + return Err(err); + } + } + } + self.charge(consumed) + } +} diff --git a/crates/module-system/sov-modules-api/src/gas/metered_utils.rs b/crates/module-system/sov-modules-api/src/gas/metered_utils.rs index 0fdc69ac53..739ffb68cb 100644 --- a/crates/module-system/sov-modules-api/src/gas/metered_utils.rs +++ b/crates/module-system/sov-modules-api/src/gas/metered_utils.rs @@ -199,21 +199,6 @@ fn charge_gas_for_sig_inner>>( ) .map_err(MeteredSigVerificationError::GasError)?; - meter - .charge_gas(::gas_to_charge_hash_update()) - .map_err(MeteredSigVerificationError::GasError)?; - - meter - .charge_linear_gas( - ::gas_to_charge_per_byte_hash_update(), - msg_len.try_into().map_err(|e: TryFromIntError| { - MeteredSigVerificationError::GasError(MeteringError::::Overflow( - e.to_string(), - )) - })?, - ) - .map_err(MeteredSigVerificationError::GasError)?; - Ok(()) } @@ -228,54 +213,55 @@ pub enum MeteredBorshDeserializeError { IOError(io::Error), } -/// Charges gas for deserialization. -pub trait MeteredBorshDeserialize: Sized { - /// Deserializes a type from a byte slice with the provided gas meter. Charge the [`GasSpec::gas_to_charge_per_byte_borsh_deserialization`] - /// amount of gas for each byte of the struct to deserialize. - fn deserialize( +/// Extension trait that charges gas for borsh deserialization as the decoder +/// actually consumes bytes. Auto-implemented for every `T: borsh::BorshDeserialize` +/// via the blanket impl below. +/// +/// The gas spec comes from the meter passed into each method — the trait itself +/// has no `Spec` parameter, so callers don't need to disambiguate it. +pub trait MeteredBorshDeserialize: Sized + borsh::BorshDeserialize { + /// Decode `Self` from a metered reader. Every read against the reader charges + /// gas, so total cost tracks actual decode work rather than the input length. + /// Gas errors stashed inside the reader's `io::Error` are unwrapped and surfaced + /// as [`MeteredBorshDeserializeError::GasError`]. + fn deserialize_reader( + reader: &mut crate::MeteredReader<'_, R, M>, + ) -> Result::Gas>> { + ::deserialize_reader(reader).map_err(|io_err| { + match io_err.downcast::::Gas>>() { + Ok(gas_err) => MeteredBorshDeserializeError::GasError(gas_err), + Err(io_err) => MeteredBorshDeserializeError::IOError(io_err), + } + }) + } + + /// Slice-driven entry point. Charges [`GasSpec::bias_borsh_deserialization`], + /// wraps `buf` in a [`crate::MeteredReader`], delegates to `deserialize_reader`, + /// and advances `*buf` by the bytes consumed. + fn deserialize_from_slice( buf: &mut &[u8], - meter: &mut impl GasMeter, - ) -> Result::Gas>>; + meter: &mut M, + ) -> Result::Gas>> { + meter + .charge_gas(::bias_borsh_deserialization()) + .map_err(MeteredBorshDeserializeError::GasError)?; + + let mut cursor = io::Cursor::new(*buf); + let mut reader = crate::MeteredReader::new(&mut cursor, meter); + let value = ::deserialize_reader(&mut reader)?; + *buf = &buf[cursor.position() as usize..]; + Ok(value) + } #[cfg(feature = "native")] - /// Deserialized a type without charging gas. - fn unmetered_deserialize( - buf: &mut &[u8], - ) -> Result::Gas>>; + /// Deserialize without charging gas. Native-only escape hatch for places that + /// already paid for the work (e.g. CLI, test scaffolding). + fn unmetered_deserialize(buf: &mut &[u8]) -> Result { + ::deserialize(buf) + } } -/// Computes the cost to deserialize the given buffer, in `Gas`, and charges it to the provided -/// `GasMeter`. -/// -/// # Errors -/// Returns an error if charging the gas for the deserialization operation fails. -pub fn charge_gas_to_deserialize( - bias_borsh_deserialization: ::Gas, - gas_to_charge_per_byte_borsh_deserialization: ::Gas, - buf_len: usize, - meter: &mut impl GasMeter, -) -> Result<(), MeteredBorshDeserializeError<::Gas>> { - // This is safe to cast here. We won't have data bigger than 4GB. - let buf_len: u32 = as_u32_or_panic(buf_len); - - // Custom gas costs to deserialize this data structure. - meter - .charge_gas(bias_borsh_deserialization) - .map_err(MeteredBorshDeserializeError::GasError)?; - - meter - .charge_linear_gas(gas_to_charge_per_byte_borsh_deserialization, buf_len) - .map_err(MeteredBorshDeserializeError::GasError)?; - - // Common gas costs to deserialize this data structure. - meter - .charge_gas(S::bias_borsh_deserialization()) - .map_err(MeteredBorshDeserializeError::GasError)?; - - meter - .charge_linear_gas(S::gas_to_charge_per_byte_borsh_deserialization(), buf_len) - .map_err(MeteredBorshDeserializeError::GasError) -} +impl MeteredBorshDeserialize for T {} /// Computes the cost to deserialize the given JSON buffer, in `Gas`, and charges it to the provided /// `GasMeter`. diff --git a/crates/module-system/sov-modules-api/src/gas/mod.rs b/crates/module-system/sov-modules-api/src/gas/mod.rs index 5f746f3ab7..6245052790 100644 --- a/crates/module-system/sov-modules-api/src/gas/mod.rs +++ b/crates/module-system/sov-modules-api/src/gas/mod.rs @@ -1,5 +1,6 @@ mod error; mod impl_macros; +mod metered_reader; mod metered_utils; mod meters; mod price; @@ -11,11 +12,11 @@ mod tests; pub use error::GasMeteringError; pub(crate) use impl_macros::*; +pub use metered_reader::MeteredReader; pub use metered_utils::charge_gas_for_sig; pub use metered_utils::{ - charge_gas_to_deserialize, charge_gas_to_deserialize_json, metered_credential, - MeteredBorshDeserialize, MeteredBorshDeserializeError, MeteredHasher, - MeteredSigVerificationError, MeteredSignature, + charge_gas_to_deserialize_json, metered_credential, MeteredBorshDeserialize, + MeteredBorshDeserializeError, MeteredHasher, MeteredSigVerificationError, MeteredSignature, }; pub use meters::*; pub use price::*; diff --git a/crates/module-system/sov-modules-api/src/gas/tests/metered_reader.rs b/crates/module-system/sov-modules-api/src/gas/tests/metered_reader.rs new file mode 100644 index 0000000000..c9189e8a89 --- /dev/null +++ b/crates/module-system/sov-modules-api/src/gas/tests/metered_reader.rs @@ -0,0 +1,221 @@ +use std::io::{self, Read}; + +use sov_mock_zkvm::MockZkvm; +use sov_rollup_interface::execution_mode::Native; +use sov_test_utils::storage::SimpleStorageManager; +use sov_test_utils::MockDaSpec; + +use crate::default_spec::DefaultSpec; +use crate::gas::tests::budget_for_reader_calls; +use crate::{ + Amount, Gas, GasMeteringError, GasPrice, GasUnit, MeteredReader, Spec, StateCheckpoint, + WorkingSet, +}; + +type S = DefaultSpec; + +const TEST_GAS_PRICE: GasPrice<2> = GasPrice { + value: [Amount::new(1); 2], +}; + +fn create_working_set(remaining_funds: Amount) -> WorkingSet> { + let storage_manager = SimpleStorageManager::new(); + let storage = storage_manager.create_storage(); + WorkingSet::new_with_gas_meter(storage, remaining_funds, &TEST_GAS_PRICE) +} + +fn budget_for_reads(per_byte: GasUnit<2>, per_read_bias: GasUnit<2>, reads: &[u32]) -> Amount { + budget_for_reader_calls(per_byte, per_read_bias, reads).value(TEST_GAS_PRICE) +} + +#[test] +fn read_exact_charges_once_per_call() { + let per_byte = GasUnit::<2>::from([10, 10]); + let per_read_bias = GasUnit::<2>::from([5, 5]); + + // Budget exactly one read of 8 bytes. A second read of any size must run out of gas. + let funds = budget_for_reads(per_byte, per_read_bias, &[8]); + let mut ws = create_working_set(funds); + + let data: Vec = (0..16).collect(); + let mut reader = + MeteredReader::new_with_prices(data.as_slice(), &mut ws, per_byte, per_read_bias); + + let mut buf = [0u8; 8]; + assert!( + reader.read_exact(&mut buf).is_ok(), + "first read_exact(8) must succeed" + ); + assert_eq!(buf, [0, 1, 2, 3, 4, 5, 6, 7]); + + // Second read_exact must fail because the budget was exactly one read; the + // returned io::Error must wrap a typed GasMeteringError. + let mut buf2 = [0u8; 1]; + let err = reader + .read_exact(&mut buf2) + .expect_err("second read_exact must fail — gas is exhausted"); + assert!( + err.downcast::::Gas>>().is_ok(), + "out-of-gas io::Error must downcast to a typed GasMeteringError" + ); +} + +#[test] +fn read_charges_per_byte_and_per_read_bias() { + let per_byte = GasUnit::<2>::from([10, 10]); + let per_read_bias = GasUnit::<2>::from([5, 5]); + + // Budget exactly one read of 4 bytes. Source has extra bytes so the second + // read actually attempts a delivery (not EOF), which means it must fail on + // the gas charge — that's what we're testing. + let funds = budget_for_reads(per_byte, per_read_bias, &[4]); + let mut ws = create_working_set(funds); + + let data = [9u8; 8]; + let mut reader = + MeteredReader::new_with_prices(data.as_slice(), &mut ws, per_byte, per_read_bias); + + let mut buf = [0u8; 4]; + let n = reader.read(&mut buf).expect("first read must succeed"); + assert_eq!(n, 4); + assert_eq!(buf, [9, 9, 9, 9]); + + let mut buf2 = [0u8; 1]; + assert!( + reader.read(&mut buf2).is_err(), + "no gas left — second read must fail at the charge step" + ); +} + +#[test] +fn partial_read_charges_only_actual_bytes() { + // A custom reader that delivers fewer bytes than requested. The metered reader + // must charge only for the delivered bytes, not for the buffer size. + struct ShortReader<'a> { + data: &'a [u8], + delivered: bool, + } + impl Read for ShortReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.delivered { + return Ok(0); + } + self.delivered = true; + // Always deliver only 3 bytes regardless of buf size. + let n = self.data.len().min(buf.len()).min(3); + buf[..n].copy_from_slice(&self.data[..n]); + Ok(n) + } + } + + let per_byte = GasUnit::<2>::from([10, 10]); + let per_read_bias = GasUnit::<2>::from([5, 5]); + + // Budget exactly 3 bytes worth of charge, not 10. If MeteredReader were charging + // by buf size instead of actual bytes, this would fail. + let funds = budget_for_reads(per_byte, per_read_bias, &[3]); + let mut ws = create_working_set(funds); + + let data: Vec = (0..10).collect(); + let short = ShortReader { + data: &data, + delivered: false, + }; + let mut reader = MeteredReader::new_with_prices(short, &mut ws, per_byte, per_read_bias); + + let mut buf = [0u8; 10]; + let n = reader + .read(&mut buf) + .expect("partial read must succeed within the 3-byte budget"); + assert_eq!(n, 3, "ShortReader always delivers 3 bytes"); +} + +#[test] +fn read_exact_propagates_inner_error_when_partial_charge_overflows_budget() { + struct PartialFailReader { + delivered: bool, + } + impl Read for PartialFailReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if !self.delivered { + self.delivered = true; + let n = buf.len().min(3); + buf[..n].fill(0xCD); + Ok(n) + } else { + Err(io::Error::other("BOOM")) + } + } + } + + let per_byte = GasUnit::<2>::from([10, 10]); + let per_read_bias = GasUnit::<2>::from([5, 5]); + let funds = budget_for_reads(per_byte, per_read_bias, &[1]); + let mut ws = create_working_set(funds); + let source = PartialFailReader { delivered: false }; + let mut reader = MeteredReader::new_with_prices(source, &mut ws, per_byte, per_read_bias); + + let mut buf = [0u8; 10]; + let err = reader + .read_exact(&mut buf) + .expect_err("read_exact must propagate inner error"); + assert!( + err.to_string().contains("BOOM"), + "inner error must reach the caller, not be clobbered by gas OOG; got: {err}" + ); +} + +#[test] +fn out_of_gas_during_read_wraps_typed_error() { + let per_byte = GasUnit::<2>::from([10, 10]); + let per_read_bias = GasUnit::<2>::from([5, 5]); + + // Budget zero gas — any read must fail at the per_read_bias charge. + let mut ws = create_working_set(Amount::new(0)); + + let data = [1u8; 16]; + let mut reader = + MeteredReader::new_with_prices(data.as_slice(), &mut ws, per_byte, per_read_bias); + + let mut buf = [0u8; 4]; + let err = reader + .read(&mut buf) + .expect_err("read with zero gas budget must fail"); + assert!( + err.downcast::::Gas>>().is_ok(), + "out-of-gas io::Error must downcast to a typed GasMeteringError" + ); +} + +#[test] +fn read_exact_zero_buf_does_not_charge() { + let per_byte = GasUnit::<2>::from([10, 10]); + let per_read_bias = GasUnit::<2>::from([5, 5]); + let mut ws = create_working_set(Amount::new(0)); + + let data: &[u8] = &[1, 2, 3]; + let mut reader = MeteredReader::new_with_prices(data, &mut ws, per_byte, per_read_bias); + + let mut buf: [u8; 0] = []; + reader + .read_exact(&mut buf) + .expect("0-byte read_exact must not charge gas"); +} + +#[test] +fn eof_read_does_not_charge() { + let per_byte = GasUnit::<2>::from([10, 10]); + let per_read_bias = GasUnit::<2>::from([5, 5]); + + // Empty input. Budget zero — if EOF charged, the read would fail. + let mut ws = create_working_set(Amount::new(0)); + + let data: &[u8] = &[]; + let mut reader = MeteredReader::new_with_prices(data, &mut ws, per_byte, per_read_bias); + + let mut buf = [0u8; 4]; + let n = reader + .read(&mut buf) + .expect("EOF (0 bytes returned) must not charge gas"); + assert_eq!(n, 0); +} diff --git a/crates/module-system/sov-modules-api/src/gas/tests/metered_utils.rs b/crates/module-system/sov-modules-api/src/gas/tests/metered_utils.rs index 2bbca12b9f..180dce4793 100644 --- a/crates/module-system/sov-modules-api/src/gas/tests/metered_utils.rs +++ b/crates/module-system/sov-modules-api/src/gas/tests/metered_utils.rs @@ -11,9 +11,9 @@ use sov_test_utils::MockDaSpec; use crate::default_spec::DefaultSpec; use crate::gas::GasArray; use crate::{ - Amount, Gas, GasMeter, GasPrice, GasUnit, MeteredBorshDeserialize, - MeteredBorshDeserializeError, MeteredHasher, MeteredSigVerificationError, MeteredSignature, - Spec, StateCheckpoint, WorkingSet, + Amount, Gas, GasPrice, GasSpec, GasUnit, MeteredBorshDeserialize, MeteredBorshDeserializeError, + MeteredHasher, MeteredSigVerificationError, MeteredSignature, Spec, StateCheckpoint, + WorkingSet, }; type S = DefaultSpec; @@ -101,14 +101,6 @@ fn test_metered_signature() { .unwrap(), ) .unwrap() - .checked_combine(S::gas_to_charge_hash_update()) - .unwrap() - .checked_combine( - S::gas_to_charge_per_byte_hash_update() - .checked_scalar_product(TEST_DATA.len() as u64) - .unwrap(), - ) - .unwrap() .value(TEST_GAS_PRICE); let mut ws = create_working_set(remaining_funds, &TEST_GAS_PRICE); @@ -155,40 +147,28 @@ pub struct BorshTestStruct { pub field2: u32, } -impl MeteredBorshDeserialize for BorshTestStruct { - fn deserialize( - buf: &mut &[u8], - meter: &mut impl GasMeter, - ) -> Result::Gas>> { - crate::charge_gas_to_deserialize( - ::Gas::zero(), - ::Gas::zero(), - buf.len(), - meter, - )?; - - ::deserialize(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } - - fn unmetered_deserialize( - buf: &mut &[u8], - ) -> Result::Gas>> { - ::deserialize(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } +/// Total gas cost to decode a `BorshTestStruct` through `deserialize_from_slice`: +/// the common entry bias plus two `read_exact(4)` calls (one per `u32` field). +fn gas_cost_for_borsh_test_struct() -> ::Gas { + let two_reads = super::budget_for_reader_calls( + ::gas_to_charge_per_byte_borsh_read(), + ::bias_borsh_per_read(), + &[4, 4], + ); + ::bias_borsh_deserialization() + .checked_combine(two_reads) + .unwrap() } #[test] fn test_metered_deserializer() { let data = TEST_BORSH_STRUCT; let serialized_data = borsh::to_vec(&data).unwrap(); - let gas_to_charge = gas_cost_to_deserialize::(&serialized_data).unwrap(); - let remaining_funds = gas_to_charge.value(TEST_GAS_PRICE); + let remaining_funds = gas_cost_for_borsh_test_struct().value(TEST_GAS_PRICE); let mut ws = create_working_set(remaining_funds, &TEST_GAS_PRICE); - let deserialized_data = >::deserialize( + let deserialized_data = ::deserialize_from_slice( &mut serialized_data.as_slice(), &mut ws, ) @@ -201,15 +181,14 @@ fn test_metered_deserializer() { fn test_metered_deserializer_not_enough_gas() { let data = TEST_BORSH_STRUCT; let serialized_data = borsh::to_vec(&data).unwrap(); - let gas_to_charge = gas_cost_to_deserialize::(&serialized_data).unwrap(); - let remaining_funds = gas_to_charge + let remaining_funds = gas_cost_for_borsh_test_struct() .value(TEST_GAS_PRICE) .checked_sub(Amount::new(1)) .unwrap(); let mut ws = create_working_set(remaining_funds, &TEST_GAS_PRICE); - let result = >::deserialize( + let result = ::deserialize_from_slice( &mut serialized_data.as_slice(), &mut ws, ); @@ -224,12 +203,11 @@ fn test_metered_deserializer_not_enough_gas() { fn test_metered_deserializer_invalid_data() { let data = TEST_BORSH_STRUCT; let serialized_data = borsh::to_vec(&data).unwrap(); - let gas_to_charge = gas_cost_to_deserialize::(&serialized_data).unwrap(); - let remaining_funds = gas_to_charge.value(TEST_GAS_PRICE); + let remaining_funds = gas_cost_for_borsh_test_struct().value(TEST_GAS_PRICE); let mut ws = create_working_set(remaining_funds, &TEST_GAS_PRICE); - let result = >::deserialize( + let result = ::deserialize_from_slice( &mut &serialized_data[1..], &mut ws, ); @@ -240,46 +218,90 @@ fn test_metered_deserializer_invalid_data() { )); } +fn set_borsh_read_constants(per_byte: &str, per_read_bias: &str) { + std::env::set_var("SOV_TEST_CONST_OVERRIDE_BORSH_PER_BYTE_READ", per_byte); + std::env::set_var("SOV_TEST_CONST_OVERRIDE_BORSH_PER_READ_BIAS", per_read_bias); +} + #[test] -fn test_total_deserialization_cost() { - let cases = [ - (GasUnit::<2>::from([1; 2]), 22, true), - (GasUnit::<2>::from([1; 2]), u64::MAX, false), - (GasUnit::<2>::from([1, 2]), u64::MAX, false), - (GasUnit::<2>::from([2; 2]), u64::MAX, false), - ]; - for (gas, buf_len, should_succeed) in cases { - assert_eq!( - total_deserialization_cost::(gas, buf_len).is_ok(), - should_succeed - ); - } +fn test_metered_deserializer_charges_per_byte_and_per_read() { + set_borsh_read_constants("[10, 10]", "[5, 5]"); + + let data = TEST_BORSH_STRUCT; + let serialized = borsh::to_vec(&data).unwrap(); + let total = gas_cost_for_borsh_test_struct().value(TEST_GAS_PRICE); + + let mut ws = create_working_set(total, &TEST_GAS_PRICE); + let decoded: BorshTestStruct = + ::deserialize_from_slice( + &mut serialized.as_slice(), + &mut ws, + ) + .unwrap(); + assert_eq!(decoded, data); + + // One unit short — the per-read accounting must actually have fired for this to fail. + let mut ws = create_working_set(total.checked_sub(Amount::new(1)).unwrap(), &TEST_GAS_PRICE); + let result = ::deserialize_from_slice( + &mut serialized.as_slice(), + &mut ws, + ); + assert!(matches!( + result, + Err(MeteredBorshDeserializeError::GasError(..)) + )); } -use crate::{GasMeteringError, GasSpec}; -fn total_deserialization_cost( - deserialization_cost: S::Gas, - buf_len: u64, -) -> Result> { - deserialization_cost - .checked_scalar_product(buf_len) - .ok_or(MeteredBorshDeserializeError::GasError( - GasMeteringError::Overflow( - "Deserialization cost overflows `u64::MAX` value".to_string(), - ), - ))? - .checked_combine(S::bias_borsh_deserialization()) - .ok_or(MeteredBorshDeserializeError::GasError( - GasMeteringError::Overflow( - "Deserialization cost overflows `u64::MAX` value".to_string(), - ), - )) +#[test] +fn test_metered_deserializer_advances_buf_by_bytes_consumed() { + let data = TEST_BORSH_STRUCT; + let mut serialized = borsh::to_vec(&data).unwrap(); + let tail = [0xAB, 0xCD, 0xEF]; + serialized.extend_from_slice(&tail); + + let mut ws = create_working_set( + gas_cost_for_borsh_test_struct().value(TEST_GAS_PRICE), + &TEST_GAS_PRICE, + ); + + let mut buf: &[u8] = &serialized; + let decoded = + ::deserialize_from_slice(&mut buf, &mut ws) + .unwrap(); + assert_eq!(decoded, data); + assert_eq!( + buf, &tail, + "deserialize_from_slice must advance *buf by exactly the bytes consumed" + ); } -fn gas_cost_to_deserialize( - buf: &[u8], -) -> Result> { - let deserialization_cost = S::gas_to_charge_per_byte_borsh_deserialization(); +#[test] +fn test_metered_deserializer_recovers_gas_error_mid_decode() { + set_borsh_read_constants("[10, 10]", "[5, 5]"); - total_deserialization_cost::(deserialization_cost, buf.len() as u64) + let data = TEST_BORSH_STRUCT; + let serialized = borsh::to_vec(&data).unwrap(); + + // Budget entry bias + exactly one read. The second field's read_exact must + // exhaust gas mid-decode; `deserialize_from_slice` must downcast the resulting + // io::Error back into a typed GasError rather than surface it as IOError. + let one_read = super::budget_for_reader_calls( + ::gas_to_charge_per_byte_borsh_read(), + ::bias_borsh_per_read(), + &[4], + ); + let budget = ::bias_borsh_deserialization() + .checked_combine(one_read) + .unwrap() + .value(TEST_GAS_PRICE); + + let mut ws = create_working_set(budget, &TEST_GAS_PRICE); + let result = ::deserialize_from_slice( + &mut serialized.as_slice(), + &mut ws, + ); + assert!(matches!( + result, + Err(MeteredBorshDeserializeError::GasError(..)) + )); } diff --git a/crates/module-system/sov-modules-api/src/gas/tests/mod.rs b/crates/module-system/sov-modules-api/src/gas/tests/mod.rs index 3da3621e73..d2d762754e 100644 --- a/crates/module-system/sov-modules-api/src/gas/tests/mod.rs +++ b/crates/module-system/sov-modules-api/src/gas/tests/mod.rs @@ -1 +1,23 @@ +mod metered_reader; mod metered_utils; + +use crate::gas::GasArray; +use crate::GasUnit; + +/// Gas cost of a sequence of reads through `MeteredReader`, each charging +/// `per_read_bias + per_byte × n`. +pub(super) fn budget_for_reader_calls( + per_byte: GasUnit<2>, + per_read_bias: GasUnit<2>, + reads: &[u32], +) -> GasUnit<2> { + let mut total = GasUnit::<2>::ZEROED; + for &n in reads { + total = total + .checked_combine(per_read_bias) + .unwrap() + .checked_combine(per_byte.checked_scalar_product(u64::from(n)).unwrap()) + .unwrap(); + } + total +} diff --git a/crates/module-system/sov-modules-api/src/module/gas_spec.rs b/crates/module-system/sov-modules-api/src/module/gas_spec.rs index 2cc343323f..48757ab52c 100644 --- a/crates/module-system/sov-modules-api/src/module/gas_spec.rs +++ b/crates/module-system/sov-modules-api/src/module/gas_spec.rs @@ -2,7 +2,6 @@ use std::fmt::Debug; use borsh::{BorshDeserialize, BorshSerialize}; use sov_modules_macros::config_value_private; -use sov_rollup_interface::common::RollupHeight; use super::Spec; use crate::gas::GAS_DIMENSIONS; @@ -74,51 +73,23 @@ pub trait GasSpec: /// Gas to charge for EVM execution fn gas_to_charge_per_evm_gas() -> Self::Gas; - /// The cost of deserializing a message using Borsh - fn gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas; - /// The bias to charge for deserializing a message using Borsh + // --- Borsh deserialization gas constants --- + /// Entry bias charged once per borsh decode. fn bias_borsh_deserialization() -> Self::Gas; + /// Per-byte cost charged inside `MeteredReader` and upfront against opaque payloads. + fn gas_to_charge_per_byte_borsh_read() -> Self::Gas; + /// Per-read fixed cost charged inside `MeteredReader`. + fn bias_borsh_per_read() -> Self::Gas; - /// The cost of deserializing a transaction using Borsh - fn tx_gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas; - /// The bias to charge for deserializing a tx using Borsh - fn tx_bias_borsh_deserialization() -> Self::Gas; - + // --- JSON deserialization gas constants --- /// The cost of deserializing a transaction using JSON fn tx_gas_to_charge_per_byte_json_deserialization() -> Self::Gas; /// The bias to charge for deserializing a tx using JSON fn tx_bias_json_deserialization() -> Self::Gas; - /// The cost of deserializing a proof using Borsh - fn proof_gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas; - /// The bias to charge for deserializing a proof using Borsh - fn proof_bias_borsh_deserialization() -> Self::Gas; - - /// The cost of deserializing a sample string using Borsh - fn string_gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas; - /// The bias to charge for deserializing a sample string using Borsh - fn string_bias_borsh_deserialization() -> Self::Gas; - // --- Gas fee adjustment parameters: See https://eips.ethereum.org/EIPS/eip-1559 for a detailed description --- - /// The initial gas limit of the rollup. - fn initial_gas_limit() -> Self::Gas; - /// The updated gas limit of the rollup. - fn updated_gas_limit() -> Self::Gas; - /// The height at which the gas limit is updated. The change should take effect immediately *after* this rollup block. - /// I.e. any rollup block (and any empty slot) after this rollup height will have the updated gas limit. - /// - /// Note: We define things in this way because the gas limit is needed inside `synchronize_chain`, but at that point we don't yet know whether a rollup block will be created. - /// however, we do know if the previous slot created a rollup block or not. - fn change_gas_limit_after_height() -> RollupHeight; - - /// Returns the gas limit for a given rollup height. - fn gas_limit_for_height(height: RollupHeight) -> Self::Gas { - if height > Self::change_gas_limit_after_height() { - Self::updated_gas_limit() - } else { - Self::initial_gas_limit() - } - } + /// The gas limit applied to every rollup block. + fn block_gas_limit() -> Self::Gas; /// The initial "base fee" that every transaction emits when executed. fn initial_base_fee_per_gas() -> ::Price; @@ -194,25 +165,20 @@ impl GasSpec for S { ) } - fn gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas { - new_constant!( - "DEFAULT_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION", - Self::Gas - ) - } - + // --- Borsh deserialization gas constants --- fn bias_borsh_deserialization() -> Self::Gas { new_constant!("BIAS_BORSH_DESERIALIZATION", Self::Gas) } - fn tx_gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas { - new_constant!("TX_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION", Self::Gas) + fn gas_to_charge_per_byte_borsh_read() -> Self::Gas { + new_constant!("BORSH_PER_BYTE_READ", Self::Gas) } - fn tx_bias_borsh_deserialization() -> Self::Gas { - new_constant!("TX_BIAS_BORSH_DESERIALIZATION", Self::Gas) + fn bias_borsh_per_read() -> Self::Gas { + new_constant!("BORSH_PER_READ_BIAS", Self::Gas) } + // --- JSON deserialization gas constants --- fn tx_gas_to_charge_per_byte_json_deserialization() -> Self::Gas { new_constant!("TX_GAS_TO_CHARGE_PER_BYTE_JSON_DESERIALIZATION", Self::Gas) } @@ -221,28 +187,6 @@ impl GasSpec for S { new_constant!("TX_BIAS_JSON_DESERIALIZATION", Self::Gas) } - fn proof_gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas { - new_constant!( - "PROOF_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION", - Self::Gas - ) - } - - fn proof_bias_borsh_deserialization() -> Self::Gas { - new_constant!("PROOF_BIAS_BORSH_DESERIALIZATION", Self::Gas) - } - - fn string_gas_to_charge_per_byte_borsh_deserialization() -> Self::Gas { - new_constant!( - "STRING_GAS_TO_CHARGE_PER_BYTE_BORSH_DESERIALIZATION", - Self::Gas - ) - } - - fn string_bias_borsh_deserialization() -> Self::Gas { - new_constant!("STRING_BIAS_BORSH_DESERIALIZATION", Self::Gas) - } - fn gas_to_charge_hash_update() -> Self::Gas { new_constant!("GAS_TO_CHARGE_HASH_UPDATE", Self::Gas) } @@ -268,16 +212,8 @@ impl GasSpec for S { ::Price::from(actual) } - fn initial_gas_limit() -> Self::Gas { - Self::Gas::from(config_value_private!("INITIAL_GAS_LIMIT")) - } - - fn change_gas_limit_after_height() -> RollupHeight { - RollupHeight::new(config_value_private!("CHANGE_GAS_LIMIT_AFTER_HEIGHT")) - } - - fn updated_gas_limit() -> Self::Gas { - Self::Gas::from(config_value_private!("UPDATED_GAS_LIMIT")) + fn block_gas_limit() -> Self::Gas { + Self::Gas::from(config_value_private!("BLOCK_GAS_LIMIT")) } fn max_tx_check_costs() -> Self::Gas { @@ -302,38 +238,3 @@ impl GasSpec for S { new_constant!("PROCESS_TX_PRE_EXEC_GAS_PER_TX_BYTE", Self::Gas) } } - -#[test] -fn test_gas_limit_for_height() { - use crate::default_spec::DefaultSpec; - use sov_mock_da::MockDaSpec; - use sov_mock_zkvm::MockZkvm; - use sov_rollup_interface::execution_mode::Native; - type S = DefaultSpec; - const UPDATED_GAS_LIMIT: [u64; 2] = [1, 1]; - const CHANGE_GAS_LIMIT_AFTER_HEIGHT: u64 = 1; - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_UPDATED_GAS_LIMIT", - format!("{:?}", UPDATED_GAS_LIMIT), - ); - std::env::set_var( - "SOV_TEST_CONST_OVERRIDE_CHANGE_GAS_LIMIT_AFTER_HEIGHT", - CHANGE_GAS_LIMIT_AFTER_HEIGHT.to_string(), - ); - - assert_ne!(::initial_gas_limit(), ::updated_gas_limit(), "Updated gas limit must be different from initial gas limit - this test needs an update. This is not a bug in the SDK"); - assert_eq!( - ::gas_limit_for_height(RollupHeight::new(0)), - ::initial_gas_limit() - ); - assert_eq!( - ::gas_limit_for_height(RollupHeight::new(1)), - ::initial_gas_limit() - ); - - // First height *AFTER* the change gas limit height should be the updated gas limit - assert_eq!( - ::gas_limit_for_height(RollupHeight::new(2)), - ::updated_gas_limit() - ); -} diff --git a/crates/module-system/sov-modules-api/src/module/spec.rs b/crates/module-system/sov-modules-api/src/module/spec.rs index 2ccc81363f..14d3d78bca 100644 --- a/crates/module-system/sov-modules-api/src/module/spec.rs +++ b/crates/module-system/sov-modules-api/src/module/spec.rs @@ -152,30 +152,41 @@ impl CryptoSpecExt for C {} /// Sequencer-provided data plus native-only execution scratchpad. #[derive(Clone, Debug)] pub struct SequencingContext { + /// Timestamp attached by the preferred sequencer. + timestamp: Option, + /// Serialized format-specific data payload. data: Option, #[cfg(feature = "native")] scratchpad: SequencingScratchpad, } impl SequencingContext { - /// Creates a new sequencing context. + /// Creates a new sequencing context, decoding the raw sequencing data attached to the + /// transaction. Undecodable sequencing data is treated as absent; this keeps execution + /// deterministic across all node types for garbage submitted by a malicious sequencer. #[must_use] - pub fn new(data: Option) -> Self { + pub(crate) fn new(raw_data: Option) -> Self { + let decoded = raw_data.and_then(|bytes| match crate::SequencingData::decode(&bytes) { + Ok(decoded) => Some(decoded), + Err(error) => { + tracing::warn!(%error, "Failed to decode sequencing data; treating it as absent"); + None + } + }); + let (timestamp, data) = decoded + .map(|decoded| (decoded.timestamp, decoded.data)) + .unwrap_or_default(); Self { + timestamp, data, #[cfg(feature = "native")] scratchpad: SequencingScratchpad::default(), } } - - /// Returns the sequencing data. - pub fn data(&self) -> &Option { - &self.data - } } #[cfg(feature = "native")] -pub use native_sequencing::SequencingScratchpad; +pub use native_sequencing::{SequencingScratchpad, SequencingScratchpadContents}; /// The context in which a transaction executes @@ -215,9 +226,50 @@ impl Context { &self.sequencer_da_address } - /// Returns the sequencing data - pub fn sequencing_data(&self) -> &Option { - self.sequencing.data() + /// Returns `true` if the transaction carries sequencing data, i.e. a timestamp or a data + /// payload. + /// + /// Only preferred-sequencer transactions carry sequencing data; data attached by any other + /// sequencer is discarded at context construction. There is deliberately no accessor for + /// the raw sequencing data: in-execution reads of the data payload must go through the + /// access-recording [`Self::sequencing_data_view`], since unrecorded reads are invisible + /// to sequencing data pruning and would make the published transaction replay differently + /// on other nodes. + pub fn has_sequencing_data(&self) -> bool { + self.sequencing.timestamp.is_some() || self.sequencing.data.is_some() + } + + /// Returns the timestamp the sequencer attached to the transaction, if any. + pub fn sequencing_timestamp(&self) -> Option { + self.sequencing.timestamp + } + + /// Returns a typed view over the format-specific sequencing data. + /// + /// `F` must be the runtime's configured + /// [`HasSequencingData::SequencingData`](crate::capabilities::HasSequencingData::SequencingData) + /// format. Recorded accesses are decoded with that format when the sequencer prunes the + /// published transaction, so reads through a view with any other format break pruning: the + /// sequencer then publishes the full unpruned payload (and logs a warning) on every + /// transaction whose scratchpad contains a foreign key. + pub fn sequencing_data_view(&self) -> crate::capabilities::SequencingDataView<'_, F> + where + F: crate::capabilities::SequencingDataFormat, + { + let data = self.sequencing.data.as_ref(); + + #[cfg(feature = "native")] + { + crate::capabilities::SequencingDataView::with_scratchpad( + data, + self.sequencing.scratchpad(), + ) + } + + #[cfg(not(feature = "native"))] + { + crate::capabilities::SequencingDataView::new(data) + } } /// Returns the rollup address which will receive any gas refund from the transaction. @@ -278,6 +330,17 @@ impl Context { execution_context: ExecutionContext, sequencer_type: SequencerType, ) -> Self { + // Sequencing data is only trusted from the preferred sequencer; discard it for every + // other sequencer type so that neither pre-dispatch hooks nor in-transaction reads + // (e.g. precompiles) can observe untrusted sequencing data. This is deterministic + // across all node types: `sequencer_type` is derived from the on-chain sequencer + // registry, so replaying nodes classify each batch identically to the sequencer that + // published it. + let sequencing_data = match sequencer_type { + SequencerType::Preferred => sequencing_data, + SequencerType::NonPreferred => None, + }; + Self { sender_credentials, sender, @@ -300,50 +363,53 @@ impl Context { mod native_sequencing { use std::sync::{Arc, Mutex}; - use sov_rollup_interface::Bytes; + /// Native-only scratchpad contents for sequencing data access tracking. + pub type SequencingScratchpadContents = std::collections::BTreeSet>; - /// Native-only scratchpad for recording data while finalizing sequencing metadata. + /// Native-only scratchpad for recording sequencing data keys accessed during execution. #[derive(Clone, Debug, Default)] pub struct SequencingScratchpad { - inner: Arc>>, + inner: Arc>, } impl SequencingScratchpad { - /// Replaces the scratchpad contents. - pub fn set(&self, value: Bytes) { - self.with_value(|slot| *slot = Some(value)); + /// Records a serialized sequencing data key. + pub(crate) fn insert(&self, value: Vec) { + let mut guard = self + .inner + .lock() + .expect("sequencing scratchpad mutex was poisoned"); + guard.insert(value); } /// Takes the scratchpad contents, leaving it empty. - pub fn take(&self) -> Option { - self.with_value(Option::take) - } - - /// Mutates the scratchpad contents under the scratchpad lock. - pub fn with_value(&self, f: impl FnOnce(&mut Option) -> R) -> R { + pub(crate) fn take(&self) -> SequencingScratchpadContents { let mut guard = self .inner .lock() .expect("sequencing scratchpad mutex was poisoned"); - f(&mut guard) + std::mem::take(&mut *guard) } } impl super::SequencingContext { /// Returns the native sequencing scratchpad. - pub fn scratchpad(&self) -> super::SequencingScratchpad { + pub(crate) fn scratchpad(&self) -> super::SequencingScratchpad { self.scratchpad.clone() } } impl super::Context { - /// Returns the native sequencing scratchpad. - pub fn sequencing_scratchpad(&self) -> super::SequencingScratchpad { - self.sequencing.scratchpad() - } - - /// Takes the native sequencing scratchpad contents. - pub fn take_sequencing_scratchpad(&self) -> Option { + /// Takes the native sequencing scratchpad contents, i.e. the record of sequencing data + /// keys accessed so far during this transaction's execution. + /// + /// This is an SDK-internal API: the STF blueprint calls it exactly once, after the + /// transaction has executed, to drive sequencing data pruning. Calling it from anywhere + /// else erases the access record mid-execution, which causes the sequencer to prune + /// data its own execution actually read and to diverge from nodes replaying the + /// published transaction. + #[doc(hidden)] + pub fn take_sequencing_scratchpad(&self) -> super::SequencingScratchpadContents { self.sequencing.scratchpad().take() } } diff --git a/crates/module-system/sov-modules-api/src/proof_metadata.rs b/crates/module-system/sov-modules-api/src/proof_metadata.rs index 803d3e0514..a90d963c94 100644 --- a/crates/module-system/sov-modules-api/src/proof_metadata.rs +++ b/crates/module-system/sov-modules-api/src/proof_metadata.rs @@ -1,12 +1,9 @@ -use std::io; - -use borsh::BorshDeserialize; use sov_rollup_interface::common::SlotNumber; use sov_rollup_interface::optimistic::{SerializedAttestation, SerializedChallenge}; use sov_rollup_interface::zk::aggregated_proof::SerializedAggregatedProof; use crate::transaction::TxDetails; -use crate::{GasMeter, GasSpec, MeteredBorshDeserialize, MeteredBorshDeserializeError, Spec}; +use crate::Spec; /// Proof type supported by the rollup. @@ -31,7 +28,7 @@ pub enum ProofType { } /// Proof with metadata need for verification. -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone, borsh::BorshDeserialize)] #[cfg_attr( feature = "native", derive(borsh::BorshSerialize, serde::Serialize, serde::Deserialize,) @@ -43,51 +40,3 @@ pub struct SerializeProofWithDetails { /// The transaction metadata. pub details: TxDetails, } - -impl SerializeProofWithDetails { - fn unmetered_deserialize_inner(buf: &mut &[u8]) -> Result { - let signature = ::deserialize(buf)?; - let pub_key = as BorshDeserialize>::deserialize(buf)?; - if !buf.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Trailing bytes after proof blob", - )); - } - - Ok(Self { - proof: signature, - details: pub_key, - }) - } -} - -impl MeteredBorshDeserialize for SerializeProofWithDetails { - #[cfg_attr(feature = "bench", crate::cycle_tracker)] - #[cfg_attr( - all(feature = "gas-constant-estimation", feature = "native"), - crate::track_gas_constants_usage - )] - fn deserialize( - buf: &mut &[u8], - meter: &mut impl GasMeter, - ) -> Result::Gas>> { - crate::charge_gas_to_deserialize( - S::proof_bias_borsh_deserialization(), - S::proof_gas_to_charge_per_byte_borsh_deserialization(), - buf.len(), - meter, - )?; - - SerializeProofWithDetails::::unmetered_deserialize_inner(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } - - #[cfg(feature = "native")] - fn unmetered_deserialize( - buf: &mut &[u8], - ) -> Result::Gas>> { - SerializeProofWithDetails::::unmetered_deserialize_inner(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } -} diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/authentication.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/authentication.rs index 1efdd6f9dc..ec6c7e442f 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/authentication.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/authentication.rs @@ -13,7 +13,8 @@ use thiserror::Error; use crate::capabilities::AuthorizationData; use crate::transaction::{ - AuthenticatedTransactionAndRawHash, Transaction, TransactionVerificationError, TxDetails, + chain_hash_fragment, AuthenticatedTransactionAndRawHash, Transaction, + TransactionVerificationError, TxDetails, }; #[cfg(feature = "native")] use crate::CryptoSpecExt; @@ -24,11 +25,6 @@ use crate::{ RawTx, Runtime, Spec, VersionReader, }; -/// The chain ID of the rollup. -pub fn config_chain_id() -> u64 { - config_value_private!("CHAIN_ID") -} - /// Resolves all valid chain hashes for a given height using configured overrides. /// /// This function loads the `CHAIN_HASH_OVERRIDES` from config and uses them @@ -248,6 +244,14 @@ pub type AuthenticationOutput = ( Decodable, ); +/// The material from which a non-malleable transaction hash can be derived. +pub enum ReplayHashMaterial<'a> { + /// The raw transaction hash is already non-malleable and can be used directly. + AlreadyNonMalleableHash(TxHash), + /// The verified message bytes are the non-malleable material and should be hashed. + VerifiedSignatureMessage(&'a [u8]), +} + /// Error variants that can be raised as a [`AuthenticationError::FatalError`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)] #[serde(rename_all = "snake_case")] @@ -278,6 +282,14 @@ pub enum FatalError { /// The actual chain id got: String, }, + /// The chain hash fragment in the transaction did not match any valid chain hash. + #[error("Invalid chain hash fragment: expected one of {expected:?}, got {got}")] + InvalidChainHashFragment { + /// The valid chain hash fragments. + expected: Vec, + /// The transaction-provided chain hash fragment. + got: u64, + }, /// The chain name was invalid. Not every type of transaction will be able to throw this (some /// will just implicitly fail signature checks). #[error("Invalid chain name: expected {expected}, got {got}")] @@ -347,21 +359,42 @@ impl From for UnregisteredAuthenticationError { } } -/// Verifies that the transaction has the correct chain ID. -pub fn verify_chain_id( +/// Verifies that the transaction has the expected chain hash fragment. +pub fn verify_chain_hash_fragment( tx_details: &TxDetails, + chain_hash: &[u8; 32], raw_tx_hash: TxHash, ) -> Result<(), AuthenticationError> { - if tx_details.chain_id != config_chain_id() { - return Err(AuthenticationError::FatalError( - FatalError::InvalidChainId { - expected: config_chain_id(), - got: tx_details.chain_id, - }, - raw_tx_hash, - )); + select_chain_hash( + tx_details, + &crate::runtime::ResolvedChainHashes { + primary: *chain_hash, + grace_period_hashes: Vec::new(), + }, + raw_tx_hash, + ) + .map(|_| ()) +} + +/// Selects the full chain hash committed to by a transaction. +pub fn select_chain_hash( + tx_details: &TxDetails, + resolved_hashes: &crate::runtime::ResolvedChainHashes, + raw_tx_hash: TxHash, +) -> Result<[u8; 32], AuthenticationError> { + for chain_hash in resolved_hashes.iter() { + if tx_details.chain_hash_fragment == chain_hash_fragment(chain_hash) { + return Ok(*chain_hash); + } } - Ok(()) + + Err(AuthenticationError::FatalError( + FatalError::InvalidChainHashFragment { + expected: resolved_hashes.iter().map(chain_hash_fragment).collect(), + got: tx_details.chain_hash_fragment, + }, + raw_tx_hash, + )) } /// Verifies the transaction signature. @@ -370,13 +403,8 @@ fn verify_signature>( chain_hash: &[u8; 32], raw_tx_hash: TxHash, meter: &mut impl GasMeter, -) -> Result<(), AuthenticationError> { - let serialized_tx = tx.serialized_with_chain_hash(chain_hash).map_err(|e| { - AuthenticationError::FatalError( - FatalError::DeserializationFailed(e.to_string()), - raw_tx_hash, - ) - })?; +) -> Result, AuthenticationError> { + let serialized_tx = tx.to_signing_bytes(chain_hash); tx.charge_gas_for_signature(serialized_tx.len(), meter) .map_err(|e| match e { @@ -391,7 +419,7 @@ fn verify_signature>( #[cfg(feature = "native")] if let Some(known_result) = SIGNATURE_CACHE.get(&(raw_tx_hash, *chain_hash)) { - return known_result; + return known_result.map(|()| serialized_tx); } let res = tx @@ -409,7 +437,20 @@ fn verify_signature>( #[cfg(feature = "native")] SIGNATURE_CACHE.insert((raw_tx_hash, *chain_hash), res.clone()); - res + res.map(|()| serialized_tx) +} + +/// Calculates the non-malleable hash to use for replay protection. +pub fn calculate_non_malleable_hash_metered, S: Spec>( + material: ReplayHashMaterial<'_>, + gas_meter: &mut G, +) -> Result> { + match material { + ReplayHashMaterial::AlreadyNonMalleableHash(hash) => Ok(hash), + ReplayHashMaterial::VerifiedSignatureMessage(message) => { + calculate_hash_metered::(message, gas_meter) + } + } } /// Authenticate and verify deserialized sov-tx. See `authenticate`. @@ -434,7 +475,8 @@ pub fn verify_and_decode_tx>( /// /// This function resolves the appropriate chain hashes for the current block height /// using configured overrides (including grace periods), falling back to `default_chain_hash` -/// when no override applies. It tries verification with each valid hash until one succeeds. +/// when no override applies. It selects the hash matching the transaction's chain +/// hash fragment before verifying the signature. /// /// # Errors /// Returns an error if gas runs out at any point, if deserialization or hashing fails, or if the @@ -455,22 +497,24 @@ pub fn authenticate< let raw_tx_hash = calculate_hash_metered::(raw_tx, state) .map_err(|e| AuthenticationError::OutOfGas(e.to_string()))?; - let tx = - match as MeteredBorshDeserialize>::deserialize(&mut raw_tx, state) { - Ok(ok) => ok, + let tx = match as MeteredBorshDeserialize>::deserialize_from_slice( + &mut raw_tx, + state, + ) { + Ok(ok) => ok, - Err(MeteredBorshDeserializeError::GasError(e)) => { - return Err(AuthenticationError::OutOfGas(format!( - "Transaction deserialization run out of gas {e}, tx hash {raw_tx_hash}" - ))) - } - Err(MeteredBorshDeserializeError::IOError(e)) => { - return Err(AuthenticationError::FatalError( - FatalError::DeserializationFailed(e.to_string()), - raw_tx_hash, - )); - } - }; + Err(MeteredBorshDeserializeError::GasError(e)) => { + return Err(AuthenticationError::OutOfGas(format!( + "Transaction deserialization run out of gas {e}, tx hash {raw_tx_hash}" + ))) + } + Err(MeteredBorshDeserializeError::IOError(e)) => { + return Err(AuthenticationError::FatalError( + FatalError::DeserializationFailed(e.to_string()), + raw_tx_hash, + )); + } + }; // Verify that the transaction is fully deserialized if !raw_tx.is_empty() { @@ -488,54 +532,38 @@ pub fn authenticate< /// Authenticate and verify deserialized sov-tx with multiple chain hashes. /// -/// Tries verification with the primary hash first, then any grace period hashes. -/// Returns success on the first hash that verifies successfully. +/// Selects the full chain hash by matching the transaction's chain hash fragment +/// against the hashes valid for this height. fn verify_and_decode_tx_multi_hash>( raw_tx_hash: TxHash, tx: Transaction, resolved_hashes: crate::runtime::ResolvedChainHashes, meter: &mut impl GasMeter, ) -> Result, AuthenticationError> { - // Extract auth_data and verify chain_id (these don't depend on chain_hash) - let (auth_data, details, runtime_call) = match &tx { - Transaction::V0(tx_v0) => { - let auth_data = tx_v0.auth_data(raw_tx_hash, meter)?; - (auth_data, &tx_v0.details, &tx_v0.runtime_call) - } - Transaction::V1(tx_v1) => { - let auth_data = tx_v1.auth_data(raw_tx_hash, meter)?; - (auth_data, &tx_v1.details, &tx_v1.runtime_call) - } + let (details, runtime_call) = match &tx { + Transaction::V0(tx_v0) => (&tx_v0.details, &tx_v0.runtime_call), + Transaction::V1(tx_v1) => (&tx_v1.details, &tx_v1.runtime_call), }; - verify_chain_id(details, raw_tx_hash)?; - - // Try signature verification with each valid chain hash - let mut last_error = None; - for chain_hash in resolved_hashes.iter() { - match verify_signature(&tx, chain_hash, raw_tx_hash, meter) { - Ok(()) => { - let tx_and_raw_hash = AuthenticatedTransactionAndRawHash { - raw_tx_hash, - authenticated_tx: details.clone().into(), - }; - return Ok((tx_and_raw_hash, auth_data, runtime_call.clone())); - } - Err(AuthenticationError::FatalError( - FatalError::SigVerificationFailed(error), - hash, - )) => { - last_error = Some(AuthenticationError::FatalError( - FatalError::SigVerificationFailed(error), - hash, - )); - } - Err(e) => return Err(e), - } - } - - // All chain hashes failed - return the last error - Err(last_error.expect("resolved_hashes always has at least one hash (the primary)")) + let chain_hash = select_chain_hash(details, &resolved_hashes, raw_tx_hash)?; + let serialized_tx = verify_signature(&tx, &chain_hash, raw_tx_hash, meter)?; + let non_malleable_hash = calculate_non_malleable_hash_metered::<_, S>( + match &tx { + Transaction::V0(_) => ReplayHashMaterial::AlreadyNonMalleableHash(raw_tx_hash), + Transaction::V1(_) => ReplayHashMaterial::VerifiedSignatureMessage(&serialized_tx), + }, + meter, + ) + .map_err(|e| AuthenticationError::OutOfGas(e.to_string()))?; + let auth_data = match &tx { + Transaction::V0(tx_v0) => tx_v0.auth_data(raw_tx_hash, non_malleable_hash, meter)?, + Transaction::V1(tx_v1) => tx_v1.auth_data(raw_tx_hash, non_malleable_hash, meter)?, + }; + let tx_and_raw_hash = AuthenticatedTransactionAndRawHash { + raw_tx_hash, + authenticated_tx: details.clone().into(), + }; + Ok((tx_and_raw_hash, auth_data, runtime_call.clone())) } /// Authenticate raw unregistered sov-transaction. @@ -570,9 +598,8 @@ pub fn decode_sov_tx>( pub fn decode_sov_tx_with_cryptospec, C: CryptoSpecExt>( mut raw_tx: &[u8], ) -> Result { - let tx = - as MeteredBorshDeserialize>::unmetered_deserialize(&mut raw_tx) - .map_err(|e| FatalError::DeserializationFailed(e.to_string()))?; + let tx = as MeteredBorshDeserialize>::unmetered_deserialize(&mut raw_tx) + .map_err(|e| FatalError::DeserializationFailed(e.to_string()))?; Ok(tx.into_runtime_call()) } diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs index 1fa294a72a..b0b889ba8c 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs @@ -10,8 +10,10 @@ use sov_rollup_interface::stf::ExecutionContext; use sov_rollup_interface::{Bytes, TxHash}; use sov_universal_wallet::UniversalWallet; +use sov_state::User; + use crate::transaction::Credentials; -use crate::{Context, SequencerType, Spec, StateAccessor}; +use crate::{Context, SequencerType, Spec, StateAccessor, StateReader}; /// Authorizes transactions to be executed. pub trait TransactionAuthorizer { @@ -21,7 +23,7 @@ pub trait TransactionAuthorizer { auth_data: &AuthorizationData, sequencer: &<::Da as DaSpec>::Address, sequencer_rollup_address: S::Address, - state: &mut impl StateAccessor, + state: &mut impl StateReader, sequencing_data: Option, execution_context: ExecutionContext, sequencer_type: SequencerType, @@ -32,7 +34,7 @@ pub trait TransactionAuthorizer { &mut self, auth_data: &AuthorizationData, sequencer: &<::Da as DaSpec>::Address, - state: &mut impl StateAccessor, + state: &mut impl StateReader, execution_context: ExecutionContext, ) -> anyhow::Result>; @@ -42,7 +44,7 @@ pub trait TransactionAuthorizer { auth_data: &AuthorizationData, context: &Context, execution_context: &ExecutionContext, - state: &mut impl StateAccessor, + state: &mut impl StateReader, ) -> anyhow::Result<()>; /// Marks a transaction as having been executed, preventing it from executing again. @@ -87,9 +89,16 @@ pub struct AuthorizationData { /// The nonce of the transaction. pub uniqueness: UniquenessData, - /// The hash of the transaction. + /// The hash of the transaction as received on the wire. pub tx_hash: TxHash, + /// The non-malleable hash used for replay protection. + /// + /// This is equal to [`Self::tx_hash`] for non-malleable transaction envelopes. For malleable + /// envelopes such as V1 rollup transactions, this instead hashes the witnessless bytes that + /// were actually signed. + pub non_malleable_hash: TxHash, + /// Credential identifier used to retrieve relevant rollup address. pub credential_id: CredentialId, @@ -97,6 +106,38 @@ pub struct AuthorizationData { /// provides information about which `Authenticator` was used to authenticate the transaction. pub credentials: Credentials, - /// The default address. + /// The authenticator-declared default address for this credential. + /// + /// When `address_override` is `None`, the admit-path uses this address as + /// the transaction sender, gated only by the permissive + /// `is_default_address_authorized` check (entry-or-`true`). The + /// authenticator is trusted to have verified the credential→address + /// binding before producing this value. + /// + /// May differ from `canonical(credential_id)`. The EVM authenticator, for + /// example, sets this to `S::Address::from_vm_address(ethereum_address)` + /// — which is the `MultiAddress::Vm` variant — while + /// `>::from(credential_id)` produces the + /// `MultiAddress::Standard` variant. Code that compares this address to + /// `canonical(credential_id)` (e.g. for off-chain inspection of + /// authorization state) MUST account for this divergence; an + /// authenticator-specific default address can be admitted by the chain + /// even though the canonical-fallback view in + /// `sov_accounts::Accounts::is_authorized_for` returns `false`. pub default_address: S::Address, + + /// Signer-declared override of the default execution address. + /// + /// - `None` => resolve to the credential's default address. Allowed unless an + /// explicit `false` entry exists for `(default_address, credential_id)` — + /// "allowed-unless-revoked". + /// - `Some(X)` => requires an explicit `(X, credential_id)` entry in + /// `account_owners`; the transaction is **skipped** otherwise. + /// + /// Footgun: `Some(default_address)` is NOT a no-op equivalent of `None`. The + /// `None` path uses the implicit allowed-unless-revoked fallback; `Some(_)` + /// requires an explicit allowlist entry. Passing the canonical default address + /// as `Some` will silently skip the transaction unless that exact pair has + /// been registered. Pass `None` for default-address semantics. + pub address_override: Option, } diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/chain_state.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/chain_state.rs index 5c822e8cdd..84f0d6c911 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/chain_state.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/chain_state.rs @@ -56,6 +56,19 @@ pub trait ChainState { state: &mut KernelStateAccessor<'_, Self::Spec>, ); + /// Updates the rollup's time oracle from the timestamp attached to a transaction by the + /// preferred sequencer. + /// + /// Invoked by the STF before dispatching every preferred-sequencer transaction that carries + /// a timestamp in its sequencing data. Implementations must be best-effort and deterministic: + /// invalid, overflowing, or regressing timestamps must be ignored rather than rejected, since + /// an error reverts the transaction. + fn update_oracle_time( + &mut self, + timestamp: crate::HDTimestamp, + state: &mut impl crate::TxState, + ) -> anyhow::Result<()>; + /// Returns the base fee per gas accessible at the current *visible* slot. /// /// ## Note @@ -80,16 +93,6 @@ pub trait ChainState { state: &mut Reader, ) -> bool; - /// Returns the slot gas limit accessible at the current *virtual* slot. - /// - /// Note that the gas limit is defined to change as of the slot immediately after the CHANGE_GAS_LIMIT_AFTER_HEIGHT rollup height. - /// so we need to know whether this is a stale height (i.e. another slot being executed at the same height so that we can determine whether to show the updated gas limit at the boundary height). - fn block_gas_limit( - &self, - current_rollup_height: RollupHeight, - is_stale_height: bool, - ) -> ::Gas; - /// Returns the visible root hash accessible at the requested rollup height /// /// ## Note @@ -121,6 +124,13 @@ pub trait ChainState { state: &mut Reader, ) -> Option; + /// Returns the global on-chain state schema version. + #[cfg(feature = "native")] + fn state_version>( + &self, + state: &mut Reader, + ) -> u64; + /// Returns the visible root hash accessible at the requested rollup height using the accessory state. /// /// ## Note diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/gas.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/gas.rs index d5b826202e..04060083bb 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/gas.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/gas.rs @@ -63,7 +63,7 @@ pub trait GasEnforcer { fn reward_prover( &mut self, prover_rewards: &ProverReward, - oprating_mode: OperatingMode, + operating_mode: OperatingMode, tx_scratchpad: &mut impl InfallibleStateAccessor, ); @@ -92,7 +92,7 @@ pub trait GasEnforcer { &mut self, funds_used: Amount, sequencer: &S::Address, - oprating_mode: OperatingMode, + operating_mode: OperatingMode, tx_scratchpad: &mut impl InfallibleStateAccessor, ) -> anyhow::Result<()>; diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/mod.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/mod.rs index 15e673fa51..2a777238c6 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/mod.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/mod.rs @@ -63,14 +63,10 @@ pub trait HasCapabilities { + SequencerAuthorization + TransactionAuthorizer + ProofProcessor - + SequencingDataHandler + SequencerRemuneration where Self: 'a; - /// The decoded sequencing metadata type used by this runtime's [`SequencingDataHandler`]. - type SequencingData: SequencingDataTrait; - /// Fetches the capabilities from the runtime. /// /// This method is only intended to be used internally on the [`HasCapabilities`] trait, if you @@ -121,15 +117,6 @@ pub trait HasCapabilities { self.capabilities().inner } - /// Returns the [`SequencingDataHandler`] implementation on [`HasCapabilities::Capabilities`]. - /// - /// This method can be overriden to provide a custom implementation. - fn sequencing_data_handler( - &mut self, - ) -> impl SequencingDataHandler { - self.capabilities().inner - } - /// Returns the [`TimelockCapability`] implementation for this runtime. /// /// Timelocks are opt-in, so the default implementation is a no-op. diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/sequencing_data.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/sequencing_data.rs index a9fe9a260a..a73f5bf0f9 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/sequencing_data.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/sequencing_data.rs @@ -1,79 +1,480 @@ +use std::marker::PhantomData; + use borsh::{BorshDeserialize, BorshSerialize}; use sov_rollup_interface::stf::FullyBakedTx; +use sov_rollup_interface::Bytes; + +use crate::{Context, HDTimestamp, SequencingData, Spec, TxState}; + +/// A serialized, key-addressable format for sequencer-provided transaction metadata. +/// +/// Implementations define how to read and prune their own serialized representation. The SDK +/// controls access tracking and only asks the format to retain the recorded keys. +/// +/// The format only governs the `data` payload of [`SequencingData`]; the accompanying timestamp +/// is SDK-managed and never pruned. +/// +/// # Determinism and idempotency requirements +/// +/// The preferred sequencer publishes the *pruned* bytes to the DA layer and later re-executes +/// them through the same execution-and-pruning path (e.g. when replaying soft-confirmed batches +/// after a restart). For this to be sound, implementations must guarantee that: +/// +/// * [`Self::get`] is deterministic: the same `bytes` and `key` always produce the same result. +/// * Reads do not depend on data that pruning may have removed. A transaction re-executed +/// against its pruned bytes must record the same set of keys as the original execution. +/// * Pruning re-serializes canonically, so that pruning already-pruned bytes with the same key +/// set is byte-for-byte identical to the input. +/// +/// Violating these requirements makes the sequencer's replayed transactions diverge from the +/// bytes it published to the DA layer. +pub trait SequencingDataFormat: Send + Sync + 'static { + /// Key type used to address entries inside the serialized sequencing data. + /// + /// Serialization must be infallible: accessed keys are recorded during native execution + /// and the SDK panics if serializing one fails. A fallible serializer cannot be allowed to + /// surface as an error, because recording only happens in native builds — the zk guest + /// would execute the same read successfully and diverge from the full node. + type Key: BorshDeserialize + BorshSerialize + Clone + Ord + Send + Sync + 'static; + + /// Value returned for an accessed key. + type Value; + + /// Reads the value at `key` from the serialized sequencing data. + /// + /// Absent keys must be reported as `Ok(None)`, and absence must be stable under pruning: + /// the SDK does not record reads that return `Ok(None)`, relying on a key absent from the + /// full payload also being absent from the pruned payload. + fn get(bytes: &[u8], key: &Self::Key) -> anyhow::Result>; -use crate::{capabilities::HasCapabilities, Context, HDTimestamp, Runtime, Spec, TxState}; + /// Prunes serialized sequencing data to the keys accessed during native execution. + /// + /// Returning `None` removes the data payload from the published transaction entirely. + /// The output must never be larger than the input and must satisfy the idempotency + /// requirements described on [the trait](Self). + #[cfg(feature = "native")] + fn prune_to_used_keys( + bytes: Bytes, + used_keys: &std::collections::BTreeSet, + ) -> anyhow::Result>; -/// Capability for handling sequencer-provided metadata attached to transactions. -pub trait SequencingDataHandler { - /// The decoded sequencing metadata type. - type SequencingData: SequencingDataTrait; + /// Creates the serialized sequencing data that the preferred sequencer attaches to each + /// accepted transaction. + /// + /// Returning `None` (the default) attaches no format-specific data; the SDK still attaches + /// a timestamp alongside it (see [`new_tx_sequencing_data`]). + /// + /// The output must fit within [`MAX_FULLY_BAKED_TX_SIZE`](sov_rollup_interface::stf::MAX_FULLY_BAKED_TX_SIZE) + /// together with the transaction it is attached to; a payload that alone exceeds that limit + /// is discarded by [`new_tx_sequencing_data`], since no node could ever deserialize the + /// resulting transaction. + #[cfg(feature = "native")] + fn create() -> Option { + None + } +} - /// Handle decoded sequencing metadata. +/// The trivial sequencing data format for runtimes that use no format-specific sequencing data. +/// +/// Transactions still carry the SDK-managed timestamp of [`SequencingData`]. +impl SequencingDataFormat for () { + type Key = (); + type Value = std::convert::Infallible; + + fn get(_bytes: &[u8], _key: &Self::Key) -> anyhow::Result> { + Ok(None) + } + + #[cfg(feature = "native")] + fn prune_to_used_keys( + _bytes: Bytes, + _used_keys: &std::collections::BTreeSet, + ) -> anyhow::Result> { + Ok(None) + } +} + +/// Runtime support for sequencer-provided transaction metadata. +/// +/// Runtimes without custom sequencing data only need to set the associated type: +/// +/// ```ignore +/// impl HasSequencingData for MyRuntime { +/// type SequencingData = (); +/// } +/// ``` +/// +/// The sequencer-attached timestamp is handled by the SDK independently of this trait: the STF +/// updates the chain-state time oracle from it before dispatching each preferred-sequencer +/// transaction. +pub trait HasSequencingData { + /// Serialized sequencing data format used by this runtime. + type SequencingData: SequencingDataFormat; + + /// Handles sequencing data before transaction dispatch. + /// + /// This hook is only invoked when the transaction carries sequencing data, which is only + /// ever the case for preferred-sequencer transactions: data attached by any other sequencer + /// is discarded before execution. Because unread + /// data is pruned from the published transaction, full nodes and provers replaying the + /// transaction may observe fewer keys than the sequencer's own execution did. To keep + /// execution deterministic between the sequencer and replaying nodes, implementations must + /// only let values obtained through recorded [`SequencingDataView::get`] reads influence + /// state, and must not condition state changes on the mere presence or absence of + /// sequencing data. Errors returned from this hook revert the transaction. fn handle_sequencing_data( &mut self, - _data: Self::SequencingData, + _data: &SequencingDataView<'_, Self::SequencingData>, _context: &Context, _state: &mut impl TxState, ) -> anyhow::Result<()> { Ok(()) } +} + +/// Builds the full serialized [`SequencingData`] (timestamp plus format-provided data) that +/// the preferred sequencer attaches to a transaction when accepting it. +/// +/// A data payload that alone exceeds +/// [`MAX_FULLY_BAKED_TX_SIZE`](sov_rollup_interface::stf::MAX_FULLY_BAKED_TX_SIZE) is discarded +/// with an error log: a transaction carrying it could never be published, since every node's +/// deserializer rejects it, and encoding an unbounded payload risks a panic. Execution then +/// proceeds without a data payload, which stays deterministic across all node types. +#[cfg(feature = "native")] +pub fn new_tx_sequencing_data>() -> Bytes { + let data = Rt::SequencingData::create().filter(|data| { + let fits = data.len() <= sov_rollup_interface::stf::MAX_FULLY_BAKED_TX_SIZE; + if !fits { + tracing::error!( + payload_len = data.len(), + max_len = sov_rollup_interface::stf::MAX_FULLY_BAKED_TX_SIZE, + "SequencingDataFormat::create() returned a payload exceeding the maximum \ + transaction size; attaching no data payload" + ); + } + fits + }); + SequencingData { + timestamp: Some(HDTimestamp::default_sequencing_data()), + data, + } + .encode() +} - /// Generates the sequencing data for a new transaction. +/// A typed view over serialized sequencing data. +/// +/// In native execution, reads automatically record the accessed key in the transaction scratchpad. +/// In guest execution, the same read API is available but access recording is a no-op. +#[derive(Debug)] +pub struct SequencingDataView<'a, F: SequencingDataFormat> { + data: Option<&'a Bytes>, #[cfg(feature = "native")] - fn create_sequencing_data(&self) -> Self::SequencingData; + scratchpad: crate::SequencingScratchpad, + _phantom: PhantomData, +} - /// Finalizes sequencing data after native transaction execution. - /// - /// WARNING: this allows the sequencer to modify concensus data. If used carelessly, this can - /// easily result in a broken rollup. Implementations must take great care that their usecase - /// is consensus-safe. - /// - /// There are two main pitfalls to avoid: - /// 1. Causing a modified execution outcome. If the final `sequencing_data` is mutated in such - /// a way that the execution output differs in *any* way (including subtle effects such as - /// gas usage), this result cause a faulty sequencer. - /// 2. Growing the data size. The initial data size is used for batch size limit checks. If - /// finalization here grows the data, this could result in an invalid batch, soft-locking - /// and penalizing the preferred sequencer. +impl<'a, F: SequencingDataFormat> SequencingDataView<'a, F> { + /// Creates a read-only sequencing data view. /// - /// A safe use for this would be if access logic uses the `scratchpad` to record - /// used entries in the SequencingData, and after execution the sequencer prunes any unused - /// entries to trim the transaction size. Since any entries accessed during execution remain - /// unchanged this would be guaranteed to not change execution outcomes, and shrinking the data - /// size is safe. + /// This constructor only exists in guest execution: every native view must record accessed + /// keys (see [`Self::with_scratchpad`]), since pruning removes any key that was not + /// recorded. + #[cfg(not(feature = "native"))] + pub fn new(data: Option<&'a Bytes>) -> Self { + Self { + data, + _phantom: PhantomData, + } + } + + /// Creates a native sequencing data view that records accessed keys. #[cfg(feature = "native")] - fn finalize_sequencing_data( - &mut self, - data: Self::SequencingData, - _scratchpad: Option, - ) -> Self::SequencingData { - data + pub fn with_scratchpad( + data: Option<&'a Bytes>, + scratchpad: crate::SequencingScratchpad, + ) -> Self { + Self { + data, + scratchpad, + _phantom: PhantomData, + } + } + + /// Reads the value at `key`, recording the access in native execution. + /// + /// Reads that observe nothing (`Ok(None)`) are not recorded: pruning can only remove + /// entries, so a key absent from the full payload is also absent from the pruned payload + /// and the replayed read observes `Ok(None)` again. Errors ARE recorded like successful + /// reads, so that a replayed read of e.g. a corrupt entry observes the same error instead + /// of `Ok(None)`. + pub fn get(&self, key: &F::Key) -> anyhow::Result> { + // Payload-less reads skip recording: pruning ignores the scratchpad when the envelope + // carries no data payload, so a record here could never be observed. + let Some(data) = self.data else { + return Ok(None); + }; + + let result = F::get(data, key); + + #[cfg(feature = "native")] + if !matches!(result, Ok(None)) { + // Panic rather than propagate: this write only exists in native builds, so a + // fallible key serializer surfacing as an error would revert the transaction on + // full nodes while the zk guest (which never records) executes it successfully — + // a consensus divergence. See the infallibility requirement on + // `SequencingDataFormat::Key`. + let mut encoded_key = Vec::new(); + borsh::BorshSerialize::serialize(key, &mut encoded_key) + .expect("sequencing data keys must serialize infallibly"); + self.scratchpad.insert(encoded_key); + } + + result } } -/// Trait for sequencing data that can be deserialized and serialized using borsh. -pub trait SequencingDataTrait: BorshDeserialize + BorshSerialize + Send + Sync + 'static { - /// Extract the timestamp from the sequencing data if it exists. - fn get_maybe_timestamp(self) -> Option; +/// Decodes used sequencing data keys from a native scratchpad. +#[cfg(feature = "native")] +pub fn decode_used_sequencing_keys( + scratchpad: crate::SequencingScratchpadContents, +) -> anyhow::Result> { + scratchpad + .into_iter() + .map(|key| { + F::Key::try_from_slice(&key).map_err(|error| { + anyhow::anyhow!( + "recorded sequencing data key {key:02x?} does not decode as the key of \ + `{}`: {error}. Was a `SequencingDataView` instantiated with a format \ + other than the runtime's `HasSequencingData::SequencingData`?", + std::any::type_name::() + ) + }) + }) + .collect() } -/// Extracts the timestamp from the sequencing data if it exists. +/// Applies SDK-controlled pruning to serialized sequencing data. +/// +/// The timestamp (if any) is always preserved; only the format-specific data payload is pruned +/// to the keys recorded during native execution. Returns `None` if neither a timestamp nor any +/// data remains. +#[cfg(feature = "native")] +pub fn prune_sequencing_data( + bytes: Bytes, + scratchpad: crate::SequencingScratchpadContents, +) -> anyhow::Result> { + let original_len = bytes.len(); + let envelope = SequencingData::decode(&bytes)?; + + let Some(data) = envelope.data else { + // There is no data payload to prune. The codec encodes every decodable value uniquely, + // so re-encoding would reproduce `bytes` exactly; keep them as-is (or drop an entirely + // empty envelope). + return Ok(envelope.timestamp.is_some().then_some(bytes)); + }; + + let used_keys = decode_used_sequencing_keys::(scratchpad)?; + let pruned = SequencingData { + timestamp: envelope.timestamp, + data: F::prune_to_used_keys(data, &used_keys)?, + }; + if pruned.is_empty() { + return Ok(None); + } + + let pruned = pruned.encode(); + anyhow::ensure!( + pruned.len() <= original_len, + "pruned sequencing data grew from {} to {} bytes", + original_len, + pruned.len() + ); + Ok(Some(pruned)) +} + +/// Extracts the sequencer-attached timestamp from a transaction's sequencing data, if any. /// /// In some cases (i.e. inside the sequencer), we expect that the sequencing data will be valid and want to report an error if it is not. /// In other cases, the tx may have come from an untrusted sequencer and we only want to log at the debug level. -pub fn get_maybe_timestamp_from_sequencing_data>( +pub fn get_timestamp_from_sequencing_data( baked_tx: &FullyBakedTx, log_deserialization_error: bool, ) -> Option { - baked_tx.sequencing_data.as_ref().and_then(|data| { - let Ok(data) = >::SequencingData::try_from_slice(data) else { + let data = baked_tx.sequencing_data.as_ref()?; + match SequencingData::decode(data) { + Ok(sequencing_data) => sequencing_data.timestamp, + Err(error) => { if log_deserialization_error { - tracing::error!("Failed to deserialize sequencing data"); + tracing::error!(%error, "Failed to deserialize sequencing data"); } else { - tracing::debug!("Failed to deserialize sequencing data"); + tracing::debug!(%error, "Failed to deserialize sequencing data"); } - return None; - }; - data.get_maybe_timestamp() - }) + None + } + } +} + +#[cfg(all(test, feature = "native"))] +mod tests { + use std::str::FromStr; + + use super::*; + + struct GrowingSequencingData; + + impl SequencingDataFormat for GrowingSequencingData { + type Key = (); + type Value = (); + + fn get(_bytes: &[u8], _key: &Self::Key) -> anyhow::Result> { + Ok(Some(())) + } + + fn prune_to_used_keys( + _bytes: Bytes, + _used_keys: &std::collections::BTreeSet, + ) -> anyhow::Result> { + Ok(Some(Bytes::from(vec![1, 2]))) + } + } + + struct DroppingSequencingData; + + impl SequencingDataFormat for DroppingSequencingData { + type Key = (); + type Value = (); + + fn get(_bytes: &[u8], _key: &Self::Key) -> anyhow::Result> { + Ok(Some(())) + } + + fn prune_to_used_keys( + _bytes: Bytes, + _used_keys: &std::collections::BTreeSet, + ) -> anyhow::Result> { + Ok(None) + } + } + + fn encoded_envelope(timestamp: Option, data: Option>) -> Bytes { + SequencingData { + timestamp, + data: data.map(Into::into), + } + .encode() + } + + #[test] + fn pruning_rejects_growth() { + let bytes = encoded_envelope(None, Some(vec![1])); + let original_len = bytes.len(); + let err = prune_sequencing_data::(bytes, Default::default()) + .expect_err("pruning must reject larger finalized sequencing data"); + assert!(err.to_string().contains(&format!( + "grew from {original_len} to {} bytes", + original_len + 1 + ))); + } + + #[test] + fn pruning_preserves_timestamp_when_data_is_dropped() { + let timestamp = HDTimestamp::from_str("17123456789012345678").unwrap(); + let bytes = encoded_envelope(Some(timestamp), Some(vec![1, 2, 3])); + + let pruned = prune_sequencing_data::(bytes, Default::default()) + .expect("pruning should succeed") + .expect("the timestamp must survive pruning"); + assert_eq!( + SequencingData::decode(&pruned).unwrap(), + SequencingData { + timestamp: Some(timestamp), + data: None + }, + "pruning must keep the timestamp and drop only the data payload" + ); + } + + #[test] + fn pruning_removes_empty_envelope() { + let bytes = encoded_envelope(None, Some(vec![1, 2, 3])); + + let pruned = prune_sequencing_data::(bytes, Default::default()) + .expect("pruning should succeed"); + assert_eq!( + pruned, None, + "sequencing data with no timestamp and fully pruned data must be removed" + ); + } + + struct KeyedSequencingData; + + impl SequencingDataFormat for KeyedSequencingData { + type Key = u16; + type Value = (); + + fn get(_bytes: &[u8], _key: &Self::Key) -> anyhow::Result> { + Ok(Some(())) + } + + fn prune_to_used_keys( + bytes: Bytes, + _used_keys: &std::collections::BTreeSet, + ) -> anyhow::Result> { + Ok(Some(bytes)) + } + } + + #[test] + fn view_records_accessed_key() { + let scratchpad = crate::SequencingScratchpad::default(); + let bytes = Bytes::from(vec![0x12, 0x34]); + let view = SequencingDataView::::with_scratchpad( + Some(&bytes), + scratchpad.clone(), + ); + + view.get(&0x1234).unwrap(); + + let used_keys = decode_used_sequencing_keys::(scratchpad.take()) + .expect("scratchpad should decode"); + assert!(used_keys.contains(&0x1234)); + } + + /// Format whose `get` observes no value for any key. + struct AbsentKeySequencingData; + + impl SequencingDataFormat for AbsentKeySequencingData { + type Key = u16; + type Value = (); + + fn get(_bytes: &[u8], _key: &Self::Key) -> anyhow::Result> { + Ok(None) + } + + fn prune_to_used_keys( + bytes: Bytes, + _used_keys: &std::collections::BTreeSet, + ) -> anyhow::Result> { + Ok(Some(bytes)) + } + } + + #[test] + fn view_does_not_record_absent_key() { + let scratchpad = crate::SequencingScratchpad::default(); + let bytes = Bytes::from(vec![0x12, 0x34]); + let view = SequencingDataView::::with_scratchpad( + Some(&bytes), + scratchpad.clone(), + ); + + assert_eq!(view.get(&0x1234).unwrap(), None); + + assert_eq!( + scratchpad.take(), + Default::default(), + "reads observing no value must not be recorded: an absent key stays absent after \ + pruning, so the record would only bloat the published data" + ); + } } diff --git a/crates/module-system/sov-modules-api/src/runtime/mod.rs b/crates/module-system/sov-modules-api/src/runtime/mod.rs index 2f7bd4c748..8e037fe4bb 100644 --- a/crates/module-system/sov-modules-api/src/runtime/mod.rs +++ b/crates/module-system/sov-modules-api/src/runtime/mod.rs @@ -5,7 +5,9 @@ pub mod capabilities; use std::io; use borsh::{BorshDeserialize, BorshSerialize}; -use capabilities::{HasCapabilities, HasKernel, TimelockPolicy, TransactionAuthenticator}; +use capabilities::{ + HasCapabilities, HasKernel, HasSequencingData, TimelockPolicy, TransactionAuthenticator, +}; use serde::{Deserialize, Serialize}; #[cfg(feature = "native")] use sov_rollup_interface::stf::GenesisParams; @@ -70,6 +72,7 @@ impl ModuleExecutionConfig for () { pub trait Runtime: DispatchCall + HasCapabilities + + HasSequencingData + HasKernel + Genesis + TxHooks @@ -188,6 +191,7 @@ pub fn decode_borsh_serialized_message( pub trait Runtime: DispatchCall + HasCapabilities + + HasSequencingData + HasKernel + Genesis + TxHooks @@ -254,13 +258,16 @@ pub fn get_runtime_schema anyhow::Result { let schema = sov_universal_wallet::schema::Schema::of_rollup_types_with_chain_data::< crate::transaction::Transaction, - crate::transaction::UnsignedTransaction, + crate::transaction::TransactionSigningPayload, R::Decodable, S::Address, >(sov_universal_wallet::schema::ChainData { chain_id: sov_modules_macros::config_value!("CHAIN_ID"), chain_name: sov_modules_macros::config_value!("CHAIN_NAME").to_string(), })?; + let overrides: &[ChainHashOverride] = + sov_modules_macros::config_value_private!("CHAIN_HASH_OVERRIDES"); + validate_chain_hash_fragments(overrides, schema.chain_hash()?)?; Ok(schema) } @@ -304,6 +311,58 @@ impl ChainHashOverride { } } +/// Rejects fragment collisions between distinct chain hashes that can be valid simultaneously. +pub(crate) fn validate_chain_hash_fragments( + overrides: &[ChainHashOverride], + default_hash: [u8; 32], +) -> anyhow::Result<()> { + for (index, first) in overrides.iter().enumerate() { + let first_valid_until = first.end_height.saturating_add(first.grace_period); + for second in &overrides[index + 1..] { + let second_valid_until = second.end_height.saturating_add(second.grace_period); + let validity_overlaps = + first.start_height < second_valid_until && second.start_height < first_valid_until; + + if validity_overlaps { + ensure_distinct_chain_hash_fragments(first.chain_hash, second.chain_hash)?; + } + } + } + + if let Some(last) = overrides.last() { + let default_start_height = last.end_height; + for hash_override in overrides { + let override_valid_until = hash_override + .end_height + .saturating_add(hash_override.grace_period); + if override_valid_until > default_start_height { + ensure_distinct_chain_hash_fragments(hash_override.chain_hash, default_hash)?; + } + } + } + + Ok(()) +} + +fn ensure_distinct_chain_hash_fragments( + first_hash: [u8; 32], + second_hash: [u8; 32], +) -> anyhow::Result<()> { + let first_fragment = crate::transaction::chain_hash_fragment(&first_hash); + if first_hash != second_hash + && first_fragment == crate::transaction::chain_hash_fragment(&second_hash) + { + anyhow::bail!( + "Distinct chain hashes that are valid simultaneously share transaction fragment \ + {first_fragment:#018x}. Adjust the grace period or slightly change the newer schema \ + or chain metadata so its chain hash has a different fragment. Colliding hashes: \ + {first_hash:02x?} and {second_hash:02x?}" + ); + } + + Ok(()) +} + /// Resolved chain hashes for a given height. /// /// Contains the primary hash and any additional hashes that are valid due to grace periods. diff --git a/crates/module-system/sov-modules-api/src/sequencing_metadata.rs b/crates/module-system/sov-modules-api/src/sequencing_metadata.rs index 9b33dcf855..0510bf524c 100644 --- a/crates/module-system/sov-modules-api/src/sequencing_metadata.rs +++ b/crates/module-system/sov-modules-api/src/sequencing_metadata.rs @@ -4,8 +4,7 @@ use std::{ }; use borsh::{BorshDeserialize, BorshSerialize}; - -use crate::capabilities::SequencingDataTrait; +use sov_rollup_interface::Bytes; /// High definition execution timestamp, in nanoseconds since the unix epoch. #[derive( @@ -23,11 +22,13 @@ use crate::capabilities::SequencingDataTrait; #[serde(transparent)] pub struct HDTimestamp(u128); -impl SequencingDataTrait for HDTimestamp { - fn get_maybe_timestamp(self) -> Option { - Some(self) - } -} +/// Name of the environment variable that overrides the timestamp returned by +/// [`HDTimestamp::default_sequencing_data`] in debug builds. +/// +/// Test support only. Exported so that writers (e.g. the `freeze_time` mechanism in +/// `sov-test-utils`) share this definition with the reader instead of duplicating the string. +#[cfg(feature = "native")] +pub const OVERRIDE_HD_TIMESTAMPS_ENV_VAR: &str = "SOV_TEST_OVERRIDE_HD_TIMESTAMPS"; impl HDTimestamp { /// Creates timestamp with current time @@ -43,6 +44,31 @@ impl HDTimestamp { pub const fn as_nanos(&self) -> u128 { self.0 } + + /// Creates the default timestamp used by the preferred sequencer when it attaches + /// [`SequencingData`] to an accepted transaction. + #[cfg(feature = "native")] + pub fn default_sequencing_data() -> Self { + if cfg!(debug_assertions) { + match std::env::var(OVERRIDE_HD_TIMESTAMPS_ENV_VAR) { + // An absent override is the normal case outside tests. + Err(std::env::VarError::NotPresent) => Self::now(), + Err(err) => { + panic!("{OVERRIDE_HD_TIMESTAMPS_ENV_VAR} is set but unreadable: {err}") + } + // An explicitly-set override that doesn't parse is a test-harness bug; falling + // back to the wall clock would silently un-freeze time and flake tests. + Ok(timestamp) => Self::from_str(×tamp).unwrap_or_else(|err| { + panic!( + "{OVERRIDE_HD_TIMESTAMPS_ENV_VAR} is set to {timestamp:?}, which does \ + not parse as a nanosecond timestamp: {err}" + ) + }), + } + } else { + Self::now() + } + } } impl std::fmt::Display for HDTimestamp { @@ -58,6 +84,99 @@ impl FromStr for HDTimestamp { } } +/// Sequencer-provided per-transaction sequencing data. +/// +/// This is the decoded form of the `sequencing_data` field of +/// [`FullyBakedTx`](sov_rollup_interface::stf::FullyBakedTx): serialized format-specific +/// sequencing data (interpreted through the runtime's +/// [`SequencingDataFormat`](crate::capabilities::SequencingDataFormat)), plus a timestamp +/// attached by the preferred sequencer. +/// +/// The timestamp is SDK-managed: the preferred sequencer attaches it on transaction acceptance +/// and it is always published as-is, while the `data` payload is pruned to the keys accessed +/// during execution. +/// +/// # Why a hand-rolled decoder instead of a borsh derive? +/// +/// The encoding is equivalent to borsh-serializing an `(Option, Option>)` tuple +/// (pinned by a test; [`Self::encode`] delegates to borsh through an equivalent borrowed +/// tuple). A derived deserializer is deliberately not used: borsh deserializes through an +/// `io::Read`-style interface that cannot borrow from the source buffer, so it would copy the +/// `data` payload (potentially large, e.g. an oracle snapshot) on every decode. [`Self::decode`] +/// instead takes the source as [`Bytes`] and reference-counts the payload out of it zero-copy. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SequencingData { + /// Timestamp attached by the preferred sequencer when it accepted the transaction. + pub timestamp: Option, + /// Serialized format-specific sequencing data, interpreted by the runtime's + /// [`SequencingDataFormat`](crate::capabilities::SequencingDataFormat). Private so that + /// in-execution reads are forced through the access-recording + /// [`Context::sequencing_data_view`](crate::Context::sequencing_data_view); see + /// [`Self::unrecorded_data`]. + pub(crate) data: Option, +} + +impl SequencingData { + /// Returns `true` if there is neither a timestamp nor any data. + pub fn is_empty(&self) -> bool { + self.timestamp.is_none() && self.data.is_none() + } + + /// Returns the format-specific data payload WITHOUT recording the access, for test + /// inspection of published transactions. + /// + /// Unrecorded reads are invisible to sequencing data pruning; in-execution reads must go + /// through [`Context::sequencing_data_view`](crate::Context::sequencing_data_view) instead. + #[cfg(feature = "test-utils")] + pub fn unrecorded_data(&self) -> Option<&Bytes> { + self.data.as_ref() + } + + /// Serializes the sequencing data. + /// + /// The payload is copied exactly once, into an exactly-sized buffer. A zero-copy encode is + /// not possible: the output must be a single contiguous buffer, since it is embedded in the + /// transaction wire format. + pub fn encode(&self) -> Bytes { + let timestamp_len = 1 + self.timestamp.map_or(0, |_| core::mem::size_of::()); + let data_len = 1 + self + .data + .as_ref() + .map_or(0, |data| core::mem::size_of::() + data.len()); + let mut out = Vec::with_capacity(timestamp_len + data_len); + BorshSerialize::serialize(&(&self.timestamp, self.data.as_deref()), &mut out) + .expect("sequencing data larger than u32::MAX bytes cannot be encoded"); + out.into() + } + + /// Deserializes sequencing data, borrowing the `data` payload from `bytes` without copying. + pub fn decode(bytes: &Bytes) -> anyhow::Result { + let mut cursor: &[u8] = bytes; + let timestamp = as BorshDeserialize>::deserialize(&mut cursor)?; + let data = match u8::deserialize(&mut cursor)? { + 0 => { + anyhow::ensure!( + cursor.is_empty(), + "sequencing data contains {} trailing bytes", + cursor.len() + ); + None + } + 1 => { + let len = u32::deserialize(&mut cursor)? as usize; + anyhow::ensure!( + cursor.len() == len, + "sequencing data declares {len} data bytes but {} remain", + cursor.len() + ); + Some(bytes.slice_ref(cursor)) + } + tag => anyhow::bail!("invalid option tag {tag} in sequencing data"), + }; + Ok(Self { timestamp, data }) + } +} + #[test] fn test_hd_timestamp_display_roundtrip() { let nanos = 17123456789012345678; @@ -67,3 +186,45 @@ fn test_hd_timestamp_display_roundtrip() { let timestamp = HDTimestamp::from_str(×tamp).unwrap(); assert_eq!(timestamp.as_nanos(), nanos); } + +#[test] +fn sequencing_data_encode_decode_roundtrip() { + let sequencing_data = SequencingData { + timestamp: Some(HDTimestamp(17123456789012345678)), + data: Some(Bytes::from(vec![0x12, 0x34, 0x56, 0x78])), + }; + + let decoded = SequencingData::decode(&sequencing_data.encode()).unwrap(); + assert_eq!(decoded, sequencing_data); +} + +#[test] +fn sequencing_data_encoding_matches_borsh() { + let sequencing_data = SequencingData { + timestamp: Some(HDTimestamp(17123456789012345678)), + data: Some(Bytes::from(vec![0x12, 0x34, 0x56, 0x78])), + }; + + let equivalent_tuple = ( + Some(17123456789012345678u128), + Some(vec![0x12u8, 0x34, 0x56, 0x78]), + ); + assert_eq!( + sequencing_data.encode(), + borsh::to_vec(&equivalent_tuple).unwrap(), + "SequencingData must stay wire-compatible with borsh (Option, Option>)" + ); +} + +#[test] +fn sequencing_data_decode_rejects_trailing_bytes() { + let mut encoded = SequencingData { + timestamp: Some(HDTimestamp(17123456789012345678)), + data: None, + } + .encode() + .to_vec(); + encoded.push(0xFF); + + SequencingData::decode(&Bytes::from(encoded)).expect_err("decoding must reject trailing bytes"); +} diff --git a/crates/module-system/sov-modules-api/src/state/traits.rs b/crates/module-system/sov-modules-api/src/state/traits.rs index 16ace50406..8387bda9fe 100644 --- a/crates/module-system/sov-modules-api/src/state/traits.rs +++ b/crates/module-system/sov-modules-api/src/state/traits.rs @@ -354,10 +354,12 @@ macro_rules! blanket_impl_metered_state_reader { storage_value .map(|storage_value| { - // We need to charge for the cost to deserialize the value - maybe_trace_span!("all_accesses::charge_per_byte_borsh_deserialization", { + // We need to charge for the cost to deserialize the value. The state codec + // is opaque to the meter (could be borsh or any other StateItemCodec impl), + // so we use the upfront-byte charge rather than routing through MeteredReader. + maybe_trace_span!("all_accesses::charge_per_byte_upfront_decode", { self.charge_linear_gas( - ::gas_to_charge_per_byte_borsh_deserialization(), + ::gas_to_charge_per_byte_borsh_read(), storage_value.size(), ) .map_err(|e| StateAccessorError::Decode { diff --git a/crates/module-system/sov-modules-api/src/tests.rs b/crates/module-system/sov-modules-api/src/tests.rs index 75b7527516..f16fcf5a4b 100644 --- a/crates/module-system/sov-modules-api/src/tests.rs +++ b/crates/module-system/sov-modules-api/src/tests.rs @@ -5,7 +5,6 @@ use sov_rollup_interface::execution_mode::Native; use sov_rollup_interface::zk::CryptoSpec; use sov_test_utils::MockDaSpec; -use crate::capabilities::config_chain_id; use crate::{ModuleId, ModuleInfo, Spec}; type TestSpec = crate::default_spec::DefaultSpec; @@ -194,17 +193,24 @@ fn test_default_signature_roundtrip() { // Grep for `SOV_TEST_CONST_OVERRIDE_CHAIN_ID` to find the relevant code. #[test] fn assert_chain_id_was_not_overridden() { - assert_eq!(config_chain_id(), 4321); + assert_eq!(sov_modules_macros::config_value_private!("CHAIN_ID"), 4321); } mod chain_hash_override_tests { - use crate::runtime::{resolve_chain_hashes, ChainHashOverride}; + use crate::runtime::{resolve_chain_hashes, validate_chain_hash_fragments, ChainHashOverride}; const DEFAULT_HASH: [u8; 32] = [0xDDu8; 32]; const OVERRIDE_HASH_1: [u8; 32] = [0x11u8; 32]; const OVERRIDE_HASH_2: [u8; 32] = [0x22u8; 32]; const OVERRIDE_HASH_3: [u8; 32] = [0x33u8; 32]; + fn colliding_hashes() -> ([u8; 32], [u8; 32]) { + let first = [0xAA; 32]; + let mut second = first; + second[31] ^= 1; + (first, second) + } + #[test] fn test_empty_overrides_returns_default() { let overrides: &[ChainHashOverride] = &[]; @@ -438,6 +444,97 @@ mod chain_hash_override_tests { assert!(!override_.in_grace_period(250)); } + #[test] + fn allows_colliding_fragments_when_validity_does_not_overlap() { + let (first_hash, second_hash) = colliding_hashes(); + let overrides = [ + ChainHashOverride { + start_height: 0, + end_height: 100, + chain_hash: first_hash, + grace_period: 0, + }, + ChainHashOverride { + start_height: 100, + end_height: 200, + chain_hash: second_hash, + grace_period: 0, + }, + ]; + + validate_chain_hash_fragments(&overrides, DEFAULT_HASH).unwrap(); + } + + #[test] + fn rejects_colliding_fragments_across_long_grace_period() { + let (first_hash, third_hash) = colliding_hashes(); + let overrides = [ + ChainHashOverride { + start_height: 0, + end_height: 100, + chain_hash: first_hash, + grace_period: 250, + }, + ChainHashOverride { + start_height: 100, + end_height: 200, + chain_hash: OVERRIDE_HASH_2, + grace_period: 0, + }, + ChainHashOverride { + start_height: 200, + end_height: 300, + chain_hash: third_hash, + grace_period: 0, + }, + ]; + + let error = validate_chain_hash_fragments(&overrides, DEFAULT_HASH).unwrap_err(); + assert!(error.to_string().contains("valid simultaneously")); + } + + #[test] + fn rejects_colliding_fragment_with_default_after_last_override() { + let (override_hash, default_hash) = colliding_hashes(); + let overrides = [ + ChainHashOverride { + start_height: 0, + end_height: 100, + chain_hash: override_hash, + grace_period: 250, + }, + ChainHashOverride { + start_height: 100, + end_height: 200, + chain_hash: OVERRIDE_HASH_2, + grace_period: 0, + }, + ]; + + let error = validate_chain_hash_fragments(&overrides, default_hash).unwrap_err(); + assert!(error.to_string().contains("valid simultaneously")); + } + + #[test] + fn allows_identical_hashes_during_overlapping_validity() { + let overrides = [ + ChainHashOverride { + start_height: 0, + end_height: 100, + chain_hash: OVERRIDE_HASH_1, + grace_period: 50, + }, + ChainHashOverride { + start_height: 100, + end_height: 200, + chain_hash: OVERRIDE_HASH_1, + grace_period: 0, + }, + ]; + + validate_chain_hash_fragments(&overrides, DEFAULT_HASH).unwrap(); + } + // Note: Validation that overrides start at 0 and are contiguous happens at // compile time in the config_value! macro. Invalid configurations will fail // to compile rather than panic at runtime. diff --git a/crates/module-system/sov-modules-api/src/transaction/data.rs b/crates/module-system/sov-modules-api/src/transaction/data.rs index 665d4709a1..4e2976354e 100644 --- a/crates/module-system/sov-modules-api/src/transaction/data.rs +++ b/crates/module-system/sov-modules-api/src/transaction/data.rs @@ -3,11 +3,52 @@ use std::rc::Rc; use borsh::{BorshDeserialize, BorshSerialize}; use derive_more::{From, Into}; +use serde::de; use serde::{Deserialize, Serialize}; +pub use sov_universal_wallet::schema::chain_hash_fragment; use sov_universal_wallet::UniversalWallet; use crate::{Amount, BasicGasMeter, Gas, GasArray, Spec}; +struct U64DecimalStringVisitor; + +impl<'de> de::Visitor<'de> for U64DecimalStringVisitor { + type Value = u64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a decimal string representing a u64") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + value.parse().map_err(de::Error::custom) + } +} + +fn deserialize_u64_from_decimal_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + if deserializer.is_human_readable() { + deserializer.deserialize_str(U64DecimalStringVisitor) + } else { + ::deserialize(deserializer) + } +} + +fn serialize_u64_as_decimal_string(value: &u64, serializer: S) -> Result +where + S: serde::Serializer, +{ + if serializer.is_human_readable() { + serializer.collect_str(value) + } else { + serializer.serialize_u64(*value) + } +} + /// A type wrapper around a u64 which represents the priority fee. /// Since the priority fee is expressed as a basis point, we should use this wrapper for /// improved type safety. @@ -108,8 +149,25 @@ pub struct TxDetails { /// Then up to `gas_limit *_scalar gas_price` gas tokens can be spent on gas execution in the transaction execution - if the /// transaction spends more than that amount, it will run out of gas and be reverted. pub gas_limit: Option, - /// The ID of the target chain. - pub chain_id: u64, + /// The 64-bit fragment of the target chain hash. + #[serde( + serialize_with = "serialize_u64_as_decimal_string", + deserialize_with = "deserialize_u64_from_decimal_string" + )] + #[sov_wallet(hidden)] + pub chain_hash_fragment: u64, +} + +impl TxDetails { + pub(crate) fn ensure_matches_chain_hash(&self, chain_hash: &[u8; 32]) -> anyhow::Result<()> { + let chain_hash_fragment = chain_hash_fragment(chain_hash); + anyhow::ensure!( + self.chain_hash_fragment == chain_hash_fragment, + "Chain hash fragment mismatch: transaction details contain {}, but the supplied chain hash has fragment {chain_hash_fragment}", + self.chain_hash_fragment, + ); + Ok(()) + } } /// Holds the original credentials to authenticate the transaction. @@ -168,6 +226,38 @@ impl AuthenticatedTransactionData { #[cfg(test)] mod tests { use super::*; + + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] + struct Fragment { + #[serde( + serialize_with = "serialize_u64_as_decimal_string", + deserialize_with = "deserialize_u64_from_decimal_string" + )] + value: u64, + } + + #[test] + fn chain_hash_fragment_serde_uses_decimal_string_for_json() { + let fragment = Fragment { value: u64::MAX }; + + let json = serde_json::to_string(&fragment).unwrap(); + assert_eq!(json, r#"{"value":"18446744073709551615"}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), fragment); + + let numeric_json = r#"{"value":18446744073709551615}"#; + assert!(serde_json::from_str::(numeric_json).is_err()); + } + + #[test] + fn chain_hash_fragment_serde_remains_binary_compatible() { + let value = 0x0102_0304_0506_0708; + let fragment = Fragment { value }; + + let bytes = bincode::serialize(&fragment).unwrap(); + assert_eq!(bytes.as_slice(), &value.to_le_bytes()); + assert_eq!(bincode::deserialize::(&bytes).unwrap(), fragment); + } + #[test] fn test_priority_fee_apply_basic() { let fee = PriorityFeeBips::from_percentage(100); diff --git a/crates/module-system/sov-modules-api/src/transaction/mod.rs b/crates/module-system/sov-modules-api/src/transaction/mod.rs index 0b5fee733b..2aea880c15 100644 --- a/crates/module-system/sov-modules-api/src/transaction/mod.rs +++ b/crates/module-system/sov-modules-api/src/transaction/mod.rs @@ -1,14 +1,19 @@ mod data; mod rewards; use std::fmt::Debug; -use std::io; +use crate::capabilities::UniquenessData; use crate::Multisig; use borsh::{BorshDeserialize, BorshSerialize}; -pub use data::{AuthenticatedTransactionData, Credentials, PriorityFeeBips, TxDetails}; +pub use data::{ + chain_hash_fragment, AuthenticatedTransactionData, Credentials, PriorityFeeBips, TxDetails, +}; use derivative::Derivative; pub(crate) use rewards::transaction_consumption_helper; pub use rewards::{ProverReward, RemainingFunds, SequencerReward, TransactionConsumption}; +pub use signing_payload::{ + TransactionSigningPayload, TransactionSigningPayloadV0, TransactionSigningPayloadV1, +}; #[cfg(feature = "native")] pub use sov_rollup_interface::crypto::PrivateKey; use sov_rollup_interface::crypto::{SigVerificationError, Signature}; @@ -23,12 +28,12 @@ pub use types::{ }; pub use unsigned::UnsignedTransaction; -use crate::capabilities::UniquenessData; use crate::{ - CryptoSpecExt, DispatchCall, Gas, GasMeter, GasMeteringError, GasSpec, MeteredBorshDeserialize, - MeteredBorshDeserializeError, MeteredSigVerificationError, MeteredSignature, Spec, + CryptoSpecExt, DispatchCall, Gas, GasMeter, GasMeteringError, MeteredSigVerificationError, + MeteredSignature, Spec, }; +mod signing_payload; #[cfg(test)] mod tests; mod types; @@ -75,7 +80,7 @@ pub enum Transaction, + Version0, ), /// A V1 (multisig) transaction. V1( @@ -83,7 +88,7 @@ pub enum Transaction, + Version1, ), } @@ -98,18 +103,17 @@ impl Transaction { ::Hasher::digest(&data).into() } - /// Creates a new signed transaction using the provided private key. + /// Creates a new signed V0 transaction using the provided private key. pub fn new_signed_tx( priv_key: &C::PrivateKey, chain_hash: &[u8; 32], - unsigned_tx: UnsignedTransaction, + mut unsigned_tx: UnsignedTransaction, ) -> Self { - let mut utx_bytes: Vec = Vec::new(); - BorshSerialize::serialize(&unsigned_tx, &mut utx_bytes).unwrap(); - utx_bytes.extend_from_slice(chain_hash); + unsigned_tx.details.chain_hash_fragment = chain_hash_fragment(chain_hash); + let signing_bytes = unsigned_tx.to_signing_bytes_v0(*chain_hash); let pub_key = priv_key.pub_key(); - let signature = priv_key.sign(&utx_bytes); + let signature = priv_key.sign(&signing_bytes); unsigned_tx.to_signed_tx(pub_key, signature) } @@ -120,52 +124,6 @@ impl Transaction { pub fn tx_bytes(&self) -> Vec { borsh::to_vec(self).expect("Serialization should be never fail") } - - fn unmetered_deserialize_inner(buf: &mut &[u8]) -> Result { - let this = as borsh::BorshDeserialize>::deserialize(buf)?; - tracing::trace!(transaction = ?this, "Deserialized transaction"); - Ok(this) - } -} - -/// Charge gas for deserializing a transaction. -pub fn charge_tx_deserialization( - meter: &mut impl GasMeter, - len: usize, -) -> Result<(), MeteredBorshDeserializeError<::Gas>> { - crate::charge_gas_to_deserialize( - S::tx_bias_borsh_deserialization(), - S::tx_gas_to_charge_per_byte_borsh_deserialization(), - len, - meter, - ) -} - -impl MeteredBorshDeserialize - for Transaction -{ - #[cfg_attr(feature = "bench", crate::cycle_tracker)] - #[cfg_attr( - all(feature = "gas-constant-estimation", feature = "native"), - crate::track_gas_constants_usage - )] - fn deserialize( - buf: &mut &[u8], - meter: &mut impl GasMeter, - ) -> Result::Gas>> { - charge_tx_deserialization(meter, buf.len())?; - - Transaction::::unmetered_deserialize_inner(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } - - #[cfg(feature = "native")] - fn unmetered_deserialize( - buf: &mut &[u8], - ) -> Result::Gas>> { - Transaction::::unmetered_deserialize_inner(buf) - .map_err(MeteredBorshDeserializeError::IOError) - } } /// Errors that can be raised by the [`Transaction::verify`] method. @@ -212,11 +170,11 @@ impl Transaction { } } - /// Returns the chain id. - pub fn chain_id(&self) -> u64 { + /// Returns the chain hash fragment. + pub fn chain_hash_fragment(&self) -> u64 { match &self { - Transaction::V0(inner) => inner.details.chain_id, - Transaction::V1(inner) => inner.details.chain_id, + Transaction::V0(inner) => inner.details.chain_hash_fragment, + Transaction::V1(inner) => inner.details.chain_hash_fragment, } } @@ -227,6 +185,7 @@ impl Transaction { signature: C::Signature, uniqueness: UniquenessData, details: TxDetails, + address_override: Option, ) -> Self { Self::V0(Version0 { signature, @@ -234,20 +193,17 @@ impl Transaction { runtime_call, uniqueness, details, + address_override, }) } - /// Serialize the transaction, appending the runtime's chain_hash. + /// Serializes the transaction signing payload. /// This is the standard serialization for Sovereign signature signing. - pub fn serialized_with_chain_hash( - &self, - chain_hash: &[u8; 32], - ) -> Result, TransactionVerificationError> { - let mut serialized_tx = borsh::to_vec(&self.to_unsigned_transaction()).map_err(|e| { - TransactionVerificationError::TransactionDeserializationError(e.to_string()) - })?; - serialized_tx.extend_from_slice(chain_hash); - Ok(serialized_tx) + pub fn to_signing_bytes(&self, chain_hash: &[u8; 32]) -> Vec { + match &self { + Transaction::V0(inner) => inner.to_signing_bytes(chain_hash), + Transaction::V1(inner) => inner.to_signing_bytes(chain_hash), + } } /// Charge gas for verifying the transaction signature against the given message. @@ -301,19 +257,11 @@ impl Transaction { Ok(()) } - /// Converts the transaction to an unsigned transaction. + /// Converts the transaction to an unsigned transaction payload. pub fn to_unsigned_transaction(&self) -> UnsignedTransaction { match &self { - Transaction::V0(inner) => UnsignedTransaction::new_with_details( - inner.runtime_call.clone(), - inner.uniqueness, - inner.details.clone(), - ), - Transaction::V1(inner) => UnsignedTransaction::new_with_details( - inner.runtime_call.clone(), - inner.uniqueness, - inner.details.clone(), - ), + Transaction::V0(inner) => inner.to_unsigned_transaction(), + Transaction::V1(inner) => inner.to_unsigned_transaction(), } } } diff --git a/crates/module-system/sov-modules-api/src/transaction/signing_payload/mod.rs b/crates/module-system/sov-modules-api/src/transaction/signing_payload/mod.rs new file mode 100644 index 0000000000..a921405246 --- /dev/null +++ b/crates/module-system/sov-modules-api/src/transaction/signing_payload/mod.rs @@ -0,0 +1,114 @@ +mod v0; +mod v1; + +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; +use sov_universal_wallet::UniversalWallet; + +pub use v0::TransactionSigningPayloadV0; +pub use v1::TransactionSigningPayloadV1; + +use crate::{ + capabilities::UniquenessData, + transaction::{TransactionCallable, TxDetails}, + Spec, +}; + +/// Versioned transaction signing payload. The borsh enum discriminant serves as the version byte +/// in signed bytes: `V0` = `0x00`, `V1` = `0x01`. +/// +/// This is the type serialized to produce the bytes that +/// are signed and verified. The enum discriminant ensures V0 and V1 signed bytes are +/// distinct, preventing cross-version replay attacks. +#[derive( + derive_more::Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, UniversalWallet, +)] +#[serde(bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned")] +pub enum TransactionSigningPayload { + /// V0 (single-sig) signing payload. + V0( + #[borsh(bound( + serialize = "R::Call: BorshSerialize", + deserialize = "R::Call: BorshDeserialize", + ))] + TransactionSigningPayloadV0, + ), + /// V1 (multisig) signing payload, includes credential commitment. + V1( + #[borsh(bound( + serialize = "R::Call: BorshSerialize", + deserialize = "R::Call: BorshDeserialize", + ))] + TransactionSigningPayloadV1, + ), +} + +impl Clone for TransactionSigningPayload { + fn clone(&self) -> Self { + match self { + Self::V0(v0) => Self::V0(v0.clone()), + Self::V1(v1) => Self::V1(v1.clone()), + } + } +} + +impl PartialEq for TransactionSigningPayload { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::V0(a), Self::V0(b)) => a == b, + (Self::V1(a), Self::V1(b)) => a == b, + _ => false, + } + } +} + +impl Eq for TransactionSigningPayload {} + +// Getters on the enum for version-agnostic field access. +impl TransactionSigningPayload { + /// Serializes the signing payload, producing the bytes used for signing and + /// signature verification. + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("Serialization to vec is infallible") + } + + /// Returns a reference to the runtime call. + pub fn runtime_call(&self) -> &R::Call { + match self { + Self::V0(v0) => &v0.runtime_call, + Self::V1(v1) => &v1.runtime_call, + } + } + + /// Returns the uniqueness data. + pub fn uniqueness(&self) -> UniquenessData { + match self { + Self::V0(v0) => v0.uniqueness, + Self::V1(v1) => v1.uniqueness, + } + } + + /// Returns a reference to the transaction details. + pub fn details(&self) -> &TxDetails { + match self { + Self::V0(v0) => &v0.details, + Self::V1(v1) => &v1.details, + } + } + + /// Returns the signer-declared address override, if any. + pub fn address_override(&self) -> Option { + match self { + Self::V0(v0) => v0.address_override, + Self::V1(v1) => v1.address_override, + } + } + + /// Returns the chain hash committed to by this signing payload. + pub fn chain_hash(&self) -> &[u8; 32] { + match self { + Self::V0(v0) => &v0.chain_hash, + Self::V1(v1) => &v1.chain_hash, + } + } +} diff --git a/crates/module-system/sov-modules-api/src/transaction/signing_payload/v0.rs b/crates/module-system/sov-modules-api/src/transaction/signing_payload/v0.rs new file mode 100644 index 0000000000..067c5f5aab --- /dev/null +++ b/crates/module-system/sov-modules-api/src/transaction/signing_payload/v0.rs @@ -0,0 +1,59 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; +use sov_universal_wallet::UniversalWallet; + +use crate::{ + capabilities::UniquenessData, + transaction::{TransactionCallable, TxDetails}, + Spec, +}; + +/// V0 transaction signing payload. This is the Borsh payload signed by single-sig +/// transactions. +#[derive( + derive_more::Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, UniversalWallet, +)] +#[serde(bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned")] +pub struct TransactionSigningPayloadV0 { + /// The runtime call + #[borsh(bound( + serialize = "R::Call: BorshSerialize", + deserialize = "R::Call: BorshDeserialize", + ))] + pub(crate) runtime_call: R::Call, + /// The uniqueness identifier + pub(crate) uniqueness: UniquenessData, + /// Data related to fees and gas handling. + pub(crate) details: TxDetails, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub(crate) address_override: Option, + /// The chain hash binding this signature to the rollup schema and metadata. + #[sov_wallet(hidden)] + pub(crate) chain_hash: [u8; 32], +} + +impl Clone for TransactionSigningPayloadV0 { + fn clone(&self) -> Self { + Self { + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + address_override: self.address_override, + chain_hash: self.chain_hash, + } + } +} + +impl PartialEq for TransactionSigningPayloadV0 { + fn eq(&self, other: &Self) -> bool { + self.chain_hash == other.chain_hash + && self.uniqueness == other.uniqueness + && self.address_override == other.address_override + && self.details == other.details + && self.runtime_call == other.runtime_call + } +} + +impl Eq for TransactionSigningPayloadV0 {} diff --git a/crates/module-system/sov-modules-api/src/transaction/signing_payload/v1.rs b/crates/module-system/sov-modules-api/src/transaction/signing_payload/v1.rs new file mode 100644 index 0000000000..ff8ed95ef2 --- /dev/null +++ b/crates/module-system/sov-modules-api/src/transaction/signing_payload/v1.rs @@ -0,0 +1,67 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; +use sov_universal_wallet::UniversalWallet; + +use crate::{ + capabilities::UniquenessData, + transaction::{TransactionCallable, TxDetails}, + Spec, +}; + +/// V1 transaction signing payload. Constructed internally from a `Version1` transaction's +/// multisig parameters. +#[derive( + derive_more::Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, UniversalWallet, +)] +#[serde(bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned")] +pub struct TransactionSigningPayloadV1 { + /// The runtime call + #[borsh(bound( + serialize = "R::Call: BorshSerialize", + deserialize = "R::Call: BorshDeserialize", + ))] + pub(crate) runtime_call: R::Call, + /// The uniqueness identifier + pub(crate) uniqueness: UniquenessData, + /// Data related to fees and gas handling. + pub(crate) details: TxDetails, + /// The multisig credential address, derived as + /// `hash(min_signers || sorted(pub_keys))` in the rollup's native address format. + /// Included in the signed bytes so that signers commit to the multisig configuration + /// and prevent credential malleability from reusing signed bytes in a different + /// multisig envelope. + pub(crate) credential_address: S::Address, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub(crate) address_override: Option, + /// The chain hash binding this signature to the rollup schema and metadata. + #[sov_wallet(hidden)] + pub(crate) chain_hash: [u8; 32], +} + +impl Clone for TransactionSigningPayloadV1 { + fn clone(&self) -> Self { + Self { + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + credential_address: self.credential_address, + address_override: self.address_override, + chain_hash: self.chain_hash, + } + } +} + +impl PartialEq for TransactionSigningPayloadV1 { + fn eq(&self, other: &Self) -> bool { + self.chain_hash == other.chain_hash + && self.uniqueness == other.uniqueness + && self.credential_address == other.credential_address + && self.address_override == other.address_override + && self.details == other.details + && self.runtime_call == other.runtime_call + } +} + +impl Eq for TransactionSigningPayloadV1 {} diff --git a/crates/module-system/sov-modules-api/src/transaction/types/v0.rs b/crates/module-system/sov-modules-api/src/transaction/types/v0.rs index 59a8e38c7a..307c7575ad 100644 --- a/crates/module-system/sov-modules-api/src/transaction/types/v0.rs +++ b/crates/module-system/sov-modules-api/src/transaction/types/v0.rs @@ -3,7 +3,9 @@ use derivative::Derivative; use sov_universal_wallet::UniversalWallet; use crate::capabilities::{AuthenticationError, AuthorizationData, UniquenessData}; -use crate::transaction::{hex_field_format, Credentials, Transaction, TransactionCallable}; +use crate::transaction::{ + hex_field_format, Credentials, Transaction, TransactionCallable, UnsignedTransaction, +}; use crate::{metered_credential, CryptoSpecExt, GasMeter, Spec, TxHash}; #[derive( @@ -17,12 +19,12 @@ use crate::{metered_credential, CryptoSpecExt, GasMeter, Spec, TxHash}; UniversalWallet, )] #[derivative( - PartialEq(bound = "Call: PartialEq + Eq"), - Eq(bound = "Call: PartialEq + Eq") + PartialEq(bound = "R::Call: PartialEq + Eq"), + Eq(bound = "R::Call: PartialEq + Eq") )] -#[serde(bound = "Call: serde::Serialize + serde::de::DeserializeOwned")] +#[serde(bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned")] /// V0 transaction. -pub struct Version0::CryptoSpec> { +pub struct Version0::CryptoSpec> { /// The signature of the transaction. #[serde(with = "hex_field_format")] #[sov_wallet(display = "hex")] @@ -32,19 +34,40 @@ pub struct Version0::CryptoSpec> { #[sov_wallet(display = "hex")] pub pub_key: C::PublicKey, /// The runtime call of the transaction. - #[sov_wallet(bound = "Call: sov_universal_wallet::schema::UniversalWallet")] - pub runtime_call: Call, + #[sov_wallet(bound = "R::Call: sov_universal_wallet::schema::UniversalWallet")] + pub runtime_call: R::Call, /// Uniqueness identifier of this transaction. see [`UniquenessData`] for more details. pub uniqueness: UniquenessData, - /// The transaction metadata. Contains gas parameters and the chain ID. + /// The transaction metadata. Contains gas parameters and the chain hash fragment. pub details: TxDetails, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub address_override: Option, } -impl Version0 { +impl Version0 { + /// Extracts the unsigned transaction payload from this signed envelope. + pub fn to_unsigned_transaction(&self) -> UnsignedTransaction { + UnsignedTransaction::new_with_details( + self.runtime_call.clone(), + self.uniqueness, + self.details.clone(), + self.address_override, + ) + } + + /// Serializes the V0 transaction signing payload for this signed envelope. + pub fn to_signing_bytes(&self, chain_hash: &[u8; 32]) -> Vec { + self.to_unsigned_transaction() + .to_signing_bytes_v0(*chain_hash) + } + /// Extracts authorization data from this transaction. pub fn auth_data>( &self, raw_tx_hash: TxHash, + non_malleable_hash: TxHash, meter: &mut M, ) -> Result, AuthenticationError> { let pub_key = self.pub_key.clone(); @@ -54,15 +77,17 @@ impl Version0 { Ok(AuthorizationData { uniqueness: self.uniqueness, tx_hash: raw_tx_hash, + non_malleable_hash, credential_id, credentials: Credentials::new(pub_key), default_address: credential_id.into(), + address_override: self.address_override, }) } } -impl From> for Transaction { - fn from(value: Version0) -> Self { +impl From> for Transaction { + fn from(value: Version0) -> Self { Transaction::V0(value) } } diff --git a/crates/module-system/sov-modules-api/src/transaction/types/v1.rs b/crates/module-system/sov-modules-api/src/transaction/types/v1.rs index 2005dffb3f..81ffbf43f9 100644 --- a/crates/module-system/sov-modules-api/src/transaction/types/v1.rs +++ b/crates/module-system/sov-modules-api/src/transaction/types/v1.rs @@ -1,5 +1,7 @@ use crate::capabilities::{AuthenticationError, AuthorizationData, UniquenessData}; -use crate::transaction::{Credentials, Transaction, TransactionCallable, TxDetails}; +use crate::transaction::{ + Credentials, Transaction, TransactionCallable, TxDetails, UnsignedTransaction, +}; use crate::{CryptoSpecExt, GasMeter, GasSpec, Multisig, Spec, TxHash}; use borsh::{BorshDeserialize, BorshSerialize}; use derivative::Derivative; @@ -50,14 +52,14 @@ impl PubKeyAndSignature { UniversalWallet, )] #[derivative( - PartialEq(bound = "Call: PartialEq + Eq"), - Eq(bound = "Call: PartialEq + Eq") + PartialEq(bound = "R::Call: PartialEq + Eq"), + Eq(bound = "R::Call: PartialEq + Eq") )] -#[serde(bound = "Call: serde::Serialize + serde::de::DeserializeOwned")] +#[serde(bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned")] /// A V1 (multisig) transaction. The number of signers is capped at 10. /// /// The credential ID for a multisig is hash(borsh(min_signers) || borsh(sort(pub_keys))) -pub struct Version1::CryptoSpec> { +pub struct Version1::CryptoSpec> { /// The signatures of the transaction. #[borsh(bound( serialize = "PubKeyAndSignature: BorshSerialize", @@ -78,39 +80,88 @@ pub struct Version1::CryptoSpec> { // This is used to compute the credential ID statelessly. pub min_signers: u8, /// The runtime call of the transaction. - #[sov_wallet(bound = "Call: sov_universal_wallet::schema::UniversalWallet")] - pub runtime_call: Call, + #[sov_wallet(bound = "R::Call: sov_universal_wallet::schema::UniversalWallet")] + pub runtime_call: R::Call, /// Uniqueness identifier of this transaction. see [`UniquenessData`] for more details. pub uniqueness: UniquenessData, - /// The transaction metadata. Contains gas parameters and the chain ID. + /// The transaction metadata. Contains gas parameters and the chain hash fragment. pub details: TxDetails, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub address_override: Option, } -impl Version1 { +impl Version1 { + /// Computes the multisig credential address from the transaction's multisig parameters. + /// This is the single source of truth for the derivation used in both signing and verification. + pub fn credential_address(&self) -> S::Address { + let all_keys: Vec<_> = self + .signatures + .iter() + .map(|s| s.pub_key.clone()) + .chain(self.unused_pub_keys.iter().cloned()) + .collect(); + Multisig::new(self.min_signers, all_keys) + .credential_id::<::Hasher>() + .into() + } + + /// Extracts the unsigned transaction payload from this signed envelope. + pub fn to_unsigned_transaction(&self) -> UnsignedTransaction { + UnsignedTransaction::new_with_details( + self.runtime_call.clone(), + self.uniqueness, + self.details.clone(), + self.address_override, + ) + } + + /// Serializes the V1 transaction signing payload for this signed envelope. + pub fn to_signing_bytes(&self, chain_hash: &[u8; 32]) -> Vec { + self.to_unsigned_transaction() + .signing_payload_v1_with_credential(self.credential_address(), *chain_hash) + .to_bytes() + } +} + +impl Version1 { /// Signs the transaction with the given key but does not add the signature to the list in the transaction. + /// + /// Returns an error if the supplied chain hash does not match the fragment stored in the + /// transaction details. #[cfg(feature = "native")] - pub fn sign_without_adding(&self, key: &C::PrivateKey, chain_hash: &[u8; 32]) -> C::Signature { - key.sign(&self.serialize_for_signing(chain_hash)) + pub fn sign_without_adding( + &self, + key: &C::PrivateKey, + chain_hash: &[u8; 32], + ) -> anyhow::Result { + self.details.ensure_matches_chain_hash(chain_hash)?; + Ok(key.sign(&self.to_signing_bytes(chain_hash))) } /// Signs and adds the signature to the transaction. #[cfg(feature = "native")] pub fn sign(&mut self, key: &C::PrivateKey, chain_hash: &[u8; 32]) -> anyhow::Result<()> { - let signature = self.sign_without_adding(key, chain_hash); - self.add_signature(signature, key.pub_key()) - } + if !self.signatures.is_empty() { + let signature = self.sign_without_adding(key, chain_hash)?; + return self.add_signature(signature, key.pub_key()); + } - /// Serializes only the `UnsignedTransaction` part of the transaction and appens the chain_hash - pub fn serialize_for_signing(&self, chain_hash: &[u8; 32]) -> Vec { - let mut out = Vec::with_capacity(64); // Preallocate a little capacity to avoid excessive reallocations - BorshSerialize::serialize(&self.runtime_call, &mut out) - .expect("Serialization to vec is infallible"); - BorshSerialize::serialize(&self.uniqueness, &mut out) - .expect("Serialization to vec is infallible"); - BorshSerialize::serialize(&self.details, &mut out) - .expect("Serialization to vec is infallible"); - out.extend_from_slice(chain_hash); - out + let mut updated = Self { + signatures: self.signatures.clone(), + unused_pub_keys: self.unused_pub_keys.clone(), + min_signers: self.min_signers, + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + address_override: self.address_override, + }; + updated.details.chain_hash_fragment = crate::transaction::chain_hash_fragment(chain_hash); + let signature = updated.sign_without_adding(key, chain_hash)?; + updated.add_signature(signature, key.pub_key())?; + *self = updated; + Ok(()) } /// Adds a signature to the signing set of the multisig, removing the public key from the set of unused pub keys. @@ -140,6 +191,7 @@ impl Version1 { pub fn auth_data>( &self, raw_tx_hash: TxHash, + non_malleable_hash: TxHash, meter: &mut M, ) -> Result, AuthenticationError> { // Charge gas; We charge for credential ID calculation based on the number of keys in the multisig @@ -161,15 +213,83 @@ impl Version1 { Ok(AuthorizationData { uniqueness: self.uniqueness, tx_hash: raw_tx_hash, + non_malleable_hash, credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), + address_override: self.address_override, }) } } -impl From> for Transaction { - fn from(value: Version1) -> Self { +impl From> for Transaction { + fn from(value: Version1) -> Self { Transaction::V1(value) } } + +#[cfg(all(test, feature = "native"))] +mod tests { + use super::*; + use crate::capabilities::UniquenessData; + use crate::transaction::{chain_hash_fragment, PriorityFeeBips}; + use crate::Amount; + use sov_mock_da::MockDaSpec; + use sov_mock_zkvm::{MockZkvm, MockZkvmCryptoSpec}; + use sov_rollup_interface::execution_mode::Native; + + type TestSpec = crate::default_spec::DefaultSpec; + type TestPrivateKey = ::PrivateKey; + + struct TestRuntime; + + impl TransactionCallable for TestRuntime { + type Call = u64; + } + + fn unsigned_transaction(chain_hash: [u8; 32]) -> UnsignedTransaction { + UnsignedTransaction::new( + 7, + chain_hash, + PriorityFeeBips::ZERO, + Amount::new(1), + UniquenessData::Generation(0), + None, + None, + ) + } + + #[test] + fn failed_first_signature_does_not_change_chain_hash_fragment() { + let member = TestPrivateKey::generate(); + let non_member = TestPrivateKey::generate(); + let original_chain_hash = [0; 32]; + let signing_chain_hash = [1; 32]; + let mut transaction = unsigned_transaction(original_chain_hash) + .to_multisig_tx(Multisig::new(1, vec![member.pub_key()])); + + let error = transaction + .sign(&non_member, &signing_chain_hash) + .unwrap_err(); + + assert!(error.to_string().contains("Public key is not a member")); + assert_eq!( + transaction.details.chain_hash_fragment, + chain_hash_fragment(&original_chain_hash) + ); + assert!(transaction.signatures.is_empty()); + } + + #[test] + fn unsigned_v1_signing_bytes_reject_mismatched_chain_hash() { + let member = TestPrivateKey::generate(); + let multisig = Multisig::new(1, vec![member.pub_key()]); + let transaction = unsigned_transaction([0; 32]); + + let error = transaction + .to_signing_bytes_v1(&multisig, [1; 32]) + .unwrap_err(); + + assert!(error.to_string().contains("Chain hash fragment mismatch")); + } +} diff --git a/crates/module-system/sov-modules-api/src/transaction/unsigned.rs b/crates/module-system/sov-modules-api/src/transaction/unsigned.rs index ca353c5dcf..5139f9dec8 100644 --- a/crates/module-system/sov-modules-api/src/transaction/unsigned.rs +++ b/crates/module-system/sov-modules-api/src/transaction/unsigned.rs @@ -6,30 +6,57 @@ use sov_universal_wallet::UniversalWallet; use crate::{ capabilities::UniquenessData, - transaction::{PriorityFeeBips, Transaction, TransactionCallable, TxDetails, Version1}, + transaction::{ + chain_hash_fragment, PriorityFeeBips, Transaction, TransactionCallable, TxDetails, + Version0, Version1, + }, Amount, CryptoSpecExt, Multisig, Spec, }; -/// An unsent transaction with the required data to be submitted to the DA layer +use super::signing_payload::{ + TransactionSigningPayload, TransactionSigningPayloadV0, TransactionSigningPayloadV1, +}; + +/// Unsigned transaction payload. This is the consumer-facing builder type used +/// to construct transactions before signing. #[derive( derive_more::Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, UniversalWallet, )] #[serde(bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned")] pub struct UnsignedTransaction { /// The runtime call + #[borsh(bound( + serialize = "R::Call: BorshSerialize", + deserialize = "R::Call: BorshDeserialize", + ))] pub runtime_call: R::Call, /// The uniqueness identifier pub uniqueness: UniquenessData, /// Data related to fees and gas handling. pub details: TxDetails, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub address_override: Option, +} + +// Manually implemented to ensure correct trait bounds (derive would require R: Clone/PartialEq) +impl Clone for UnsignedTransaction { + fn clone(&self) -> Self { + Self { + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + address_override: self.address_override, + } + } } -// Manually implemented to ensure correct trait bounds for the same reason as for `Transaction` -// above impl PartialEq for UnsignedTransaction { fn eq(&self, other: &Self) -> bool { self.runtime_call == other.runtime_call && self.uniqueness == other.uniqueness && self.details == other.details + && self.address_override == other.address_override } } impl Eq for UnsignedTransaction {} @@ -50,13 +77,16 @@ impl UnsignedTransaction { impl UnsignedTransaction { /// Creates a new [`UnsignedTransaction`] with the given arguments. + /// + /// The transaction stores the 64-bit fragment of `chain_hash` in its details. pub const fn new( runtime_call: R::Call, - chain_id: u64, + chain_hash: [u8; 32], max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, uniqueness: UniquenessData, gas_limit: Option, + address_override: Option, ) -> Self { Self { runtime_call, @@ -65,8 +95,9 @@ impl UnsignedTransaction { max_priority_fee_bips, max_fee, gas_limit, - chain_id, + chain_hash_fragment: chain_hash_fragment(&chain_hash), }, + address_override, } } @@ -75,11 +106,13 @@ impl UnsignedTransaction { runtime_call: R::Call, uniqueness: UniquenessData, details: TxDetails, + address_override: Option, ) -> Self { Self { runtime_call, uniqueness, details, + address_override, } } @@ -90,20 +123,21 @@ impl UnsignedTransaction { pub_key: C::PublicKey, signature: C::Signature, ) -> Transaction { - Transaction::new_with_details_v0( - pub_key, - self.runtime_call, + Transaction::V0(Version0 { signature, - self.uniqueness, - self.details, - ) + pub_key, + runtime_call: self.runtime_call, + uniqueness: self.uniqueness, + details: self.details, + address_override: self.address_override, + }) } /// Creates a new `V1` transaction from this unsigned transaction. pub fn to_multisig_tx( self, multisig: Multisig<::PublicKey>, - ) -> Version1 { + ) -> Version1 { Version1 { signatures: SafeVec::new(), unused_pub_keys: multisig @@ -114,11 +148,72 @@ impl UnsignedTransaction { runtime_call: self.runtime_call, uniqueness: self.uniqueness, details: self.details, + address_override: self.address_override, } } - /// Returns a copy of the RuntimeCall from this unsigned transaction. - pub fn call(&self) -> R::Call { - self.runtime_call.clone() + fn signing_payload_v0(&self, chain_hash: [u8; 32]) -> TransactionSigningPayload { + TransactionSigningPayload::V0(TransactionSigningPayloadV0 { + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + address_override: self.address_override, + chain_hash, + }) + } + + pub(crate) fn signing_payload_v1_with_credential( + &self, + credential_address: S::Address, + chain_hash: [u8; 32], + ) -> TransactionSigningPayload { + TransactionSigningPayload::V1(TransactionSigningPayloadV1 { + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + credential_address, + address_override: self.address_override, + chain_hash, + }) + } + + /// Serializes the V0 transaction signing payload for this unsigned transaction. + pub fn to_signing_bytes_v0(&self, chain_hash: [u8; 32]) -> Vec { + self.signing_payload_v0(chain_hash).to_bytes() + } + + /// Serializes the V1 transaction signing payload for this unsigned transaction. + /// + /// # Errors + /// + /// Returns an error if `chain_hash` does not match the fragment stored in the transaction + /// details. + pub fn to_signing_bytes_v1( + &self, + multisig: &Multisig<::PublicKey>, + chain_hash: [u8; 32], + ) -> anyhow::Result> { + self.details.ensure_matches_chain_hash(&chain_hash)?; + let credential_address = multisig + .credential_id::<::Hasher>() + .into(); + Ok(self + .signing_payload_v1_with_credential(credential_address, chain_hash) + .to_bytes()) + } + + /// Returns a reference to the runtime call. + pub fn runtime_call(&self) -> &R::Call { + &self.runtime_call + } + + /// Returns the transaction uniqueness data. + pub fn uniqueness(&self) -> UniquenessData { + self.uniqueness + } + + /// Returns the transaction details. + pub fn details(&self) -> &TxDetails { + &self.details } } diff --git a/crates/module-system/sov-modules-api/tests/integration/transaction.rs b/crates/module-system/sov-modules-api/tests/integration/transaction.rs index 017e58ecee..e0cb3f59e2 100644 --- a/crates/module-system/sov-modules-api/tests/integration/transaction.rs +++ b/crates/module-system/sov-modules-api/tests/integration/transaction.rs @@ -1,6 +1,6 @@ use sov_mock_zkvm::MockZkvmCryptoSpec; use sov_modules_api::capabilities::UniquenessData; -use sov_modules_api::transaction::{Transaction, TxDetails, UnsignedTransaction, Version0}; +use sov_modules_api::transaction::{Transaction, TransactionSigningPayload, TxDetails, Version0}; use sov_modules_api::CryptoSpec; use sov_test_utils::runtime::{sov_value_setter, TestOptimisticRuntime, TestOptimisticRuntimeCall}; use sov_test_utils::TestSpec; @@ -29,7 +29,7 @@ fn test_serde_serialize_tx() { max_priority_fee_bips: sov_modules_api::transaction::PriorityFeeBips(1), max_fee: sov_bank::Amount(10000), gas_limit: Some(vec![500, 500].try_into().unwrap()), - chain_id: 1337, + chain_hash_fragment: 1337, }; let native_tx = Version0 { signature: native_sig, @@ -37,6 +37,7 @@ fn test_serde_serialize_tx() { runtime_call: TestOptimisticRuntimeCall::ValueSetter(native_call), uniqueness: uniq, details, + address_override: None, }; let native = Transaction::::V0(native_tx); let native_json = serde_json::to_value(&native).unwrap(); @@ -74,8 +75,9 @@ fn test_schema_and_native_serialization_consistency() { "max_priority_fee_bips": 1, "max_fee": 10000, "gas_limit": [500, 500], - "chain_id": 1337 - } + "chain_hash_fragment": 1337 + }, + "address_override": null } }"#; let schema = Schema::of_single_type::>().unwrap(); @@ -96,7 +98,7 @@ fn test_schema_and_native_serialization_consistency() { max_priority_fee_bips: sov_modules_api::transaction::PriorityFeeBips(1), max_fee: sov_bank::Amount(10000), gas_limit: Some(vec![500, 500].try_into().unwrap()), - chain_id: 1337, + chain_hash_fragment: 1337, }; let native_tx = Version0 { signature: native_sig, @@ -104,6 +106,7 @@ fn test_schema_and_native_serialization_consistency() { runtime_call: TestOptimisticRuntimeCall::ValueSetter(native_call), uniqueness: uniq, details, + address_override: None, }; let native = Transaction::::V0(native_tx); let native_bytes = borsh::to_vec(&native).unwrap(); @@ -140,7 +143,7 @@ mod web3_compatibility { #[test] fn test_unsigned_tx_wallet_serialization_none_gas_limit() { - let json = r#"{ + let json = r#"{"V0": { "runtime_call": { "value_setter": { "set_value": { @@ -156,17 +159,20 @@ mod web3_compatibility { "max_priority_fee_bips": 1, "max_fee": 10000, "gas_limit": null, - "chain_id": 1337 - } - }"#; - let schema = Schema::of_single_type::>().unwrap(); + "chain_hash_fragment": 1337 + }, + "address_override": null, + "chain_hash": "0x0000000000000000000000000000000000000000000000000000000000000000" + }}"#; + let schema = + Schema::of_single_type::>().unwrap(); assert!(schema.json_to_borsh(0, json).is_ok(), "{ASSERT_MSG}"); } #[test] fn test_unsigned_tx_wallet_serialization_some_gas_limit() { - let json = r#"{ + let json = r#"{"V0": { "runtime_call": { "value_setter": { "set_value": { @@ -182,17 +188,20 @@ mod web3_compatibility { "max_priority_fee_bips": 1, "max_fee": 10000, "gas_limit": [500, 500], - "chain_id": 1337 - } - }"#; - let schema = Schema::of_single_type::>().unwrap(); + "chain_hash_fragment": 1337 + }, + "address_override": null, + "chain_hash": "0x0000000000000000000000000000000000000000000000000000000000000000" + }}"#; + let schema = + Schema::of_single_type::>().unwrap(); assert!(schema.json_to_borsh(0, json).is_ok(), "{ASSERT_MSG}"); } #[test] fn test_unsigned_tx_wallet_serialization_window_uniqueness() { - let json = r#"{ + let json = r#"{"V0": { "runtime_call": { "value_setter": { "set_value": { @@ -208,10 +217,13 @@ mod web3_compatibility { "max_priority_fee_bips": 1, "max_fee": 10000, "gas_limit": null, - "chain_id": 1337 - } - }"#; - let schema = Schema::of_single_type::>().unwrap(); + "chain_hash_fragment": 1337 + }, + "address_override": null, + "chain_hash": "0x0000000000000000000000000000000000000000000000000000000000000000" + }}"#; + let schema = + Schema::of_single_type::>().unwrap(); assert!(schema.json_to_borsh(0, json).is_ok(), "{ASSERT_MSG}"); } @@ -238,8 +250,9 @@ mod web3_compatibility { "max_priority_fee_bips": 1, "max_fee": 10000, "gas_limit": [500, 500], - "chain_id": 1337 - } + "chain_hash_fragment": 1337 + }, + "address_override": null } }"#; let schema = Schema::of_single_type::>().unwrap(); @@ -269,8 +282,9 @@ mod web3_compatibility { "max_priority_fee_bips": 1, "max_fee": 10000, "gas_limit": null, - "chain_id": 1337 - } + "chain_hash_fragment": 1337 + }, + "address_override": null } }"#; let schema = Schema::of_single_type::>().unwrap(); diff --git a/crates/module-system/sov-modules-macros/src/cli_parser.rs b/crates/module-system/sov-modules-macros/src/cli_parser.rs index c99093cd53..184fda709c 100644 --- a/crates/module-system/sov-modules-macros/src/cli_parser.rs +++ b/crates/module-system/sov-modules-macros/src/cli_parser.rs @@ -21,7 +21,6 @@ pub(crate) fn derive_cli_wallet( let mut module_json_parser_arms = vec![]; let mut module_message_arms = vec![]; - let mut tx_args_subcommand_match_arms_chain_id = vec![]; let mut tx_args_subcommand_match_arms_max_priority_fee_bips = vec![]; let mut tx_args_subcommand_match_arms_max_fee = vec![]; let mut tx_args_subcommand_match_arms_gas_limit = vec![]; @@ -80,10 +79,6 @@ pub(crate) fn derive_cli_wallet( RuntimeMessage::#field_name { contents } => RuntimeMessage::#field_name { contents: contents.try_into()? }, }); - tx_args_subcommand_match_arms_chain_id.push(quote! { - RuntimeSubcommand::#field_name { contents } => <__Inner as ::sov_modules_api::cli::CliTxImportArg>::chain_id(&contents), - }); - tx_args_subcommand_match_arms_max_priority_fee_bips.push(quote! { RuntimeSubcommand::#field_name { contents } => <__Inner as ::sov_modules_api::cli::CliTxImportArg>::max_priority_fee_bips(&contents), }); @@ -206,13 +201,6 @@ pub(crate) fn derive_cli_wallet( } impl #impl_generics_with_inner ::sov_modules_api::cli::CliTxImportArg for RuntimeSubcommand #ty_generics_with_inner #where_clause_with_deserialize_bounds, __Inner: clap::Args + ::sov_modules_api::cli::CliTxImportArg { - fn chain_id(&self) -> u64 { - match self { - #( #tx_args_subcommand_match_arms_chain_id )* - _ => unreachable!(), - } - } - fn max_priority_fee_bips(&self) -> u64 { match self { #( #tx_args_subcommand_match_arms_max_priority_fee_bips )* diff --git a/crates/module-system/sov-modules-macros/tests/integration/derive_wallet.rs b/crates/module-system/sov-modules-macros/tests/integration/derive_wallet.rs index 2e1672d2f3..41818f19d7 100644 --- a/crates/module-system/sov-modules-macros/tests/integration/derive_wallet.rs +++ b/crates/module-system/sov-modules-macros/tests/integration/derive_wallet.rs @@ -155,8 +155,6 @@ fn build_runtime_call() { "chain-state", "--json", r#"{"first_field": 1, "str_field": "hello"}"#, - "--chain-id", - "0", "--max-fee", "0", ]) @@ -171,8 +169,6 @@ fn build_runtime_call() { "second", "--json", r#"{"Bar": 2}"#, - "--chain-id", - "0", "--max-fee", "0", ]) diff --git a/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types.rs b/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types.rs index b7b4113eeb..8304cdebcc 100644 --- a/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types.rs +++ b/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types.rs @@ -145,6 +145,7 @@ fn associated_types() { inner_code_commitment: Default::default(), outer_code_commitment: Default::default(), genesis_da_height: 0, + state_version: 1, admin: None, }; let config = GenesisConfig::new(chain_state_config, 22); diff --git a/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types_nested.rs b/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types_nested.rs index afb247daa5..eaa0272721 100644 --- a/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types_nested.rs +++ b/crates/module-system/sov-modules-macros/tests/integration/rpc_gen/rpc_gen_associated_types_nested.rs @@ -169,6 +169,7 @@ fn associated_types_nested() { inner_code_commitment: Default::default(), outer_code_commitment: Default::default(), genesis_da_height: 0, + state_version: 1, admin: None, }; let config = GenesisConfig::new(chain_state_config, 22); diff --git a/crates/module-system/sov-modules-rollup-blueprint/src/native_only/mod.rs b/crates/module-system/sov-modules-rollup-blueprint/src/native_only/mod.rs index a391be68cf..14178776ef 100644 --- a/crates/module-system/sov-modules-rollup-blueprint/src/native_only/mod.rs +++ b/crates/module-system/sov-modules-rollup-blueprint/src/native_only/mod.rs @@ -10,7 +10,7 @@ use futures::future; use sov_db::ledger_db::LedgerDb; use sov_db::schema::{DeltaReader, SchemaBatch}; use sov_modules_api::capabilities::{ - ChainState, HasCapabilities, HasKernel, ProofProcessor, RollupHeight, + ChainState as _, HasCapabilities, HasKernel, ProofProcessor, RollupHeight, }; use sov_modules_api::execution_mode::ExecutionMode; use sov_modules_api::provable_height_tracker::MaximumProvableHeight; @@ -584,8 +584,9 @@ pub trait FullNodeBlueprint: RollupBlueprint { .await ); - let checkpoint = StateCheckpoint::new(prover_storage, &rt.kernel()); + let mut checkpoint = StateCheckpoint::new(prover_storage, &rt.kernel()); let current_height = checkpoint.rollup_height_to_access(); + validate_state_version::(&mut rt, &mut checkpoint)?; startup_step!(validate_heights( current_height, @@ -893,6 +894,27 @@ async fn wait_for_failed_startup_tasks( } } +fn validate_state_version( + runtime: &mut RT, + state: &mut StateCheckpoint, +) -> anyhow::Result<()> +where + S: Spec, + RT: RuntimeTrait, +{ + let compiled_state_version: u64 = sov_modules_api::macros::config_value!("STATE_VERSION"); + let on_disk_state_version = runtime + .chain_state() + .state_version(&mut state.accessory_state()); + + anyhow::ensure!( + on_disk_state_version == compiled_state_version, + "State version mismatch: on-disk state has version {on_disk_state_version}, but this binary was compiled with STATE_VERSION {compiled_state_version}. Run the appropriate migration binary or use a matching rollup binary." + ); + + Ok(()) +} + fn validate_heights( current_height: RollupHeight, start_at_rollup_height: Option, diff --git a/crates/module-system/sov-modules-rollup-blueprint/src/native_only/proof_sender.rs b/crates/module-system/sov-modules-rollup-blueprint/src/native_only/proof_sender.rs index cdaf69006f..98ce8f591b 100644 --- a/crates/module-system/sov-modules-rollup-blueprint/src/native_only/proof_sender.rs +++ b/crates/module-system/sov-modules-rollup-blueprint/src/native_only/proof_sender.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use async_trait::async_trait; use borsh::{BorshDeserialize, BorshSerialize}; -use sov_modules_api::capabilities::config_chain_id; use sov_modules_api::proof_metadata::{ProofType, SerializeProofWithDetails}; use sov_modules_api::transaction::{PriorityFeeBips, TxDetails}; use sov_modules_api::{Amount, ProofSender, Spec}; @@ -113,7 +112,7 @@ fn make_details(max_fee: Amount) -> TxDetails { max_priority_fee_bips: PriorityFeeBips::ZERO, max_fee, gas_limit: None, - chain_id: config_chain_id(), + chain_hash_fragment: 0, } } diff --git a/crates/module-system/sov-modules-stf-blueprint/src/lib.rs b/crates/module-system/sov-modules-stf-blueprint/src/lib.rs index 70eaebfe3b..1ffc022fe1 100644 --- a/crates/module-system/sov-modules-stf-blueprint/src/lib.rs +++ b/crates/module-system/sov-modules-stf-blueprint/src/lib.rs @@ -65,7 +65,7 @@ pub struct ApplyTxResult { /// Native scratchpad recorded while executing with the transaction's sequencing data. #[cfg(feature = "native")] #[serde(skip)] - pub sequencing_scratchpad: Option, + pub sequencing_scratchpad: sov_modules_api::SequencingScratchpadContents, } /// Genesis parameters for a blueprint @@ -653,9 +653,7 @@ where // Note: The gas price should be computed after all the capabilities involving the [`KernelStateAccessor`] to have the // most recent version of the visible rollup height. let gas_price = runtime.chain_state().base_fee_per_gas(&mut state).expect("The base fee per gas for the current slot should be known at this point! This is a bug. Please report it"); - let block_gas_limit = runtime - .chain_state() - .block_gas_limit(state.rollup_height_to_access(), !creates_rollup_block); + let block_gas_limit = ::block_gas_limit(); let preferred_sequencer = runtime .sequencer_remuneration() diff --git a/crates/module-system/sov-modules-stf-blueprint/src/proof_processing.rs b/crates/module-system/sov-modules-stf-blueprint/src/proof_processing.rs index 49cf79b77b..ddd082bbe1 100644 --- a/crates/module-system/sov-modules-stf-blueprint/src/proof_processing.rs +++ b/crates/module-system/sov-modules-stf-blueprint/src/proof_processing.rs @@ -56,7 +56,7 @@ where }; // The `pre_exec_working_set` is initialized, indicating that the sequencer is bonded and we can begin charging gas. - match SerializeProofWithDetails::::deserialize( + match SerializeProofWithDetails::::deserialize_from_slice( &mut &raw_proof[..], &mut pre_exec_working_set, ) { diff --git a/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs b/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs index ce79182088..466d4bf9c9 100644 --- a/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs +++ b/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/common.rs @@ -1,8 +1,7 @@ -use borsh::BorshDeserialize; +use sov_modules_api::capabilities::ChainState as _; use sov_modules_api::capabilities::{AuthenticationError, AuthenticationOutput, FatalError}; use sov_modules_api::capabilities::{ - HasCapabilities, SequencingDataHandler, TimelockCapability, TimelockProposalData, - TimelockProposalOutcome, + TimelockCapability, TimelockProposalData, TimelockProposalOutcome, }; use sov_modules_api::transaction::AuthenticatedTransactionData; use sov_modules_api::{ @@ -11,7 +10,7 @@ use sov_modules_api::{ }; use sov_rollup_interface::Bytes; use sov_rollup_interface::TxHash; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; use super::registered::IncrementalBatchReceipt; use crate::stf_blueprint::convert_to_runtime_events; @@ -133,12 +132,16 @@ fn attempt_tx, I: StateProvider>( runtime: &mut RT, state: &mut WorkingSet, ) -> Result<(), Error> { - if let Some(sequencing_data) = ctx.sequencing_data().as_ref() { - let mut handler = runtime.sequencing_data_handler(); - match >::SequencingData::try_from_slice(sequencing_data) { - Ok(decoded) => handler.handle_sequencing_data(decoded, ctx, state)?, - Err(error) => warn!(%error, "Invalid sequencing metadata; ignoring"), - } + // The context only carries sequencing data for preferred-sequencer transactions + // (enforced at `Context` construction), so both the oracle update and the runtime hook + // exclusively observe trusted data. The timestamp channel is SDK-managed and never + // pruned, so replaying nodes observe the same oracle update. + if let Some(timestamp) = ctx.sequencing_timestamp() { + runtime.chain_state().update_oracle_time(timestamp, state)?; + } + if ctx.has_sequencing_data() { + let view = ctx.sequencing_data_view::(); + runtime.handle_sequencing_data(&view, ctx, state)?; } runtime.pre_dispatch_tx_hook(tx, state)?; diff --git a/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/registered.rs b/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/registered.rs index 99ff6d38c7..547135c530 100644 --- a/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/registered.rs +++ b/crates/module-system/sov-modules-stf-blueprint/src/sequencer_mode/registered.rs @@ -663,7 +663,7 @@ enum AuthAndProcessOutcome { transaction_consumption: TransactionConsumption, receipt: TransactionReceipt, #[cfg(feature = "native")] - sequencing_scratchpad: Option, + sequencing_scratchpad: sov_modules_api::SequencingScratchpadContents, }, } @@ -675,24 +675,12 @@ struct AuthAndProcessOutput> { } fn penalize_sequencer, I: StateProvider>( - sequencer_bond: &SequencerBondForTx, runtime: &mut RT, auth_cost: Amount, sequencer_address: &S::Address, operating_mode: OperatingMode, tx_scratchpad: &mut TxScratchpad, ) { - // After the gas limit change height, the preferred sequencer doesn't bond for pre execution checks. - // In this case, we no longer attempt to penalize the sequencer. This feature should only be active for rollups. - // that don't use zk proving, since this would be a griefing vector against the prover. - if let SequencerBondForTx::Preferred(bond_amount) = sequencer_bond { - if bond_amount == &Amount::ZERO - && tx_scratchpad.rollup_height_to_access() - > ::change_gas_limit_after_height() - { - return; - } - } runtime .gas_enforcer() .reward_prover_from_sequencer_balance( @@ -747,14 +735,8 @@ where } }; - // If the sequencer isn't bonded enough, reject the input unless unless we've passed the gas limit update height (meaning we've done an update) *AND* - // the tx is from the preferred sequencer with zero escrow. - // (note: The preferred sequencer sending txs with no escrow is a new pattern that becomes legal - // after we update the gas limit.) - if sequencer_bond.amount() < max_tx_check_value - && !(scratchpad.rollup_height_to_access() > ::change_gas_limit_after_height() - && matches!(sequencer_bond, SequencerBondForTx::Preferred(Amount::ZERO))) - { + // If the sequencer isn't bonded enough, reject the input. + if sequencer_bond.amount() < max_tx_check_value { return AuthAndProcessOutput { outcome: AuthAndProcessOutcome::IllegalSequencer { reason: OutOfFundsReason::SequencerBondTooLow { @@ -768,10 +750,7 @@ where } // 3. The slot gas is higher than the gas needed to validate the transaction. - // If we've updated the gas limit, disable this check. - if slot_gas.dim_is_less_or_eq(max_tx_check_costs) - && scratchpad.rollup_height_to_access() <= ::change_gas_limit_after_height() - { + if slot_gas.dim_is_less_or_eq(max_tx_check_costs) { return AuthAndProcessOutput { outcome: AuthAndProcessOutcome::IllegalSequencer { reason: OutOfFundsReason::SlotGasLimitExhausted { @@ -829,7 +808,6 @@ where return match pre_exec_error { AuthenticationError::FatalError(err, tx_hash) => { penalize_sequencer( - &sequencer_bond, runtime, funds_used_for_authentication, &sequencer_rollup_address, @@ -849,7 +827,6 @@ where } AuthenticationError::OutOfGas(e) => { penalize_sequencer( - &sequencer_bond, runtime, funds_used_for_authentication, &sequencer_rollup_address, @@ -906,7 +883,6 @@ where match tx_result { Err((error, raw_tx)) => { penalize_sequencer( - &sequencer_bond, runtime, pre_exec_gas_meter.gas_info().gas_value, &sequencer_rollup_address, diff --git a/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs b/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs index 1d157eb6b7..ebb255c99c 100644 --- a/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs +++ b/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs @@ -5,8 +5,8 @@ use serde::de::DeserializeOwned; use serde::Serialize; use sov_modules_api::capabilities::AuthorizationData; use sov_modules_api::capabilities::{ - calculate_hash_metered, verify_chain_id, AuthenticationError, AuthenticationOutput, FatalError, - UniquenessData, + calculate_hash_metered, calculate_non_malleable_hash_metered, verify_chain_hash_fragment, + AuthenticationError, AuthenticationOutput, FatalError, ReplayHashMaterial, UniquenessData, }; use sov_modules_api::transaction::AuthenticatedTransactionAndRawHash; use sov_modules_api::transaction::Credentials; @@ -28,14 +28,16 @@ static SIGNATURE_CACHE: std::sync::LazyLock> = std::sync::LazyLock::new(|| SignatureVerificationCache::new(DEFAULT_SIGNATURE_CACHE_SIZE)); pub use self::parsing::simple::MULTISIG_SIMPLE_DISCRIMINATOR; -pub use self::parsing::simple::{SolanaOffchainSimpleMessage, SolanaOffchainSimpleMultisigMessage}; +pub use self::parsing::simple::{ + SolanaOffchainSimpleEnvelope, SolanaOffchainSimpleMultisigEnvelope, +}; pub(crate) use self::parsing::spec_compliant::format_constants as spec_compliant_constants; pub use self::parsing::spec_compliant::SPEC_COMPLIANT_DISCRIMINATOR; pub use self::parsing::spec_compliant::{ - RawSolanaOffchainMessagePreamble, SolanaOffchainSpecCompliantMessage, - SolanaOffchainSpecCompliantMultisigMessage, + RawSolanaOffchainMessagePreamble, SolanaOffchainSpecCompliantEnvelope, + SolanaOffchainSpecCompliantMultisigEnvelope, }; -pub use self::payload::{SolanaOffchainUnsignedTransactionV0, SolanaOffchainUnsignedTransactionV1}; +pub use self::payload::{SolanaOffchainSigningPayloadV0, SolanaOffchainSigningPayloadV1}; fn charge_sig_gas( signature: &::Signature, @@ -125,9 +127,23 @@ fn verify_signatures( fn build_auth_data( unpacked: &UnpackedSolanaMessage, uniqueness: UniquenessData, + address_override: Option, raw_tx_hash: TxHash, meter: &mut impl GasMeter, ) -> Result, AuthenticationError> { + let non_malleable_hash = calculate_non_malleable_hash_metered::<_, S>( + match unpacked { + UnpackedSolanaMessage::V0 { .. } => { + ReplayHashMaterial::AlreadyNonMalleableHash(raw_tx_hash) + } + UnpackedSolanaMessage::V1 { .. } => { + ReplayHashMaterial::VerifiedSignatureMessage(unpacked.signed_bytes()) + } + }, + meter, + ) + .map_err(|e| AuthenticationError::OutOfGas(e.to_string()))?; + match unpacked { UnpackedSolanaMessage::V0 { pub_key, .. } => { let credential_id = @@ -137,9 +153,11 @@ fn build_auth_data( Ok(AuthorizationData { uniqueness, tx_hash: raw_tx_hash, + non_malleable_hash, credential_id, credentials: Credentials::new(pub_key.clone()), default_address: credential_id.into(), + address_override, }) } UnpackedSolanaMessage::V1 { @@ -164,9 +182,11 @@ fn build_auth_data( Ok(AuthorizationData { uniqueness, tx_hash: raw_tx_hash, + non_malleable_hash, credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), + address_override, }) } } @@ -217,17 +237,17 @@ where let deser_err = |e: serde_json::Error| FatalError::DeserializationFailed(e.to_string()); let unsigned_tx = match &unpacked_message { UnpackedSolanaMessage::V0 { .. } => { - SolanaOffchainUnsignedTransactionV0::::unmetered_deserialize(json_bytes) + SolanaOffchainSigningPayloadV0::::unmetered_deserialize(json_bytes) .map_err(deser_err)? - .into_unsigned_tx() + .into_unsigned_transaction() } UnpackedSolanaMessage::V1 { .. } => { - SolanaOffchainUnsignedTransactionV1::::unmetered_deserialize(json_bytes) + SolanaOffchainSigningPayloadV1::::unmetered_deserialize(json_bytes) .map_err(deser_err)? - .into_unsigned_tx() + .into_unsigned_transaction() } }; - Ok(unsigned_tx.call()) + Ok(unsigned_tx.runtime_call().clone()) } pub fn authenticate( @@ -248,7 +268,7 @@ where let unpacked_message = unpack_solana_message::(raw_tx) .map_err(|e| AuthenticationError::FatalError(e, raw_tx_hash))?; - // Deserialize the JSON unsigned transaction. + // Deserialize the Solana JSON signing payload. // V0 (single-sig) and V1 (multisig) use different structs; for V1, we also verify // that the credential_id committed to in the signed message matches the envelope. let json_slice = unpacked_message.json_bytes(); @@ -263,11 +283,16 @@ where raw_tx_hash, ) }; - let (provided_chain_name, unsigned_tx) = match &unpacked_message { + let (provided_chain_name, address_override, unsigned_tx) = match &unpacked_message { UnpackedSolanaMessage::V0 { .. } => { - let tx = SolanaOffchainUnsignedTransactionV0::::unmetered_deserialize(json_slice) + let tx = SolanaOffchainSigningPayloadV0::::unmetered_deserialize(json_slice) .map_err(deser_err)?; - (tx.chain_name.to_string(), tx.into_unsigned_tx()) + let address_override = tx.address_override; + ( + tx.chain_name.to_string(), + address_override, + tx.into_unsigned_transaction(), + ) } UnpackedSolanaMessage::V1 { signatures, @@ -275,7 +300,7 @@ where min_signers, .. } => { - let tx = SolanaOffchainUnsignedTransactionV1::::unmetered_deserialize(json_slice) + let tx = SolanaOffchainSigningPayloadV1::::unmetered_deserialize(json_slice) .map_err(deser_err)?; verify_multisig_commitment::( tx.multisig_id, @@ -284,7 +309,12 @@ where *min_signers, raw_tx_hash, )?; - (tx.chain_name.to_string(), tx.into_unsigned_tx()) + let address_override = tx.address_override; + ( + tx.chain_name.to_string(), + address_override, + tx.into_unsigned_transaction(), + ) } }; @@ -309,7 +339,7 @@ where )); } - verify_chain_id(&unsigned_tx.details, raw_tx_hash)?; + verify_chain_hash_fragment(unsigned_tx.details(), runtime_chain_hash, raw_tx_hash)?; // Verify signatures (branches internally for single-sig vs multisig) verify_signatures::(&unpacked_message, raw_tx_hash, state)?; @@ -317,20 +347,21 @@ where // Build authorization data (branches internally for single-sig vs multisig) let authorization_data = build_auth_data::( &unpacked_message, - unsigned_tx.uniqueness, + unsigned_tx.uniqueness(), + address_override, raw_tx_hash, state, )?; let tx_and_raw_hash = AuthenticatedTransactionAndRawHash { raw_tx_hash, - authenticated_tx: unsigned_tx.details.into(), + authenticated_tx: unsigned_tx.details().clone().into(), }; Ok(( tx_and_raw_hash, authorization_data, - unsigned_tx.runtime_call, + unsigned_tx.runtime_call().clone(), )) } @@ -362,7 +393,7 @@ pub mod test { signed_message.extend_from_slice(&preamble); signed_message.extend_from_slice(message); - let envelope = SolanaOffchainSpecCompliantMessage:: { + let envelope = SolanaOffchainSpecCompliantEnvelope:: { signed_message_with_preamble: signed_message.clone(), signature: signature.clone(), }; @@ -394,7 +425,7 @@ pub mod test { let pubkey = Ed25519PrivateKey::generate().pub_key(); let signature: Ed25519Signature = [4u8; 64].as_slice().try_into().unwrap(); - let raw_message = SolanaOffchainSimpleMessage:: { + let raw_message = SolanaOffchainSimpleEnvelope:: { signed_message: message.to_vec(), chain_hash: TEST_CHAIN_HASH, pubkey: pubkey.clone(), @@ -433,7 +464,7 @@ pub mod test { let sig1: Ed25519Signature = [1u8; 64].as_slice().try_into().unwrap(); let sig2: Ed25519Signature = [2u8; 64].as_slice().try_into().unwrap(); - let envelope = SolanaOffchainSimpleMultisigMessage:: { + let envelope = SolanaOffchainSimpleMultisigEnvelope:: { wire_bytes, chain_hash: TEST_CHAIN_HASH, signatures: vec![ @@ -499,7 +530,7 @@ pub mod test { signed_message.extend_from_slice(&header); signed_message.extend_from_slice(message); - let envelope = SolanaOffchainSpecCompliantMessage:: { + let envelope = SolanaOffchainSpecCompliantEnvelope:: { signed_message_with_preamble: signed_message, signature: signature.clone(), }; @@ -534,7 +565,7 @@ pub mod test { signed_message.extend_from_slice(json_message); // Signers 0 and 2 signed (bitfield = 0b101 = 5), signer 1 did not - let envelope = SolanaOffchainSpecCompliantMultisigMessage:: { + let envelope = SolanaOffchainSpecCompliantMultisigEnvelope:: { signed_message_with_preamble: signed_message.clone(), signatures: vec![sig1.clone(), sig3.clone()].try_into().unwrap(), signer_bitfield: 0b101, @@ -592,7 +623,7 @@ pub mod test { signed_message.extend_from_slice(json_message); // 1 signature but bitfield says 2 signers (0b11) - let envelope = SolanaOffchainSpecCompliantMultisigMessage:: { + let envelope = SolanaOffchainSpecCompliantMultisigEnvelope:: { signed_message_with_preamble: signed_message, signatures: vec![sig1].try_into().unwrap(), signer_bitfield: 0b11, @@ -629,7 +660,7 @@ pub mod test { signed_message.extend_from_slice(json_message); // Bit 2 is set but there are only 2 signers (indices 0 and 1) - let envelope = SolanaOffchainSpecCompliantMultisigMessage:: { + let envelope = SolanaOffchainSpecCompliantMultisigEnvelope:: { signed_message_with_preamble: signed_message, signatures: vec![sig1].try_into().unwrap(), signer_bitfield: 0b100, diff --git a/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/simple.rs b/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/simple.rs index e590ee5a0b..3f0df02076 100644 --- a/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/simple.rs +++ b/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/simple.rs @@ -18,8 +18,8 @@ pub const MULTISIG_SIMPLE_DISCRIMINATOR: u8 = 0x80; /// start with an ASCII character (normally, '{'), allowing us to unambiguously differentiate them. /// Without the preamble present, we need to include the pubkey explicitly. #[derive(BorshSerialize, BorshDeserialize)] -pub struct SolanaOffchainSimpleMessage { - /// The message is a JSON-serialized SolanaOffchainUnsignedTransaction, unaltered. +pub struct SolanaOffchainSimpleEnvelope { + /// The message is a JSON-serialized SolanaOffchainSigningPayloadV0, unaltered. pub signed_message: Vec, pub chain_hash: [u8; 32], pub pubkey: ::PublicKey, @@ -29,8 +29,8 @@ pub struct SolanaOffchainSimpleMessage { /// The envelope for a multisig "simple" (preamble-less) solana offchain message. /// Each signer independently signs the JSON payload (the bytes after the discriminator prefix). #[derive(BorshSerialize, BorshDeserialize)] -pub struct SolanaOffchainSimpleMultisigMessage { - /// Wire format: `[0x80][JSON-serialized SolanaOffchainUnsignedTransactionV1]`. +pub struct SolanaOffchainSimpleMultisigEnvelope { + /// Wire format: `[0x80][JSON-serialized SolanaOffchainSigningPayloadV1]`. /// The `0x80` prefix is a parsing discriminator only — the signed content is the JSON /// portion (everything after the first byte). pub wire_bytes: Vec, @@ -55,7 +55,7 @@ pub struct SolanaOffchainSimpleMultisigMessage { pub(super) fn unpack_simple_message( raw_tx: &[u8], ) -> Result, FatalError> { - let raw_message: SolanaOffchainSimpleMessage = + let raw_message: SolanaOffchainSimpleEnvelope = borsh::from_slice(raw_tx).map_err(|e| FatalError::DeserializationFailed(e.to_string()))?; Ok(UnpackedSolanaMessage::V0 { @@ -70,7 +70,7 @@ pub(super) fn unpack_simple_message( pub(super) fn unpack_multisig_simple_message( raw_tx: &[u8], ) -> Result, FatalError> { - let msg: SolanaOffchainSimpleMultisigMessage = + let msg: SolanaOffchainSimpleMultisigEnvelope = borsh::from_slice(raw_tx).map_err(|e| FatalError::DeserializationFailed(e.to_string()))?; if msg.wire_bytes.first() != Some(&MULTISIG_SIMPLE_DISCRIMINATOR) { diff --git a/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/spec_compliant.rs b/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/spec_compliant.rs index 53819b5a40..e512dcb74e 100644 --- a/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/spec_compliant.rs +++ b/crates/module-system/sov-solana-offchain-auth/src/authentication/parsing/spec_compliant.rs @@ -53,8 +53,8 @@ const SIGNERS_START: usize = SIGNER_COUNT_OFFSET + SIGNER_COUNT_LEN; /// The envelope for a signed spec-compliant solana offchain message, where the signed message /// includes the preamble. #[derive(BorshSerialize, BorshDeserialize)] -pub struct SolanaOffchainSpecCompliantMessage { - /// The message is a JSON-serialized SolanaOffchainUnsignedTransaction with the standard +pub struct SolanaOffchainSpecCompliantEnvelope { + /// The message is a JSON-serialized SolanaOffchainSigningPayloadV0 with the standard /// preamble prepended. pub signed_message_with_preamble: Vec, pub signature: ::Signature, @@ -64,9 +64,9 @@ pub struct SolanaOffchainSpecCompliantMessage { /// All pubkeys are embedded in the preamble (part of the signed bytes). The envelope carries only /// signatures, a bitfield mapping each signature to its pubkey in the preamble, and the threshold. #[derive(BorshSerialize, BorshDeserialize)] -pub struct SolanaOffchainSpecCompliantMultisigMessage { +pub struct SolanaOffchainSpecCompliantMultisigEnvelope { /// Preamble (with all N pubkeys) followed by JSON-serialized - /// `SolanaOffchainUnsignedTransactionV1`. + /// `SolanaOffchainSigningPayloadV1`. pub signed_message_with_preamble: Vec, /// One signature per signer, ordered to match set bits in `signer_bitfield` from LSB to MSB. #[borsh(bound( @@ -288,7 +288,7 @@ pub(super) fn unpack_spec_compliant_message( ) -> Result, FatalError> { let single_key_preamble_len = preamble_len(1); - let envelope: SolanaOffchainSpecCompliantMessage = + let envelope: SolanaOffchainSpecCompliantEnvelope = borsh::from_slice(raw_tx).map_err(|e| FatalError::DeserializationFailed(e.to_string()))?; if envelope.signed_message_with_preamble.len() < single_key_preamble_len { @@ -324,7 +324,7 @@ pub(super) fn unpack_spec_compliant_message( pub(super) fn unpack_spec_compliant_multisig_message( raw_tx: &[u8], ) -> Result, FatalError> { - let envelope: SolanaOffchainSpecCompliantMultisigMessage = + let envelope: SolanaOffchainSpecCompliantMultisigEnvelope = borsh::from_slice(raw_tx).map_err(|e| FatalError::DeserializationFailed(e.to_string()))?; let data = &envelope.signed_message_with_preamble; diff --git a/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs b/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs index 92c8df6abf..c9d43e3b4e 100644 --- a/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs +++ b/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs @@ -7,19 +7,18 @@ use sov_modules_api::macros::UniversalWallet; use sov_modules_api::transaction::{TransactionCallable, TxDetails, UnsignedTransaction}; use sov_modules_api::{SafeString, Spec}; -/// The payload for a solana offchain message. -/// Essentially a wrapper around `sov_modules_api::transaction::UnsignedTransaction` that also -/// includes the chain_name, in order to ensure the name gets displayed to the user and signed as -/// part of the message. -/// We duplicate the UnsignedTransaction type rather than wrapping it to ensure the JSON displayed -/// to the user doesn't get too nested. +/// The V0 Solana-specific payload that wallets sign as JSON. +/// +/// This duplicates the fields from [`UnsignedTransaction`] instead of wrapping it, so that the JSON +/// displayed to users stays flat. The extra `chain_name` field is signed as part of the payload, +/// ensuring the destination chain is visible to the user before signing. #[serde_with::serde_as] #[derive(Debug, Serialize, Deserialize, UniversalWallet)] #[serde( deny_unknown_fields, bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned" )] -pub struct SolanaOffchainUnsignedTransactionV0 { +pub struct SolanaOffchainSigningPayloadV0 { /// The runtime call pub runtime_call: R::Call, /// The uniqueness identifier @@ -30,34 +29,45 @@ pub struct SolanaOffchainUnsignedTransactionV0 /// from malicious chains (if the chain name matches some other chain the use but didn't expect /// to be signing for right now). pub chain_name: SafeString, + /// Signer-declared address override. + /// See [`sov_modules_api::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address_override: Option, + /// Message format version. Must be `0` for this struct. + #[serde(deserialize_with = "deserialize_version_0")] + pub version: u8, } -impl SolanaOffchainUnsignedTransactionV0 +impl SolanaOffchainSigningPayloadV0 where S: Spec, R: TransactionCallable, ::Call: Serialize + DeserializeOwned, { - pub(super) fn into_unsigned_tx(self) -> UnsignedTransaction { + pub(super) fn into_unsigned_transaction(self) -> UnsignedTransaction { UnsignedTransaction { runtime_call: self.runtime_call, uniqueness: self.uniqueness, details: self.details, + address_override: self.address_override, } } pub(super) fn unmetered_deserialize(buf: &[u8]) -> Result { - serde_json::from_slice::>(buf) + serde_json::from_slice::>(buf) } } +/// The V1 Solana-specific payload that multisig wallets sign as JSON. +/// +/// V1 extends the V0 payload with a signed multisig credential and explicit message format version. #[serde_with::serde_as] #[derive(Debug, Serialize, Deserialize, UniversalWallet)] #[serde( deny_unknown_fields, bound = "R::Call: serde::Serialize + serde::de::DeserializeOwned" )] -pub struct SolanaOffchainUnsignedTransactionV1 { +pub struct SolanaOffchainSigningPayloadV1 { /// The runtime call pub runtime_call: R::Call, /// The uniqueness identifier @@ -75,11 +85,27 @@ pub struct SolanaOffchainUnsignedTransactionV1 /// This is the "multisig address" except if the credential is mapped to another address in /// `sov-accounts`. pub multisig_id: S::Address, + /// Signer-declared address override. + /// See [`sov_modules_api::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address_override: Option, /// Message format version. Must be `1` for this struct. #[serde(deserialize_with = "deserialize_version_1")] pub version: u8, } +fn deserialize_version_0<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result { + let v = ::deserialize(deserializer)?; + if v != 0 { + return Err(serde::de::Error::custom(format!( + "expected message version 0, got {v}" + ))); + } + Ok(v) +} + fn deserialize_version_1<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result { @@ -92,21 +118,22 @@ fn deserialize_version_1<'de, D: serde::Deserializer<'de>>( Ok(v) } -impl SolanaOffchainUnsignedTransactionV1 +impl SolanaOffchainSigningPayloadV1 where S: Spec, R: TransactionCallable, ::Call: Serialize + DeserializeOwned, { - pub(super) fn into_unsigned_tx(self) -> UnsignedTransaction { + pub(super) fn into_unsigned_transaction(self) -> UnsignedTransaction { UnsignedTransaction { runtime_call: self.runtime_call, uniqueness: self.uniqueness, details: self.details, + address_override: self.address_override, } } pub(super) fn unmetered_deserialize(buf: &[u8]) -> Result { - serde_json::from_slice::>(buf) + serde_json::from_slice::>(buf) } } diff --git a/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs b/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs index 169131880a..a15a2960e7 100644 --- a/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs +++ b/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs @@ -25,10 +25,9 @@ use sov_paymaster::{ use sov_rollup_interface::execution_mode::Native; use sov_sequencer::rest_api::AcceptTx; use sov_solana_offchain_auth::authentication::{ - SolanaOffchainSimpleMessage, SolanaOffchainSimpleMultisigMessage, - SolanaOffchainSpecCompliantMessage, SolanaOffchainSpecCompliantMultisigMessage, - SolanaOffchainUnsignedTransactionV0, SolanaOffchainUnsignedTransactionV1, - MULTISIG_SIMPLE_DISCRIMINATOR, + SolanaOffchainSigningPayloadV0, SolanaOffchainSigningPayloadV1, SolanaOffchainSimpleEnvelope, + SolanaOffchainSimpleMultisigEnvelope, SolanaOffchainSpecCompliantEnvelope, + SolanaOffchainSpecCompliantMultisigEnvelope, MULTISIG_SIMPLE_DISCRIMINATOR, }; use sov_solana_offchain_auth::utils::{ make_multisig_preamble_for_message, make_preamble_for_message, @@ -91,9 +90,51 @@ type RT = TestRuntime; type S = SolanaTestSpec; type TestHasher = <::CryptoSpec as CryptoSpec>::Hasher; +/// Produces a 32-byte `Base58Address` whose bytes are NOT a valid ed25519 public key +/// (off-curve). Used as override targets for genesis registration because `sov-accounts` +/// now rejects natural pubkey-derived addresses at genesis (synthetic-only invariant). +/// +/// SHA256 outputs are ~50% on-curve, so we walk a counter until we find an off-curve hash. +/// In practice this finishes in 1-2 iterations. +fn make_synthetic_base58_address(label: &str) -> Base58Address { + use sov_modules_api::digest::Digest; + type Pk = <::CryptoSpec as CryptoSpec>::PublicKey; + for counter in 0u32..32 { + let mut hasher = TestHasher::new(); + hasher.update(label.as_bytes()); + hasher.update(counter.to_le_bytes()); + let bytes: [u8; 32] = hasher.finalize().into(); + if >>::try_from(bytes.to_vec()).is_err() { + return Base58Address::from(bytes); + } + } + panic!("could not find an off-curve 32-byte hash for label `{label}`"); +} + async fn create_test_rollup() -> anyhow::Result<( TestRollup>, TestUser, +)> { + create_test_rollup_with_extra_account_owners(|_| vec![]).await +} + +/// Variant of [`create_test_rollup`] that seeds additional `(credential_id, address)` pairs +/// into `sov-accounts` at genesis. Callers use this to authorize a multisig credential for a +/// specific address override so V1 transactions carrying `address_override = Some(X)` can resolve +/// their sender to `X` instead of the multisig's default address. +/// +/// `extra_account_owners_for` is a closure invoked with the generated admin user so the caller +/// can bind extra `AccountData` entries to admin's address without needing to materialize admin +/// before calling this helper. +async fn create_test_rollup_with_extra_account_owners( + extra_account_owners_for: impl FnOnce( + &TestUser, + ) -> Vec< + sov_test_utils::runtime::sov_accounts::AccountData<::Address>, + >, +) -> anyhow::Result<( + TestRollup>, + TestUser, )> { // Create genesis config let genesis_config = HighLevelOptimisticGenesisConfig::::generate() @@ -101,7 +142,7 @@ async fn create_test_rollup() -> anyhow::Result<( let sequencer = genesis_config.initial_sequencer.clone(); let admin = genesis_config.additional_accounts()[0].clone(); - let rt_genesis_config = >::GenesisConfig::from_minimal_config( + let mut rt_genesis_config = >::GenesisConfig::from_minimal_config( genesis_config.clone().into(), ValueSetterConfig { admin: admin.address(), @@ -127,6 +168,10 @@ async fn create_test_rollup() -> anyhow::Result<( .unwrap(), }, ); + rt_genesis_config + .accounts + .accounts + .extend(extra_account_owners_for(&admin)); let genesis_params = GenesisParams { runtime: rt_genesis_config, @@ -174,6 +219,14 @@ async fn create_test_rollup() -> anyhow::Result<( } fn create_transfer_tx_json(amount: Amount, recipient: &str) -> String { + create_transfer_tx_json_with_address_override(amount, recipient, None) +} + +fn create_transfer_tx_json_with_address_override( + amount: Amount, + recipient: &str, + address_override: Option<::Address>, +) -> String { let msg: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { to: ::Address::from_str(recipient).unwrap(), coins: Coins { @@ -183,22 +236,40 @@ fn create_transfer_tx_json(amount: Amount, recipient: &str) -> String { }); let unsigned_tx = UnsignedTransaction::::new( msg, - config_value!("CHAIN_ID"), + RT::CHAIN_HASH, TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, UniquenessData::Generation(0), Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, ); - let solana_unsigned_tx = SolanaOffchainUnsignedTransactionV0:: { + let solana_unsigned_tx = SolanaOffchainSigningPayloadV0:: { runtime_call: unsigned_tx.runtime_call, uniqueness: unsigned_tx.uniqueness, details: unsigned_tx.details, chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), + address_override, + version: 0, }; serde_json::to_string(&solana_unsigned_tx).unwrap() } +async fn submit_simple_json_tx( + client: &sov_api_spec::client::Client, + json: String, + signer: &Ed25519PrivateKey, +) -> reqwest::Response { + let signed_message = json.into_bytes(); + let message = SolanaOffchainSimpleEnvelope:: { + signed_message: signed_message.clone(), + chain_hash: RT::CHAIN_HASH, + pubkey: signer.pub_key(), + signature: signer.sign(&signed_message), + }; + submit_tx(client, borsh::to_vec(&message).unwrap()).await +} + async fn submit_tx( client: &sov_api_spec::client::Client, raw_tx_bytes: Vec, @@ -248,6 +319,7 @@ async fn test_rollup_initialization() { } #[tokio::test(flavor = "multi_thread")] +#[ignore = "requires new Ledger signatures after the transaction details format change"] async fn test_submit_ledger_signed_transaction() { // From the test Ledger device used to generate this const LEDGER_ADDRESS: &str = "8YkzDTyLd3buhMw9CMfYYt3FLmcu1BeFr5nMeierYM1v"; @@ -262,7 +334,7 @@ async fn test_submit_ledger_signed_transaction() { let pubkey = signer.pub_key(); let signature = signer.sign(&encoded_tx); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx, chain_hash: RT::CHAIN_HASH, pubkey, @@ -284,7 +356,7 @@ async fn test_submit_ledger_signed_transaction() { // updated.) assert_eq!( transfer_json_tx, - r#"{"runtime_call":{"bank":{"transfer":{"to":"4zdwHNaEa5npHtRtaZ3RL1m6rptuQZ6RBLHG6cAyVHjL","coins":{"amount":"5000","token_id":"token_1nyl0e0yweragfsatygt24zmd8jrr2vqtvdfptzjhxkguz2xxx3vs0y07u7"}}}},"uniqueness":{"generation":0},"details":{"max_priority_fee_bips":0,"max_fee":"100000000000","gas_limit":[1000000000,1000000000],"chain_id":4321},"chain_name":"TestChain"}"#, + r#"{"runtime_call":{"bank":{"transfer":{"to":"4zdwHNaEa5npHtRtaZ3RL1m6rptuQZ6RBLHG6cAyVHjL","coins":{"amount":"5000","token_id":"token_1nyl0e0yweragfsatygt24zmd8jrr2vqtvdfptzjhxkguz2xxx3vs0y07u7"}}}},"uniqueness":{"generation":0},"details":{"max_priority_fee_bips":0,"max_fee":"100000000000","gas_limit":[1000000000,1000000000],"chain_id":4321},"chain_name":"TestChain","version":0}"#, "JSON changed - re-sign on Ledger and update the hardcoded signature" ); let encoded_tx = transfer_json_tx.as_bytes().to_vec(); @@ -294,7 +366,7 @@ async fn test_submit_ledger_signed_transaction() { .try_into() .unwrap(); let signature: Ed25519Signature = bs58::decode( - "3GBYQrmcKtUiXAQLz2bUR55Kh7YfgUy2g199ePXYSUHbRHLAsdjcTctSrt98oiA79nZVQU79AbBpiKU23Z2UTstQ", + "3kY989YQ614HgcoToFeZp4rWggqzkpQJPeusDNoVRLt74X1goAaoF6LH8wXB8dwrvmjnxBNvSJq1LFpG3MBh6fAd", ) .into_vec() .unwrap() @@ -310,17 +382,28 @@ async fn test_submit_ledger_signed_transaction() { let message_str = bs58::encode(&signed_message_with_preamble).into_string(); assert_eq!( message_str, - "45bxAZgjJHtL6EmowbCZiBduiwEePySEehCCaCVVJouRB7hQRL3qsv4PNmQvE9NVDfFKmfmVNaNS5a32X1fpSmjJVk19Dk9VSqLyYXxeVuGCZR4jCx7JTx1qbLHD3amNkHvmCnhkgLbT8HgkPwHZWPMeapAo2cL9N3CRzPMZFM5ikWb8yJXzFpCzBjsL1fkCtkDz2BoZPHAtrh5Zvhdae6W9Qypme1iUys8iu4A4e3mk6Nh2us2iLgPcEJhK7xsNxm66CxogjGnwBW3ioTfjRby9LHVzJwQ2dFLJT8kugres5xGG6PxKFNkhFRV4bPgYJvh4ZUUUZSWKkVeW4Ep3nH4BTn1N5WkouYWjKvhy54FCasbM8AyWYAyCnSpX5e8jFEN12rBzmq4HEEJxJY51rTULCEK8Uq2ZJKAe5yTXUisQRw1hZo5bQGku5DwfGyupyfiE6b78vm7uJvQcipLFBoDByrrwPRGhYXK3m8axmcDUFm2PiCpRfYSMk7kGsNn1LLD7D2EwovWDCVERVEB5zftfj6gx9ZeFeZi1c2PdUYsvhZjHdVEa8JVFmts5Q8hD1Wf53DjQAmCFa8GzTK1cekDN47ENFfPaPQsLRFTEXn", - "Multisig message bytes changed - re-sign on ledger and update the hardcoded signature" + "FspUdENNoCTH4VASSjcgC8cLUmq79qrXg2NPyYx6h6fw1fQvwpCCNFEC4VH3wVWYU3FKv54nNKE1guEqUDJ9cBv2R5aUDFiTiSt1uyTQMiacVcB9tknHJTWDKKkoYwiAqeLYvK2yACXfd8XpajNMk8FexFqv6jWXcwHyEBp9TVnrwqxTvXJkVUhVRXv1pkSPHL2ZwUNtb4b98YzuwjcPXiKmqfXZz1FRARFJPSdWoSsKihj7VUB9SLuDmVvQpuK4AhQbeJCbZfhAnu3BccvCYjX6YEKwejDkSApzCNZEeuRHDHcw4WtfMeTREkW3qQr98N1ZZY8mx3zoZ59EMnfwdfgTAM5LH2hLRZk6FxtXz9oLXPDFBzaa1K5ikjYtqGvgf3vzDWrTCH4cYAZXP6xvs9rAs5JYyWcwm2ToCSKvS2Qxe867XhBf5pNnDtieLwZjSByQJLM27w2RXeJ2FR7T7p2KphFeKxYEdR76JDuA58aXWtw8NvRHoVULnxMuj65epCipxnvWkFqALsUaSAeQMQV1U3v9PF8MJDHWpyENBF9YG66ZZf2NwrA3kRNGVm3F8hCJgnuiN3jxiZWgAMuqxX1eeY26WKufP63SwBXQbkE3Yo7nx5yfEMApRi", + "Ledger message bytes changed - re-sign on ledger and update the hardcoded signature" ); - let message = SolanaOffchainSpecCompliantMessage:: { + let message = SolanaOffchainSpecCompliantEnvelope:: { signed_message_with_preamble, signature, }; let raw_tx_bytes = borsh::to_vec(&message).unwrap(); + { + let request = AcceptTx { + body: sov_sequencer::rest_api::Base64Blob { + blob: raw_tx_bytes.clone(), + }, + }; + println!( + "SINGLE_SIG LEDGER PAYLOAD: {}", + serde_json::to_string(&request).unwrap() + ); + } let response = submit_tx(test_rollup.api_client(), raw_tx_bytes).await; assert!( response.status().is_success(), @@ -355,10 +438,11 @@ async fn test_submit_raw_signed_message_transaction() { let tx_str = create_transfer_tx_json(Amount(10_000), RECIPIENT_ADDRESS); let encoded_tx = tx_str.as_bytes().to_vec(); let signer = admin.private_key(); + println!("SINGLE SIG SIGNER KEY: {}", hex::encode(signer)); let pubkey = signer.pub_key(); let signature = signer.sign(&encoded_tx); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx, chain_hash: RT::CHAIN_HASH, pubkey, @@ -366,6 +450,17 @@ async fn test_submit_raw_signed_message_transaction() { }; let raw_tx_bytes = borsh::to_vec(&message).unwrap(); + { + let request = AcceptTx { + body: sov_sequencer::rest_api::Base64Blob { + blob: raw_tx_bytes.clone(), + }, + }; + println!( + "SINGLE_SIG PAYLOAD: {}", + serde_json::to_string(&request).unwrap() + ); + } let response = submit_tx(test_rollup.api_client(), raw_tx_bytes).await; assert!( @@ -394,7 +489,7 @@ async fn test_submit_invalid_raw_signed_message_transaction() { signature_bytes[5] = signature_bytes[5].wrapping_add(1); let signature: Ed25519Signature = signature_bytes.as_slice().try_into().unwrap(); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx, chain_hash: RT::CHAIN_HASH, pubkey, @@ -443,6 +538,7 @@ fn create_multisig_transfer_tx_json( amount: Amount, recipient: &str, multisig_id: ::Address, + address_override: Option<::Address>, ) -> String { let msg: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { to: ::Address::from_str(recipient).unwrap(), @@ -453,18 +549,20 @@ fn create_multisig_transfer_tx_json( }); let unsigned_tx = UnsignedTransaction::::new( msg, - config_value!("CHAIN_ID"), + RT::CHAIN_HASH, TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, UniquenessData::Nonce(0), Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, ); - let solana_unsigned_tx = SolanaOffchainUnsignedTransactionV1:: { + let solana_unsigned_tx = SolanaOffchainSigningPayloadV1:: { runtime_call: unsigned_tx.runtime_call, uniqueness: unsigned_tx.uniqueness, details: unsigned_tx.details, chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), multisig_id, + address_override, version: 1, }; serde_json::to_string(&solana_unsigned_tx).unwrap() @@ -509,7 +607,7 @@ async fn test_submit_multisig_simple_message_transaction() { let pubkey = signer.pub_key(); let signature = signer.sign(&encoded_tx); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx, chain_hash: RT::CHAIN_HASH, pubkey, @@ -529,7 +627,7 @@ async fn test_submit_multisig_simple_message_transaction() { // Build a transfer from the multisig to the recipient, using V1 format which commits // to the credential_id in the signed message. let transfer_json = - create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); @@ -538,7 +636,7 @@ async fn test_submit_multisig_simple_message_transaction() { let sig3 = key3.sign(json_bytes); let sig1 = key1.sign(json_bytes); - let multisig_msg = SolanaOffchainSimpleMultisigMessage:: { + let multisig_msg = SolanaOffchainSimpleMultisigEnvelope:: { wire_bytes, chain_hash: RT::CHAIN_HASH, signatures: vec![ @@ -613,7 +711,7 @@ async fn test_submit_multisig_insufficient_signatures() { let funding_json = create_transfer_tx_json(Amount(20_000), &multisig_address_str); let encoded_tx = funding_json.as_bytes().to_vec(); let signer = admin.private_key(); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx.clone(), chain_hash: RT::CHAIN_HASH, pubkey: signer.pub_key(), @@ -625,12 +723,12 @@ async fn test_submit_multisig_insufficient_signatures() { // Only 1 signer for a 2-of-3 multisig — should fail let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); let sig1 = key1.sign(json_bytes); - let multisig_msg = SolanaOffchainSimpleMultisigMessage:: { + let multisig_msg = SolanaOffchainSimpleMultisigEnvelope:: { wire_bytes, chain_hash: RT::CHAIN_HASH, signatures: vec![PubKeyAndSignature { @@ -678,7 +776,7 @@ async fn test_submit_multisig_invalid_signature() { let funding_json = create_transfer_tx_json(Amount(20_000), &multisig_address_str); let encoded_tx = funding_json.as_bytes().to_vec(); let signer = admin.private_key(); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx.clone(), chain_hash: RT::CHAIN_HASH, pubkey: signer.pub_key(), @@ -690,7 +788,7 @@ async fn test_submit_multisig_invalid_signature() { // Corrupt the second signature let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); let sig1 = key1.sign(json_bytes); @@ -698,7 +796,7 @@ async fn test_submit_multisig_invalid_signature() { sig2_bytes[10] = sig2_bytes[10].wrapping_add(1); let sig2: Ed25519Signature = sig2_bytes.as_slice().try_into().unwrap(); - let multisig_msg = SolanaOffchainSimpleMultisigMessage:: { + let multisig_msg = SolanaOffchainSimpleMultisigEnvelope:: { wire_bytes, chain_hash: RT::CHAIN_HASH, signatures: vec![ @@ -758,7 +856,7 @@ async fn test_submit_multisig_spec_compliant_message_transaction() { let pubkey = signer.pub_key(); let signature = signer.sign(&encoded_tx); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx, chain_hash: RT::CHAIN_HASH, pubkey, @@ -777,7 +875,7 @@ async fn test_submit_multisig_spec_compliant_message_transaction() { // Build a transfer from the multisig using spec-compliant format. let transfer_json = - create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); // Build the multisig preamble with a canonical signer ordering so the emitted payload @@ -810,7 +908,7 @@ async fn test_submit_multisig_spec_compliant_message_transaction() { } assert_eq!(signatures.len(), 2); - let multisig_msg = SolanaOffchainSpecCompliantMultisigMessage:: { + let multisig_msg = SolanaOffchainSpecCompliantMultisigEnvelope:: { signed_message_with_preamble, signatures: signatures.try_into().unwrap(), signer_bitfield, @@ -873,7 +971,7 @@ async fn test_submit_spec_compliant_multisig_insufficient_signatures() { let funding_json = create_transfer_tx_json(Amount(20_000), &multisig_address_str); let encoded_tx = funding_json.as_bytes().to_vec(); let signer = admin.private_key(); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx.clone(), chain_hash: RT::CHAIN_HASH, pubkey: signer.pub_key(), @@ -885,7 +983,7 @@ async fn test_submit_spec_compliant_multisig_insufficient_signatures() { // Only 1 signer when 2 are required let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let preamble = make_multisig_preamble_for_message( @@ -900,7 +998,7 @@ async fn test_submit_spec_compliant_multisig_insufficient_signatures() { let sig1 = key1.sign(&signed_message_with_preamble); // Only signer 0 signed → bitfield 0b001 - let multisig_msg = SolanaOffchainSpecCompliantMultisigMessage:: { + let multisig_msg = SolanaOffchainSpecCompliantMultisigEnvelope:: { signed_message_with_preamble, signatures: vec![sig1].try_into().unwrap(), signer_bitfield: 0b001, @@ -942,7 +1040,7 @@ async fn test_submit_spec_compliant_multisig_invalid_signature() { let funding_json = create_transfer_tx_json(Amount(20_000), &multisig_address_str); let encoded_tx = funding_json.as_bytes().to_vec(); let signer = admin.private_key(); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx.clone(), chain_hash: RT::CHAIN_HASH, pubkey: signer.pub_key(), @@ -953,7 +1051,7 @@ async fn test_submit_spec_compliant_multisig_invalid_signature() { } let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let preamble = make_multisig_preamble_for_message( @@ -972,7 +1070,7 @@ async fn test_submit_spec_compliant_multisig_invalid_signature() { let sig2: Ed25519Signature = sig2_bytes.as_slice().try_into().unwrap(); // Signers 0 and 1 → bitfield 0b011 - let multisig_msg = SolanaOffchainSpecCompliantMultisigMessage:: { + let multisig_msg = SolanaOffchainSpecCompliantMultisigEnvelope:: { signed_message_with_preamble, signatures: vec![sig1, sig2].try_into().unwrap(), signer_bitfield: 0b011, @@ -991,6 +1089,7 @@ async fn test_submit_spec_compliant_multisig_invalid_signature() { /// two are mock Ed25519 keys with hardcoded seeds. The Ledger signature and exact JSON bytes are /// hardcoded — if the transaction format changes, re-sign on the Ledger and update the constants. #[tokio::test(flavor = "multi_thread")] +#[ignore = "requires new Ledger signatures after the transaction details format change"] async fn test_submit_ledger_signed_multisig_transaction() { // Ledger device pubkey (signer index 0 in the preamble) const LEDGER_ADDRESS: &str = "8YkzDTyLd3buhMw9CMfYYt3FLmcu1BeFr5nMeierYM1v"; @@ -1030,7 +1129,7 @@ async fn test_submit_ledger_signed_multisig_transaction() { let pubkey = signer.pub_key(); let signature = signer.sign(&encoded_tx); - let message = SolanaOffchainSimpleMessage:: { + let message = SolanaOffchainSimpleEnvelope:: { signed_message: encoded_tx, chain_hash: RT::CHAIN_HASH, pubkey, @@ -1046,7 +1145,7 @@ async fn test_submit_ledger_signed_multisig_transaction() { // Build the V1 multisig transfer transaction let transfer_json_tx = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let encoded_tx = transfer_json_tx.as_bytes(); // Build the multisig preamble with all 3 pubkeys (Ledger at index 0) @@ -1082,7 +1181,7 @@ async fn test_submit_ledger_signed_multisig_transaction() { let sig2 = key2.sign(&signed_message_with_preamble); // Bitfield: signers 0 (Ledger) and 1 (key2) signed → 0b011 - let multisig_msg = SolanaOffchainSpecCompliantMultisigMessage:: { + let multisig_msg = SolanaOffchainSpecCompliantMultisigEnvelope:: { signed_message_with_preamble, signatures: vec![ledger_signature, sig2].try_into().unwrap(), signer_bitfield: 0b011, @@ -1110,3 +1209,424 @@ async fn test_submit_ledger_signed_multisig_transaction() { "Expected recipient to have received 5,000 tokens" ); } + +/// End-to-end plumbing test: a V1 transaction carrying `address_override = Some(target)` — where +/// genesis authorizes `(target, multisig_credential_id)` in `account_owners` — routes +/// execution as `target`, not as the multisig's default address. Proves that `address_override` +/// flows from the signed JSON through `authenticate`, `build_auth_data`, `AuthorizationData`, +/// and into `resolve_context`. The target is a synthetic (off-curve) address because the +/// sov-accounts genesis invariant rejects natural pubkey-derived addresses. +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_multisig_with_authorized_address_override() { + // Build a 2-of-3 multisig (its default address is never funded). + let key1 = Ed25519PrivateKey::generate(); + let key2 = Ed25519PrivateKey::generate(); + let key3 = Ed25519PrivateKey::generate(); + let pub1 = key1.pub_key(); + let pub2 = key2.pub_key(); + let pub3 = key3.pub_key(); + let min_signers: u8 = 2; + let multisig = + sov_modules_api::Multisig::new(min_signers, vec![pub1.clone(), pub2.clone(), pub3.clone()]); + let multisig_credential_id = multisig.credential_id::(); + let multisig_default_address: ::Address = multisig_credential_id.into(); + + // Synthetic 32-byte target the multisig is delegated to. Off-curve by construction + // so it satisfies the sov-accounts genesis synthetic-only invariant. + let target_address = make_synthetic_base58_address("multisig_override_target"); + let target_address_str = target_address.to_string(); + + let (test_rollup, admin) = create_test_rollup_with_extra_account_owners(|_admin| { + vec![sov_test_utils::runtime::sov_accounts::AccountData { + credential_id: multisig_credential_id, + address: target_address, + }] + }) + .await + .expect("Failed to create rollup"); + + // Fund the synthetic target so the multisig has something to spend. + let funding_response = submit_simple_json_tx( + test_rollup.api_client(), + create_transfer_tx_json(Amount(10_000), &target_address_str), + admin.private_key(), + ) + .await; + assert!( + funding_response.status().is_success(), + "Failed to fund target address. Response: {funding_response:?}" + ); + + let target_balance_before = query_balance(&test_rollup.client, &target_address_str).await; + assert_eq!( + target_balance_before, + Some(Amount::new(10_000)), + "Expected synthetic target funded with 10,000 before override-routed multisig tx" + ); + + // Build a V1 multisig transfer targeting the synthetic address. + let transfer_json = create_multisig_transfer_tx_json( + Amount(7_000), + RECIPIENT_ADDRESS, + multisig_default_address, + Some(target_address), + ); + let json_bytes = transfer_json.as_bytes(); + let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); + + // Two of three signers sign. + let sig1 = key1.sign(json_bytes); + let sig2 = key2.sign(json_bytes); + let multisig_msg = SolanaOffchainSimpleMultisigEnvelope:: { + wire_bytes, + chain_hash: RT::CHAIN_HASH, + signatures: vec![ + PubKeyAndSignature { + signature: sig1, + pub_key: pub1, + }, + PubKeyAndSignature { + signature: sig2, + pub_key: pub2, + }, + ] + .try_into() + .unwrap(), + unused_pub_keys: vec![pub3].try_into().unwrap(), + min_signers, + }; + + let raw_tx_bytes = borsh::to_vec(&multisig_msg).unwrap(); + let response = submit_tx(test_rollup.api_client(), raw_tx_bytes).await; + assert!( + response.status().is_success(), + "Expected multisig-with-address_override transaction to succeed. Response: {response:?}" + ); + + // Recipient got the transfer. + let recipient_balance = query_balance(&test_rollup.client, RECIPIENT_ADDRESS).await; + assert_eq!( + recipient_balance, + Some(Amount::new(7_000)), + "Expected recipient to have received 7,000 tokens via override-routed multisig tx" + ); + + // The synthetic target's balance dropped by the transferred amount (gas is paid by the + // paymaster, not by the target). + let target_balance_after = query_balance(&test_rollup.client, &target_address_str).await; + assert_eq!( + target_balance_after, + Some(Amount::new(3_000)), + "Expected target balance to drop by 7,000 after override-routed multisig tx; before={target_balance_before:?}, after={target_balance_after:?}" + ); + + // The multisig's default address was never funded and should still have no balance — + // proving that the tx did NOT route through the default resolver. + let multisig_default_balance = + query_balance(&test_rollup.client, &multisig_default_address.to_string()).await; + assert!( + multisig_default_balance.is_none() || multisig_default_balance == Some(Amount::new(0)), + "Multisig default address must not have been used as sender; got balance {multisig_default_balance:?}" + ); +} + +/// Helper: builds a `SolanaOffchainSigningPayloadV1` with an arbitrary recipient for the +/// serde round-trip / byte-equivalence tests below. +fn build_v1_payload( + recipient: &str, + multisig_id: ::Address, + address_override: Option<::Address>, +) -> SolanaOffchainSigningPayloadV1 { + let call: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { + to: ::Address::from_str(recipient).unwrap(), + coins: Coins { + amount: Amount(1_000), + token_id: config_value!("GAS_TOKEN_ID"), + }, + }); + let unsigned_tx = UnsignedTransaction::::new( + call, + RT::CHAIN_HASH, + TEST_DEFAULT_MAX_PRIORITY_FEE, + TEST_DEFAULT_MAX_FEE, + UniquenessData::Nonce(0), + Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, + ); + SolanaOffchainSigningPayloadV1:: { + runtime_call: unsigned_tx.runtime_call, + uniqueness: unsigned_tx.uniqueness, + details: unsigned_tx.details, + chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), + multisig_id, + address_override, + version: 1, + } +} + +/// `address_override: None` is omitted from the serialized JSON. +#[test] +fn test_v1_payload_omits_address_override_when_none() { + let multisig_address: ::Address = [0xABu8; 32].into(); + let payload = build_v1_payload(RECIPIENT_ADDRESS, multisig_address, None); + + let json = serde_json::to_string(&payload).expect("serialize"); + assert!( + !json.contains("address_override"), + "address_override must not appear in JSON when it is None; got: {json}" + ); +} + +/// Pre-change JSON (no `address_override` key) deserializes into a payload with `address_override: +/// None`. Pins the `#[serde(default)]` promise: future refactors cannot silently break deployed +/// Solana signers. +#[test] +fn test_v1_payload_without_address_override_field_deserializes_as_none() { + let multisig_address: ::Address = [0xABu8; 32].into(); + let expected = build_v1_payload(RECIPIENT_ADDRESS, multisig_address, None); + let canonical_json = serde_json::to_string(&expected).expect("serialize baseline"); + assert!( + !canonical_json.contains("address_override"), + "guard: the canonical None-payload already omits address_override" + ); + + let parsed: SolanaOffchainSigningPayloadV1 = + serde_json::from_str(&canonical_json).expect("deserialize"); + assert_eq!( + parsed.address_override, None, + "missing address_override field must default to None" + ); +} + +/// A `address_override: Some(X)` round-trips through serialize/deserialize unchanged. +#[test] +fn test_v1_payload_with_address_override_round_trips() { + let multisig_address: ::Address = [0xABu8; 32].into(); + let target: ::Address = + ::Address::from_str(RECIPIENT_ADDRESS).expect("parse recipient"); + let original = build_v1_payload(RECIPIENT_ADDRESS, multisig_address, Some(target)); + + let json = serde_json::to_string(&original).expect("serialize"); + assert!( + json.contains("address_override"), + "address_override must appear in JSON when it is Some; got: {json}" + ); + + let parsed: SolanaOffchainSigningPayloadV1 = + serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.address_override, Some(target)); + assert_eq!(parsed.multisig_id, multisig_address); +} + +/// End-to-end plumbing test for single-sig V0: admin's credential is explicitly +/// authorized for a delegated address at genesis, that delegated address is funded, +/// and a V0 tx carrying `address_override = Some(delegated)` spends from it. +/// +/// The delegated target is a synthetic (off-curve) 32-byte address — `sov-accounts` +/// rejects natural pubkey-derived addresses at genesis. +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_single_sig_with_authorized_address_override() { + let delegated_address = make_synthetic_base58_address("single_sig_delegated_v0"); + let delegated_address_str = delegated_address.to_string(); + let (test_rollup, admin) = create_test_rollup_with_extra_account_owners(|admin| { + vec![sov_test_utils::runtime::sov_accounts::AccountData { + credential_id: admin.credential_id(), + address: delegated_address, + }] + }) + .await + .expect("Failed to create rollup"); + + let response = submit_simple_json_tx( + test_rollup.api_client(), + create_transfer_tx_json(Amount(8_000), &delegated_address_str), + admin.private_key(), + ) + .await; + assert!( + response.status().is_success(), + "Expected delegated funding tx to succeed. Response: {response:?}" + ); + + let delegated_balance_before = query_balance(&test_rollup.client, &delegated_address_str).await; + assert_eq!( + delegated_balance_before, + Some(Amount::new(8_000)), + "Expected delegated address to be funded before override-routed transfer" + ); + + let response = submit_simple_json_tx( + test_rollup.api_client(), + create_transfer_tx_json_with_address_override( + Amount(7_000), + RECIPIENT_ADDRESS, + Some(delegated_address), + ), + admin.private_key(), + ) + .await; + assert!( + response.status().is_success(), + "Expected single-sig V0 override-routed tx to succeed. Response: {response:?}" + ); + + let recipient_balance = query_balance(&test_rollup.client, RECIPIENT_ADDRESS).await; + assert_eq!( + recipient_balance, + Some(Amount::new(7_000)), + "Expected recipient to receive 7,000 tokens via override-routed V0 tx" + ); + + let delegated_balance_after = query_balance(&test_rollup.client, &delegated_address_str).await; + assert_eq!( + delegated_balance_after, + Some(Amount::new(1_000)), + "Expected override-routed V0 tx to spend from delegated address" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_single_sig_with_unauthorized_address_override_fails() { + let (test_rollup, admin) = create_test_rollup().await.expect("Failed to create rollup"); + let unowned_address = TestUser::::generate_with_default_balance().address(); + + let response = submit_simple_json_tx( + test_rollup.api_client(), + create_transfer_tx_json_with_address_override( + Amount(1_000), + RECIPIENT_ADDRESS, + Some(unowned_address), + ), + admin.private_key(), + ) + .await; + + assert_eq!( + response.status(), + 400, + "Expected 400 status for unauthorized address override" + ); + let response_text = response.text().await.expect("Failed to read response body"); + assert!( + response_text.contains("not authorized for address override"), + "Expected unauthorized address override error, got: {response_text}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_single_sig_with_tampered_address_override_fails_signature() { + let delegated_address = make_synthetic_base58_address("single_sig_tampered_v0"); + let (test_rollup, admin) = create_test_rollup_with_extra_account_owners(|admin| { + vec![sov_test_utils::runtime::sov_accounts::AccountData { + credential_id: admin.credential_id(), + address: delegated_address, + }] + }) + .await + .expect("Failed to create rollup"); + + let json = create_transfer_tx_json_with_address_override( + Amount(1_000), + RECIPIENT_ADDRESS, + Some(delegated_address), + ); + let signature = admin.private_key().sign(json.as_bytes()); + let mut payload: SolanaOffchainSigningPayloadV0 = + serde_json::from_str(&json).expect("deserialize"); + payload.address_override = Some(admin.address()); + let tampered_json = serde_json::to_string(&payload).expect("serialize"); + let message = SolanaOffchainSimpleEnvelope:: { + signed_message: tampered_json.into_bytes(), + chain_hash: RT::CHAIN_HASH, + pubkey: admin.private_key().pub_key(), + signature, + }; + + let response = submit_tx(test_rollup.api_client(), borsh::to_vec(&message).unwrap()).await; + assert_eq!( + response.status(), + 400, + "Expected 400 status for tampered address override" + ); + let response_text = response.text().await.expect("Failed to read response body"); + assert!( + response_text.contains("Signature verification failed") + || response_text.contains("Verification equation was not satisfied"), + "Expected signature verification error, got: {response_text}" + ); +} + +fn build_v0_payload( + recipient: &str, + address_override: Option<::Address>, +) -> SolanaOffchainSigningPayloadV0 { + let call: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { + to: ::Address::from_str(recipient).unwrap(), + coins: Coins { + amount: Amount(1_000), + token_id: config_value!("GAS_TOKEN_ID"), + }, + }); + let unsigned_tx = UnsignedTransaction::::new( + call, + RT::CHAIN_HASH, + TEST_DEFAULT_MAX_PRIORITY_FEE, + TEST_DEFAULT_MAX_FEE, + UniquenessData::Nonce(0), + Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, + ); + SolanaOffchainSigningPayloadV0:: { + runtime_call: unsigned_tx.runtime_call, + uniqueness: unsigned_tx.uniqueness, + details: unsigned_tx.details, + chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), + address_override, + version: 0, + } +} + +#[test] +fn test_v0_payload_omits_address_override_when_none() { + let payload = build_v0_payload(RECIPIENT_ADDRESS, None); + + let json = serde_json::to_string(&payload).expect("serialize"); + assert!( + !json.contains("address_override"), + "address_override must not appear in JSON when it is None; got: {json}" + ); +} + +#[test] +fn test_v0_payload_without_address_override_field_deserializes_as_none() { + let expected = build_v0_payload(RECIPIENT_ADDRESS, None); + let canonical_json = serde_json::to_string(&expected).expect("serialize baseline"); + assert!( + !canonical_json.contains("address_override"), + "guard: the canonical None-payload already omits address_override" + ); + + let parsed: SolanaOffchainSigningPayloadV0 = + serde_json::from_str(&canonical_json).expect("deserialize"); + assert_eq!( + parsed.address_override, None, + "missing address_override field must default to None" + ); +} + +#[test] +fn test_v0_payload_with_address_override_round_trips() { + let target: ::Address = + ::Address::from_str(RECIPIENT_ADDRESS).expect("parse recipient"); + let original = build_v0_payload(RECIPIENT_ADDRESS, Some(target)); + + let json = serde_json::to_string(&original).expect("serialize"); + assert!( + json.contains("address_override"), + "address_override must appear in JSON when it is Some; got: {json}" + ); + + let parsed: SolanaOffchainSigningPayloadV0 = + serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.address_override, Some(target)); +} diff --git a/crates/rollup-interface/src/state_machine/stf/batch.rs b/crates/rollup-interface/src/state_machine/stf/batch.rs index 2859f2778b..721992bdd5 100644 --- a/crates/rollup-interface/src/state_machine/stf/batch.rs +++ b/crates/rollup-interface/src/state_machine/stf/batch.rs @@ -94,18 +94,19 @@ impl<'de> Deserialize<'de> for FullyBakedTx { let helper = FullyBakedTxHelper::deserialize(deserializer)?; - let total_size = helper.data.len() + helper.sequencing_data.as_ref().map_or(0, |d| d.len()); + let tx = Self { + data: helper.data, + sequencing_data: helper.sequencing_data, + }; + let total_size = tx.payload_len(); if total_size > MAX_FULLY_BAKED_TX_SIZE { return Err(serde::de::Error::custom(format!( "FullyBakedTx total size {total_size} exceeds maximum allowed size of {MAX_FULLY_BAKED_TX_SIZE} bytes", ))); } - Ok(Self { - data: helper.data, - sequencing_data: helper.sequencing_data, - }) + Ok(tx) } } @@ -127,14 +128,6 @@ impl FullyBakedTx { } } - /// Sets sequencing metadata - /// - /// Note that the `get_maybe_timestamp_from_sequencing_data` function relies on this method to serialize the SequencingData using borsh - /// without other modification. Changing that behavior will require a change to `get_maybe_timestamp_from_sequencing_data` - pub fn set_sequencing_metadata(&mut self, metadata: &impl BorshSerialize) { - self.sequencing_data = Some(borsh::to_vec(metadata).unwrap().into()); - } - /// Returns the total serialized length of the transaction, including both /// the transaction data and sequencing metadata (if present). /// This is the length that will be sent on the DA layer. @@ -145,6 +138,14 @@ impl FullyBakedTx { .len() } + /// Returns the combined length of the raw transaction data and sequencing data fields, + /// without encoding overhead. This is the quantity that [`MAX_FULLY_BAKED_TX_SIZE`] bounds: + /// the deserializers reject any transaction whose payload length exceeds it. + #[must_use] + pub fn payload_len(&self) -> usize { + self.data.len() + self.sequencing_data.as_ref().map_or(0, |d| d.len()) + } + /// Returns true if the transaction has no data #[must_use] pub fn is_empty(&self) -> bool { @@ -406,7 +407,7 @@ mod tests { let data = vec![1u8; 1024]; // 1KB let mut tx = FullyBakedTx::new(data.clone()); let seq_data = vec![2u8; 512]; // 512 bytes - tx.set_sequencing_metadata(&seq_data); + tx.sequencing_data = Some(seq_data.into()); // Test borsh let serialized = borsh::to_vec(&tx).unwrap(); @@ -421,7 +422,7 @@ mod tests { let data = vec![1u8; MAX_FULLY_BAKED_TX_SIZE - 500]; let mut tx = FullyBakedTx::new(data); let seq_data = vec![2u8; 600]; // This will push total over limit - tx.set_sequencing_metadata(&seq_data); + tx.sequencing_data = Some(seq_data.into()); // Serialize with borsh let serialized = borsh::to_vec(&tx).unwrap(); diff --git a/crates/universal-wallet/schema/src/schema/mod.rs b/crates/universal-wallet/schema/src/schema/mod.rs index 8ac6eae10a..8eb7e8983f 100644 --- a/crates/universal-wallet/schema/src/schema/mod.rs +++ b/crates/universal-wallet/schema/src/schema/mod.rs @@ -32,6 +32,25 @@ use crate::visitors::eip712::{Context as Eip712Context, Eip712Error, Eip712Visit #[cfg(feature = "eip712")] use alloy_dyn_abi::{Eip712Types, Error as AlloyEip712Error, PropertyDef, TypedData}; +/// Returns the 64-bit fragment used to identify a full chain hash in transaction +/// details. +/// +/// The fragment is the first eight bytes of the chain hash. Interpreting it as +/// little-endian means Borsh serializes the `u64` back to those same eight bytes. +#[must_use] +pub const fn chain_hash_fragment(chain_hash: &[u8; 32]) -> u64 { + u64::from_le_bytes([ + chain_hash[0], + chain_hash[1], + chain_hash[2], + chain_hash[3], + chain_hash[4], + chain_hash[5], + chain_hash[6], + chain_hash[7], + ]) +} + #[derive(Debug, Error)] pub enum SchemaError { #[error(transparent)] @@ -179,7 +198,7 @@ impl ItemId { #[derive(Debug, Copy, Clone)] pub enum RollupRoots { Transaction = 0, - UnsignedTransaction = 1, + TransactionSigningPayload = 1, RuntimeCall = 2, Address = 3, } @@ -281,13 +300,13 @@ impl Schema { } /// Instantiate a schema for a standard set of rollup types: its complete transaction, its - /// unsigned transaction, and its call message type. + /// transaction signing payload, and its call message type. /// The types will be accessible using the indices stored in root_type_indices (in the above /// order); they can also be queried using the `RollupRoots` enum through the `_rollup`-tagged /// functions on the schema pub fn of_rollup_types_with_chain_data< Transaction: UniversalWallet, - UnsignedTransaction: UniversalWallet, + TransactionSigningPayload: UniversalWallet, RuntimeCall: UniversalWallet, Address: UniversalWallet, >( @@ -298,7 +317,7 @@ impl Schema { ..Self::default() }; Transaction::make_root_of(&mut schema); - UnsignedTransaction::make_root_of(&mut schema); + TransactionSigningPayload::make_root_of(&mut schema); RuntimeCall::make_root_of(&mut schema); Address::make_root_of(&mut schema); diff --git a/crates/universal-wallet/schema/tests/integration/schema.rs b/crates/universal-wallet/schema/tests/integration/schema.rs index d6ef94c2f6..c8afce73b4 100644 --- a/crates/universal-wallet/schema/tests/integration/schema.rs +++ b/crates/universal-wallet/schema/tests/integration/schema.rs @@ -1590,7 +1590,7 @@ fn test_multiobject_schema() { schema .display( schema - .rollup_expected_index(RollupRoots::UnsignedTransaction) + .rollup_expected_index(RollupRoots::TransactionSigningPayload) .unwrap(), &struct_borsh_ser ) @@ -1601,7 +1601,7 @@ fn test_multiobject_schema() { schema .json_to_borsh( schema - .rollup_expected_index(RollupRoots::UnsignedTransaction) + .rollup_expected_index(RollupRoots::TransactionSigningPayload) .unwrap(), &struct_json ) diff --git a/crates/utils/sov-migrations/Cargo.toml b/crates/utils/sov-migrations/Cargo.toml new file mode 100644 index 0000000000..79bb2598f6 --- /dev/null +++ b/crates/utils/sov-migrations/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sov-migrations" +description = "Reusable offline state migrations for Sovereign SDK rollups" +authors = { workspace = true } +edition = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +rockbound = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sov-accounts = { workspace = true, features = ["native"] } +sov-chain-state = { workspace = true, features = ["native"] } +sov-db = { workspace = true, features = ["migration-script"] } +sov-modules-api = { workspace = true, features = ["native"] } +sov-rollup-interface = { workspace = true, features = ["native"] } +sov-state = { workspace = true, features = ["native"] } +sov-full-node-configs = { workspace = true } diff --git a/crates/utils/sov-migrations/src/lib.rs b/crates/utils/sov-migrations/src/lib.rs new file mode 100644 index 0000000000..b247f44619 --- /dev/null +++ b/crates/utils/sov-migrations/src/lib.rs @@ -0,0 +1,190 @@ +//! Reusable offline state migrations for Sovereign SDK rollups. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context}; +use rockbound::SchemaBatch; +use serde::{Deserialize, Serialize}; +use sov_chain_state::ChainState; +use sov_db::config::RollupDbConfig; +use sov_db::ledger_db::LedgerDb; +use sov_db::schema::tables::SlotByNumber; +use sov_db::storage_manager::NomtChangeSet; +use sov_full_node_configs::runner::from_toml_path; +use sov_modules_api::capabilities::{BlockGasInfo, RollupHeight}; +use sov_modules_api::prelude::UnwrapInfallible; +use sov_modules_api::runtime::capabilities::Kernel as KernelTrait; +use sov_modules_api::{BootstrapWorkingSet, Spec, StateCheckpoint}; +use sov_rollup_interface::common::{SlotNumber, VisibleSlotNumber}; +use sov_state::NativeStorage; + +pub mod v1; + +#[derive(Deserialize)] +struct StorageOnlyRollupConfig { + storage: RollupDbConfig, +} + +/// CLI-shaped inputs for running a migration against a rollup database. +#[derive(Debug, Clone)] +pub struct MigrationArgs { + /// Path to the rollup config file used by the running node. + pub rollup_config_path: PathBuf, + /// Override `storage.path` from the rollup config. + pub db_path: Option, + /// Compute the post-migration state root but do not commit changes. + pub dry_run: bool, +} + +/// NOMT storage operations needed to rewrite an existing head version. +pub trait MigrationStorage: NativeStorage { + /// Materializes a state update at the current ledger-head version. + fn materialize_migration_changes_at_version( + self, + state_update: Self::StateUpdate, + version: SlotNumber, + ) -> NomtChangeSet; +} + +impl MigrationStorage for sov_state::nomt::prover_storage::NomtProverStorage +where + S: sov_state::MerkleProofSpec, + K: Clone + Eq + std::hash::Hash, +{ + fn materialize_migration_changes_at_version( + self, + state_update: Self::StateUpdate, + version: SlotNumber, + ) -> NomtChangeSet { + self.materialize_changes_at_version(state_update, version) + } +} + +struct MigrationKernel<'a, S: Spec> { + chain_state: &'a ChainState, +} + +impl KernelTrait for MigrationKernel<'_, S> { + fn true_slot_number(&self, state: &mut BootstrapWorkingSet<'_, S>) -> SlotNumber { + self.chain_state.true_slot_number_at_bootstrap(state) + } + + fn next_visible_slot_number( + &self, + state: &mut BootstrapWorkingSet<'_, S>, + ) -> VisibleSlotNumber { + self.chain_state.next_visible_slot_number(state) + } + + fn rollup_height(&self, state: &mut BootstrapWorkingSet<'_, S>) -> RollupHeight { + self.chain_state.rollup_height(state).unwrap_infallible() + } + + fn record_gas_usage( + &mut self, + _state: &mut StateCheckpoint, + _final_gas_info: BlockGasInfo, + _rollup_height: RollupHeight, + ) { + unreachable!("offline migration checkpoints do not record block gas usage") + } +} + +/// Inputs for a migration that rewrites the current head state. +pub struct MigrationOptions { + /// Database configuration for the rollup state to migrate. + pub storage: RollupDbConfig, + /// Compute the post-migration state root but do not commit changes. + pub dry_run: bool, +} + +/// JSON-serializable summary for an offline migration. +#[derive(Debug, Serialize)] +pub struct MigrationOutcome { + /// Whether the migration was run without committing changes. + pub dry_run: bool, + /// Database path used by the migration. + pub db_path: String, + /// State root at the ledger head before the migration. + pub pre_state_root: String, + /// State root after applying the migration. + pub post_state_root: String, + /// Ledger head slot that was migrated. + pub head_rollup_slot: u64, + /// Migration-specific report. + pub migration: Report, +} + +/// Loads the storage config from a rollup TOML config and applies an optional DB path override. +pub fn load_storage_config( + rollup_config_path: impl AsRef, + db_path_override: Option<&Path>, +) -> anyhow::Result { + let rollup_config: StorageOnlyRollupConfig = + from_toml_path(&rollup_config_path).with_context(|| { + format!( + "failed to read rollup config from {}", + rollup_config_path.as_ref().display() + ) + })?; + + let mut storage = rollup_config.storage; + if let Some(db_path_override) = db_path_override { + storage.path = db_path_override.to_path_buf(); + } + Ok(storage) +} + +fn assert_storage_latest_version_matches_ledger_head( + storage: &S, + ledger_db: &LedgerDb, + phase: &str, +) -> anyhow::Result { + let (head_slot_number, _head_slot) = ledger_db + .get_head_slot()? + .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot migrate an empty DB"))?; + let storage_latest_version = storage.latest_version(); + if storage_latest_version != head_slot_number { + bail!( + "{phase} invariant failed: storage.latest_version ({}) != ledger head slot ({})", + storage_latest_version, + head_slot_number + ); + } + Ok(head_slot_number) +} + +fn assert_ledger_head_state_root_matches_storage_root( + storage: &S, + ledger_db: &LedgerDb, + phase: &str, +) -> anyhow::Result<()> { + let (head_slot_number, head_slot) = ledger_db + .get_head_slot()? + .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot migrate an empty DB"))?; + let storage_root = storage + .get_root_hash(head_slot_number) + .context("failed to read storage root at ledger head slot")?; + if head_slot.state_root.as_ref() != storage_root.as_ref() { + bail!( + "{phase} invariant failed: ledger head state_root does not match storage root at slot {}", + head_slot_number + ); + } + Ok(()) +} + +fn make_ledger_root_patch( + ledger_db: &LedgerDb, + new_state_root: &[u8], +) -> anyhow::Result { + let (head_slot_number, mut head_slot) = ledger_db + .get_head_slot()? + .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot patch state root"))?; + + head_slot.state_root = new_state_root.to_vec().into(); + + let mut batch = SchemaBatch::new(); + batch.put::(&head_slot_number, &head_slot)?; + Ok(batch) +} diff --git a/crates/utils/sov-migrations/src/v1.rs b/crates/utils/sov-migrations/src/v1.rs new file mode 100644 index 0000000000..5244f87696 --- /dev/null +++ b/crates/utils/sov-migrations/src/v1.rs @@ -0,0 +1,307 @@ +//! Migration from state version 0 to state version 1. + +use anyhow::Context; +use serde::Serialize; +use sov_accounts::{Account, Accounts}; +use sov_chain_state::ChainState; +use sov_modules_api::{ + AccessoryStateValue, CredentialId, ModuleInfo, Spec, StateCheckpoint, StateWriter, +}; +use sov_state::{BorshCodec, Kernel, NativeStorage, Prefix, SlotKey, SlotValue, StateUpdate}; + +use crate::MigrationStorage as _; + +/// The state version expected before applying this migration. +pub const SOURCE_STATE_VERSION: u64 = 0; +/// The state version written after applying this migration. +pub const TARGET_STATE_VERSION: u64 = 1; + +/// Data collected before the migration checkpoint consumes storage. +pub struct MigrationData { + legacy_accounts: Vec<(CredentialId, Account)>, + kernel_roundtrip: KernelRoundtrip, +} + +struct KernelRoundtrip { + key: SlotKey, + value: SlotValue, +} + +/// Summary of the state-version-0 to state-version-1 migration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MigrationReport { + /// State version read before the migration. + pub from_state_version: u64, + /// State version written by the migration. + pub to_state_version: u64, + /// Summary of the sov-accounts sub-migration. + pub accounts: sov_accounts::migrations::MigrationReport, +} + +/// Collects all data needed for the v1 migration before storage is moved into a checkpoint. +pub fn collect( + accounts: &Accounts, + chain_state: &ChainState, + storage: &Storage, +) -> anyhow::Result> +where + S: Spec, + Storage: NativeStorage, +{ + let legacy_accounts = + sov_accounts::migrations::collect_legacy_account_entries(accounts, storage) + .context("failed to collect legacy accounts entries")?; + let kernel_roundtrip = collect_kernel_roundtrip(chain_state, storage)?; + + Ok(MigrationData { + legacy_accounts, + kernel_roundtrip, + }) +} + +/// Applies the v1 migration to a checkpoint. +pub fn apply( + accounts: &mut Accounts, + chain_state: &mut ChainState, + data: MigrationData, + state: &mut StateCheckpoint, +) -> anyhow::Result +where + S: Spec, +{ + let from_state_version = { + let mut accessory_state = state.accessory_state(); + chain_state + .state_version(&mut accessory_state) + .context("failed to read chain-state state_version")? + }; + anyhow::ensure!( + from_state_version == SOURCE_STATE_VERSION, + "cannot apply v1 migration: on-disk state_version is {from_state_version}, expected {SOURCE_STATE_VERSION}" + ); + + let accounts_report = sov_accounts::migrations::apply_legacy_account_migration( + accounts, + &data.legacy_accounts, + state, + ) + .context("failed to apply legacy-accounts migration")?; + { + let mut accessory_state = state.accessory_state(); + state_version_value(chain_state) + .set(&TARGET_STATE_VERSION, &mut accessory_state) + .context("failed to update chain-state state_version")?; + } + StateWriter::::set( + state, + &data.kernel_roundtrip.key, + data.kernel_roundtrip.value, + ) + .context("failed to round-trip kernel value for historical-state invariant")?; + + Ok(MigrationReport { + from_state_version, + to_state_version: TARGET_STATE_VERSION, + accounts: accounts_report, + }) +} + +/// Runs the v1 migration and writes the JSON report to stdout. +pub fn run( + args: crate::MigrationArgs, + accounts: &mut Accounts, + chain_state: &mut ChainState, +) -> anyhow::Result<()> +where + S: Spec, + H: sov_rollup_interface::reexports::digest::Digest< + OutputSize = sov_rollup_interface::reexports::digest::typenum::U32, + > + Send + + Sync, + S::Storage: sov_db::storage_manager::InitializableNativeNomtStorage< + H, + ::SlotHash, + > + crate::MigrationStorage, +{ + let storage = crate::load_storage_config(&args.rollup_config_path, args.db_path.as_deref())?; + let report = run_with_options::( + crate::MigrationOptions { + storage, + dry_run: args.dry_run, + }, + accounts, + chain_state, + )?; + let json = serde_json::to_string_pretty(&report) + .context("failed to serialize migration report JSON")?; + println!("{json}"); + Ok(()) +} + +/// Runs the v1 migration with an already-loaded storage config. +pub fn run_with_options( + options: crate::MigrationOptions, + accounts: &mut Accounts, + chain_state: &mut ChainState, +) -> anyhow::Result> +where + S: Spec, + H: sov_rollup_interface::reexports::digest::Digest< + OutputSize = sov_rollup_interface::reexports::digest::typenum::U32, + > + Send + + Sync, + S::Storage: sov_db::storage_manager::InitializableNativeNomtStorage< + H, + ::SlotHash, + > + crate::MigrationStorage, +{ + let db_path = options.storage.path.clone(); + let mut storage_manager = + sov_db::storage_manager::NomtStorageManager::::new( + options.storage, + false, + ) + .with_context(|| format!("failed to open storage manager at {}", db_path.display()))?; + let (storage, ledger_reader) = storage_manager + .create_state_for_migration() + .context("failed to create migration storage view")?; + let ledger_db = sov_db::ledger_db::LedgerDb::with_reader(ledger_reader) + .context("failed to initialize ledger db")?; + + let head_slot_number = crate::assert_storage_latest_version_matches_ledger_head( + &storage, + &ledger_db, + "pre-migration", + )?; + crate::assert_ledger_head_state_root_matches_storage_root( + &storage, + &ledger_db, + "pre-migration", + )?; + + let pre_state_root = storage + .get_root_hash(head_slot_number) + .context("failed to read pre-migration state root")?; + + // Idempotency guard: if the migration has already been applied (state_version is at or + // beyond the target), do nothing and exit successfully. This makes the migration safe to + // re-run, e.g. when a node restarts and re-invokes the migration binary. + let from_state_version = { + let value = state_version_value(chain_state); + match storage.get_accessory_unbound(value.slot_key(), Some(head_slot_number)) { + Some(slot_value) => value.decode_unwrap(&slot_value), + // Absent ⇒ version 0, mirroring `ChainState::state_version`'s `unwrap_or(0)`. + None => 0, + } + }; + if from_state_version >= TARGET_STATE_VERSION { + eprintln!( + "v1 migration already applied: on-disk state_version is {from_state_version} \ + (target {TARGET_STATE_VERSION}); nothing to do" + ); + return Ok(crate::MigrationOutcome { + dry_run: options.dry_run, + db_path: db_path.display().to_string(), + pre_state_root: pre_state_root.to_string(), + post_state_root: pre_state_root.to_string(), + head_rollup_slot: head_slot_number.get(), + migration: MigrationReport { + from_state_version, + to_state_version: from_state_version, + accounts: sov_accounts::migrations::MigrationReport { + entries_migrated: 0, + }, + }, + }); + } + + let migration_data = collect(accounts, chain_state, &storage)?; + + let mut checkpoint = { + let kernel = crate::MigrationKernel { + chain_state: &*chain_state, + }; + sov_modules_api::StateCheckpoint::new(storage, &kernel) + }; + let migration_report = apply(accounts, chain_state, migration_data, &mut checkpoint)?; + + let (next_state_root, mut state_update, accessory_delta, _witness, storage_after) = + checkpoint.materialize_update(pre_state_root.clone()); + state_update.add_accessory_items(accessory_delta.freeze()); + let change_set = + storage_after.materialize_migration_changes_at_version(state_update, head_slot_number); + + let post_state_root = if options.dry_run { + next_state_root + } else { + let ledger_change_set = + crate::make_ledger_root_patch(&ledger_db, next_state_root.as_ref())?; + storage_manager + .commit_migration_change_set_at_head(head_slot_number, change_set, ledger_change_set) + .context("failed to commit migration changeset at head version")?; + + let (post_storage, post_ledger_reader) = storage_manager + .create_state_for_migration() + .context("failed to create post-migration storage view")?; + let post_ledger_db = sov_db::ledger_db::LedgerDb::with_reader(post_ledger_reader) + .context("failed to initialize post-migration ledger db view")?; + let post_head_slot_number = crate::assert_storage_latest_version_matches_ledger_head( + &post_storage, + &post_ledger_db, + "post-migration", + )?; + if post_head_slot_number != head_slot_number { + anyhow::bail!( + "post-migration head slot changed unexpectedly: expected {head_slot_number}, found {post_head_slot_number}" + ); + } + crate::assert_ledger_head_state_root_matches_storage_root( + &post_storage, + &post_ledger_db, + "post-migration", + )?; + post_storage + .get_root_hash(post_head_slot_number) + .context("failed to read post-migration state root")? + }; + + Ok(crate::MigrationOutcome { + dry_run: options.dry_run, + db_path: db_path.display().to_string(), + pre_state_root: pre_state_root.to_string(), + post_state_root: post_state_root.to_string(), + head_rollup_slot: head_slot_number.get(), + migration: migration_report, + }) +} + +fn collect_kernel_roundtrip( + chain_state: &ChainState, + storage: &Storage, +) -> anyhow::Result +where + S: Spec, + Storage: NativeStorage, +{ + let key = SlotKey::singleton(&Prefix::new( + chain_state.discriminant(), + ChainState::::TRUE_SLOT_NUMBER_ITEM_DISCRIMINANT, + )); + let value = storage.get_unbound::(key.clone()).ok_or_else(|| { + anyhow::anyhow!( + "kernel `chain_state.true_slot_number` is unset; cannot perform no-op kernel write" + ) + })?; + + Ok(KernelRoundtrip { key, value }) +} + +fn state_version_value(chain_state: &ChainState) -> AccessoryStateValue { + AccessoryStateValue::with_codec( + Prefix::new( + chain_state.discriminant(), + ChainState::::STATE_VERSION_ITEM_DISCRIMINANT, + ), + BorshCodec, + ) +} diff --git a/crates/utils/sov-soak-testing-lib/src/lib.rs b/crates/utils/sov-soak-testing-lib/src/lib.rs index 423927b7d8..13cb60b050 100644 --- a/crates/utils/sov-soak-testing-lib/src/lib.rs +++ b/crates/utils/sov-soak-testing-lib/src/lib.rs @@ -5,7 +5,6 @@ use std::time::Duration; use rand::Rng; use sov_bank::Bank; use sov_bank::CallMessageDiscriminants::Transfer; -use sov_modules_api::capabilities::config_chain_id; use sov_modules_api::macros::config_value; use sov_modules_api::prelude::arbitrary::{self, Unstructured}; use sov_modules_api::prelude::tracing; @@ -47,7 +46,7 @@ pub fn plain_tx_with_default_details, S: Spec>( max_priority_fee_bips: TEST_DEFAULT_MAX_PRIORITY_FEE, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: None, - chain_id: config_chain_id(), + chain_hash_fragment: 0, }, } } diff --git a/crates/utils/sov-test-utils/src/generators/bank.rs b/crates/utils/sov-test-utils/src/generators/bank.rs index b3cb255927..c6074893d3 100644 --- a/crates/utils/sov-test-utils/src/generators/bank.rs +++ b/crates/utils/sov-test-utils/src/generators/bank.rs @@ -256,7 +256,6 @@ impl MessageGenerator for BankMessageGenerator { fn create_messages( &self, - chain_id: u64, max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, gas_usage: Option<::Gas>, @@ -270,7 +269,6 @@ impl MessageGenerator for BankMessageGenerator { messages.push(Message::new( create_message.minter_pkey.clone(), create_token_tx::(create_message), - chain_id, max_priority_fee_bips, max_fee, gas_usage, @@ -284,7 +282,6 @@ impl MessageGenerator for BankMessageGenerator { messages.push(Message::new( transfer_message.sender_pkey.clone(), transfer_token_tx::(transfer_message), - Self::default_chain_id(), TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, gas_limit, diff --git a/crates/utils/sov-test-utils/src/generators/mod.rs b/crates/utils/sov-test-utils/src/generators/mod.rs index 8550a3965b..2144a907d1 100644 --- a/crates/utils/sov-test-utils/src/generators/mod.rs +++ b/crates/utils/sov-test-utils/src/generators/mod.rs @@ -9,7 +9,6 @@ use std::sync::Arc; use sov_blob_storage::PreferredBatchData; use sov_modules_api::capabilities::{TransactionAuthenticator, UniquenessData}; -use sov_modules_api::macros::config_value; use sov_modules_api::transaction::{PriorityFeeBips, Transaction, TxDetails, UnsignedTransaction}; use sov_modules_api::{Amount, CryptoSpec, EncodeCall, FullyBakedTx, Module, RawTx, Spec}; use sov_modules_stf_blueprint::Runtime; @@ -39,7 +38,6 @@ impl Message { fn new( sender_key: Rc<::PrivateKey>, content: Mod::CallMessage, - chain_id: u64, max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, gas_limit: Option, @@ -49,7 +47,7 @@ impl Message { sender_key, content, details: TxDetails { - chain_id, + chain_hash_fragment: 0, max_priority_fee_bips, max_fee, gas_limit, @@ -67,11 +65,12 @@ impl Message { &RT::CHAIN_HASH, UnsignedTransaction::new( >::to_decodable(self.content), - self.details.chain_id, + RT::CHAIN_HASH, self.details.max_priority_fee_bips, self.details.max_fee, UniquenessData::Generation(self.generation), self.details.gas_limit, + None, ), ) } @@ -99,15 +98,9 @@ pub trait MessageGenerator { /// Module spec type Spec: Spec; - /// The default chain ID to use for the messages. Defaults to `constants.toml` constant. - fn default_chain_id() -> u64 { - config_value!("CHAIN_ID") - } - /// Generates a list of messages originating from the module using the provided transaction details. fn create_messages( &self, - chain_id: u64, max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, estimated_gas_usage: Option<::Gas>, @@ -117,7 +110,6 @@ pub trait MessageGenerator { /// Note: sets the gas usage to the default gas limit. fn create_default_messages(&self) -> Vec> { self.create_messages( - Self::default_chain_id(), TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, Some(::Gas::from(TEST_DEFAULT_GAS_LIMIT)), @@ -126,12 +118,7 @@ pub trait MessageGenerator { /// Generates a list of messages originating from the module using default transaction details and no gas usage. fn create_default_messages_without_gas_usage(&self) -> Vec> { - self.create_messages( - Self::default_chain_id(), - TEST_DEFAULT_MAX_PRIORITY_FEE, - TEST_DEFAULT_MAX_FEE, - None, - ) + self.create_messages(TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, None) } /// Creates a vector of raw transactions from the module. @@ -139,7 +126,6 @@ pub trait MessageGenerator { &self, ) -> Vec { self.create_encoded_txs::( - Self::default_chain_id(), TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, Some(::Gas::from(TEST_DEFAULT_GAS_LIMIT)), @@ -152,29 +138,18 @@ pub trait MessageGenerator { >( &self, ) -> Vec { - self.create_encoded_txs::( - Self::default_chain_id(), - TEST_DEFAULT_MAX_PRIORITY_FEE, - TEST_DEFAULT_MAX_FEE, - None, - ) + self.create_encoded_txs::(TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, None) } /// Creates a vector of raw transactions from the module. fn create_encoded_txs + EncodeCall>( &self, - chain_id: u64, max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, estimated_gas_usage: Option<::Gas>, ) -> Vec { let messages_iter = self - .create_messages( - chain_id, - max_priority_fee_bips, - max_fee, - estimated_gas_usage, - ) + .create_messages(max_priority_fee_bips, max_fee, estimated_gas_usage) .into_iter(); let mut serialized_messages = Vec::default(); for message in messages_iter { diff --git a/crates/utils/sov-test-utils/src/generators/sequencer_registry.rs b/crates/utils/sov-test-utils/src/generators/sequencer_registry.rs index f8ce267b5e..2bcf1b47d9 100644 --- a/crates/utils/sov-test-utils/src/generators/sequencer_registry.rs +++ b/crates/utils/sov-test-utils/src/generators/sequencer_registry.rs @@ -90,7 +90,6 @@ impl MessageGenerator for SequencerRegistryMessageGenerator { fn create_messages( &self, - chain_id: u64, max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, estimated_gas_usage: Option<::Gas>, @@ -107,7 +106,6 @@ impl MessageGenerator for SequencerRegistryMessageGenerator { .expect("Generated sequencer address was invalid"), amount: msg.amount, }, - chain_id, max_priority_fee_bips, max_fee, estimated_gas_usage, @@ -124,7 +122,6 @@ impl MessageGenerator for SequencerRegistryMessageGenerator { .expect("Generated sequencer address was invalid"), amount: msg.amount, }, - chain_id, max_priority_fee_bips, max_fee, estimated_gas_usage, diff --git a/crates/utils/sov-test-utils/src/generators/value_setter.rs b/crates/utils/sov-test-utils/src/generators/value_setter.rs index f92523931f..992d88ddd3 100644 --- a/crates/utils/sov-test-utils/src/generators/value_setter.rs +++ b/crates/utils/sov-test-utils/src/generators/value_setter.rs @@ -39,7 +39,6 @@ impl MessageGenerator for ValueSetterMessages { fn create_messages( &self, - chain_id: u64, max_priority_fee_bips: PriorityFeeBips, max_fee: Amount, gas_usage: Option<::Gas>, @@ -60,7 +59,6 @@ impl MessageGenerator for ValueSetterMessages { messages.push(Message::new( admin.clone(), set_value_msg, - chain_id, max_priority_fee_bips, max_fee, gas_usage, diff --git a/crates/utils/sov-test-utils/src/interface/inputs.rs b/crates/utils/sov-test-utils/src/interface/inputs.rs index 824d756b74..ed0ce938d4 100644 --- a/crates/utils/sov-test-utils/src/interface/inputs.rs +++ b/crates/utils/sov-test-utils/src/interface/inputs.rs @@ -60,15 +60,6 @@ impl, S: Spec> TransactionType { } } - /// Set the chain ID of the transaction. - pub fn with_chain_id(mut self, chain_id: u64) -> Self { - if let Some(details) = self.details_mut() { - details.chain_id = chain_id; - } - - self - } - /// Set the max priority fee of the transaction. pub fn with_max_priority_fee_bips(mut self, max_priority_fee_bips: PriorityFeeBips) -> Self { if let Some(details) = self.details_mut() { @@ -150,11 +141,12 @@ impl, S: Spec> TransactionType { chain_hash, UnsignedTransaction::new( msg, - details.chain_id, + *chain_hash, details.max_priority_fee_bips, details.max_fee, UniquenessData::Nonce(nonce), details.gas_limit, + None, ), ) } diff --git a/crates/utils/sov-test-utils/src/lib.rs b/crates/utils/sov-test-utils/src/lib.rs index a36c54f617..0cc5c99c0c 100644 --- a/crates/utils/sov-test-utils/src/lib.rs +++ b/crates/utils/sov-test-utils/src/lib.rs @@ -21,7 +21,6 @@ pub use sov_mock_da::MockHash; pub use sov_mock_zkvm::{MockZkvm, MockZkvmCryptoSpec}; use sov_modules_api::capabilities::UniquenessData; use sov_modules_api::default_spec::DefaultSpec; -use sov_modules_api::macros::config_value; use sov_modules_api::transaction::{ PriorityFeeBips, Transaction, TransactionCallable, TxDetails, UnsignedTransaction, }; @@ -229,7 +228,7 @@ pub fn default_test_tx_details() -> TxDetails { max_priority_fee_bips: TEST_DEFAULT_MAX_PRIORITY_FEE, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: None, - chain_id: config_value!("CHAIN_ID"), + chain_hash_fragment: 0, } } @@ -280,11 +279,12 @@ pub fn test_signed_transaction( chain_hash, UnsignedTransaction::new( msg.clone(), - tx_details.chain_id, + *chain_hash, tx_details.max_priority_fee_bips, tx_details.max_fee, uniqueness, tx_details.gas_limit, + None, ), ) } diff --git a/crates/utils/sov-test-utils/src/runtime/genesis/mod.rs b/crates/utils/sov-test-utils/src/runtime/genesis/mod.rs index ff93b3b542..7be27bed61 100644 --- a/crates/utils/sov-test-utils/src/runtime/genesis/mod.rs +++ b/crates/utils/sov-test-utils/src/runtime/genesis/mod.rs @@ -170,6 +170,7 @@ impl BasicGenesisConfig { ChainStateConfig { current_time: Default::default(), genesis_da_height: 0, + state_version: sov_modules_api::macros::config_value!("STATE_VERSION"), operating_mode, inner_code_commitment, outer_code_commitment, diff --git a/crates/utils/sov-test-utils/src/runtime/genesis/optimistic/tests.rs b/crates/utils/sov-test-utils/src/runtime/genesis/optimistic/tests.rs index ff16b9a668..93874b9428 100644 --- a/crates/utils/sov-test-utils/src/runtime/genesis/optimistic/tests.rs +++ b/crates/utils/sov-test-utils/src/runtime/genesis/optimistic/tests.rs @@ -187,6 +187,7 @@ fn create_test_rt_genesis_config( chain_state: ChainStateConfig { current_time: Default::default(), genesis_da_height: 0, + state_version: sov_modules_api::macros::config_value!("STATE_VERSION"), operating_mode: sov_modules_api::OperatingMode::Optimistic, inner_code_commitment, outer_code_commitment, diff --git a/crates/utils/sov-test-utils/src/runtime/macros.rs b/crates/utils/sov-test-utils/src/runtime/macros.rs index f07ef70ab5..e818f2ea50 100644 --- a/crates/utils/sov-test-utils/src/runtime/macros.rs +++ b/crates/utils/sov-test-utils/src/runtime/macros.rs @@ -133,7 +133,7 @@ macro_rules! generate_runtime_without_capabilities { use $crate::sov_rollup_apis::endpoints::schema::{SchemaEndpoint, StandardSchemaEndpoint}; use $crate::sov_universal_wallet::schema::{ChainData, Schema}; use ::sov_modules_api::macros::config_value; - use ::sov_modules_api::transaction::{Transaction, UnsignedTransaction}; + use ::sov_modules_api::transaction::{Transaction, TransactionSigningPayload}; use ::sov_modules_api::rest::HasRestApi; let axum_router = Self::default().rest_api(api_state.clone()); @@ -144,7 +144,7 @@ macro_rules! generate_runtime_without_capabilities { let schema = Schema::of_rollup_types_with_chain_data::< Transaction, - UnsignedTransaction, + TransactionSigningPayload, ::Decodable, S::Address, >(ChainData { @@ -285,7 +285,6 @@ macro_rules! generate_runtime { $($runtime_trait_impl_bounds)* { type Capabilities<'a> = $crate::runtime::StandardProvenRollupCapabilities<'a, S, &'a mut $gas_enforcer_ty>; - type SequencingData = ::sov_modules_api::HDTimestamp; fn capabilities(&mut self) -> ::sov_modules_api::capabilities::Guard> { ::sov_modules_api::capabilities::Guard::new( @@ -305,6 +304,14 @@ macro_rules! generate_runtime { $crate::__impl_runtime_timelock_capability!($($timelock_capability_expr)?); } + + impl ::sov_modules_api::capabilities::HasSequencingData for $id + where + S: ::sov_modules_api::Spec, + $($runtime_trait_impl_bounds)* + { + type SequencingData = (); + } }; ( name: $id:ident, @@ -345,7 +352,6 @@ macro_rules! generate_runtime { $($runtime_trait_impl_bounds)* { type Capabilities<'a> = $crate::runtime::StandardProvenRollupCapabilities<'a, S>; - type SequencingData = ::sov_modules_api::HDTimestamp; fn capabilities(&mut self) -> ::sov_modules_api::capabilities::Guard> { ::sov_modules_api::capabilities::Guard::new( @@ -365,6 +371,14 @@ macro_rules! generate_runtime { $crate::__impl_runtime_timelock_capability!($($timelock_capability_expr)?); } + + impl ::sov_modules_api::capabilities::HasSequencingData for $id + where + S: ::sov_modules_api::Spec, + $($runtime_trait_impl_bounds)* + { + type SequencingData = (); + } } } diff --git a/crates/utils/sov-test-utils/src/runtime/mod.rs b/crates/utils/sov-test-utils/src/runtime/mod.rs index b13b2c71e7..c2eb1502cb 100644 --- a/crates/utils/sov-test-utils/src/runtime/mod.rs +++ b/crates/utils/sov-test-utils/src/runtime/mod.rs @@ -72,7 +72,7 @@ pub mod traits; use traits::MinimalGenesis; type NoncesMap = HashMap<<::CryptoSpec as CryptoSpec>::PublicKey, u64>; -const OVERRIDE_HD_TIMESTAMPS_ENV_VAR: &str = "SOV_TEST_OVERRIDE_HD_TIMESTAMPS"; +use sov_modules_api::OVERRIDE_HD_TIMESTAMPS_ENV_VAR; /// Metadata about a blob. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/utils/sov-test-utils/src/test_rollup.rs b/crates/utils/sov-test-utils/src/test_rollup.rs index c3050232e5..2a94971967 100644 --- a/crates/utils/sov-test-utils/src/test_rollup.rs +++ b/crates/utils/sov-test-utils/src/test_rollup.rs @@ -16,11 +16,13 @@ use crate::{ TEST_MAX_CONCURRENT_BATCH_BLOBS, TEST_MAX_CONCURRENT_PROOF_BLOBS, }; use anyhow::Context; +use borsh::BorshDeserialize; use serde::Deserialize; use sov_api_spec::types; use sov_api_spec::types::TxInfoWithConfirmation; use sov_api_spec::WsSubscription; use sov_blob_sender::BlobExecutionStatus; +use sov_blob_storage::PreferredBatchData; use sov_cli::wallet_state::PrivateKeyAndAddress; use sov_cli::NodeClient; use sov_db::config::{PrunerConfig, RollupDbConfig}; @@ -30,12 +32,14 @@ use sov_mock_da::storable::rpc::StorableMockDaClient; use sov_mock_da::storable::StorableMockDaService; use sov_mock_da::{BlockProducingConfig, MockAddress, MockDaConfig, MockDaSpec}; use sov_modules_api::capabilities::RollupHeight; +use sov_modules_api::capabilities::TransactionAuthenticator; use sov_modules_api::execution_mode::Native; use sov_modules_api::prelude::axum; use sov_modules_api::prelude::axum::extract::Request; use sov_modules_api::prelude::axum::ServiceExt; use sov_modules_api::ModuleExecutionConfig; use sov_modules_api::Spec; +use sov_modules_api::{BlobReaderTrait, FullyBakedTx}; pub use sov_modules_rollup_blueprint::FullNodeBlueprint; use sov_modules_rollup_blueprint::RollupBlueprint; use sov_modules_stf_blueprint::Runtime; @@ -45,6 +49,7 @@ use sov_rollup_interface::common::SlotNumber; use sov_rollup_interface::node::da::DaService; use sov_rollup_interface::node::SyncStatus; use sov_rollup_interface::storage::HierarchicalStorageManager; +use sov_rollup_interface::TxHash; use sov_sequencer::preferred::{ConfiguredNodeRole, PostgresConfig, PreferredSequencerConfig}; use sov_sequencer::test_stateless::TestStatelessSequencer; use sov_sequencer::SeqConfigExtension; @@ -1214,6 +1219,51 @@ where } } + /// Produces DA blocks and scans new batch blobs until every hash in `tx_hashes` has been + /// published in a preferred-sequencer batch, returning the published txs keyed by hash. + /// + /// Hashes are computed with the blueprint runtime's transaction authenticator. + /// Scanning starts after `last_checked_height`. Panics if `timeout` elapses first, or if a + /// requested tx is published more than once within the scanned blocks. + pub async fn wait_for_txs_on_da( + &self, + tx_hashes: &std::collections::BTreeSet, + mut last_checked_height: u64, + timeout: Duration, + ) -> anyhow::Result> { + tokio::time::timeout(timeout, async { + let mut found = std::collections::BTreeMap::new(); + loop { + self.da_service.produce_block_now().await?; + let head_height = self.da_service.get_head_block_header().await?.height; + for height in last_checked_height + 1..=head_height { + let mut block = self.da_service.get_block_at(height).await?; + for blob in block.batch_blobs.iter_mut() { + let batch = PreferredBatchData::try_from_slice(blob.full_data())?; + for tx in batch.data.iter() { + let tx_hash = <>::Auth + as TransactionAuthenticator>::compute_tx_hash(tx)?; + if tx_hashes.contains(&tx_hash) { + let previous = found.insert(tx_hash, tx.clone()); + assert!( + previous.is_none(), + "tx {tx_hash} was published more than once on the DA layer" + ); + } + } + } + } + if found.len() == tx_hashes.len() { + return Ok(found); + } + last_checked_height = head_height; + tokio::task::yield_now().await; + } + }) + .await + .expect("timed out waiting for the submitted txs to be published on the DA layer") + } + /// Produce DA blocks, but wait enough time in between, that finalized header poller sees each of them. pub async fn tenderly_produce_blocks(&self, n: usize) -> anyhow::Result<()> { let da_polling_interval = diff --git a/crates/utils/sov-test-utils/tests/integration/test_rollup.rs b/crates/utils/sov-test-utils/tests/integration/test_rollup.rs index 80af3de1a5..45b1d28098 100644 --- a/crates/utils/sov-test-utils/tests/integration/test_rollup.rs +++ b/crates/utils/sov-test-utils/tests/integration/test_rollup.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use sov_db::ledger_db::LedgerDb; use sov_mock_da::MockBlock; +use sov_modules_api::macros::config_value; use sov_modules_api::Runtime; use sov_modules_rollup_blueprint::logging::initialize_logging; use sov_modules_rollup_blueprint::FullNodeBlueprint; @@ -78,6 +79,72 @@ async fn validates_rollup_mode_before_genesis_initialization() { assert!(ledger_db.get_head_slot().unwrap().is_none()); } +fn genesis_params_with_state_version( + state_version: u64, +) -> GenesisParams< as Runtime>::GenesisConfig> { + let mut runtime = + as Runtime>::GenesisConfig::from_minimal_config( + HighLevelOptimisticGenesisConfig::generate().into(), + ); + runtime.chain_state.state_version = state_version; + GenesisParams { runtime } +} + +fn rollup_builder_with_state_version(state_version: u64) -> RollupBuilder { + RollupBuilder::::new( + GenesisSource::CustomParams(genesis_params_with_state_version(state_version)), + TEST_DEFAULT_MOCK_DA_PERIODIC_PRODUCING, + 1, + ) + .set_config(|c| { + c.rollup_prover_config = RollupProverConfig::Disabled; + }) + .with_standard_sequencer() +} + +#[tokio::test(flavor = "multi_thread")] +async fn rollup_startup_accepts_matching_state_version() { + let _guard = initialize_logging(); + let state_version: u64 = config_value!("STATE_VERSION"); + + let test_rollup = rollup_builder_with_state_version(state_version) + .start_test_rollup() + .await + .unwrap(); + + test_rollup.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rollup_startup_rejects_mismatched_state_version() { + let _guard = initialize_logging(); + let expected_state_version: u64 = config_value!("STATE_VERSION"); + let mismatched_state_version = expected_state_version + 1; + + let Err(err) = rollup_builder_with_state_version(mismatched_state_version) + .start_test_rollup() + .await + else { + panic!("rollup startup unexpectedly succeeded with a mismatched state version"); + }; + let err = format!("{err:#}"); + + assert!( + err.contains("State version mismatch"), + "unexpected error: {err}" + ); + assert!( + err.contains(&format!( + "on-disk state has version {mismatched_state_version}" + )), + "unexpected error: {err}" + ); + assert!( + err.contains(&format!("STATE_VERSION {expected_state_version}")), + "unexpected error: {err}" + ); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "Fails too often on my machine"] async fn flaky_test_rollup_shutdown_works_as_expected() { diff --git a/crates/utils/sov-test-utils/tests/integration/transaction.rs b/crates/utils/sov-test-utils/tests/integration/transaction.rs index a0e90384c5..a2c7d7a930 100644 --- a/crates/utils/sov-test-utils/tests/integration/transaction.rs +++ b/crates/utils/sov-test-utils/tests/integration/transaction.rs @@ -1,7 +1,7 @@ use sov_bank::{config_gas_token_id, Bank, Coins}; -use sov_modules_api::macros::config_value; +use sov_modules_api::capabilities::UniquenessData; use sov_modules_api::prelude::UnwrapInfallible; -use sov_modules_api::transaction::{PriorityFeeBips, TxDetails}; +use sov_modules_api::transaction::{PriorityFeeBips, TxDetails, UnsignedTransaction}; use sov_modules_api::{Amount, GasUnit}; use sov_test_utils::runtime::TestOptimisticRuntimeCall; use sov_test_utils::{ @@ -13,21 +13,34 @@ use sov_value_setter::ValueSetter; use crate::helpers::{setup, RT, S}; -/// Checks that the chain id of a transaction can be overridden. +/// Checks that the chain hash fragment of a transaction is authenticated. #[test] -fn test_custom_transaction_details_chain_id() { +fn test_custom_transaction_details_chain_hash_fragment() { let (admin, mut runner) = setup(); - let real_chain_id = config_value!("CHAIN_ID"); - let fake_chain_id = real_chain_id + 1; - - runner.execute_batch(BatchTestCase { - input: vec![admin - .create_plain_message::>(sov_value_setter::CallMessage::SetValue { + let mut bad_chain_hash = >::CHAIN_HASH; + bad_chain_hash[0] ^= 1; + let unsigned_tx = UnsignedTransaction::::new( + >>::to_decodable( + sov_value_setter::CallMessage::SetValue { value: 1, gas: None, - }) - .with_chain_id(fake_chain_id)] + }, + ), + bad_chain_hash, + TEST_DEFAULT_MAX_PRIORITY_FEE, + TEST_DEFAULT_MAX_FEE, + UniquenessData::Generation(0), + None, + None, + ); + + runner.execute_batch(BatchTestCase { + input: vec![TransactionType::pre_signed( + unsigned_tx, + admin.private_key(), + &bad_chain_hash, + )] .into(), assert: Box::new(move |result, _state| { let batch_receipt = result.batch_receipt.as_ref().unwrap(); @@ -194,7 +207,7 @@ fn test_default_transaction_details() { assert_eq!(details.max_fee, TEST_DEFAULT_MAX_FEE); assert_eq!(details.gas_limit, None); - assert_eq!(details.chain_id, 4321); + assert_eq!(details.chain_hash_fragment, 0); } _ => panic!("The message is not a plain message"), } @@ -214,8 +227,7 @@ fn test_custom_transaction_format() { }) .with_max_fee(Amount::new(100)) .with_max_priority_fee_bips(PriorityFeeBips::from_percentage(10)) - .with_gas_limit(Some(GasUnit::from([5; 2]))) - .with_chain_id(5555); + .with_gas_limit(Some(GasUnit::from([5; 2]))); match message { TransactionType::Plain { @@ -243,7 +255,7 @@ fn test_custom_transaction_format() { assert_eq!(details.max_fee, 100); assert_eq!(details.gas_limit, Some(GasUnit::from([5; 2]))); - assert_eq!(details.chain_id, 5555); + assert_eq!(details.chain_hash_fragment, 0); } _ => panic!("The message is not a plain message"), } @@ -264,7 +276,7 @@ fn test_custom_transaction_format_2() { max_fee: Amount::new(100), max_priority_fee_bips: PriorityFeeBips::from_percentage(10), gas_limit: Some(GasUnit::from([5; 2])), - chain_id: 5555, + chain_hash_fragment: 5555, }); match message { @@ -293,7 +305,7 @@ fn test_custom_transaction_format_2() { assert_eq!(details.max_fee, 100); assert_eq!(details.gas_limit, Some(GasUnit::from([5; 2]))); - assert_eq!(details.chain_id, 5555); + assert_eq!(details.chain_hash_fragment, 5555); } _ => panic!("The message is not a plain message"), } diff --git a/crates/utils/transaction-generator/src/generators/access_pattern/mod.rs b/crates/utils/transaction-generator/src/generators/access_pattern/mod.rs index b7886fd9e2..6bc83e1707 100644 --- a/crates/utils/transaction-generator/src/generators/access_pattern/mod.rs +++ b/crates/utils/transaction-generator/src/generators/access_pattern/mod.rs @@ -551,8 +551,8 @@ impl AccessPatternMessageGenerator { .map(|_| rand_ascii_char(u)) .collect::>()?; - let serialized_string = borsh::to_vec(&MeteredBorshDeserializeString(input)) - .expect("Impossible to serialize string"); + let serialized_string = + borsh::to_vec(&input).expect("Impossible to serialize string"); Ok(GeneratedMessage { message: AccessPatternMessages::DeserializeCustomString { @@ -569,8 +569,8 @@ impl AccessPatternMessageGenerator { .map(|_| rand_ascii_char(u)) .collect::>()?; - let serialized_string = borsh::to_vec(&MeteredBorshDeserializeString(input)) - .expect("Impossible to serialize string"); + let serialized_string = + borsh::to_vec(&input).expect("Impossible to serialize string"); Ok(GeneratedMessage { message: AccessPatternMessages::StoreSerializedString { diff --git a/crates/utils/transaction-generator/src/generators/transaction.rs b/crates/utils/transaction-generator/src/generators/transaction.rs index 54de9f0747..0c3df5c4b0 100644 --- a/crates/utils/transaction-generator/src/generators/transaction.rs +++ b/crates/utils/transaction-generator/src/generators/transaction.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use sov_mock_da::MockDaSpec; use sov_mock_da::MockHash; -use sov_modules_api::capabilities::config_chain_id; use sov_modules_api::prelude::arbitrary; use sov_modules_api::transaction::TxDetails; use sov_modules_api::{ @@ -260,7 +259,7 @@ where max_priority_fee_bips: TEST_DEFAULT_MAX_PRIORITY_FEE, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: None, - chain_id: config_chain_id(), + chain_hash_fragment: 0, }, }; diff --git a/crates/utils/transaction-generator/tests/message_generators/main.rs b/crates/utils/transaction-generator/tests/message_generators/main.rs index 88f312adf5..744bfaeb31 100644 --- a/crates/utils/transaction-generator/tests/message_generators/main.rs +++ b/crates/utils/transaction-generator/tests/message_generators/main.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use sov_bank::Bank; -use sov_modules_api::capabilities::config_chain_id; use sov_modules_api::prelude::arbitrary::{self}; use sov_modules_api::transaction::TxDetails; use sov_modules_api::{Amount, DispatchCall, EncodeCall, Runtime}; @@ -120,7 +119,7 @@ pub fn plain_tx_with_default_details>( max_priority_fee_bips: TEST_DEFAULT_MAX_PRIORITY_FEE, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: None, - chain_id: config_chain_id(), + chain_hash_fragment: 0, }, } } diff --git a/crates/web3/README.md b/crates/web3/README.md index cf8ef302bc..faabb51a1e 100644 --- a/crates/web3/README.md +++ b/crates/web3/README.md @@ -72,7 +72,7 @@ let signed_tx = builder.build_and_sign(&private_key_bytes)?; **Example Usage:** ```rust -use sovereign_web3::schema::{Serializer, TransactionBuilder, json}; +use sovereign_web3::schema::{chain_hash_fragment, json, Serializer, TransactionBuilder}; // Load schema from URL or JSON string let serializer = Serializer::from_url("https://rollup.example.com/schema")?; @@ -90,7 +90,7 @@ let call = json!({ }); let unsigned_tx = TransactionBuilder::new(call) - .chain_id(1234) + .chain_hash_fragment(chain_hash_fragment(&serializer.chain_hash()?)) .max_fee(100000u128) .build()?; @@ -114,4 +114,3 @@ cargo test # Run schema integration tests (requires running rollup) cargo test -- --ignored ``` - diff --git a/crates/web3/src/rust.rs b/crates/web3/src/rust.rs index 471daed883..8065ff44d6 100644 --- a/crates/web3/src/rust.rs +++ b/crates/web3/src/rust.rs @@ -35,7 +35,6 @@ //! parameters, this module provides better performance and compile-time guarantees. //! For language bindings or when generics are not available, use the `schema` module. -use sov_modules_api::capabilities::config_chain_id; use sov_modules_api::{CallMessage, CryptoSpec, RuntimeDiscriminant, UnmanagedRuntimeCall}; pub use sov_modules_api::capabilities::UniquenessData; @@ -50,12 +49,12 @@ pub enum TransactionBuilderError { PrivateKeyInvalid, } -/// Trait for providing chain-specific hash values. +/// Trait for providing chain-specific schema hash values. /// -/// This trait must be implemented by types that need to provide -/// a unique 32-byte hash identifying a specific blockchain. +/// This trait must be implemented by types that provide the 32-byte chain hash +/// committed to by transaction signing payloads. pub trait ChainHash { - /// Returns the 32-byte hash that uniquely identifies the chain. + /// Returns the 32-byte hash for the target rollup schema and metadata. fn chain_hash() -> [u8; 32]; } @@ -110,6 +109,7 @@ pub struct TransactionBuilder, max_fee: Option, gas_limit: Option>, + address_override: Option, _phantom: std::marker::PhantomData, } @@ -129,6 +129,7 @@ impl TransactionBui priority_fee_bips: None, max_fee: None, gas_limit: None, + address_override: None, _phantom: Default::default(), } } @@ -186,6 +187,12 @@ impl TransactionBui self } + /// Sets the explicit address override for the transaction. + pub fn address_override(mut self, address_override: S::Address) -> Self { + self.address_override = Some(address_override); + self + } + /// Builds an unsigned transaction with the configured parameters. /// /// Uses default values for any parameters that were not explicitly set: @@ -210,18 +217,20 @@ impl TransactionBui Ok(UnsignedTransaction::new( self.call, - config_chain_id(), + C::chain_hash(), priority_fee, max_fee, uniqueness, gas_limit, + self.address_override, )) } /// Builds and signs a transaction in one step. /// /// This is a convenience method that builds the transaction with the configured - /// parameters and immediately signs it with the provided private key. + /// parameters and immediately signs its canonical signing payload with the + /// provided private key. The signing payload commits to `C::chain_hash()`. /// /// # Arguments /// diff --git a/crates/web3/src/schema.rs b/crates/web3/src/schema.rs index 7b99a4c0de..3bff018b1a 100644 --- a/crates/web3/src/schema.rs +++ b/crates/web3/src/schema.rs @@ -2,8 +2,9 @@ //! //! This module provides utilities for serializing transactions using schema-based //! serialization, converting from JSON representations to efficient binary formats -//! using Borsh serialization. It supports both unsigned and signed transactions -//! with configurable schema validation. +//! using Borsh serialization. Consumers build an [`UnsignedTransaction`], convert it +//! into a versioned [`TransactionSigningPayload`] when producing signing bytes, and +//! then submit a signed [`Transaction`]. //! //! ## When to Use This Module //! @@ -41,6 +42,7 @@ use serde_with::serde_as; use sov_universal_wallet::schema::{RollupRoots, Schema}; pub use serde_json::json; +pub use sov_universal_wallet::schema::chain_hash_fragment; /// Errors that can occur during schema-based serialization operations. #[derive(thiserror::Error, Debug)] @@ -56,6 +58,16 @@ pub enum SerializerError { InvalidSchema(#[source] serde_json::Error), #[error("Failed to calculate chain hash: {0}")] ChainHash(#[source] sov_universal_wallet::schema::SchemaError), + /// The transaction targets a different chain hash than the serializer's schema. + #[error( + "Chain hash fragment mismatch: transaction details contain {actual}, but the serializer chain hash has fragment {expected}" + )] + ChainHashFragmentMismatch { + /// Fragment stored in the transaction details. + actual: u64, + /// Fragment derived from the serializer's chain hash. + expected: u64, + }, /// Error occurred during HTTP request to fetch schema from URL. #[error("HTTP request error: {0}")] HttpRequest(#[from] reqwest::Error), @@ -144,10 +156,9 @@ impl Serializer { /// Retrieves the 32-byte chain hash from the schema. /// - /// The chain hash is a unique identifier for the blockchain network that helps - /// prevent cross-chain replay attacks. This hash is embedded in the schema - /// definition and must be concatenated to the unsigned transaction bytes when - /// signing a transaction to ensure signatures are bound to a specific chain. + /// The chain hash commits to the rollup schema and metadata. It is embedded in + /// [`TransactionSigningPayload`] values before serialization so signatures are + /// bound to a specific schema version and chain. /// /// # Returns /// @@ -161,19 +172,14 @@ impl Serializer { self.schema.chain_hash().map_err(SerializerError::ChainHash) } - /// Serializes an unsigned transaction to binary format. + /// Serializes a transaction signing payload to binary format. /// - /// Converts the provided unsigned transaction into a Borsh-serialized binary + /// Converts the provided signing payload into a Borsh-serialized binary /// representation using the configured schema. /// - /// # Note - /// - /// When signing an unsigned transaction, the chain hash must be appended, preferably - /// use `UnsignedTransaction::bytes_for_signing` which handles this automatically. - /// /// # Arguments /// - /// * `unsigned_tx` - The unsigned transaction to serialize + /// * `signing_payload` - The transaction signing payload to serialize /// /// # Returns /// @@ -183,11 +189,11 @@ impl Serializer { /// /// * [`SerializerError::JsonSerialization`] - If the transaction cannot be converted to JSON /// * [`SerializerError::JsonToBorsh`] - If the JSON cannot be converted to Borsh format - pub fn serialize_unsigned_tx( + pub fn serialize_signing_payload( &self, - unsigned_tx: &UnsignedTransaction, + signing_payload: &TransactionSigningPayload, ) -> Result, SerializerError> { - self.serialize(unsigned_tx, RollupRoots::UnsignedTransaction) + self.serialize(signing_payload, RollupRoots::TransactionSigningPayload) } /// Serializes a signed transaction to binary format. @@ -213,8 +219,9 @@ impl Serializer { /// Internal method to serialize any serializable type using the schema. /// - /// This method handles the common serialization logic for both unsigned and signed transactions. - /// It first converts the input to JSON, then uses the schema to convert the JSON to Borsh format. + /// This method handles the common serialization logic for signing payloads and + /// signed transactions. It first converts the input to JSON, then uses the schema + /// to convert the JSON to Borsh format. /// /// # Arguments /// @@ -278,9 +285,9 @@ pub fn default_uniqueness() -> Result { /// Errors that can occur when building transactions using the schema-based approach. #[derive(thiserror::Error, Debug)] pub enum TransactionBuilderError { - /// The chain ID is required but was not provided in the transaction details. - #[error("chain_id is a required field but was not provided")] - MissingChainId, + /// The chain hash fragment is required but was not provided in the transaction details. + #[error("chain_hash_fragment is a required field but was not provided")] + MissingChainHashFragment, #[error("system time error: {0}")] TimeError(#[from] std::time::SystemTimeError), } @@ -342,18 +349,20 @@ pub struct TxDetails { /// If `None`, the transaction has no gas limit. If `Some(vec)`, each element /// represents a gas limit for different execution phases or modules. pub gas_limit: Option>, - /// Chain identifier to prevent cross-chain replay attacks. + /// Chain hash fragment to prevent cross-chain replay attacks. /// /// This ensures transactions can only be executed on the intended blockchain. - pub chain_id: u64, + pub chain_hash_fragment: u64, } -/// An unsigned transaction ready to be signed. +/// Consumer-facing transaction payload before signing. /// /// This structure represents a complete transaction that has been constructed /// with all necessary parameters but has not yet been cryptographically signed. /// It contains the call to be executed, uniqueness data to prevent replays, -/// and execution details such as fees and gas limits. +/// and execution details such as fees and gas limits. To produce signing bytes, +/// it is wrapped in a versioned [`TransactionSigningPayload`] that also commits +/// to the chain hash. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct UnsignedTransaction { @@ -363,16 +372,80 @@ pub struct UnsignedTransaction { pub uniqueness: UniquenessData, /// Transaction execution details including fees and gas limits. pub details: TxDetails, + /// Optional address override for execution; null uses default routing. + pub address_override: Option, +} + +/// Version 0 transaction signing payload. +/// +/// This mirrors the V0 core signing payload so schema-based serialization includes +/// the version discriminant and chain hash expected by the rollup schema. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct TransactionSigningPayloadV0 { + /// The runtime call to be executed when this transaction is processed. + pub runtime_call: RuntimeCall, + /// Uniqueness data to prevent transaction replay attacks. + pub uniqueness: UniquenessData, + /// Transaction execution details including fees and gas limits. + pub details: TxDetails, + /// Optional address override for execution; null uses default routing. + pub address_override: Option, + /// Chain hash binding the signature to the rollup schema and metadata. + pub chain_hash: [u8; 32], +} + +/// A versioned transaction signing payload envelope. +/// +/// This mirrors the core transaction types so schema-based serialization includes +/// the version discriminant expected by the rollup schema. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransactionSigningPayload { + /// Version 0 transaction signing payload. + V0(TransactionSigningPayloadV0), +} + +impl From for TransactionSigningPayload { + fn from(value: TransactionSigningPayloadV0) -> Self { + Self::V0(value) + } } impl UnsignedTransaction { + /// Builds the V0 signing payload for this unsigned transaction. + fn signing_payload_v0(&self, chain_hash: [u8; 32]) -> TransactionSigningPayload { + TransactionSigningPayload::V0(TransactionSigningPayloadV0 { + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + address_override: self.address_override.clone(), + chain_hash, + }) + } + + /// Serializes the canonical V0 signing payload for this unsigned transaction. + /// + /// The chain hash is read from the serializer's schema and included as a field + /// in the serialized signing payload. + /// + /// # Errors + /// + /// Returns [`SerializerError::ChainHashFragmentMismatch`] if the transaction details + /// target a different chain hash than the serializer's schema. It also returns an error + /// if the chain hash cannot be calculated or the signing payload cannot be serialized. pub fn bytes_for_signing(&self, serializer: &Serializer) -> Result, SerializerError> { - let mut bytes = serializer.serialize_unsigned_tx(self)?; let chain_hash = serializer.chain_hash()?; - bytes.extend_from_slice(&chain_hash); - Ok(bytes) + let expected = chain_hash_fragment(&chain_hash); + if self.details.chain_hash_fragment != expected { + return Err(SerializerError::ChainHashFragmentMismatch { + actual: self.details.chain_hash_fragment, + expected, + }); + } + serializer.serialize_signing_payload(&self.signing_payload_v0(chain_hash)) } + /// Combines this unsigned transaction with an externally produced signature and public key. pub fn to_signed(&self, pub_key: Vec, signature: Vec) -> Transaction { Transaction::V0(TransactionV0 { pub_key, @@ -380,6 +453,7 @@ impl UnsignedTransaction { runtime_call: self.runtime_call.clone(), uniqueness: self.uniqueness, details: self.details.clone(), + address_override: self.address_override.clone(), }) } } @@ -405,6 +479,8 @@ pub struct TransactionV0 { pub uniqueness: UniquenessData, /// Transaction execution details including fees and gas limits. pub details: TxDetails, + /// Optional address override for execution; null uses default routing. + pub address_override: Option, } /// A versioned transaction envelope supporting different transaction formats. @@ -432,7 +508,7 @@ pub enum Transaction { /// let builder = TransactionBuilder::new(my_call) /// .max_fee(1000u128) /// .priority_fee_bips(100u64) -/// .chain_id(1) +/// .chain_hash_fragment(chain_hash_fragment(&serializer.chain_hash()?)) /// .uniqueness(my_uniqueness_data); /// /// let unsigned_tx = builder.build()?; @@ -443,15 +519,16 @@ pub struct TransactionBuilder { priority_fee_bips: Option, max_fee: Option, gas_limit: Option>>, - chain_id: Option, + chain_hash_fragment: Option, + address_override: Option, } impl TransactionBuilder { /// Creates a new transaction builder with the specified runtime call. /// /// All optional parameters are initially unset and will use default values - /// when the transaction is built. The chain_id is required and must be set - /// before calling `build()`. + /// when the transaction is built. The chain hash fragment is required and + /// must be set before calling `build()`. /// /// # Arguments /// @@ -463,7 +540,8 @@ impl TransactionBuilder { priority_fee_bips: None, max_fee: None, gas_limit: None, - chain_id: None, + chain_hash_fragment: None, + address_override: None, } } @@ -521,16 +599,22 @@ impl TransactionBuilder { self } - /// Sets the chain ID for the transaction. + /// Sets the chain hash fragment for the transaction. /// - /// The chain ID is required and must be set before calling `build()`. + /// The chain hash fragment is required and must be set before calling `build()`. /// It prevents transactions from being replayed on different chains. /// /// # Arguments /// - /// * `chain_id` - The chain identifier - pub fn chain_id(mut self, chain_id: u64) -> Self { - self.chain_id = Some(chain_id); + /// * `chain_hash_fragment` - The chain hash fragment + pub fn chain_hash_fragment(mut self, chain_hash_fragment: u64) -> Self { + self.chain_hash_fragment = Some(chain_hash_fragment); + self + } + + /// Sets the explicit address override for the transaction. + pub fn address_override(mut self, address_override: impl Into) -> Self { + self.address_override = Some(address_override.into()); self } @@ -542,7 +626,7 @@ impl TransactionBuilder { /// - Gas limit: `None` (unlimited) /// - Uniqueness: Generated via [`default_uniqueness`] /// - /// The chain_id must be set explicitly before calling this method. + /// The chain hash fragment must be set explicitly before calling this method. /// /// # Returns /// @@ -551,7 +635,7 @@ impl TransactionBuilder { /// /// # Errors /// - /// * [`TransactionBuilderError::MissingChainId`] - If chain_id was not set + /// * [`TransactionBuilderError::MissingChainHashFragment`] - If chain hash fragment was not set pub fn build(self) -> Result { let priority_fee = self .priority_fee_bips @@ -562,9 +646,9 @@ impl TransactionBuilder { Some(u) => u, None => default_uniqueness()?, }; - let chain_id = self - .chain_id - .ok_or(TransactionBuilderError::MissingChainId)?; + let chain_hash_fragment = self + .chain_hash_fragment + .ok_or(TransactionBuilderError::MissingChainHashFragment)?; Ok(UnsignedTransaction { runtime_call: self.call, @@ -573,8 +657,9 @@ impl TransactionBuilder { max_priority_fee_bips: priority_fee, max_fee, gas_limit, - chain_id, + chain_hash_fragment, }, + address_override: self.address_override, }) } } diff --git a/crates/web3/tests/integration/schema_tests.rs b/crates/web3/tests/integration/schema_tests.rs index a67f5e25fe..8ea758aa37 100644 --- a/crates/web3/tests/integration/schema_tests.rs +++ b/crates/web3/tests/integration/schema_tests.rs @@ -1,9 +1,28 @@ use base64::Engine; use sov_mock_zkvm::crypto::private_key::Ed25519PrivateKey; use sov_modules_api::PrivateKey; -use sovereign_web3::schema::{json, Serializer, TransactionBuilder}; +use sovereign_web3::schema::{ + chain_hash_fragment, json, Serializer, SerializerError, TransactionBuilder, +}; -const CHAIN_ID: u64 = 4321; +#[test] +fn stale_chain_hash_fragment_is_rejected_before_signing() { + let serializer = Serializer::from_json(include_str!( + "../../../../examples/demo-rollup/demo-rollup-schema.json" + )) + .unwrap(); + let expected_fragment = chain_hash_fragment(&serializer.chain_hash().unwrap()); + let stale_tx = TransactionBuilder::new(json!(null)) + .chain_hash_fragment(expected_fragment ^ 1) + .build() + .unwrap(); + + assert!(matches!( + stale_tx.bytes_for_signing(&serializer).unwrap_err(), + SerializerError::ChainHashFragmentMismatch { actual, expected } + if actual == expected_fragment ^ 1 && expected == expected_fragment + )); +} // Run with: cargo test -- --ignored // This is intended as a simple manual smoke test against a pre-running local demo rollup @@ -32,7 +51,7 @@ fn test_basic_schema_transaction_submission() { } }); let unsigned_tx = TransactionBuilder::new(call) - .chain_id(CHAIN_ID) + .chain_hash_fragment(chain_hash_fragment(&serializer.chain_hash().unwrap())) .build() .unwrap(); let tx_bytes = unsigned_tx.bytes_for_signing(&serializer).unwrap(); diff --git a/deny.toml b/deny.toml index 233a95ac88..7f572a83fa 100644 --- a/deny.toml +++ b/deny.toml @@ -455,6 +455,11 @@ name = "sov-build" expression = "LicenseRef-Sovereign-Permissionless-Commercial-License" license-files = [] +[[licenses.clarify]] +name = "sov-migrations" +expression = "LicenseRef-Sovereign-Permissionless-Commercial-License" +license-files = [] + [[licenses.clarify]] name = "universal-wallet-fuzz" expression = "LicenseRef-Sovereign-Permissionless-Commercial-License" diff --git a/examples/demo-rollup/Cargo.toml b/examples/demo-rollup/Cargo.toml index 41df6ea9d7..32360b7055 100644 --- a/examples/demo-rollup/Cargo.toml +++ b/examples/demo-rollup/Cargo.toml @@ -24,15 +24,17 @@ sov-rollup-full-node-interface = { workspace = true } sov-stf-runner = { workspace = true } # Sovereign crates +sov-accounts = { workspace = true, features = ["native"] } sov-bank = { workspace = true, features = ["native"] } -sov-blob-storage = { workspace = true, features = ["native"], optional = true } -sov-chain-state = { workspace = true, features = ["native"], optional = true } -sov-paymaster = { workspace = true, features = ["native"], optional = true } -sov-sequencer-registry = { workspace = true, features = ["native"], optional = true } +sov-blob-storage = { workspace = true, features = ["native"] } +sov-chain-state = { workspace = true, features = ["native"] } +sov-paymaster = { workspace = true, features = ["native"] } +sov-sequencer-registry = { workspace = true, features = ["native"] } sov-cli = { workspace = true } sov-db = { workspace = true } sov-ethereum = { workspace = true, features = ["local"] } sov-kernels = { workspace = true, features = ["native"] } +sov-migrations = { workspace = true } sov-mock-zkvm = { workspace = true, features = ["native"] } sov-modules-api = { workspace = true, features = ["native"] } sov-node-client = { workspace = true } @@ -114,6 +116,7 @@ sov-synthetic-load = { workspace = true, features = ["native"] } futures = { workspace = true } demo-stf = { workspace = true, features = ["native"] } demo-stf-json-client = { path = "./stf/demo-stf-json-client", default-features = false } + tracing-panic = { version = "0.1.2" } base64 = { workspace = true } @@ -162,17 +165,13 @@ default = ["mock_da"] mock_da = ["dep:sov-mock-da", "dep:sov-sp1-adapter", "dep:sp1"] celestia_da = ["dep:sov-celestia-adapter", "dep:sov-risc0-adapter", "dep:risc0"] -migration-script = [ +da-migration-script = [ # The migration binary bridges both DAs (references MockDemoRollup + CelestiaDemoRollup). "mock_da", "celestia_da", - "dep:sov-blob-storage", "dep:rockbound", "dep:bincode", "dep:hex", - "dep:sov-chain-state", - "dep:sov-paymaster", - "dep:sov-sequencer-registry", "sov-db/migration-script", ] @@ -199,7 +198,12 @@ required-features = ["celestia_da"] [[bin]] name = "mockda-to-celestia-migrate" path = "src/migrations/mockda_to_celestia.rs" -required-features = ["migration-script"] +required-features = ["da-migration-script"] + +[[bin]] +name = "migrate_to_v1" +path = "src/migrations/legacy_accounts.rs" +required-features = ["mock_da"] [[bin]] name = "sov-hive-genesis-adapter" diff --git a/examples/demo-rollup/Makefile b/examples/demo-rollup/Makefile index 920a083015..55ed86e5c4 100644 --- a/examples/demo-rollup/Makefile +++ b/examples/demo-rollup/Makefile @@ -164,7 +164,7 @@ test-create-token: check-sov-cli test-create-token: $(SOV_CLI_REL_PATH) node set-url http://127.0.0.1:12346 $(SOV_CLI_REL_PATH) keys import --skip-if-present --nickname DANGER__DO_NOT_USE_WITH_REAL_MONEY --path ../test-data/keys/token_deployer_private_key.json - $(SOV_CLI_REL_PATH) transactions import from-file bank --chain-id 4321 --max-fee 100000000 --path ../test-data/requests/create_token.json + $(SOV_CLI_REL_PATH) transactions import from-file bank --max-fee 100000000 --path ../test-data/requests/create_token.json @echo "Listing transactions" $(SOV_CLI_REL_PATH) transactions list @echo "Submitting a batch" @@ -173,7 +173,7 @@ test-create-token: test-create-token-second-seq: check-sov-cli test-create-token-second-seq: $(SOV_CLI_REL_PATH) node set-url http://127.0.0.1:12356 - $(SOV_CLI_REL_PATH) transactions import from-file bank --chain-id 4321 --max-fee 100000000 --path ../test-data/requests/create_token.json + $(SOV_CLI_REL_PATH) transactions import from-file bank --max-fee 100000000 --path ../test-data/requests/create_token.json @echo "Submitting a batch" $(SOV_CLI_REL_PATH) node submit-batch --wait-for-processing by-nickname DANGER__DO_NOT_USE_WITH_REAL_MONEY @@ -181,7 +181,7 @@ register-second-sequencer: check-sov-cli register-second-sequencer: $(SOV_CLI_REL_PATH) node set-url http://127.0.0.1:12356 $(SOV_CLI_REL_PATH) keys import --nickname DANGER__DO_NOT_USE_WITH_REAL_MONEY --path ../test-data/keys/token_deployer_private_key.json - $(SOV_CLI_REL_PATH) transactions import from-file sequencer-registry --chain-id 4321 --path ../test-data/requests/register_sequencer.json + $(SOV_CLI_REL_PATH) transactions import from-file sequencer-registry --path ../test-data/requests/register_sequencer.json @echo "Submitting a batch" $(SOV_CLI_REL_PATH) node submit-batch by-nickname DANGER__DO_NOT_USE_WITH_REAL_MONEY diff --git a/examples/demo-rollup/README.md b/examples/demo-rollup/README.md index 7d61f2b975..371f01fb68 100644 --- a/examples/demo-rollup/README.md +++ b/examples/demo-rollup/README.md @@ -109,9 +109,9 @@ Once a batch is submitted, the output should also contain the transaction hashes ```text 2025-10-24T12:40:48.335845Z INFO sov_cli::workflows::node: Executing node workflow -2025-10-24T12:40:48.348358Z INFO sov_cli::workflows::node: Submitting tx index=0 tx_hash=0xe87b872b001576ba4a72b5e0c41402ae1ae161c97be5e7099c1c03c3fb3ddba8 +2025-10-24T12:40:48.348358Z INFO sov_cli::workflows::node: Submitting tx index=0 tx_hash=0x891d5051281ecf1637ff42b4bc68ddda37787e949a5362467e9c123635d18093 2025-10-24T12:40:48.348379Z INFO sov_node_client: Calling `publish_batch` sequencer endpoint txs_included=1 -2025-10-24T12:40:48.358028Z INFO sov_node_client: Submitted tx hash="0xe87b872b001576ba4a72b5e0c41402ae1ae161c97be5e7099c1c03c3fb3ddba8" +2025-10-24T12:40:48.358028Z INFO sov_node_client: Submitted tx hash="0x891d5051281ecf1637ff42b4bc68ddda37787e949a5362467e9c123635d18093" 2025-10-24T12:40:48.358060Z INFO sov_node_client: Going to wait for batch to be processed max_waiting_time=300s 2025-10-24T12:40:50.477229Z INFO sov_node_client: Rollup has processed the submitted batch! ``` @@ -121,7 +121,7 @@ this case have the TokenCreated Event ```sh,test-ci,bashtestmd:compare-output $ sleep 5 -$ curl -sS http://127.0.0.1:12346/ledger/txs/0xe87b872b001576ba4a72b5e0c41402ae1ae161c97be5e7099c1c03c3fb3ddba8/events | jq +$ curl -sS http://127.0.0.1:12346/ledger/txs/0x891d5051281ecf1637ff42b4bc68ddda37787e949a5362467e9c123635d18093/events | jq [ { "type": "event", @@ -155,7 +155,7 @@ $ curl -sS http://127.0.0.1:12346/ledger/txs/0xe87b872b001576ba4a72b5e0c41402ae1 "type": "moduleRef", "name": "Bank" }, - "tx_hash": "0xe87b872b001576ba4a72b5e0c41402ae1ae161c97be5e7099c1c03c3fb3ddba8" + "tx_hash": "0x891d5051281ecf1637ff42b4bc68ddda37787e949a5362467e9c123635d18093" } ] ``` @@ -334,12 +334,12 @@ Adding the following transaction to batch: } } }, - "chain_hash": "0xb4be2c15ed35b8b7e0406b41e9dddd883948129501f3bcf952a47daf02b6c4a2", + "chain_hash": "0x1bd36e60c454585ce482f322e5cf5c61f460c3448acf4b4b2a90fe6453d1dc87", "details": { "max_priority_fee_bips": 0, "max_fee": "100000000", "gas_limit": null, - "chain_id": 4321 + "chain_hash_fragment": "6654161651848106779" } } ``` diff --git a/examples/demo-rollup/README_CELESTIA.md b/examples/demo-rollup/README_CELESTIA.md index 11d5e1c818..5c4415c5ff 100644 --- a/examples/demo-rollup/README_CELESTIA.md +++ b/examples/demo-rollup/README_CELESTIA.md @@ -280,7 +280,7 @@ Options: Let's go ahead and import the transaction into the wallet ```bash,test-ci,bashtestmd:compare-output -$ ./../../target/debug/sov-cli transactions import from-file bank --chain-id 4321 --max-fee 100000000 --path ../test-data/requests/transfer.json +$ ./../../target/debug/sov-cli transactions import from-file bank --max-fee 100000000 --path ../test-data/requests/transfer.json Adding the following transaction to batch: { "tx": { @@ -294,12 +294,12 @@ Adding the following transaction to batch: } } }, - "chain_hash": "0xb4be2c15ed35b8b7e0406b41e9dddd883948129501f3bcf952a47daf02b6c4a2", + "chain_hash": "0x1bd36e60c454585ce482f322e5cf5c61f460c3448acf4b4b2a90fe6453d1dc87", "details": { "max_priority_fee_bips": 0, "max_fee": "100000000", "gas_limit": null, - "chain_id": 4321 + "chain_hash_fragment": "6654161651848106779" } } ``` diff --git a/examples/demo-rollup/demo-rollup-schema.bin b/examples/demo-rollup/demo-rollup-schema.bin index 5ed91dac1b..41b326de32 100644 Binary files a/examples/demo-rollup/demo-rollup-schema.bin and b/examples/demo-rollup/demo-rollup-schema.bin differ diff --git a/examples/demo-rollup/demo-rollup-schema.json b/examples/demo-rollup/demo-rollup-schema.json index d6b868da87..5fabc986fa 100644 --- a/examples/demo-rollup/demo-rollup-schema.json +++ b/examples/demo-rollup/demo-rollup-schema.json @@ -17,7 +17,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 170 + "ByIndex": 177 } } ], @@ -83,7 +83,7 @@ "display_name": "uniqueness", "silent": false, "value": { - "ByIndex": 164 + "ByIndex": 171 }, "doc": "" }, @@ -91,7 +91,15 @@ "display_name": "details", "silent": false, "value": { - "ByIndex": 168 + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 }, "doc": "" } @@ -155,7 +163,7 @@ "discriminant": 6, "template": null, "value": { - "ByIndex": 51 + "ByIndex": 55 } }, { @@ -163,7 +171,7 @@ "discriminant": 7, "template": null, "value": { - "ByIndex": 53 + "ByIndex": 57 } }, { @@ -171,7 +179,7 @@ "discriminant": 8, "template": null, "value": { - "ByIndex": 56 + "ByIndex": 60 } }, { @@ -179,7 +187,7 @@ "discriminant": 9, "template": null, "value": { - "ByIndex": 57 + "ByIndex": 61 } }, { @@ -187,7 +195,7 @@ "discriminant": 10, "template": null, "value": { - "ByIndex": 86 + "ByIndex": 90 } }, { @@ -195,7 +203,7 @@ "discriminant": 11, "template": null, "value": { - "ByIndex": 91 + "ByIndex": 95 } }, { @@ -203,7 +211,7 @@ "discriminant": 12, "template": null, "value": { - "ByIndex": 100 + "ByIndex": 104 } }, { @@ -211,7 +219,7 @@ "discriminant": 13, "template": null, "value": { - "ByIndex": 110 + "ByIndex": 114 } }, { @@ -219,7 +227,7 @@ "discriminant": 14, "template": null, "value": { - "ByIndex": 111 + "ByIndex": 115 } }, { @@ -227,7 +235,7 @@ "discriminant": 15, "template": null, "value": { - "ByIndex": 131 + "ByIndex": 135 } }, { @@ -235,7 +243,7 @@ "discriminant": 16, "template": null, "value": { - "ByIndex": 145 + "ByIndex": 152 } }, { @@ -243,7 +251,7 @@ "discriminant": 17, "template": null, "value": { - "ByIndex": 159 + "ByIndex": 166 } } ], @@ -1236,6 +1244,38 @@ "value": { "ByIndex": 48 } + }, + { + "name": "AddCredentialToAddress", + "discriminant": 1, + "template": null, + "value": { + "ByIndex": 51 + } + }, + { + "name": "RemoveCredentialFromAddress", + "discriminant": 2, + "template": null, + "value": { + "ByIndex": 52 + } + }, + { + "name": "RotateCredentialOnAddress", + "discriminant": 3, + "template": null, + "value": { + "ByIndex": 53 + } + }, + { + "name": "CreateSyntheticAddress", + "discriminant": 4, + "template": null, + "value": { + "ByIndex": 54 + } } ], "hide_tag": false @@ -1291,6 +1331,111 @@ ] } }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_AddCredentialToAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_RemoveCredentialFromAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_RotateCredentialOnAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "old_credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + }, + { + "display_name": "new_credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_CreateSyntheticAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "salt", + "silent": false, + "value": { + "Immediate": { + "ByteArray": { + "len": 32, + "display": "Hex" + } + } + }, + "doc": "" + } + ] + } + }, { "Tuple": { "template": null, @@ -1298,7 +1443,7 @@ "fields": [ { "value": { - "ByIndex": 52 + "ByIndex": 56 }, "silent": false, "doc": "" @@ -1320,7 +1465,7 @@ "fields": [ { "value": { - "ByIndex": 54 + "ByIndex": 58 }, "silent": false, "doc": "" @@ -1343,7 +1488,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 55 + "ByIndex": 59 } } ], @@ -1379,7 +1524,7 @@ "fields": [ { "value": { - "ByIndex": 52 + "ByIndex": 56 }, "silent": false, "doc": "" @@ -1394,7 +1539,7 @@ "fields": [ { "value": { - "ByIndex": 58 + "ByIndex": 62 }, "silent": false, "doc": "" @@ -1411,7 +1556,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 59 + "ByIndex": 63 } }, { @@ -1419,7 +1564,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 75 + "ByIndex": 79 } }, { @@ -1427,7 +1572,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 76 + "ByIndex": 80 } } ], @@ -1444,7 +1589,7 @@ "display_name": "policy", "silent": false, "value": { - "ByIndex": 60 + "ByIndex": 64 }, "doc": "" } @@ -1461,7 +1606,7 @@ "display_name": "default_payee_policy", "silent": false, "value": { - "ByIndex": 61 + "ByIndex": 65 }, "doc": "" }, @@ -1469,7 +1614,7 @@ "display_name": "payees", "silent": false, "value": { - "ByIndex": 70 + "ByIndex": 74 }, "doc": "" }, @@ -1485,7 +1630,7 @@ "display_name": "authorized_sequencers", "silent": false, "value": { - "ByIndex": 72 + "ByIndex": 76 }, "doc": "" } @@ -1501,7 +1646,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 62 + "ByIndex": 66 } }, { @@ -1532,7 +1677,7 @@ "display_name": "gas_limit", "silent": false, "value": { - "ByIndex": 63 + "ByIndex": 67 }, "doc": "" }, @@ -1540,7 +1685,7 @@ "display_name": "max_gas_price", "silent": false, "value": { - "ByIndex": 66 + "ByIndex": 70 }, "doc": "" }, @@ -1548,7 +1693,7 @@ "display_name": "transaction_limit", "silent": false, "value": { - "ByIndex": 69 + "ByIndex": 73 }, "doc": "" } @@ -1558,7 +1703,7 @@ { "Option": { "value": { - "ByIndex": 64 + "ByIndex": 68 } } }, @@ -1569,7 +1714,7 @@ "fields": [ { "value": { - "ByIndex": 65 + "ByIndex": 69 }, "silent": false, "doc": "" @@ -1593,7 +1738,7 @@ { "Option": { "value": { - "ByIndex": 67 + "ByIndex": 71 } } }, @@ -1607,7 +1752,7 @@ "display_name": "value", "silent": false, "value": { - "ByIndex": 68 + "ByIndex": 72 }, "doc": "" } @@ -1637,7 +1782,7 @@ { "Vec": { "value": { - "ByIndex": 71 + "ByIndex": 75 } } }, @@ -1655,7 +1800,7 @@ }, { "value": { - "ByIndex": 61 + "ByIndex": 65 }, "silent": false, "doc": "" @@ -1678,7 +1823,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 73 + "ByIndex": 77 } } ], @@ -1692,7 +1837,7 @@ "fields": [ { "value": { - "ByIndex": 74 + "ByIndex": 78 }, "silent": false, "doc": "" @@ -1742,7 +1887,7 @@ "display_name": "update", "silent": false, "value": { - "ByIndex": 77 + "ByIndex": 81 }, "doc": "" } @@ -1759,7 +1904,7 @@ "display_name": "sequencer_update", "silent": false, "value": { - "ByIndex": 78 + "ByIndex": 82 }, "doc": "" }, @@ -1767,7 +1912,7 @@ "display_name": "updaters_to_add", "silent": false, "value": { - "ByIndex": 83 + "ByIndex": 87 }, "doc": "" }, @@ -1775,7 +1920,7 @@ "display_name": "updaters_to_remove", "silent": false, "value": { - "ByIndex": 83 + "ByIndex": 87 }, "doc": "" }, @@ -1783,7 +1928,7 @@ "display_name": "payee_policies_to_set", "silent": false, "value": { - "ByIndex": 84 + "ByIndex": 88 }, "doc": "" }, @@ -1791,7 +1936,7 @@ "display_name": "payee_policies_to_delete", "silent": false, "value": { - "ByIndex": 83 + "ByIndex": 87 }, "doc": "" }, @@ -1799,7 +1944,7 @@ "display_name": "default_policy", "silent": false, "value": { - "ByIndex": 85 + "ByIndex": 89 }, "doc": "" } @@ -1809,7 +1954,7 @@ { "Option": { "value": { - "ByIndex": 79 + "ByIndex": 83 } } }, @@ -1828,7 +1973,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 80 + "ByIndex": 84 } } ], @@ -1842,7 +1987,7 @@ "fields": [ { "value": { - "ByIndex": 81 + "ByIndex": 85 }, "silent": false, "doc": "" @@ -1860,7 +2005,7 @@ "display_name": "to_add", "silent": false, "value": { - "ByIndex": 82 + "ByIndex": 86 }, "doc": "" }, @@ -1868,7 +2013,7 @@ "display_name": "to_remove", "silent": false, "value": { - "ByIndex": 82 + "ByIndex": 86 }, "doc": "" } @@ -1878,7 +2023,7 @@ { "Option": { "value": { - "ByIndex": 74 + "ByIndex": 78 } } }, @@ -1892,14 +2037,14 @@ { "Option": { "value": { - "ByIndex": 70 + "ByIndex": 74 } } }, { "Option": { "value": { - "ByIndex": 61 + "ByIndex": 65 } } }, @@ -1910,7 +2055,7 @@ "fields": [ { "value": { - "ByIndex": 87 + "ByIndex": 91 }, "silent": false, "doc": "" @@ -1939,7 +2084,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 88 + "ByIndex": 92 } }, { @@ -1947,7 +2092,7 @@ "discriminant": 3, "template": null, "value": { - "ByIndex": 89 + "ByIndex": 93 } }, { @@ -1955,7 +2100,7 @@ "discriminant": 4, "template": null, "value": { - "ByIndex": 90 + "ByIndex": 94 } } ], @@ -2025,7 +2170,7 @@ "fields": [ { "value": { - "ByIndex": 92 + "ByIndex": 96 }, "silent": false, "doc": "" @@ -2042,7 +2187,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 93 + "ByIndex": 97 } }, { @@ -2050,7 +2195,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 96 + "ByIndex": 100 } }, { @@ -2058,7 +2203,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 97 + "ByIndex": 101 } } ], @@ -2096,7 +2241,7 @@ "display_name": "body", "silent": false, "value": { - "ByIndex": 94 + "ByIndex": 98 }, "doc": "" }, @@ -2104,7 +2249,7 @@ "display_name": "metadata", "silent": false, "value": { - "ByIndex": 95 + "ByIndex": 99 }, "doc": "" }, @@ -2149,7 +2294,7 @@ { "Option": { "value": { - "ByIndex": 94 + "ByIndex": 98 } } }, @@ -2163,7 +2308,7 @@ "display_name": "metadata", "silent": false, "value": { - "ByIndex": 94 + "ByIndex": 98 }, "doc": "" }, @@ -2171,7 +2316,7 @@ "display_name": "message", "silent": false, "value": { - "ByIndex": 94 + "ByIndex": 98 }, "doc": "" } @@ -2188,7 +2333,7 @@ "display_name": "validator_address", "silent": false, "value": { - "ByIndex": 98 + "ByIndex": 102 }, "doc": "" }, @@ -2204,7 +2349,7 @@ "display_name": "signature", "silent": false, "value": { - "ByIndex": 99 + "ByIndex": 103 }, "doc": "" } @@ -2258,7 +2403,7 @@ "fields": [ { "value": { - "ByIndex": 101 + "ByIndex": 105 }, "silent": false, "doc": "" @@ -2275,7 +2420,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 102 + "ByIndex": 106 } }, { @@ -2283,7 +2428,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 108 + "ByIndex": 112 } }, { @@ -2291,7 +2436,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 109 + "ByIndex": 113 } } ], @@ -2308,7 +2453,7 @@ "display_name": "domain_oracle_data", "silent": false, "value": { - "ByIndex": 103 + "ByIndex": 107 }, "doc": "" }, @@ -2316,7 +2461,7 @@ "display_name": "domain_default_gas", "silent": false, "value": { - "ByIndex": 106 + "ByIndex": 110 }, "doc": "" }, @@ -2342,7 +2487,7 @@ { "Vec": { "value": { - "ByIndex": 104 + "ByIndex": 108 } } }, @@ -2369,7 +2514,7 @@ "display_name": "data_value", "silent": false, "value": { - "ByIndex": 105 + "ByIndex": 109 }, "doc": "" } @@ -2409,7 +2554,7 @@ { "Vec": { "value": { - "ByIndex": 107 + "ByIndex": 111 } } }, @@ -2466,7 +2611,7 @@ "display_name": "oracle_data", "silent": false, "value": { - "ByIndex": 105 + "ByIndex": 109 }, "doc": "" } @@ -2516,7 +2661,7 @@ "fields": [ { "value": { - "ByIndex": 112 + "ByIndex": 116 }, "silent": false, "doc": "" @@ -2533,7 +2678,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 113 + "ByIndex": 117 } }, { @@ -2541,7 +2686,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 125 + "ByIndex": 129 } }, { @@ -2549,7 +2694,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 128 + "ByIndex": 132 } }, { @@ -2557,7 +2702,7 @@ "discriminant": 3, "template": null, "value": { - "ByIndex": 129 + "ByIndex": 133 } }, { @@ -2565,7 +2710,7 @@ "discriminant": 4, "template": null, "value": { - "ByIndex": 130 + "ByIndex": 134 } } ], @@ -2582,7 +2727,7 @@ "display_name": "admin", "silent": false, "value": { - "ByIndex": 114 + "ByIndex": 118 }, "doc": "" }, @@ -2590,7 +2735,7 @@ "display_name": "token_source", "silent": false, "value": { - "ByIndex": 116 + "ByIndex": 120 }, "doc": "" }, @@ -2598,7 +2743,7 @@ "display_name": "ism", "silent": false, "value": { - "ByIndex": 119 + "ByIndex": 123 }, "doc": "" }, @@ -2606,7 +2751,7 @@ "display_name": "remote_routers", "silent": false, "value": { - "ByIndex": 123 + "ByIndex": 127 }, "doc": "" }, @@ -2660,7 +2805,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 115 + "ByIndex": 119 } } ], @@ -2691,7 +2836,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 117 + "ByIndex": 121 } }, { @@ -2699,7 +2844,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 118 + "ByIndex": 122 } }, { @@ -2782,7 +2927,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 120 + "ByIndex": 124 } }, { @@ -2790,7 +2935,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 121 + "ByIndex": 125 } } ], @@ -2824,7 +2969,7 @@ "display_name": "validators", "silent": false, "value": { - "ByIndex": 122 + "ByIndex": 126 }, "doc": "" }, @@ -2847,14 +2992,14 @@ { "Vec": { "value": { - "ByIndex": 98 + "ByIndex": 102 } } }, { "Vec": { "value": { - "ByIndex": 124 + "ByIndex": 128 } } }, @@ -2903,7 +3048,7 @@ "display_name": "admin", "silent": false, "value": { - "ByIndex": 126 + "ByIndex": 130 }, "doc": "" }, @@ -2911,7 +3056,7 @@ "display_name": "ism", "silent": false, "value": { - "ByIndex": 127 + "ByIndex": 131 }, "doc": "" }, @@ -2953,14 +3098,14 @@ { "Option": { "value": { - "ByIndex": 114 + "ByIndex": 118 } } }, { "Option": { "value": { - "ByIndex": 119 + "ByIndex": 123 } } }, @@ -3101,7 +3246,7 @@ "fields": [ { "value": { - "ByIndex": 132 + "ByIndex": 136 }, "silent": false, "doc": "" @@ -3118,7 +3263,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 133 + "ByIndex": 137 } }, { @@ -3126,7 +3271,15 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 135 + "ByIndex": 139 + } + }, + { + "name": "UpdateEnabledCustomPrecompiles", + "discriminant": 2, + "template": null, + "value": { + "ByIndex": 149 } } ], @@ -3140,7 +3293,7 @@ "fields": [ { "value": { - "ByIndex": 134 + "ByIndex": 138 }, "silent": false, "doc": "" @@ -3176,7 +3329,7 @@ "fields": [ { "value": { - "ByIndex": 136 + "ByIndex": 140 }, "silent": false, "doc": "" @@ -3194,7 +3347,7 @@ "display_name": "new_hardfork", "silent": false, "value": { - "ByIndex": 137 + "ByIndex": 141 }, "doc": "" }, @@ -3202,7 +3355,7 @@ "display_name": "new_contract_creation_policy", "silent": false, "value": { - "ByIndex": 139 + "ByIndex": 143 }, "doc": "" }, @@ -3210,7 +3363,7 @@ "display_name": "chain_spec_update", "silent": false, "value": { - "ByIndex": 142 + "ByIndex": 146 }, "doc": "" }, @@ -3228,7 +3381,7 @@ { "Option": { "value": { - "ByIndex": 138 + "ByIndex": 142 } } }, @@ -3267,7 +3420,7 @@ { "Option": { "value": { - "ByIndex": 140 + "ByIndex": 144 } } }, @@ -3286,7 +3439,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 141 + "ByIndex": 145 } } ], @@ -3303,7 +3456,7 @@ "display_name": "add", "silent": false, "value": { - "ByIndex": 122 + "ByIndex": 126 }, "doc": "" }, @@ -3311,7 +3464,7 @@ "display_name": "remove", "silent": false, "value": { - "ByIndex": 122 + "ByIndex": 126 }, "doc": "" } @@ -3321,7 +3474,7 @@ { "Option": { "value": { - "ByIndex": 143 + "ByIndex": 147 } } }, @@ -3335,7 +3488,7 @@ "display_name": "new_limit_contract_code_size", "silent": false, "value": { - "ByIndex": 144 + "ByIndex": 148 }, "doc": "" }, @@ -3343,7 +3496,7 @@ "display_name": "new_block_gas_limit", "silent": false, "value": { - "ByIndex": 69 + "ByIndex": 73 }, "doc": "" }, @@ -3351,7 +3504,7 @@ "display_name": "new_tx_gas_limit", "silent": false, "value": { - "ByIndex": 69 + "ByIndex": 73 }, "doc": "" } @@ -3377,7 +3530,54 @@ "fields": [ { "value": { - "ByIndex": 146 + "ByIndex": 150 + }, + "silent": false, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "EnabledCustomPrecompilesUpdate", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "add", + "silent": false, + "value": { + "ByIndex": 151 + }, + "doc": "" + }, + { + "display_name": "remove", + "silent": false, + "value": { + "ByIndex": 151 + }, + "doc": "" + } + ] + } + }, + { + "Vec": { + "value": { + "ByIndex": 13 + } + } + }, + { + "Tuple": { + "template": null, + "peekable": false, + "fields": [ + { + "value": { + "ByIndex": 153 }, "silent": false, "doc": "" @@ -3394,7 +3594,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 147 + "ByIndex": 154 } }, { @@ -3402,7 +3602,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 148 + "ByIndex": 155 } }, { @@ -3410,7 +3610,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 150 + "ByIndex": 157 } }, { @@ -3418,7 +3618,7 @@ "discriminant": 3, "template": null, "value": { - "ByIndex": 151 + "ByIndex": 158 } }, { @@ -3426,7 +3626,7 @@ "discriminant": 4, "template": null, "value": { - "ByIndex": 152 + "ByIndex": 159 } }, { @@ -3434,7 +3634,7 @@ "discriminant": 5, "template": null, "value": { - "ByIndex": 153 + "ByIndex": 160 } }, { @@ -3448,7 +3648,7 @@ "discriminant": 7, "template": null, "value": { - "ByIndex": 154 + "ByIndex": 161 } }, { @@ -3456,7 +3656,7 @@ "discriminant": 8, "template": null, "value": { - "ByIndex": 155 + "ByIndex": 162 } }, { @@ -3470,7 +3670,7 @@ "discriminant": 10, "template": null, "value": { - "ByIndex": 156 + "ByIndex": 163 } }, { @@ -3478,7 +3678,7 @@ "discriminant": 11, "template": null, "value": { - "ByIndex": 157 + "ByIndex": 164 } }, { @@ -3486,7 +3686,7 @@ "discriminant": 12, "template": null, "value": { - "ByIndex": 158 + "ByIndex": 165 } } ], @@ -3564,7 +3764,7 @@ "display_name": "content", "silent": false, "value": { - "ByIndex": 149 + "ByIndex": 156 }, "doc": "" } @@ -3856,7 +4056,7 @@ "fields": [ { "value": { - "ByIndex": 160 + "ByIndex": 167 }, "silent": false, "doc": "" @@ -3873,7 +4073,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 161 + "ByIndex": 168 } }, { @@ -3881,7 +4081,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 162 + "ByIndex": 169 } }, { @@ -3889,7 +4089,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 163 + "ByIndex": 170 } } ], @@ -4010,7 +4210,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 165 + "ByIndex": 172 } }, { @@ -4018,7 +4218,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 166 + "ByIndex": 173 } }, { @@ -4026,7 +4226,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 167 + "ByIndex": 174 } } ], @@ -4103,7 +4303,7 @@ "display_name": "max_priority_fee_bips", "silent": false, "value": { - "ByIndex": 169 + "ByIndex": 176 }, "doc": "" }, @@ -4119,13 +4319,13 @@ "display_name": "gas_limit", "silent": false, "value": { - "ByIndex": 63 + "ByIndex": 67 }, "doc": "" }, { - "display_name": "chain_id", - "silent": false, + "display_name": "chain_hash_fragment", + "silent": true, "value": { "Immediate": { "Integer": [ @@ -4166,7 +4366,7 @@ "fields": [ { "value": { - "ByIndex": 171 + "ByIndex": 178 }, "silent": false, "doc": "" @@ -4184,7 +4384,7 @@ "display_name": "signatures", "silent": false, "value": { - "ByIndex": 172 + "ByIndex": 179 }, "doc": "" }, @@ -4192,7 +4392,7 @@ "display_name": "unused_pub_keys", "silent": false, "value": { - "ByIndex": 174 + "ByIndex": 181 }, "doc": "" }, @@ -4221,7 +4421,7 @@ "display_name": "uniqueness", "silent": false, "value": { - "ByIndex": 164 + "ByIndex": 171 }, "doc": "" }, @@ -4229,7 +4429,15 @@ "display_name": "details", "silent": false, "value": { - "ByIndex": 168 + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 }, "doc": "" } @@ -4239,7 +4447,7 @@ { "Vec": { "value": { - "ByIndex": 173 + "ByIndex": 180 } } }, @@ -4290,9 +4498,48 @@ } } }, + { + "Enum": { + "type_name": "TransactionSigningPayload", + "variants": [ + { + "name": "V0", + "discriminant": 0, + "template": null, + "value": { + "ByIndex": 183 + } + }, + { + "name": "V1", + "discriminant": 1, + "template": null, + "value": { + "ByIndex": 185 + } + } + ], + "hide_tag": false + } + }, + { + "Tuple": { + "template": null, + "peekable": false, + "fields": [ + { + "value": { + "ByIndex": 184 + }, + "silent": false, + "doc": "" + } + ] + } + }, { "Struct": { - "type_name": "UnsignedTransaction", + "type_name": "TransactionSigningPayloadV0", "template": null, "peekable": false, "fields": [ @@ -4308,7 +4555,7 @@ "display_name": "uniqueness", "silent": false, "value": { - "ByIndex": 164 + "ByIndex": 171 }, "doc": "" }, @@ -4316,7 +4563,105 @@ "display_name": "details", "silent": false, "value": { - "ByIndex": 168 + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 + }, + "doc": "" + }, + { + "display_name": "chain_hash", + "silent": true, + "value": { + "Immediate": { + "ByteArray": { + "len": 32, + "display": "Hex" + } + } + }, + "doc": "" + } + ] + } + }, + { + "Tuple": { + "template": null, + "peekable": false, + "fields": [ + { + "value": { + "ByIndex": 186 + }, + "silent": false, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "TransactionSigningPayloadV1", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "runtime_call", + "silent": false, + "value": { + "ByIndex": 3 + }, + "doc": "" + }, + { + "display_name": "uniqueness", + "silent": false, + "value": { + "ByIndex": 171 + }, + "doc": "" + }, + { + "display_name": "details", + "silent": false, + "value": { + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "credential_address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 + }, + "doc": "" + }, + { + "display_name": "chain_hash", + "silent": true, + "value": { + "Immediate": { + "ByteArray": { + "len": 32, + "display": "Hex" + } + } }, "doc": "" } @@ -4326,7 +4671,7 @@ ], "root_type_indices": [ 0, - 175, + 182, 3, 9 ], @@ -4456,6 +4801,9 @@ }, { "name": "details" + }, + { + "name": "address_override" } ] }, @@ -4864,6 +5212,18 @@ "fields_or_variants": [ { "name": "insert_credential_id" + }, + { + "name": "add_credential_to_address" + }, + { + "name": "remove_credential_from_address" + }, + { + "name": "rotate_credential_on_address" + }, + { + "name": "create_synthetic_address" } ] }, @@ -4879,6 +5239,50 @@ "name": "", "fields_or_variants": [] }, + { + "name": "__SovVirtualWallet_CallMessage_AddCredentialToAddress", + "fields_or_variants": [ + { + "name": "address" + }, + { + "name": "credential" + } + ] + }, + { + "name": "__SovVirtualWallet_CallMessage_RemoveCredentialFromAddress", + "fields_or_variants": [ + { + "name": "address" + }, + { + "name": "credential" + } + ] + }, + { + "name": "__SovVirtualWallet_CallMessage_RotateCredentialOnAddress", + "fields_or_variants": [ + { + "name": "address" + }, + { + "name": "old_credential" + }, + { + "name": "new_credential" + } + ] + }, + { + "name": "__SovVirtualWallet_CallMessage_CreateSyntheticAddress", + "fields_or_variants": [ + { + "name": "salt" + } + ] + }, { "name": "", "fields_or_variants": [] @@ -5604,6 +6008,9 @@ }, { "name": "UpdateRuntimeConfig" + }, + { + "name": "UpdateEnabledCustomPrecompiles" } ] }, @@ -5700,6 +6107,25 @@ "name": "", "fields_or_variants": [] }, + { + "name": "EnabledCustomPrecompilesUpdate", + "fields_or_variants": [ + { + "name": "add" + }, + { + "name": "remove" + } + ] + }, + { + "name": "", + "fields_or_variants": [] + }, + { + "name": "", + "fields_or_variants": [] + }, { "name": "AccessPatternMessages", "fields_or_variants": [ @@ -5956,7 +6382,7 @@ "name": "gas_limit" }, { - "name": "chain_id" + "name": "chain_hash_fragment" } ] }, @@ -5988,6 +6414,9 @@ }, { "name": "details" + }, + { + "name": "address_override" } ] }, @@ -6011,7 +6440,22 @@ "fields_or_variants": [] }, { - "name": "UnsignedTransaction", + "name": "TransactionSigningPayload", + "fields_or_variants": [ + { + "name": "V0" + }, + { + "name": "V1" + } + ] + }, + { + "name": "", + "fields_or_variants": [] + }, + { + "name": "TransactionSigningPayloadV0", "fields_or_variants": [ { "name": "runtime_call" @@ -6021,6 +6465,39 @@ }, { "name": "details" + }, + { + "name": "address_override" + }, + { + "name": "chain_hash" + } + ] + }, + { + "name": "", + "fields_or_variants": [] + }, + { + "name": "TransactionSigningPayloadV1", + "fields_or_variants": [ + { + "name": "runtime_call" + }, + { + "name": "uniqueness" + }, + { + "name": "details" + }, + { + "name": "credential_address" + }, + { + "name": "address_override" + }, + { + "name": "chain_hash" } ] } diff --git a/examples/demo-rollup/provers/risc0/guest-celestia/Cargo.lock b/examples/demo-rollup/provers/risc0/guest-celestia/Cargo.lock index c8f624e260..f7bbae0819 100644 --- a/examples/demo-rollup/provers/risc0/guest-celestia/Cargo.lock +++ b/examples/demo-rollup/provers/risc0/guest-celestia/Cargo.lock @@ -4040,6 +4040,7 @@ dependencies = [ "schemars 1.2.0", "serde", "serde_with", + "sov-chain-state", "sov-modules-api", "sov-state", ] diff --git a/examples/demo-rollup/provers/risc0/guest-mock/Cargo.lock b/examples/demo-rollup/provers/risc0/guest-mock/Cargo.lock index 551886ccdf..1fa2fccb4c 100644 --- a/examples/demo-rollup/provers/risc0/guest-mock/Cargo.lock +++ b/examples/demo-rollup/provers/risc0/guest-mock/Cargo.lock @@ -3408,6 +3408,7 @@ dependencies = [ "schemars 1.2.0", "serde", "serde_with", + "sov-chain-state", "sov-modules-api", "sov-state", ] diff --git a/examples/demo-rollup/provers/sp1/guest-aggregation-mock/Cargo.lock b/examples/demo-rollup/provers/sp1/guest-aggregation-mock/Cargo.lock index 82b8324dc6..4197f18c9c 100644 --- a/examples/demo-rollup/provers/sp1/guest-aggregation-mock/Cargo.lock +++ b/examples/demo-rollup/provers/sp1/guest-aggregation-mock/Cargo.lock @@ -5685,6 +5685,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_with", + "sov-chain-state", "sov-modules-api", "sov-state", ] diff --git a/examples/demo-rollup/provers/sp1/guest-mock/Cargo.lock b/examples/demo-rollup/provers/sp1/guest-mock/Cargo.lock index fd4164c9b6..84471c5c4d 100644 --- a/examples/demo-rollup/provers/sp1/guest-mock/Cargo.lock +++ b/examples/demo-rollup/provers/sp1/guest-mock/Cargo.lock @@ -5670,6 +5670,7 @@ dependencies = [ "schemars 1.2.0", "serde", "serde_with", + "sov-chain-state", "sov-modules-api", "sov-state", ] diff --git a/examples/demo-rollup/sov-benchmarks/src/bench_runner/helpers.rs b/examples/demo-rollup/sov-benchmarks/src/bench_runner/helpers.rs index f2108bddb3..1249e7137d 100644 --- a/examples/demo-rollup/sov-benchmarks/src/bench_runner/helpers.rs +++ b/examples/demo-rollup/sov-benchmarks/src/bench_runner/helpers.rs @@ -7,8 +7,7 @@ use anyhow::ensure; use backon::Retryable; use futures::{Stream, StreamExt}; use sov_mock_da::storable::StorableMockDaService; -use sov_modules_api::capabilities::config_chain_id; -use sov_modules_api::transaction::TxDetails; +use sov_modules_api::transaction::{chain_hash_fragment, TxDetails}; use sov_modules_api::{CryptoSpec, HexHash, Runtime, Spec}; use sov_node_client::NodeClient; use sov_rollup_interface::node::ledger_api::IncludeChildren; @@ -233,7 +232,7 @@ impl BatchSender { max_priority_fee_bips: TEST_DEFAULT_MAX_PRIORITY_FEE, max_fee: TEST_DEFAULT_MAX_FEE, gas_limit: None, - chain_id: config_chain_id(), + chain_hash_fragment: chain_hash_fragment(&RT::CHAIN_HASH), }, &mut self.generation_numbers, ), diff --git a/examples/demo-rollup/src/migrations/common.rs b/examples/demo-rollup/src/migrations/common.rs new file mode 100644 index 0000000000..88a90889cb --- /dev/null +++ b/examples/demo-rollup/src/migrations/common.rs @@ -0,0 +1,66 @@ +//! Shared helpers for offline migration binaries. +//! +//! Each binary in `examples/demo-rollup/src/migrations` includes this file via +//! `#[path = "common.rs"] mod common;` since binaries don't share a crate +//! root with the package library. + +use anyhow::{bail, Context}; +use rockbound::SchemaBatch; +use sov_db::ledger_db::LedgerDb; +use sov_db::schema::tables::SlotByNumber; +use sov_rollup_interface::common::SlotNumber; +use sov_state::NativeStorage; + +pub fn assert_storage_latest_version_matches_ledger_head( + storage: &S, + ledger_db: &LedgerDb, + phase: &str, +) -> anyhow::Result { + let (head_slot_number, _head_slot) = ledger_db + .get_head_slot()? + .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot migrate an empty DB"))?; + let storage_latest_version = storage.latest_version(); + if storage_latest_version != head_slot_number { + bail!( + "{phase} invariant failed: storage.latest_version ({}) != ledger head slot ({})", + storage_latest_version, + head_slot_number + ); + } + Ok(head_slot_number) +} + +pub fn assert_ledger_head_state_root_matches_storage_root( + storage: &S, + ledger_db: &LedgerDb, + phase: &str, +) -> anyhow::Result<()> { + let (head_slot_number, head_slot) = ledger_db + .get_head_slot()? + .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot migrate an empty DB"))?; + let storage_root = storage + .get_root_hash(head_slot_number) + .context("failed to read storage root at ledger head slot")?; + if head_slot.state_root.as_ref() != storage_root.as_ref() { + bail!( + "{phase} invariant failed: ledger head state_root does not match storage root at slot {}", + head_slot_number + ); + } + Ok(()) +} + +pub fn make_ledger_root_patch( + ledger_db: &LedgerDb, + new_state_root: &[u8], +) -> anyhow::Result { + let (head_slot_number, mut head_slot) = ledger_db + .get_head_slot()? + .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot patch state root"))?; + + head_slot.state_root = new_state_root.to_vec().into(); + + let mut batch = SchemaBatch::new(); + batch.put::(&head_slot_number, &head_slot)?; + Ok(batch) +} diff --git a/examples/demo-rollup/src/migrations/legacy_accounts.rs b/examples/demo-rollup/src/migrations/legacy_accounts.rs new file mode 100644 index 0000000000..94aa92815c --- /dev/null +++ b/examples/demo-rollup/src/migrations/legacy_accounts.rs @@ -0,0 +1,56 @@ +//! Offline migration tool that upgrades demo-rollup state from version 0 to version 1. + +use std::path::PathBuf; + +use clap::Parser; +use demo_stf::runtime::Runtime; +use sov_demo_rollup::MockDemoRollup; +use sov_modules_api::execution_mode::Native; +use sov_modules_api::{CryptoSpec, Spec}; +use sov_modules_rollup_blueprint::RollupBlueprint; + +#[derive(Parser, Debug)] +#[command( + author, + version, + about = "Offline migration tool for demo-rollup state version 0 to version 1." +)] +struct Args { + /// Path to the rollup config file used by the running node. + #[arg(long)] + rollup_config_path: PathBuf, + + /// Override `storage.path` from the rollup config. + #[arg(long)] + db_path: Option, + + /// Compute the post-migration state root but do not commit changes. + #[arg(long, default_value_t = false)] + dry_run: bool, +} + +type RollupSpec = as RollupBlueprint>::Spec; +type Hasher = <::CryptoSpec as CryptoSpec>::Hasher; + +fn main() { + if let Err(err) = run() { + eprintln!("migrate_to_v1 failed: {err:#}"); + std::process::exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let args = Args::parse(); + let mut runtime = Runtime::::default(); + let runtime_inner = &mut *runtime; + sov_migrations::v1::run::( + sov_migrations::MigrationArgs { + rollup_config_path: args.rollup_config_path, + db_path: args.db_path, + dry_run: args.dry_run, + }, + &mut runtime_inner.accounts, + &mut runtime_inner.chain_state, + )?; + Ok(()) +} diff --git a/examples/demo-rollup/src/migrations/mockda_to_celestia.rs b/examples/demo-rollup/src/migrations/mockda_to_celestia.rs index b5c356ac36..c58af26d56 100644 --- a/examples/demo-rollup/src/migrations/mockda_to_celestia.rs +++ b/examples/demo-rollup/src/migrations/mockda_to_celestia.rs @@ -14,7 +14,7 @@ use sov_celestia_adapter::types::TmHash; use sov_celestia_adapter::verifier::address::CelestiaAddress; use sov_db::config::RollupDbConfig; use sov_db::ledger_db::LedgerDb; -use sov_db::schema::tables::{BatchByNumber, SlotByNumber}; +use sov_db::schema::tables::BatchByNumber; use sov_db::schema::types::{BatchNumber, DbBytes, StoredBatch}; use sov_db::storage_manager::NomtStorageManager; use sov_full_node_configs::runner::from_toml_path; @@ -35,6 +35,13 @@ use sov_stf_runner::RollupConfig; use sov_demo_rollup::{CelestiaDemoRollup, MockDemoRollup}; +#[path = "common.rs"] +mod common; +use common::{ + assert_ledger_head_state_root_matches_storage_root, + assert_storage_latest_version_matches_ledger_head, make_ledger_root_patch, +}; + #[derive(Parser, Debug)] #[command( author, @@ -1216,21 +1223,6 @@ fn select_source_sequencer( Ok(only.clone()) } -fn make_ledger_root_patch( - ledger_db: &LedgerDb, - new_state_root: &[u8], -) -> anyhow::Result { - let (head_slot_number, mut head_slot) = ledger_db - .get_head_slot()? - .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot patch state root"))?; - - head_slot.state_root = new_state_root.to_vec().into(); - - let mut batch = SchemaBatch::new(); - batch.put::(&head_slot_number, &head_slot)?; - Ok(batch) -} - fn make_batch_receipt_patch( ledger_db: &LedgerDb, sequencer_plan: &ResolvedSequencerPlan, @@ -1351,44 +1343,3 @@ fn migrate_stored_batch_receipt( ); Ok(BatchReceiptMigrationOutcome::NeedsRewrite(batch)) } - -fn assert_storage_latest_version_matches_ledger_head( - storage: &S, - ledger_db: &LedgerDb, - phase: &str, -) -> anyhow::Result { - let (head_slot_number, _head_slot) = ledger_db - .get_head_slot()? - .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot migrate an empty DB"))?; - let storage_latest_version = storage.latest_version(); - if storage_latest_version != head_slot_number { - bail!( - "{phase} invariant failed: storage.latest_version ({}) != ledger head slot ({})", - storage_latest_version, - head_slot_number - ); - } - - Ok(head_slot_number) -} - -fn assert_ledger_head_state_root_matches_storage_root( - storage: &S, - ledger_db: &LedgerDb, - phase: &str, -) -> anyhow::Result<()> { - let (head_slot_number, head_slot) = ledger_db - .get_head_slot()? - .ok_or_else(|| anyhow::anyhow!("ledger has no head slot; cannot migrate an empty DB"))?; - let storage_root = storage - .get_root_hash(head_slot_number) - .context("failed to read storage root at ledger head slot")?; - if head_slot.state_root.as_ref() != storage_root.as_ref() { - bail!( - "{phase} invariant failed: ledger head state_root does not match storage root at slot {}", - head_slot_number - ); - } - - Ok(()) -} diff --git a/examples/demo-rollup/stf/src/authentication.rs b/examples/demo-rollup/stf/src/authentication.rs index 3f924bf1e3..f54bfb8ce2 100644 --- a/examples/demo-rollup/stf/src/authentication.rs +++ b/examples/demo-rollup/stf/src/authentication.rs @@ -76,7 +76,7 @@ where match input { EvmAndSolanaOffchainAuthenticatorInput::Evm(tx) => { let (tx_and_raw_hash, auth_data, runtime_call) = - sov_evm::authenticate::<_, _>(&tx.data, state)?; + sov_evm::authenticate::<_, _>(&tx.data, &Rt::CHAIN_HASH, state)?; Ok(( tx_and_raw_hash, @@ -191,7 +191,7 @@ where } Self::Input::Evm(tx) => { let (tx_and_raw_hash, auth_data, runtime_call) = - sov_evm::authenticate::<_, _>(&tx.data, state)?; + sov_evm::authenticate::<_, _>(&tx.data, &Rt::CHAIN_HASH, state)?; Ok(( tx_and_raw_hash, auth_data, diff --git a/examples/demo-rollup/stf/src/runtime.rs b/examples/demo-rollup/stf/src/runtime.rs index d77cc7e701..22a5f1e3af 100644 --- a/examples/demo-rollup/stf/src/runtime.rs +++ b/examples/demo-rollup/stf/src/runtime.rs @@ -15,7 +15,9 @@ use sov_evm::EthereumAuthenticator; use sov_kernels::soft_confirmations::SoftConfirmationsKernel; #[cfg(feature = "native")] use sov_modules_api::capabilities::KernelWithSlotMapping; -use sov_modules_api::capabilities::{Guard, HasCapabilities, HasKernel, TransactionAuthenticator}; +use sov_modules_api::capabilities::{ + Guard, HasCapabilities, HasKernel, HasSequencingData, TransactionAuthenticator, +}; use sov_modules_api::Base58Address; use sov_modules_api::{RawTx, Spec}; @@ -150,7 +152,6 @@ where S::Address: FromVmAddress + FromVmAddress + HyperlaneAddress, { type Capabilities<'a> = StandardCapabilities<'a, S, &'a mut sov_paymaster::Paymaster>; - type SequencingData = sov_modules_api::HDTimestamp; fn capabilities(&mut self) -> Guard> { Guard::new(StandardCapabilities { @@ -167,6 +168,13 @@ where } } +impl HasSequencingData for Runtime +where + S::Address: FromVmAddress + FromVmAddress + HyperlaneAddress, +{ + type SequencingData = (); +} + impl HasKernel for Runtime where S::Address: FromVmAddress + FromVmAddress + HyperlaneAddress, diff --git a/examples/demo-rollup/tests/all_tests.rs b/examples/demo-rollup/tests/all_tests.rs index fc1bb61bec..6bdf636290 100644 --- a/examples/demo-rollup/tests/all_tests.rs +++ b/examples/demo-rollup/tests/all_tests.rs @@ -2,6 +2,7 @@ mod bank; mod evm; mod external_mock_da; mod forced_sequencer_registration; +mod migrations; mod prover; mod replica; mod rest_api; diff --git a/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs b/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs index 759646358b..9ceee2827d 100644 --- a/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs +++ b/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs @@ -103,7 +103,6 @@ fn build_register_sequencer_tx( da_address: UNREGISTERED_SENDER, amount: MINIMUM_BOND, }); - let chain_id = config_value!("CHAIN_ID"); let max_priority_fee_bips = PriorityFeeBips::ZERO; let max_fee = MAX_TX_FEE; let gas_limit = None; @@ -112,11 +111,12 @@ fn build_register_sequencer_tx( &CHAIN_HASH, UnsignedTransaction::new( msg, - chain_id, + CHAIN_HASH, max_priority_fee_bips, max_fee, UniquenessData::Nonce(nonce), gas_limit, + None, ), ) } @@ -486,7 +486,6 @@ fn build_state_heavy_tx( max_heavy_state_size: data_size, salt: 42, }); - let chain_id = config_value!("CHAIN_ID"); let max_priority_fee_bips = PriorityFeeBips::ZERO; let max_fee = MAX_TX_FEE; Transaction::, TestSpec>::new_signed_tx( @@ -494,11 +493,12 @@ fn build_state_heavy_tx( &CHAIN_HASH, UnsignedTransaction::new( msg, - chain_id, + CHAIN_HASH, max_priority_fee_bips, max_fee, UniquenessData::Nonce(nonce), None, + None, ), ) } diff --git a/examples/demo-rollup/tests/migrations/mod.rs b/examples/demo-rollup/tests/migrations/mod.rs new file mode 100644 index 0000000000..d29bdd61d9 --- /dev/null +++ b/examples/demo-rollup/tests/migrations/mod.rs @@ -0,0 +1,331 @@ +use anyhow::Context; +use demo_stf::runtime::Runtime as DemoRuntime; +use sov_accounts::{Account, Accounts}; +use sov_bank::config_gas_token_id; +use sov_chain_state::ChainState; +use sov_db::config::RollupDbConfig; +use sov_db::ledger_db::LedgerDb; +use sov_db::schema::tables::SlotByNumber; +use sov_db::storage_manager::NomtStorageManager; +use sov_demo_rollup::MockDemoRollup; +use sov_migrations::MigrationStorage as _; +use sov_mock_da::BlockProducingConfig; +use sov_modules_api::capabilities::HasKernel; +use sov_modules_api::execution_mode::Native; +use sov_modules_api::{ + AccessoryStateValue, CredentialId, CryptoSpec, ModuleInfo, OperatingMode, Spec, + StateCheckpoint, StateMap, StateWriter, +}; +use sov_rollup_interface::storage::HierarchicalStorageManager; +use sov_state::{ + BorshCodec, Kernel, NativeStorage as _, Prefix, SlotKey, SlotValue, StateUpdate as _, +}; +use sov_test_utils::test_rollup::{read_private_key, RollupBuilder}; + +use crate::test_helpers::{build_transfer_token_tx, test_genesis_source, DemoRollupSpec}; + +type Rollup = MockDemoRollup; +type Hasher = <::CryptoSpec as CryptoSpec>::Hasher; +type DemoStorageManager = + NomtStorageManager<::Da, Hasher, ::Storage>; +type DemoLedgerChangeSet = ::Da, +>>::LedgerChangeSet; + +struct KernelRoundtrip { + key: SlotKey, + value: SlotValue, +} + +#[tokio::test(flavor = "multi_thread")] +async fn v1_migration_rewrites_live_demo_state() -> anyhow::Result<()> { + let (builder, storage_config, credential_id, account_address) = + build_and_force_v0_demo_state().await?; + + let report = run_v1_migration(storage_config.clone())?; + + assert!(!report.dry_run); + assert!(report.head_rollup_slot > 0); + assert_ne!(report.pre_state_root, report.post_state_root); + assert_eq!( + report.migration.from_state_version, + sov_migrations::v1::SOURCE_STATE_VERSION + ); + assert_eq!( + report.migration.to_state_version, + sov_migrations::v1::TARGET_STATE_VERSION + ); + assert_eq!(report.migration.accounts.entries_migrated, 1); + + assert_migrated_state(storage_config, credential_id, account_address)?; + + let restarted_rollup = builder.start().await?; + restarted_rollup.wait_for_node_synced().await?; + restarted_rollup.shutdown().await?; + + Ok(()) +} + +/// Re-running the migration against an already-migrated database must be a graceful no-op: it +/// exits `Ok` without changing state, so a restarted node that re-invokes the migration binary +/// does not fail. +#[tokio::test(flavor = "multi_thread")] +async fn v1_migration_is_idempotent() -> anyhow::Result<()> { + let (_builder, storage_config, credential_id, account_address) = + build_and_force_v0_demo_state().await?; + + // First run actually applies the migration, establishing the already-migrated precondition. + let first = run_v1_migration(storage_config.clone())?; + assert_eq!( + first.migration.accounts.entries_migrated, 1, + "first run should migrate the seeded legacy account" + ); + assert_ne!( + first.pre_state_root, first.post_state_root, + "first run should change the state root" + ); + + // Second run sees state_version already at the target and must do nothing. + let second = run_v1_migration(storage_config.clone())?; + assert_eq!( + second.migration.from_state_version, + sov_migrations::v1::TARGET_STATE_VERSION, + "second run should observe the already-migrated state version" + ); + assert_eq!( + second.migration.to_state_version, + sov_migrations::v1::TARGET_STATE_VERSION, + "second run should leave the state version at the target" + ); + assert_eq!( + second.migration.accounts.entries_migrated, 0, + "second run must not migrate any accounts" + ); + assert_eq!( + second.pre_state_root, second.post_state_root, + "second run must not change the state root" + ); + assert_eq!( + second.pre_state_root, first.post_state_root, + "state must be unchanged between the first and second runs" + ); + + assert_migrated_state(storage_config, credential_id, account_address)?; + + Ok(()) +} + +/// Builds a live demo rollup, advances it past genesis with one transfer, shuts it down, and +/// rewrites its head into a state-version-0 database carrying a legacy account entry. Returns the +/// shutdown builder (for an optional restart), the storage config, and the seeded account ids. +async fn build_and_force_v0_demo_state() -> anyhow::Result<( + RollupBuilder, + RollupDbConfig, + CredentialId, + ::Address, +)> { + let credential_id = credential_id(0x11); + let account_address = account_address(0x22); + + let test_rollup = RollupBuilder::::new( + test_genesis_source(OperatingMode::Operator), + BlockProducingConfig::Manual, + 0, + ) + .set_persistent_da() + .start() + .await?; + + test_rollup.wait_for_node_synced().await?; + test_rollup.produce_enough_finalized_slots().await; + test_rollup.wait_for_sequencer_ready().await?; + let tx_signer = read_private_key::("tx_signer_private_key.json"); + let tx = build_transfer_token_tx( + &tx_signer.private_key, + config_gas_token_id(), + account_address, + 1, + 0, + ); + test_rollup.send_tx_to_sequencer(&tx).await?; + let initial_height = test_rollup.height().await.get(); + test_rollup.force_close_batch().await?; + test_rollup.produce_enough_finalized_slots().await; + test_rollup.wait_for_height(initial_height + 1).await; + test_rollup.wait_for_node_synced().await?; + + let storage_config = test_rollup.rollup_config.storage.clone(); + let builder = test_rollup.shutdown().await?; + + prepare_v0_state_with_legacy_account(storage_config.clone(), credential_id, account_address)?; + + Ok((builder, storage_config, credential_id, account_address)) +} + +/// Runs the v1 migration against `storage_config` with a fresh runtime, returning the outcome. +fn run_v1_migration( + storage_config: RollupDbConfig, +) -> anyhow::Result> { + let mut runtime = DemoRuntime::::default(); + let runtime_inner = &mut *runtime; + sov_migrations::v1::run_with_options::( + sov_migrations::MigrationOptions { + storage: storage_config, + dry_run: false, + }, + &mut runtime_inner.accounts, + &mut runtime_inner.chain_state, + ) +} + +fn prepare_v0_state_with_legacy_account( + storage_config: RollupDbConfig, + credential_id: CredentialId, + address: ::Address, +) -> anyhow::Result<()> { + rewrite_head(storage_config, |runtime, state| { + chain_state_state_version_value(&runtime.chain_state) + .set(&sov_migrations::v1::SOURCE_STATE_VERSION, state)?; + legacy_accounts_map(&runtime.accounts).set( + &credential_id, + &Account { addr: address }, + state, + )?; + Ok(()) + }) +} + +fn assert_migrated_state( + storage_config: RollupDbConfig, + credential_id: CredentialId, + address: ::Address, +) -> anyhow::Result<()> { + let storage_manager = DemoStorageManager::new(storage_config, false) + .context("failed to open post-migration storage manager")?; + let (storage, _ledger_reader) = storage_manager + .create_state_for_migration() + .context("failed to create post-migration storage view")?; + let mut runtime = DemoRuntime::::default(); + let mut checkpoint = StateCheckpoint::new(storage, &runtime.kernel()); + + let state_version = runtime + .chain_state + .state_version(&mut checkpoint.accessory_state()) + .context("failed to read migrated state_version")?; + assert_eq!(state_version, sov_migrations::v1::TARGET_STATE_VERSION); + assert!(runtime.accounts.is_explicitly_authorized( + &address, + &credential_id, + &mut checkpoint + )?); + assert!(legacy_accounts_map(&runtime.accounts) + .get(&credential_id, &mut checkpoint)? + .is_none()); + + Ok(()) +} + +fn rewrite_head( + storage_config: RollupDbConfig, + apply: impl FnOnce( + &mut DemoRuntime, + &mut StateCheckpoint, + ) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + let db_path = storage_config.path.clone(); + let mut storage_manager = DemoStorageManager::new(storage_config, false) + .with_context(|| format!("failed to open storage manager at {}", db_path.display()))?; + let (storage, ledger_reader) = storage_manager + .create_state_for_migration() + .context("failed to create migration setup storage view")?; + let ledger_db = + LedgerDb::with_reader(ledger_reader).context("failed to initialize migration ledger db")?; + let (head_slot_number, mut head_slot) = ledger_db + .get_head_slot()? + .context("ledger has no head slot; cannot rewrite state")?; + let pre_state_root = storage + .get_root_hash(head_slot_number) + .context("failed to read pre-rewrite state root")?; + + anyhow::ensure!( + storage.latest_version() == head_slot_number, + "storage latest version does not match ledger head slot" + ); + anyhow::ensure!( + head_slot.state_root.as_ref() == pre_state_root.as_ref(), + "ledger head state root does not match storage root" + ); + + let mut runtime = DemoRuntime::::default(); + let kernel_roundtrip = kernel_roundtrip(&runtime.chain_state, &storage)?; + let mut checkpoint = StateCheckpoint::new(storage, &runtime.kernel()); + apply(&mut runtime, &mut checkpoint)?; + StateWriter::::set( + &mut checkpoint, + &kernel_roundtrip.key, + kernel_roundtrip.value, + ) + .context("failed to round-trip kernel state during setup rewrite")?; + + let (next_state_root, mut state_update, accessory_delta, _witness, storage_after) = + checkpoint.materialize_update(pre_state_root); + state_update.add_accessory_items(accessory_delta.freeze()); + let change_set = + storage_after.materialize_migration_changes_at_version(state_update, head_slot_number); + + head_slot.state_root = next_state_root.as_ref().to_vec().into(); + let mut ledger_change_set: DemoLedgerChangeSet = Default::default(); + ledger_change_set.put::(&head_slot_number, &head_slot)?; + storage_manager + .commit_migration_change_set_at_head(head_slot_number, change_set, ledger_change_set) + .context("failed to commit rewritten state")?; + + Ok(()) +} + +fn legacy_accounts_map( + accounts: &Accounts, +) -> StateMap> { + StateMap::with_codec( + Prefix::new( + accounts.discriminant(), + Accounts::::ACCOUNTS_ITEM_DISCRIMINANT, + ), + BorshCodec, + ) +} + +fn chain_state_state_version_value( + chain_state: &ChainState, +) -> AccessoryStateValue { + AccessoryStateValue::with_codec( + Prefix::new( + chain_state.discriminant(), + ChainState::::STATE_VERSION_ITEM_DISCRIMINANT, + ), + BorshCodec, + ) +} + +fn kernel_roundtrip( + chain_state: &ChainState, + storage: &::Storage, +) -> anyhow::Result { + let key = SlotKey::singleton(&Prefix::new( + chain_state.discriminant(), + ChainState::::TRUE_SLOT_NUMBER_ITEM_DISCRIMINANT, + )); + let value = storage.get_unbound::(key.clone()).ok_or_else(|| { + anyhow::anyhow!("kernel chain_state.true_slot_number is unset; cannot rewrite state") + })?; + + Ok(KernelRoundtrip { key, value }) +} + +fn credential_id(byte: u8) -> CredentialId { + [byte; 32].into() +} + +fn account_address(byte: u8) -> ::Address { + credential_id(byte).into() +} diff --git a/examples/demo-rollup/tests/resync/data/mock_da.sqlite b/examples/demo-rollup/tests/resync/data/mock_da.sqlite index c876b5dec7..06f2f1d7fc 100644 Binary files a/examples/demo-rollup/tests/resync/data/mock_da.sqlite and b/examples/demo-rollup/tests/resync/data/mock_da.sqlite differ diff --git a/examples/demo-rollup/tests/resync/mod.rs b/examples/demo-rollup/tests/resync/mod.rs index 0404de324a..10f5b45931 100644 --- a/examples/demo-rollup/tests/resync/mod.rs +++ b/examples/demo-rollup/tests/resync/mod.rs @@ -113,6 +113,7 @@ async fn start_rollup( seq_config.batch_execution_time_limit_millis = TEST_DEFAULT_MOCK_DA_BLOCK_TIME_MS * 3; seq_config.ideal_lag_behind_finalized_slot = 3; + seq_config.num_cache_warmup_workers = 0; } }) .start(), @@ -259,7 +260,7 @@ async fn test_rollup_resync() -> anyhow::Result<()> { { let _span = tracing::info_span!("sync-1").entered(); - sync_rollup_with_path(rollup_storage_path.clone(), 10_000) + sync_rollup_with_path(rollup_storage_path.clone(), 200) .await .context("Sync 1")?; } @@ -305,7 +306,7 @@ async fn test_rollup_resync() -> anyhow::Result<()> { ); { - sync_rollup_with_path(rollup_storage_path, 20_000) + sync_rollup_with_path(rollup_storage_path, 300) .await .context("Sync 2")?; } diff --git a/examples/demo-rollup/tests/wallet/mod.rs b/examples/demo-rollup/tests/wallet/mod.rs index 4e6ec54d72..f229bf3400 100644 --- a/examples/demo-rollup/tests/wallet/mod.rs +++ b/examples/demo-rollup/tests/wallet/mod.rs @@ -4,9 +4,8 @@ use demo_stf::runtime::{Runtime, RuntimeCall}; use sov_bank::{CallMessage, Coins, TokenId}; use sov_modules_api::capabilities::UniquenessData; use sov_modules_api::sov_universal_wallet::schema::{ChainData, RollupRoots, Schema}; -use sov_modules_api::transaction::{Transaction, UnsignedTransaction}; +use sov_modules_api::transaction::{Transaction, TransactionSigningPayload, UnsignedTransaction}; use sov_modules_api::{Address, Amount, DispatchCall, PrivateKey, Spec}; -use sov_modules_macros::config_value; use sov_test_utils::{ TestUser, TEST_DEFAULT_GAS_LIMIT, TEST_DEFAULT_MAX_FEE, TEST_DEFAULT_MAX_PRIORITY_FEE, }; @@ -31,11 +30,12 @@ fn make_unsigned_tx() -> UnsignedTransaction, S> { }); UnsignedTransaction::<_, S>::new( msg, - config_value!("CHAIN_ID"), + CHAIN_HASH, TEST_DEFAULT_MAX_PRIORITY_FEE, TEST_DEFAULT_MAX_FEE, UniquenessData::Generation(0), Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, ) } @@ -63,7 +63,7 @@ fn test_transfer_template() { }"#; let schema = Schema::of_rollup_types_with_chain_data::< Transaction, S>, - UnsignedTransaction, S>, + TransactionSigningPayload, S>, RuntimeCall, Address, >(ChainData { @@ -84,10 +84,9 @@ fn test_transfer_template() { #[test] fn test_display_unsigned_tx() { let unsigned_tx = make_unsigned_tx(); - let unsigned_data = borsh::to_vec(&unsigned_tx).unwrap(); let schema = Schema::of_rollup_types_with_chain_data::< Transaction, S>, - UnsignedTransaction, S>, + TransactionSigningPayload, S>, RuntimeCall, Address, >(ChainData { @@ -95,16 +94,17 @@ fn test_display_unsigned_tx() { chain_name: "TestChain".to_string(), }) .unwrap(); + let signing_payload_data = unsigned_tx.to_signing_bytes_v0(schema.chain_hash().unwrap()); assert_eq!( schema .display( schema - .rollup_expected_index(RollupRoots::UnsignedTransaction) + .rollup_expected_index(RollupRoots::TransactionSigningPayload) .unwrap(), - &unsigned_data + &signing_payload_data ) .unwrap(), - r#"{ runtime_call: Bank.Mint { coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }, uniqueness: Generation(0), details: { max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000], chain_id: 4321 } }"# + r#"V0 { runtime_call: Bank.Mint { coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }, uniqueness: Generation(0), details: { max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000] }, address_override: None }"# ); } @@ -117,7 +117,7 @@ fn test_display_signed_tx() { let signed_data = borsh::to_vec(&signed_tx).unwrap(); let schema = Schema::of_rollup_types_with_chain_data::< Transaction, S>, - UnsignedTransaction, S>, + TransactionSigningPayload, S>, RuntimeCall, Address, >(ChainData { @@ -142,13 +142,15 @@ fn test_display_signed_tx() { &signed_data ) .unwrap(), - format!("V0 {{ signature: 0x{signature_display}, pub_key: 0x{pubkey_display}, runtime_call: Bank.Mint {{ coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }}, uniqueness: Generation(0), details: {{ max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000], chain_id: 4321 }} }}") + format!("V0 {{ signature: 0x{signature_display}, pub_key: 0x{pubkey_display}, runtime_call: Bank.Mint {{ coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }}, uniqueness: Generation(0), details: {{ max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000] }}, address_override: None }}") ); } -#[ignore = "Ignored for rapid schema iteration, re-enable when CI timebomb goes off"] #[test] fn detect_schema_has_breaking_change() { - let current_hash = [0u8; 32]; + let current_hash: [u8; 32] = [ + 27, 211, 110, 96, 196, 84, 88, 92, 228, 130, 243, 34, 229, 207, 92, 97, 244, 96, 195, 68, + 138, 207, 75, 75, 42, 144, 254, 100, 83, 209, 220, 135, + ]; assert_eq!(CHAIN_HASH, current_hash, "The chain hash changed. Update the \"current_hash\" value in this test but be aware: this is a breaking change for any production rollups."); } diff --git a/examples/test-data/genesis/demo/celestia/chain_state.json b/examples/test-data/genesis/demo/celestia/chain_state.json index 499b6765fa..b782219eec 100644 --- a/examples/test-data/genesis/demo/celestia/chain_state.json +++ b/examples/test-data/genesis/demo/celestia/chain_state.json @@ -3,5 +3,6 @@ "operating_mode": "operator", "inner_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], "outer_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], - "genesis_da_height": 3 + "genesis_da_height": 3, + "state_version": 1 } diff --git a/examples/test-data/genesis/demo/mock/chain_state.json b/examples/test-data/genesis/demo/mock/chain_state.json index 43a0465b5a..806650744f 100644 --- a/examples/test-data/genesis/demo/mock/chain_state.json +++ b/examples/test-data/genesis/demo/mock/chain_state.json @@ -3,5 +3,6 @@ "operating_mode": "zk", "inner_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], "outer_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], - "genesis_da_height": 0 + "genesis_da_height": 0, + "state_version": 1 } diff --git a/examples/test-data/genesis/integration-tests/chain_state_op.json b/examples/test-data/genesis/integration-tests/chain_state_op.json index 130f190a40..73d2927f91 100644 --- a/examples/test-data/genesis/integration-tests/chain_state_op.json +++ b/examples/test-data/genesis/integration-tests/chain_state_op.json @@ -3,5 +3,6 @@ "operating_mode": "optimistic", "inner_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], "outer_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], - "genesis_da_height": 0 + "genesis_da_height": 0, + "state_version": 1 } diff --git a/examples/test-data/genesis/integration-tests/chain_state_operator.json b/examples/test-data/genesis/integration-tests/chain_state_operator.json index 03117e15f2..f39c71100c 100644 --- a/examples/test-data/genesis/integration-tests/chain_state_operator.json +++ b/examples/test-data/genesis/integration-tests/chain_state_operator.json @@ -3,5 +3,6 @@ "operating_mode": "operator", "inner_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], "outer_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], - "genesis_da_height": 0 + "genesis_da_height": 0, + "state_version": 1 } diff --git a/examples/test-data/genesis/integration-tests/chain_state_zk.json b/examples/test-data/genesis/integration-tests/chain_state_zk.json index 43a0465b5a..806650744f 100644 --- a/examples/test-data/genesis/integration-tests/chain_state_zk.json +++ b/examples/test-data/genesis/integration-tests/chain_state_zk.json @@ -3,5 +3,6 @@ "operating_mode": "zk", "inner_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], "outer_code_commitment": [0, 0, 0, 0, 0, 0, 0, 0], - "genesis_da_height": 0 + "genesis_da_height": 0, + "state_version": 1 } diff --git a/packages_to_publish.yml b/packages_to_publish.yml index a3f9b8de81..2926f4a060 100644 --- a/packages_to_publish.yml +++ b/packages_to_publish.yml @@ -29,6 +29,7 @@ - sov-zkvm-utils - sov-build - full-node-configs +- sov-migrations - sovereign-web3 - sov-ethereum #TODO: Find correct spot in order - sov-eip-712 #TODO: Find correct spot in order diff --git a/python/py_sovereign_web3/README.md b/python/py_sovereign_web3/README.md index fbede81b84..d92dde17c7 100644 --- a/python/py_sovereign_web3/README.md +++ b/python/py_sovereign_web3/README.md @@ -25,7 +25,7 @@ serializer = Serializer.from_url("http://localhost:12346/rollup/schema") # Create transaction call = {"bank": {"create_token": {"token_name": "MyToken", "initial_balance": "1000"}}} -details = TxDetails(chain_id=4321) +details = TxDetails(chain_hash_fragment=serializer.chain_hash_fragment()) unsigned_tx = UnsignedTransaction(runtime_call=call, details=details) # Get bytes for signing @@ -40,5 +40,5 @@ serialized = serializer.serialize_tx(signed_tx) - `Serializer`: Schema-based transaction serialization - `UnsignedTransaction`: Unsigned transaction with runtime calls -- `TxDetails`: Transaction metadata (chain ID, fees, gas) +- `TxDetails`: Transaction metadata (chain hash fragment, fees, gas) - `UniquenessData`: Transaction uniqueness (nonce, generation, or window) diff --git a/python/py_sovereign_web3/rust/src/lib.rs b/python/py_sovereign_web3/rust/src/lib.rs index 694f8c626a..0a8431ee27 100644 --- a/python/py_sovereign_web3/rust/src/lib.rs +++ b/python/py_sovereign_web3/rust/src/lib.rs @@ -2,8 +2,8 @@ use pyo3::exceptions::PyValueError; use pyo3::types::PyDict; use pyo3::{prelude::*, types::PyType}; use sovereign_web3::schema::{ - default_uniqueness, Serializer, Transaction, TxDetails, UniquenessData, UnsignedTransaction, - DEFAULT_MAX_FEE, DEFAULT_MAX_PRIORITY_FEE_BIPS, + chain_hash_fragment, default_uniqueness, Serializer, Transaction, TxDetails, UniquenessData, + UnsignedTransaction, DEFAULT_MAX_FEE, DEFAULT_MAX_PRIORITY_FEE_BIPS, }; #[pyclass(name = "Serializer")] @@ -35,12 +35,20 @@ impl PySerializer { Ok(hash.to_vec()) } - fn serialize_unsigned_tx(&self, unsigned_tx: &PyUnsignedTransaction) -> PyResult> { - let bytes = self + fn chain_hash_fragment(&self) -> PyResult { + let hash = self + .inner + .chain_hash() + .map_err(|e| PyValueError::new_err(format!("Failed to get chain hash: {e}")))?; + Ok(chain_hash_fragment(&hash)) + } + + fn serialize_signing_payload(&self, unsigned_tx: &PyUnsignedTransaction) -> PyResult> { + let bytes = unsigned_tx .inner - .serialize_unsigned_tx(&unsigned_tx.inner) + .bytes_for_signing(&self.inner) .map_err(|e| { - PyValueError::new_err(format!("Failed to serialize unsigned transaction: {e}")) + PyValueError::new_err(format!("Failed to serialize signing payload: {e}")) })?; Ok(bytes) } @@ -62,9 +70,9 @@ struct PyTxDetails { #[pymethods] impl PyTxDetails { #[new] - #[pyo3(signature = (chain_id, max_fee=DEFAULT_MAX_FEE, max_priority_fee_bips=DEFAULT_MAX_PRIORITY_FEE_BIPS, gas_limit=None))] + #[pyo3(signature = (chain_hash_fragment, max_fee=DEFAULT_MAX_FEE, max_priority_fee_bips=DEFAULT_MAX_PRIORITY_FEE_BIPS, gas_limit=None))] fn new( - chain_id: u64, + chain_hash_fragment: u64, max_fee: u128, max_priority_fee_bips: u64, gas_limit: Option>, @@ -74,19 +82,19 @@ impl PyTxDetails { max_priority_fee_bips, max_fee, gas_limit, - chain_id, + chain_hash_fragment, }, } } #[getter] - fn get_chain_id(&self) -> u64 { - self.inner.chain_id + fn get_chain_hash_fragment(&self) -> u64 { + self.inner.chain_hash_fragment } #[setter] - fn set_chain_id(&mut self, chain_id: u64) { - self.inner.chain_id = chain_id; + fn set_chain_hash_fragment(&mut self, chain_hash_fragment: u64) { + self.inner.chain_hash_fragment = chain_hash_fragment; } #[getter] @@ -118,11 +126,12 @@ struct PyUnsignedTransaction { #[pymethods] impl PyUnsignedTransaction { #[new] - #[pyo3(signature = (runtime_call, details, uniqueness=None))] + #[pyo3(signature = (runtime_call, details, uniqueness=None, address_override=None))] fn new( runtime_call: &Bound<'_, PyDict>, details: &PyTxDetails, uniqueness: Option<&PyUniquenessData>, + address_override: Option, ) -> PyResult { let call: serde_json::Value = pythonize::depythonize(runtime_call).map_err(|e| { PyValueError::new_err(format!("Failed to convert runtime_call dict to JSON: {e}")) @@ -139,6 +148,7 @@ impl PyUnsignedTransaction { runtime_call: call, uniqueness, details: details.inner.clone(), + address_override, }; Ok(PyUnsignedTransaction { inner: unsigned_tx }) diff --git a/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi b/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi index b97a4d431e..b1e4623e63 100644 --- a/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi +++ b/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi @@ -42,8 +42,19 @@ class Serializer: """ ... - def serialize_unsigned_tx(self, unsigned_tx: "UnsignedTransaction") -> bytes: - """Serialize an unsigned transaction. + def chain_hash_fragment(self) -> int: + """Get the 64-bit chain hash fragment. + + Returns: + Chain hash fragment as an integer + + Raises: + ValueError: If chain hash cannot be computed + """ + ... + + def serialize_signing_payload(self, unsigned_tx: "UnsignedTransaction") -> bytes: + """Serialize an unsigned transaction signing payload. Args: unsigned_tx: Transaction to serialize @@ -75,7 +86,7 @@ class TxDetails: def __init__( self, - chain_id: int, + chain_hash_fragment: int, max_fee: int = ..., max_priority_fee_bips: int = ..., gas_limit: Optional[List[int]] = None, @@ -83,7 +94,7 @@ class TxDetails: """Create transaction details. Args: - chain_id: Chain identifier + chain_hash_fragment: 64-bit chain hash fragment max_fee: Maximum fee (default: DEFAULT_MAX_FEE) max_priority_fee_bips: Maximum priority fee in basis points (default: DEFAULT_MAX_PRIORITY_FEE_BIPS) gas_limit: Optional gas limit per module @@ -91,13 +102,13 @@ class TxDetails: ... @property - def chain_id(self) -> int: - """Chain identifier.""" + def chain_hash_fragment(self) -> int: + """64-bit chain hash fragment.""" ... - @chain_id.setter - def chain_id(self, value: int) -> None: - """Set chain identifier.""" + @chain_hash_fragment.setter + def chain_hash_fragment(self, value: int) -> None: + """Set the 64-bit chain hash fragment.""" ... @property @@ -179,6 +190,7 @@ class UnsignedTransaction: runtime_call: Dict[str, Any], details: TxDetails, uniqueness: Optional[UniquenessData] = None, + address_override: Optional[str] = None, ) -> None: """Create unsigned transaction. @@ -186,6 +198,7 @@ class UnsignedTransaction: runtime_call: Runtime call data as dictionary details: Transaction details uniqueness: Optional uniqueness data (defaults to default uniqueness) + address_override: Optional explicit address override Raises: ValueError: If runtime_call cannot be converted to JSON or uniqueness creation fails diff --git a/python/py_sovereign_web3/src/tests/test_basic.py b/python/py_sovereign_web3/src/tests/test_basic.py index 4d57b49fef..ca00cbb8ce 100644 --- a/python/py_sovereign_web3/src/tests/test_basic.py +++ b/python/py_sovereign_web3/src/tests/test_basic.py @@ -25,7 +25,7 @@ def test_basic_transaction_submission(): } } } - details = TxDetails(chain_id=4321) + details = TxDetails(chain_hash_fragment=serializer.chain_hash_fragment()) unsigned_tx = UnsignedTransaction(runtime_call=call, details=details) tx_bytes = unsigned_tx.bytes_for_signing(serializer) diff --git a/scripts/switcheroo/presets/default.json b/scripts/switcheroo/presets/default.json index 821aac331f..8050ec8b90 100644 --- a/scripts/switcheroo/presets/default.json +++ b/scripts/switcheroo/presets/default.json @@ -16,6 +16,7 @@ "crates/utils/nearly-linear", "crates/utils/sov-zkvm-utils", "crates/utils/sov-build", + "crates/utils/sov-migrations", "crates/module-system/hyperlane", "crates/module-system/sov-cli", "crates/module-system/sov-modules-stf-blueprint", diff --git a/typescript/.changeset/fluffy-fishes-decide.md b/typescript/.changeset/fluffy-fishes-decide.md new file mode 100644 index 0000000000..8d2f18239c --- /dev/null +++ b/typescript/.changeset/fluffy-fishes-decide.md @@ -0,0 +1,8 @@ +--- +"@sovereign-sdk/multisig": minor +--- + +Multisig rework. +- Replace `MultisigTransaction` with payload-agnostic `Multisig` signer state and move transaction finalization into `@sovereign-sdk/web3`. +- Migrate payload serialization to `rollup.multisigSigningBytes(unsignedTx)`, collect signatures with `multisig.addSignature(signature, pubKey)`, and finalize with `multisig.toTransaction(unsignedTx)`. +- See the new code examples in the READMEs for detailed examples. diff --git a/typescript/.changeset/light-bananas-obey.md b/typescript/.changeset/light-bananas-obey.md new file mode 100644 index 0000000000..c2dd4bb850 --- /dev/null +++ b/typescript/.changeset/light-bananas-obey.md @@ -0,0 +1,12 @@ +--- +"@sovereign-sdk/universal-wallet-wasm": minor +"@sovereign-sdk/integration-tests": minor +"@sovereign-sdk/serializers": minor +"@sovereign-sdk/multisig": minor +"@sovereign-sdk/signers": minor +"@sovereign-sdk/types": minor +"@sovereign-sdk/utils": minor +"@sovereign-sdk/web3": minor +--- + +Internal transaction types have been renamed: `UnsignedTransaction` now refers to the user-constructed transaction data, and `TransactionSigningPayload{V0, V1}` is the version-dependent structure serialized for signing. Normal usage should be largely unaffected. diff --git a/typescript/.changeset/silent-pans-wave.md b/typescript/.changeset/silent-pans-wave.md new file mode 100644 index 0000000000..e33691f880 --- /dev/null +++ b/typescript/.changeset/silent-pans-wave.md @@ -0,0 +1,7 @@ +--- +"@sovereign-sdk/types": minor +--- + +Multisig rework. +- `UnsignedTransaction` is now an enum with two variants, representing the bytes serialized for signing. +- Migration: in most cases, `UnsignedTransactionV0` for constructing the payload. `UnsignedTransactionV1` is constructed automatically when using `rollup.multisigSigningBytes(unsignedTx)`. diff --git a/typescript/.changeset/silly-nails-thank.md b/typescript/.changeset/silly-nails-thank.md new file mode 100644 index 0000000000..092c57f98d --- /dev/null +++ b/typescript/.changeset/silly-nails-thank.md @@ -0,0 +1,8 @@ +--- +"@sovereign-sdk/web3": minor +--- + +Multisig rework. + +- Align unsigned transaction signing with Rust's versioned `UnsignedTransaction` enum, add first-class standard multisig helpers. +- See the migration notes and README.md in `@sovereign-sdk/multisig` for detailed migration notes on multisigs. This affects both standard and solana-signable rollups. diff --git a/typescript/examples/phantom/.env.example b/typescript/examples/phantom/.env.example index 8562ec5733..6eb532dbce 100644 --- a/typescript/examples/phantom/.env.example +++ b/typescript/examples/phantom/.env.example @@ -1,3 +1,2 @@ VITE_ROLLUP_URL=http://localhost:12346 -VITE_CHAIN_ID=4321 VITE_SOLANA_ENDPOINT=/sequencer/accept-solana-offchain-tx diff --git a/typescript/examples/phantom/README.md b/typescript/examples/phantom/README.md index 6360383caf..c5af9f91d1 100644 --- a/typescript/examples/phantom/README.md +++ b/typescript/examples/phantom/README.md @@ -11,7 +11,6 @@ This package demonstrates how to connect a Phantom wallet and submit Solana offc Default values used when `.env` is absent: ```bash VITE_ROLLUP_URL=http://localhost:12346 -VITE_CHAIN_ID=4321 VITE_SOLANA_ENDPOINT=/sequencer/accept-solana-offchain-tx ``` diff --git a/typescript/examples/phantom/e2e/auth-and-send.spec.ts b/typescript/examples/phantom/e2e/auth-and-send.spec.ts index 380459b3df..c4bdf7430c 100644 --- a/typescript/examples/phantom/e2e/auth-and-send.spec.ts +++ b/typescript/examples/phantom/e2e/auth-and-send.spec.ts @@ -1,16 +1,11 @@ -import { - generateKeyPairSync, - sign, - verify, - type KeyObject, -} from "node:crypto"; +import { generateKeyPairSync, sign, verify, type KeyObject } from "node:crypto"; import { Buffer } from "node:buffer"; import { expect, test } from "@playwright/test"; +import { chainHashFragment } from "@sovereign-sdk/web3"; const ROLLUP_URL = process.env.VITE_ROLLUP_URL ?? "http://localhost:12346"; const SOLANA_ENDPOINT = process.env.VITE_SOLANA_ENDPOINT || "/sequencer/accept-solana-offchain-tx"; -const CHAIN_ID = Number(process.env.VITE_CHAIN_ID ?? "4321"); const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; @@ -24,7 +19,7 @@ type SolanaSimpleEnvelope = { type SolanaUnsignedTx = { chain_name?: string; details?: { - chain_id?: number; + chain_hash_fragment?: string; }; runtime_call?: { bank?: { @@ -67,7 +62,9 @@ function publicKeyToRawBytes(publicKey: KeyObject): Buffer { return der.subarray(der.length - 32); } -function parseSolanaSimpleEnvelope(payloadBase64: string): SolanaSimpleEnvelope { +function parseSolanaSimpleEnvelope( + payloadBase64: string, +): SolanaSimpleEnvelope { const serialized = Buffer.from(payloadBase64, "base64"); const minEnvelopeSize = 4 + 32 + 32 + 64; if (serialized.length < minEnvelopeSize) { @@ -94,7 +91,9 @@ function parseSolanaSimpleEnvelope(payloadBase64: string): SolanaSimpleEnvelope return { signedMessage: serialized.subarray(signedMessageStart, signedMessageEnd), - chainHashHex: serialized.subarray(chainHashStart, chainHashEnd).toString("hex"), + chainHashHex: serialized + .subarray(chainHashStart, chainHashEnd) + .toString("hex"), pubkeyHex: serialized.subarray(pubkeyStart, pubkeyEnd).toString("hex"), signature: serialized.subarray(signatureStart, signatureEnd), }; @@ -144,10 +143,13 @@ test.describe("Phantom mocked-provider with real rollup", () => { const walletAddress = base58Encode(new Uint8Array(publicKeyRaw)); test.beforeEach(async ({ page }) => { - await page.exposeFunction("__signEd25519Message", async (message: number[]) => { - const signature = sign(null, Buffer.from(message), privateKey); - return Array.from(signature); - }); + await page.exposeFunction( + "__signEd25519Message", + async (message: number[]) => { + const signature = sign(null, Buffer.from(message), privateKey); + return Array.from(signature); + }, + ); await page.addInitScript( ({ address, publicKeyBytes }) => { @@ -225,7 +227,9 @@ test.describe("Phantom mocked-provider with real rollup", () => { ).toBeVisible(); await expect( - page.getByText("Phantom was not detected. Install the Phantom browser extension"), + page.getByText( + "Phantom was not detected. Install the Phantom browser extension", + ), ).toHaveCount(0); await page.getByRole("button", { name: "Connect Phantom" }).click(); @@ -233,12 +237,16 @@ test.describe("Phantom mocked-provider with real rollup", () => { timeout: 15_000, }); - await page.getByRole("button", { name: "Sign with Phantom and Send" }).click(); + await page + .getByRole("button", { name: "Sign with Phantom and Send" }) + .click(); const submissionRequest = await submissionRequestPromise; const postBody = submissionRequest.postData(); if (!postBody) { - throw new Error("Expected request body for solana offchain tx submission"); + throw new Error( + "Expected request body for solana offchain tx submission", + ); } const parsedBody = JSON.parse(postBody) as { body?: string }; @@ -249,7 +257,9 @@ test.describe("Phantom mocked-provider with real rollup", () => { } const envelope = parseSolanaSimpleEnvelope(parsedBody.body); - const unsignedTx = JSON.parse(envelope.signedMessage.toString("utf8")) as SolanaUnsignedTx; + const unsignedTx = JSON.parse( + envelope.signedMessage.toString("utf8"), + ) as SolanaUnsignedTx; const liveChainHash = await fetchRollupChainHash(); expect(envelope.chainHashHex).toBe(liveChainHash); @@ -259,7 +269,9 @@ test.describe("Phantom mocked-provider with real rollup", () => { ).toBeTruthy(); expect(unsignedTx.chain_name).toBe("TestChain"); - expect(unsignedTx.details?.chain_id).toBe(CHAIN_ID); + expect(unsignedTx.details?.chain_hash_fragment).toBe( + chainHashFragment(Buffer.from(liveChainHash, "hex")), + ); expect(unsignedTx.runtime_call?.bank?.create_token?.mint_to_address).toBe( walletAddress, ); @@ -281,6 +293,8 @@ test.describe("Phantom mocked-provider with real rollup", () => { throw new Error(`Rollup rejected transaction:\n${errorText}`); } - await expect(successMessage).toContainText("Transaction Submitted Successfully!"); + await expect(successMessage).toContainText( + "Transaction Submitted Successfully!", + ); }); }); diff --git a/typescript/examples/phantom/playwright.config.ts b/typescript/examples/phantom/playwright.config.ts index 93d97f6107..617d345cf3 100644 --- a/typescript/examples/phantom/playwright.config.ts +++ b/typescript/examples/phantom/playwright.config.ts @@ -23,7 +23,6 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, env: { VITE_ROLLUP_URL: process.env.VITE_ROLLUP_URL || "http://localhost:12346", - VITE_CHAIN_ID: process.env.VITE_CHAIN_ID || "4321", VITE_SOLANA_ENDPOINT: process.env.VITE_SOLANA_ENDPOINT || "/sequencer/accept-solana-offchain-tx", diff --git a/typescript/examples/phantom/src/App.tsx b/typescript/examples/phantom/src/App.tsx index ea60ce828e..7d4ec53424 100644 --- a/typescript/examples/phantom/src/App.tsx +++ b/typescript/examples/phantom/src/App.tsx @@ -14,7 +14,6 @@ type TxResponse = SovereignClient.SovereignSDK.Sequencer.TxCreateResponse; const ROLLUP_URL = import.meta.env.VITE_ROLLUP_URL || "http://localhost:12346"; const SOLANA_ENDPOINT = import.meta.env.VITE_SOLANA_ENDPOINT || "/sequencer/accept-solana-offchain-tx"; -const CHAIN_ID_FROM_ENV = Number(import.meta.env.VITE_CHAIN_ID || "4321"); const DEFAULT_TX = { bank: { @@ -136,18 +135,8 @@ export default function App() { const parsedTx: RuntimeCall = JSON.parse(txString); // Create rollup client and Phantom signer - const rollupConfig = Number.isInteger(CHAIN_ID_FROM_ENV) - ? { - url: ROLLUP_URL, - context: { - defaultTxDetails: { - chain_id: CHAIN_ID_FROM_ENV, - }, - }, - } - : { url: ROLLUP_URL }; const rollup = await createSolanaSignableRollup( - rollupConfig, + { url: ROLLUP_URL }, SOLANA_ENDPOINT, ); const signer = new PhantomSigner(walletProvider); diff --git a/typescript/examples/soak-testing/src/index.ts b/typescript/examples/soak-testing/src/index.ts index 23d4908594..d10dd140eb 100644 --- a/typescript/examples/soak-testing/src/index.ts +++ b/typescript/examples/soak-testing/src/index.ts @@ -36,7 +36,7 @@ const defaultTxDetails = { max_priority_fee_bips: 0, max_fee: "100000000", gas_limit: null, - chain_id: 4321, + chain_hash_fragment: "0", }; /** @@ -97,7 +97,7 @@ class BankTransferGenerator extends TransactionGenerator { async onSubmitted(result) { assert( result.events?.length === 1, - "tranfer should only emit one event" + "tranfer should only emit one event", ); const event = result.events[0]?.value; @@ -117,7 +117,7 @@ class BankTransferGenerator extends TransactionGenerator { }, }, }, - "transfer event should include the expected fields" + "transfer event should include the expected fields", ); }, }; @@ -156,7 +156,7 @@ async function main(): Promise { const rollup = await createStandardRollup(); const generator = new BasicGeneratorStrategy( - new BankTransferGenerator(keypairs) + new BankTransferGenerator(keypairs), ); const runner = new TestRunner({ rollup, generator }); diff --git a/typescript/packages/__fixtures__/demo-rollup-schema.json b/typescript/packages/__fixtures__/demo-rollup-schema.json index d6b868da87..5fabc986fa 100644 --- a/typescript/packages/__fixtures__/demo-rollup-schema.json +++ b/typescript/packages/__fixtures__/demo-rollup-schema.json @@ -17,7 +17,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 170 + "ByIndex": 177 } } ], @@ -83,7 +83,7 @@ "display_name": "uniqueness", "silent": false, "value": { - "ByIndex": 164 + "ByIndex": 171 }, "doc": "" }, @@ -91,7 +91,15 @@ "display_name": "details", "silent": false, "value": { - "ByIndex": 168 + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 }, "doc": "" } @@ -155,7 +163,7 @@ "discriminant": 6, "template": null, "value": { - "ByIndex": 51 + "ByIndex": 55 } }, { @@ -163,7 +171,7 @@ "discriminant": 7, "template": null, "value": { - "ByIndex": 53 + "ByIndex": 57 } }, { @@ -171,7 +179,7 @@ "discriminant": 8, "template": null, "value": { - "ByIndex": 56 + "ByIndex": 60 } }, { @@ -179,7 +187,7 @@ "discriminant": 9, "template": null, "value": { - "ByIndex": 57 + "ByIndex": 61 } }, { @@ -187,7 +195,7 @@ "discriminant": 10, "template": null, "value": { - "ByIndex": 86 + "ByIndex": 90 } }, { @@ -195,7 +203,7 @@ "discriminant": 11, "template": null, "value": { - "ByIndex": 91 + "ByIndex": 95 } }, { @@ -203,7 +211,7 @@ "discriminant": 12, "template": null, "value": { - "ByIndex": 100 + "ByIndex": 104 } }, { @@ -211,7 +219,7 @@ "discriminant": 13, "template": null, "value": { - "ByIndex": 110 + "ByIndex": 114 } }, { @@ -219,7 +227,7 @@ "discriminant": 14, "template": null, "value": { - "ByIndex": 111 + "ByIndex": 115 } }, { @@ -227,7 +235,7 @@ "discriminant": 15, "template": null, "value": { - "ByIndex": 131 + "ByIndex": 135 } }, { @@ -235,7 +243,7 @@ "discriminant": 16, "template": null, "value": { - "ByIndex": 145 + "ByIndex": 152 } }, { @@ -243,7 +251,7 @@ "discriminant": 17, "template": null, "value": { - "ByIndex": 159 + "ByIndex": 166 } } ], @@ -1236,6 +1244,38 @@ "value": { "ByIndex": 48 } + }, + { + "name": "AddCredentialToAddress", + "discriminant": 1, + "template": null, + "value": { + "ByIndex": 51 + } + }, + { + "name": "RemoveCredentialFromAddress", + "discriminant": 2, + "template": null, + "value": { + "ByIndex": 52 + } + }, + { + "name": "RotateCredentialOnAddress", + "discriminant": 3, + "template": null, + "value": { + "ByIndex": 53 + } + }, + { + "name": "CreateSyntheticAddress", + "discriminant": 4, + "template": null, + "value": { + "ByIndex": 54 + } } ], "hide_tag": false @@ -1291,6 +1331,111 @@ ] } }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_AddCredentialToAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_RemoveCredentialFromAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_RotateCredentialOnAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "old_credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + }, + { + "display_name": "new_credential", + "silent": false, + "value": { + "ByIndex": 49 + }, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "__SovVirtualWallet_CallMessage_CreateSyntheticAddress", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "salt", + "silent": false, + "value": { + "Immediate": { + "ByteArray": { + "len": 32, + "display": "Hex" + } + } + }, + "doc": "" + } + ] + } + }, { "Tuple": { "template": null, @@ -1298,7 +1443,7 @@ "fields": [ { "value": { - "ByIndex": 52 + "ByIndex": 56 }, "silent": false, "doc": "" @@ -1320,7 +1465,7 @@ "fields": [ { "value": { - "ByIndex": 54 + "ByIndex": 58 }, "silent": false, "doc": "" @@ -1343,7 +1488,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 55 + "ByIndex": 59 } } ], @@ -1379,7 +1524,7 @@ "fields": [ { "value": { - "ByIndex": 52 + "ByIndex": 56 }, "silent": false, "doc": "" @@ -1394,7 +1539,7 @@ "fields": [ { "value": { - "ByIndex": 58 + "ByIndex": 62 }, "silent": false, "doc": "" @@ -1411,7 +1556,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 59 + "ByIndex": 63 } }, { @@ -1419,7 +1564,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 75 + "ByIndex": 79 } }, { @@ -1427,7 +1572,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 76 + "ByIndex": 80 } } ], @@ -1444,7 +1589,7 @@ "display_name": "policy", "silent": false, "value": { - "ByIndex": 60 + "ByIndex": 64 }, "doc": "" } @@ -1461,7 +1606,7 @@ "display_name": "default_payee_policy", "silent": false, "value": { - "ByIndex": 61 + "ByIndex": 65 }, "doc": "" }, @@ -1469,7 +1614,7 @@ "display_name": "payees", "silent": false, "value": { - "ByIndex": 70 + "ByIndex": 74 }, "doc": "" }, @@ -1485,7 +1630,7 @@ "display_name": "authorized_sequencers", "silent": false, "value": { - "ByIndex": 72 + "ByIndex": 76 }, "doc": "" } @@ -1501,7 +1646,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 62 + "ByIndex": 66 } }, { @@ -1532,7 +1677,7 @@ "display_name": "gas_limit", "silent": false, "value": { - "ByIndex": 63 + "ByIndex": 67 }, "doc": "" }, @@ -1540,7 +1685,7 @@ "display_name": "max_gas_price", "silent": false, "value": { - "ByIndex": 66 + "ByIndex": 70 }, "doc": "" }, @@ -1548,7 +1693,7 @@ "display_name": "transaction_limit", "silent": false, "value": { - "ByIndex": 69 + "ByIndex": 73 }, "doc": "" } @@ -1558,7 +1703,7 @@ { "Option": { "value": { - "ByIndex": 64 + "ByIndex": 68 } } }, @@ -1569,7 +1714,7 @@ "fields": [ { "value": { - "ByIndex": 65 + "ByIndex": 69 }, "silent": false, "doc": "" @@ -1593,7 +1738,7 @@ { "Option": { "value": { - "ByIndex": 67 + "ByIndex": 71 } } }, @@ -1607,7 +1752,7 @@ "display_name": "value", "silent": false, "value": { - "ByIndex": 68 + "ByIndex": 72 }, "doc": "" } @@ -1637,7 +1782,7 @@ { "Vec": { "value": { - "ByIndex": 71 + "ByIndex": 75 } } }, @@ -1655,7 +1800,7 @@ }, { "value": { - "ByIndex": 61 + "ByIndex": 65 }, "silent": false, "doc": "" @@ -1678,7 +1823,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 73 + "ByIndex": 77 } } ], @@ -1692,7 +1837,7 @@ "fields": [ { "value": { - "ByIndex": 74 + "ByIndex": 78 }, "silent": false, "doc": "" @@ -1742,7 +1887,7 @@ "display_name": "update", "silent": false, "value": { - "ByIndex": 77 + "ByIndex": 81 }, "doc": "" } @@ -1759,7 +1904,7 @@ "display_name": "sequencer_update", "silent": false, "value": { - "ByIndex": 78 + "ByIndex": 82 }, "doc": "" }, @@ -1767,7 +1912,7 @@ "display_name": "updaters_to_add", "silent": false, "value": { - "ByIndex": 83 + "ByIndex": 87 }, "doc": "" }, @@ -1775,7 +1920,7 @@ "display_name": "updaters_to_remove", "silent": false, "value": { - "ByIndex": 83 + "ByIndex": 87 }, "doc": "" }, @@ -1783,7 +1928,7 @@ "display_name": "payee_policies_to_set", "silent": false, "value": { - "ByIndex": 84 + "ByIndex": 88 }, "doc": "" }, @@ -1791,7 +1936,7 @@ "display_name": "payee_policies_to_delete", "silent": false, "value": { - "ByIndex": 83 + "ByIndex": 87 }, "doc": "" }, @@ -1799,7 +1944,7 @@ "display_name": "default_policy", "silent": false, "value": { - "ByIndex": 85 + "ByIndex": 89 }, "doc": "" } @@ -1809,7 +1954,7 @@ { "Option": { "value": { - "ByIndex": 79 + "ByIndex": 83 } } }, @@ -1828,7 +1973,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 80 + "ByIndex": 84 } } ], @@ -1842,7 +1987,7 @@ "fields": [ { "value": { - "ByIndex": 81 + "ByIndex": 85 }, "silent": false, "doc": "" @@ -1860,7 +2005,7 @@ "display_name": "to_add", "silent": false, "value": { - "ByIndex": 82 + "ByIndex": 86 }, "doc": "" }, @@ -1868,7 +2013,7 @@ "display_name": "to_remove", "silent": false, "value": { - "ByIndex": 82 + "ByIndex": 86 }, "doc": "" } @@ -1878,7 +2023,7 @@ { "Option": { "value": { - "ByIndex": 74 + "ByIndex": 78 } } }, @@ -1892,14 +2037,14 @@ { "Option": { "value": { - "ByIndex": 70 + "ByIndex": 74 } } }, { "Option": { "value": { - "ByIndex": 61 + "ByIndex": 65 } } }, @@ -1910,7 +2055,7 @@ "fields": [ { "value": { - "ByIndex": 87 + "ByIndex": 91 }, "silent": false, "doc": "" @@ -1939,7 +2084,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 88 + "ByIndex": 92 } }, { @@ -1947,7 +2092,7 @@ "discriminant": 3, "template": null, "value": { - "ByIndex": 89 + "ByIndex": 93 } }, { @@ -1955,7 +2100,7 @@ "discriminant": 4, "template": null, "value": { - "ByIndex": 90 + "ByIndex": 94 } } ], @@ -2025,7 +2170,7 @@ "fields": [ { "value": { - "ByIndex": 92 + "ByIndex": 96 }, "silent": false, "doc": "" @@ -2042,7 +2187,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 93 + "ByIndex": 97 } }, { @@ -2050,7 +2195,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 96 + "ByIndex": 100 } }, { @@ -2058,7 +2203,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 97 + "ByIndex": 101 } } ], @@ -2096,7 +2241,7 @@ "display_name": "body", "silent": false, "value": { - "ByIndex": 94 + "ByIndex": 98 }, "doc": "" }, @@ -2104,7 +2249,7 @@ "display_name": "metadata", "silent": false, "value": { - "ByIndex": 95 + "ByIndex": 99 }, "doc": "" }, @@ -2149,7 +2294,7 @@ { "Option": { "value": { - "ByIndex": 94 + "ByIndex": 98 } } }, @@ -2163,7 +2308,7 @@ "display_name": "metadata", "silent": false, "value": { - "ByIndex": 94 + "ByIndex": 98 }, "doc": "" }, @@ -2171,7 +2316,7 @@ "display_name": "message", "silent": false, "value": { - "ByIndex": 94 + "ByIndex": 98 }, "doc": "" } @@ -2188,7 +2333,7 @@ "display_name": "validator_address", "silent": false, "value": { - "ByIndex": 98 + "ByIndex": 102 }, "doc": "" }, @@ -2204,7 +2349,7 @@ "display_name": "signature", "silent": false, "value": { - "ByIndex": 99 + "ByIndex": 103 }, "doc": "" } @@ -2258,7 +2403,7 @@ "fields": [ { "value": { - "ByIndex": 101 + "ByIndex": 105 }, "silent": false, "doc": "" @@ -2275,7 +2420,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 102 + "ByIndex": 106 } }, { @@ -2283,7 +2428,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 108 + "ByIndex": 112 } }, { @@ -2291,7 +2436,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 109 + "ByIndex": 113 } } ], @@ -2308,7 +2453,7 @@ "display_name": "domain_oracle_data", "silent": false, "value": { - "ByIndex": 103 + "ByIndex": 107 }, "doc": "" }, @@ -2316,7 +2461,7 @@ "display_name": "domain_default_gas", "silent": false, "value": { - "ByIndex": 106 + "ByIndex": 110 }, "doc": "" }, @@ -2342,7 +2487,7 @@ { "Vec": { "value": { - "ByIndex": 104 + "ByIndex": 108 } } }, @@ -2369,7 +2514,7 @@ "display_name": "data_value", "silent": false, "value": { - "ByIndex": 105 + "ByIndex": 109 }, "doc": "" } @@ -2409,7 +2554,7 @@ { "Vec": { "value": { - "ByIndex": 107 + "ByIndex": 111 } } }, @@ -2466,7 +2611,7 @@ "display_name": "oracle_data", "silent": false, "value": { - "ByIndex": 105 + "ByIndex": 109 }, "doc": "" } @@ -2516,7 +2661,7 @@ "fields": [ { "value": { - "ByIndex": 112 + "ByIndex": 116 }, "silent": false, "doc": "" @@ -2533,7 +2678,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 113 + "ByIndex": 117 } }, { @@ -2541,7 +2686,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 125 + "ByIndex": 129 } }, { @@ -2549,7 +2694,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 128 + "ByIndex": 132 } }, { @@ -2557,7 +2702,7 @@ "discriminant": 3, "template": null, "value": { - "ByIndex": 129 + "ByIndex": 133 } }, { @@ -2565,7 +2710,7 @@ "discriminant": 4, "template": null, "value": { - "ByIndex": 130 + "ByIndex": 134 } } ], @@ -2582,7 +2727,7 @@ "display_name": "admin", "silent": false, "value": { - "ByIndex": 114 + "ByIndex": 118 }, "doc": "" }, @@ -2590,7 +2735,7 @@ "display_name": "token_source", "silent": false, "value": { - "ByIndex": 116 + "ByIndex": 120 }, "doc": "" }, @@ -2598,7 +2743,7 @@ "display_name": "ism", "silent": false, "value": { - "ByIndex": 119 + "ByIndex": 123 }, "doc": "" }, @@ -2606,7 +2751,7 @@ "display_name": "remote_routers", "silent": false, "value": { - "ByIndex": 123 + "ByIndex": 127 }, "doc": "" }, @@ -2660,7 +2805,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 115 + "ByIndex": 119 } } ], @@ -2691,7 +2836,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 117 + "ByIndex": 121 } }, { @@ -2699,7 +2844,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 118 + "ByIndex": 122 } }, { @@ -2782,7 +2927,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 120 + "ByIndex": 124 } }, { @@ -2790,7 +2935,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 121 + "ByIndex": 125 } } ], @@ -2824,7 +2969,7 @@ "display_name": "validators", "silent": false, "value": { - "ByIndex": 122 + "ByIndex": 126 }, "doc": "" }, @@ -2847,14 +2992,14 @@ { "Vec": { "value": { - "ByIndex": 98 + "ByIndex": 102 } } }, { "Vec": { "value": { - "ByIndex": 124 + "ByIndex": 128 } } }, @@ -2903,7 +3048,7 @@ "display_name": "admin", "silent": false, "value": { - "ByIndex": 126 + "ByIndex": 130 }, "doc": "" }, @@ -2911,7 +3056,7 @@ "display_name": "ism", "silent": false, "value": { - "ByIndex": 127 + "ByIndex": 131 }, "doc": "" }, @@ -2953,14 +3098,14 @@ { "Option": { "value": { - "ByIndex": 114 + "ByIndex": 118 } } }, { "Option": { "value": { - "ByIndex": 119 + "ByIndex": 123 } } }, @@ -3101,7 +3246,7 @@ "fields": [ { "value": { - "ByIndex": 132 + "ByIndex": 136 }, "silent": false, "doc": "" @@ -3118,7 +3263,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 133 + "ByIndex": 137 } }, { @@ -3126,7 +3271,15 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 135 + "ByIndex": 139 + } + }, + { + "name": "UpdateEnabledCustomPrecompiles", + "discriminant": 2, + "template": null, + "value": { + "ByIndex": 149 } } ], @@ -3140,7 +3293,7 @@ "fields": [ { "value": { - "ByIndex": 134 + "ByIndex": 138 }, "silent": false, "doc": "" @@ -3176,7 +3329,7 @@ "fields": [ { "value": { - "ByIndex": 136 + "ByIndex": 140 }, "silent": false, "doc": "" @@ -3194,7 +3347,7 @@ "display_name": "new_hardfork", "silent": false, "value": { - "ByIndex": 137 + "ByIndex": 141 }, "doc": "" }, @@ -3202,7 +3355,7 @@ "display_name": "new_contract_creation_policy", "silent": false, "value": { - "ByIndex": 139 + "ByIndex": 143 }, "doc": "" }, @@ -3210,7 +3363,7 @@ "display_name": "chain_spec_update", "silent": false, "value": { - "ByIndex": 142 + "ByIndex": 146 }, "doc": "" }, @@ -3228,7 +3381,7 @@ { "Option": { "value": { - "ByIndex": 138 + "ByIndex": 142 } } }, @@ -3267,7 +3420,7 @@ { "Option": { "value": { - "ByIndex": 140 + "ByIndex": 144 } } }, @@ -3286,7 +3439,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 141 + "ByIndex": 145 } } ], @@ -3303,7 +3456,7 @@ "display_name": "add", "silent": false, "value": { - "ByIndex": 122 + "ByIndex": 126 }, "doc": "" }, @@ -3311,7 +3464,7 @@ "display_name": "remove", "silent": false, "value": { - "ByIndex": 122 + "ByIndex": 126 }, "doc": "" } @@ -3321,7 +3474,7 @@ { "Option": { "value": { - "ByIndex": 143 + "ByIndex": 147 } } }, @@ -3335,7 +3488,7 @@ "display_name": "new_limit_contract_code_size", "silent": false, "value": { - "ByIndex": 144 + "ByIndex": 148 }, "doc": "" }, @@ -3343,7 +3496,7 @@ "display_name": "new_block_gas_limit", "silent": false, "value": { - "ByIndex": 69 + "ByIndex": 73 }, "doc": "" }, @@ -3351,7 +3504,7 @@ "display_name": "new_tx_gas_limit", "silent": false, "value": { - "ByIndex": 69 + "ByIndex": 73 }, "doc": "" } @@ -3377,7 +3530,54 @@ "fields": [ { "value": { - "ByIndex": 146 + "ByIndex": 150 + }, + "silent": false, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "EnabledCustomPrecompilesUpdate", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "add", + "silent": false, + "value": { + "ByIndex": 151 + }, + "doc": "" + }, + { + "display_name": "remove", + "silent": false, + "value": { + "ByIndex": 151 + }, + "doc": "" + } + ] + } + }, + { + "Vec": { + "value": { + "ByIndex": 13 + } + } + }, + { + "Tuple": { + "template": null, + "peekable": false, + "fields": [ + { + "value": { + "ByIndex": 153 }, "silent": false, "doc": "" @@ -3394,7 +3594,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 147 + "ByIndex": 154 } }, { @@ -3402,7 +3602,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 148 + "ByIndex": 155 } }, { @@ -3410,7 +3610,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 150 + "ByIndex": 157 } }, { @@ -3418,7 +3618,7 @@ "discriminant": 3, "template": null, "value": { - "ByIndex": 151 + "ByIndex": 158 } }, { @@ -3426,7 +3626,7 @@ "discriminant": 4, "template": null, "value": { - "ByIndex": 152 + "ByIndex": 159 } }, { @@ -3434,7 +3634,7 @@ "discriminant": 5, "template": null, "value": { - "ByIndex": 153 + "ByIndex": 160 } }, { @@ -3448,7 +3648,7 @@ "discriminant": 7, "template": null, "value": { - "ByIndex": 154 + "ByIndex": 161 } }, { @@ -3456,7 +3656,7 @@ "discriminant": 8, "template": null, "value": { - "ByIndex": 155 + "ByIndex": 162 } }, { @@ -3470,7 +3670,7 @@ "discriminant": 10, "template": null, "value": { - "ByIndex": 156 + "ByIndex": 163 } }, { @@ -3478,7 +3678,7 @@ "discriminant": 11, "template": null, "value": { - "ByIndex": 157 + "ByIndex": 164 } }, { @@ -3486,7 +3686,7 @@ "discriminant": 12, "template": null, "value": { - "ByIndex": 158 + "ByIndex": 165 } } ], @@ -3564,7 +3764,7 @@ "display_name": "content", "silent": false, "value": { - "ByIndex": 149 + "ByIndex": 156 }, "doc": "" } @@ -3856,7 +4056,7 @@ "fields": [ { "value": { - "ByIndex": 160 + "ByIndex": 167 }, "silent": false, "doc": "" @@ -3873,7 +4073,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 161 + "ByIndex": 168 } }, { @@ -3881,7 +4081,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 162 + "ByIndex": 169 } }, { @@ -3889,7 +4089,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 163 + "ByIndex": 170 } } ], @@ -4010,7 +4210,7 @@ "discriminant": 0, "template": null, "value": { - "ByIndex": 165 + "ByIndex": 172 } }, { @@ -4018,7 +4218,7 @@ "discriminant": 1, "template": null, "value": { - "ByIndex": 166 + "ByIndex": 173 } }, { @@ -4026,7 +4226,7 @@ "discriminant": 2, "template": null, "value": { - "ByIndex": 167 + "ByIndex": 174 } } ], @@ -4103,7 +4303,7 @@ "display_name": "max_priority_fee_bips", "silent": false, "value": { - "ByIndex": 169 + "ByIndex": 176 }, "doc": "" }, @@ -4119,13 +4319,13 @@ "display_name": "gas_limit", "silent": false, "value": { - "ByIndex": 63 + "ByIndex": 67 }, "doc": "" }, { - "display_name": "chain_id", - "silent": false, + "display_name": "chain_hash_fragment", + "silent": true, "value": { "Immediate": { "Integer": [ @@ -4166,7 +4366,7 @@ "fields": [ { "value": { - "ByIndex": 171 + "ByIndex": 178 }, "silent": false, "doc": "" @@ -4184,7 +4384,7 @@ "display_name": "signatures", "silent": false, "value": { - "ByIndex": 172 + "ByIndex": 179 }, "doc": "" }, @@ -4192,7 +4392,7 @@ "display_name": "unused_pub_keys", "silent": false, "value": { - "ByIndex": 174 + "ByIndex": 181 }, "doc": "" }, @@ -4221,7 +4421,7 @@ "display_name": "uniqueness", "silent": false, "value": { - "ByIndex": 164 + "ByIndex": 171 }, "doc": "" }, @@ -4229,7 +4429,15 @@ "display_name": "details", "silent": false, "value": { - "ByIndex": 168 + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 }, "doc": "" } @@ -4239,7 +4447,7 @@ { "Vec": { "value": { - "ByIndex": 173 + "ByIndex": 180 } } }, @@ -4290,9 +4498,48 @@ } } }, + { + "Enum": { + "type_name": "TransactionSigningPayload", + "variants": [ + { + "name": "V0", + "discriminant": 0, + "template": null, + "value": { + "ByIndex": 183 + } + }, + { + "name": "V1", + "discriminant": 1, + "template": null, + "value": { + "ByIndex": 185 + } + } + ], + "hide_tag": false + } + }, + { + "Tuple": { + "template": null, + "peekable": false, + "fields": [ + { + "value": { + "ByIndex": 184 + }, + "silent": false, + "doc": "" + } + ] + } + }, { "Struct": { - "type_name": "UnsignedTransaction", + "type_name": "TransactionSigningPayloadV0", "template": null, "peekable": false, "fields": [ @@ -4308,7 +4555,7 @@ "display_name": "uniqueness", "silent": false, "value": { - "ByIndex": 164 + "ByIndex": 171 }, "doc": "" }, @@ -4316,7 +4563,105 @@ "display_name": "details", "silent": false, "value": { - "ByIndex": 168 + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 + }, + "doc": "" + }, + { + "display_name": "chain_hash", + "silent": true, + "value": { + "Immediate": { + "ByteArray": { + "len": 32, + "display": "Hex" + } + } + }, + "doc": "" + } + ] + } + }, + { + "Tuple": { + "template": null, + "peekable": false, + "fields": [ + { + "value": { + "ByIndex": 186 + }, + "silent": false, + "doc": "" + } + ] + } + }, + { + "Struct": { + "type_name": "TransactionSigningPayloadV1", + "template": null, + "peekable": false, + "fields": [ + { + "display_name": "runtime_call", + "silent": false, + "value": { + "ByIndex": 3 + }, + "doc": "" + }, + { + "display_name": "uniqueness", + "silent": false, + "value": { + "ByIndex": 171 + }, + "doc": "" + }, + { + "display_name": "details", + "silent": false, + "value": { + "ByIndex": 175 + }, + "doc": "" + }, + { + "display_name": "credential_address", + "silent": false, + "value": { + "ByIndex": 9 + }, + "doc": "" + }, + { + "display_name": "address_override", + "silent": false, + "value": { + "ByIndex": 25 + }, + "doc": "" + }, + { + "display_name": "chain_hash", + "silent": true, + "value": { + "Immediate": { + "ByteArray": { + "len": 32, + "display": "Hex" + } + } }, "doc": "" } @@ -4326,7 +4671,7 @@ ], "root_type_indices": [ 0, - 175, + 182, 3, 9 ], @@ -4456,6 +4801,9 @@ }, { "name": "details" + }, + { + "name": "address_override" } ] }, @@ -4864,6 +5212,18 @@ "fields_or_variants": [ { "name": "insert_credential_id" + }, + { + "name": "add_credential_to_address" + }, + { + "name": "remove_credential_from_address" + }, + { + "name": "rotate_credential_on_address" + }, + { + "name": "create_synthetic_address" } ] }, @@ -4879,6 +5239,50 @@ "name": "", "fields_or_variants": [] }, + { + "name": "__SovVirtualWallet_CallMessage_AddCredentialToAddress", + "fields_or_variants": [ + { + "name": "address" + }, + { + "name": "credential" + } + ] + }, + { + "name": "__SovVirtualWallet_CallMessage_RemoveCredentialFromAddress", + "fields_or_variants": [ + { + "name": "address" + }, + { + "name": "credential" + } + ] + }, + { + "name": "__SovVirtualWallet_CallMessage_RotateCredentialOnAddress", + "fields_or_variants": [ + { + "name": "address" + }, + { + "name": "old_credential" + }, + { + "name": "new_credential" + } + ] + }, + { + "name": "__SovVirtualWallet_CallMessage_CreateSyntheticAddress", + "fields_or_variants": [ + { + "name": "salt" + } + ] + }, { "name": "", "fields_or_variants": [] @@ -5604,6 +6008,9 @@ }, { "name": "UpdateRuntimeConfig" + }, + { + "name": "UpdateEnabledCustomPrecompiles" } ] }, @@ -5700,6 +6107,25 @@ "name": "", "fields_or_variants": [] }, + { + "name": "EnabledCustomPrecompilesUpdate", + "fields_or_variants": [ + { + "name": "add" + }, + { + "name": "remove" + } + ] + }, + { + "name": "", + "fields_or_variants": [] + }, + { + "name": "", + "fields_or_variants": [] + }, { "name": "AccessPatternMessages", "fields_or_variants": [ @@ -5956,7 +6382,7 @@ "name": "gas_limit" }, { - "name": "chain_id" + "name": "chain_hash_fragment" } ] }, @@ -5988,6 +6414,9 @@ }, { "name": "details" + }, + { + "name": "address_override" } ] }, @@ -6011,7 +6440,22 @@ "fields_or_variants": [] }, { - "name": "UnsignedTransaction", + "name": "TransactionSigningPayload", + "fields_or_variants": [ + { + "name": "V0" + }, + { + "name": "V1" + } + ] + }, + { + "name": "", + "fields_or_variants": [] + }, + { + "name": "TransactionSigningPayloadV0", "fields_or_variants": [ { "name": "runtime_call" @@ -6021,6 +6465,39 @@ }, { "name": "details" + }, + { + "name": "address_override" + }, + { + "name": "chain_hash" + } + ] + }, + { + "name": "", + "fields_or_variants": [] + }, + { + "name": "TransactionSigningPayloadV1", + "fields_or_variants": [ + { + "name": "runtime_call" + }, + { + "name": "uniqueness" + }, + { + "name": "details" + }, + { + "name": "credential_address" + }, + { + "name": "address_override" + }, + { + "name": "chain_hash" } ] } diff --git a/typescript/packages/integration-tests/package.json b/typescript/packages/integration-tests/package.json index d83a9a3707..d68c99e64f 100644 --- a/typescript/packages/integration-tests/package.json +++ b/typescript/packages/integration-tests/package.json @@ -13,7 +13,9 @@ "devDependencies": { "@noble/ed25519": "^2.1.0", "@noble/hashes": "^1.5.0", + "@sovereign-sdk/multisig": "workspace:^", "@sovereign-sdk/signers": "workspace:^", + "@sovereign-sdk/utils": "workspace:^", "@sovereign-sdk/web3": "workspace:^", "@types/node": "^22.7.4", "bech32": "^2.0.0", diff --git a/typescript/packages/multisig/tests/multisig.integration-test.ts b/typescript/packages/integration-tests/tests/multisig.integration-test.ts similarity index 61% rename from typescript/packages/multisig/tests/multisig.integration-test.ts rename to typescript/packages/integration-tests/tests/multisig.integration-test.ts index 52cd33be00..c2080bc6a9 100644 --- a/typescript/packages/multisig/tests/multisig.integration-test.ts +++ b/typescript/packages/integration-tests/tests/multisig.integration-test.ts @@ -1,5 +1,5 @@ +import { Multisig } from "@sovereign-sdk/multisig"; import { Ed25519Signer, type Signer } from "@sovereign-sdk/signers"; -import type { UnsignedTransaction } from "@sovereign-sdk/types"; import { bytesToHex } from "@sovereign-sdk/utils"; import { DEFAULT_TX_DETAILS, @@ -7,7 +7,6 @@ import { createStandardRollup, } from "@sovereign-sdk/web3"; import { beforeAll, describe, expect, it } from "vitest"; -import { MultisigTransaction } from "../src"; const testAddress = { Standard: "sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf", @@ -23,9 +22,7 @@ function generateSigners(count = 5): Signer[] { const signers = []; for (let i = 0; i < count; i++) { - const pk = generatePrivateKey(); - const signer = new Ed25519Signer(pk); - signers.push(signer); + signers.push(new Ed25519Signer(generatePrivateKey())); } return signers; @@ -33,13 +30,9 @@ function generateSigners(count = 5): Signer[] { describe("multisig", async () => { let rollup: StandardRollup; - // gets populated in beforeAll - let chain_id = 0; beforeAll(async () => { rollup = await createStandardRollup(); - const constants = await rollup.rollup.constants(); - chain_id = constants.chain_id; }); it("should submit a multisig transaction successfully", async () => { @@ -55,27 +48,37 @@ describe("multisig", async () => { }, }, }; - const unsignedTx: UnsignedTransaction = { + const unsignedTx = { runtime_call, uniqueness: { nonce: 0 }, - details: { ...DEFAULT_TX_DETAILS, chain_id }, + details: rollup.context.defaultTxDetails, + address_override: null, }; const requiredSigners = 3; const multiSigSigners = generateSigners(requiredSigners); const allPublicKeyBytes = await Promise.all( - multiSigSigners.map((s) => s.publicKey()), + multiSigSigners.map((signer) => signer.publicKey()), ); - const allPubKeys = allPublicKeyBytes.map(bytesToHex); - const signedTransactions = await Promise.all( - multiSigSigners.map((s) => rollup.signTransaction(unsignedTx, s)), + const multisig = Multisig.fromPubKeys( + allPublicKeyBytes.map(bytesToHex), + requiredSigners, + ); + + for (const signer of multiSigSigners) { + const signingBytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + ); + multisig.addSignature( + bytesToHex(await signer.sign(signingBytes)), + bytesToHex(await signer.publicKey()), + ); + } + + const response = await rollup.submitTransaction( + multisig.toTransaction(unsignedTx), ); - const multisig = MultisigTransaction.fromTransactions({ - txns: signedTransactions, - minSigners: requiredSigners, - allPubKeys, - }); - const response = await rollup.submitTransaction(multisig.asTransaction()); expect(response.status).toEqual("submitted"); }); diff --git a/typescript/packages/multisig/README.md b/typescript/packages/multisig/README.md index 49eaa6556a..7c4ae380ad 100644 --- a/typescript/packages/multisig/README.md +++ b/typescript/packages/multisig/README.md @@ -3,7 +3,9 @@ [![npm version](https://img.shields.io/npm/v/@sovereign-sdk/multisig.svg)](https://www.npmjs.com/package/@sovereign-sdk/multisig) [![CI](https://github.com/Sovereign-Labs/sovereign-sdk/actions/workflows/typescript.ci.yml/badge.svg)](https://github.com/Sovereign-Labs/sovereign-sdk/actions/workflows/typescript.ci.yml) -A TypeScript library for creating and managing multisig transactions on Sovereign SDK rollups. +Payload-agnostic multisig signer state for Sovereign SDK applications. + +Use `@sovereign-sdk/multisig` when you want to collect signatures outside the rollup client and finalize them into a standard `TransactionV1` once the threshold is met. ## Installation @@ -11,119 +13,46 @@ A TypeScript library for creating and managing multisig transactions on Sovereig npm install @sovereign-sdk/multisig ``` -## Features - -- **Multisig Transaction Management**: Create and manage transactions requiring multiple signatures -- **Threshold Signatures**: Configurable minimum signature requirements (M-of-N) -- **Signature Collection**: Collect signatures from multiple parties before submission -- **Transaction Conversion**: Convert between V0 (single-sig) and V1 (multisig) transaction formats -- **Safety Checks**: Validates transaction consistency and prevents replay attacks - ## Usage -### Basic Multisig Workflow - ```typescript -import { StandardRollup, createStandardRollup } from "@sovereign-sdk/web3"; -import { MultisigTransaction } from "@sovereign-sdk/multisig"; +import { Multisig } from "@sovereign-sdk/multisig"; +import { createStandardRollup } from "@sovereign-sdk/web3"; import type { UnsignedTransaction } from "@sovereign-sdk/types"; +import { bytesToHex } from "@sovereign-sdk/utils"; + +const rollup = await createStandardRollup(); -// 1. Create an unsigned transaction const unsignedTx: UnsignedTransaction = { runtime_call: { // Your rollup-specific call data - value_setter: { set_value: { value: 100, gas: null } }, }, - uniqueness: { nonce: 1 }, // Must be nonce-based for multisig + uniqueness: { nonce: 1 }, details: { - max_priority_fee_bips: 100, + max_priority_fee_bips: 0, max_fee: "1000000", gas_limit: null, - chain_id: 4321, + chain_hash_fragment: "6654161651848106779", }, }; -// 2. Initialize multisig with all participant public keys -const allPubKeys = ["0xpubkey1", "0xpubkey2", "0xpubkey3"]; -const minSigners = 2; // 2-of-3 multisig +const multisig = Multisig.fromPubKeys( + ["pubkey1hex", "pubkey2hex", "pubkey3hex"], + 2, +); -const multisig = MultisigTransaction.empty(unsignedTx, minSigners, allPubKeys); +const signingBytes = await rollup.multisigSigningBytes(unsignedTx, multisig); +multisig.addSignature( + bytesToHex(await signer1.sign(signingBytes)), + bytesToHex(await signer1.publicKey()), +); -// 3. Add signatures as they're collected -multisig.addSignature("0xsignature1", "0xpubkey1"); -multisig.addSignature("0xsignature2", "0xpubkey2"); +multisig.addSignature( + bytesToHex(await signer2.sign(signingBytes)), + bytesToHex(await signer2.publicKey()), +); -// 4. Check if ready for submission if (multisig.isComplete) { - // Convert to rollup transaction for submission - const finalTx = multisig.asTransaction(); - const rollup = await createStandardRollup(); - // Submit the transaction to the rollup - const result = await rollup.submitTransaction(finalTx); - console.log("Multisig transaction submitted", result); + await rollup.submitTransaction(multisig.toTransaction(unsignedTx)); } ``` - -### Creating from Existing Signed Transactions - -```typescript -import { MultisigTransaction } from '@sovereign-sdk/multisig'; -import type { Transaction } from '@sovereign-sdk/types'; - -// If you have individual V0 transactions from different signers -const signedTxns: Transaction[] = [ - { - V0: { - pub_key: "0xpubkey1", - signature: "0xsignature1", - runtime_call: /* same call data */, - uniqueness: { nonce: 1 }, - details: /* same details */, - } - }, - { - V0: { - pub_key: "0xpubkey2", - signature: "0xsignature2", - runtime_call: /* same call data */, - uniqueness: { nonce: 1 }, - details: /* same details */, - } - } -]; - -// Create multisig from existing transactions -const multisig = MultisigTransaction.fromTransactions({ - txns: signedTxns, - minSigners: 2, - allPubKeys: ["0xpubkey1", "0xpubkey2", "0xpubkey3"] -}); -``` - -### Adding Individual Signed Transactions - -```typescript -// Add a complete signed transaction to the multisig -const signedTx: Transaction = { - V0: { - pub_key: "0xpubkey3", - signature: "0xsignature3", - // ... same transaction data - }, -}; - -multisig.addSignedTransaction(signedTx); - -// Now check if complete and submit -if (multisig.isComplete) { - const finalTx = multisig.asTransaction(); - // Submit to rollup... -} -``` - -## Important Notes - -- **Nonce-based transactions only**: Multisig currently only supports transactions with `{ nonce: number }` uniqueness -- **Transaction consistency**: All signatures must be for the exact same unsigned transaction -- **Public key validation**: Public keys must be known members of the multisig and can only sign once - diff --git a/typescript/packages/multisig/package.json b/typescript/packages/multisig/package.json index 0ab89f84ce..b9dccc0755 100644 --- a/typescript/packages/multisig/package.json +++ b/typescript/packages/multisig/package.json @@ -8,7 +8,6 @@ "fix": "biome check --write", "lint": "biome lint", "test": "vitest run **/*.test.ts", - "test:integration": "vitest run **/*.integration-test.ts", "typecheck": "tsc --noEmit" }, "repository": { @@ -28,7 +27,6 @@ "types": "./dist/index.d.ts", "devDependencies": { "@sovereign-sdk/types": "workspace:^", - "@sovereign-sdk/web3": "workspace:^", "tsup": "^8.3.0", "typescript": "^5.6.3", "vitest": "4.0.7" @@ -42,7 +40,6 @@ }, "dependencies": { "@noble/hashes": "^1.5.0", - "@sovereign-sdk/signers": "workspace:^", "@sovereign-sdk/utils": "workspace:^", "borsh": "0.7.0" } diff --git a/typescript/packages/multisig/src/index.test.ts b/typescript/packages/multisig/src/index.test.ts index 0c645ecda8..70e82d1513 100644 --- a/typescript/packages/multisig/src/index.test.ts +++ b/typescript/packages/multisig/src/index.test.ts @@ -1,262 +1,236 @@ -import type { - Transaction, - TransactionV0, - TransactionV1, - UnsignedTransaction, -} from "@sovereign-sdk/types"; import { bytesToHex } from "@sovereign-sdk/utils"; import { describe, expect, it } from "vitest"; import { InvalidMultisigParameterError, - InvalidTransactionVersionError, - MultisigTransaction, - TransactionMismatchError, + MAX_SIGNERS, + Multisig, + type MultisigParams, } from "./index"; -const createUnsignedTx = (nonce = 1): UnsignedTransaction => ({ - runtime_call: "test_call", - uniqueness: { nonce }, - details: { - max_priority_fee_bips: 100, - max_fee: "1000", - gas_limit: null, - chain_id: 1, - }, +const pubkey1 = + "33dd646d7c43830b52289c4f277d3a5a26b3ab0a10ee05c871cb654bc046e545"; +const pubkey2 = + "8c7788ad88084f12ce1556703b33e673c5e450eec793c1e8e1a55b1140b4dfb8"; +const pubkey3 = + "74fc1e9c39b21173ef47c1cdfb37158764486c8b8ba81d8f29c6dd77800cd57f"; + +const createParams = (overrides?: Partial): MultisigParams => ({ + signatures: [], + unusedPubKeys: [pubkey1, pubkey2, pubkey3], + minSigners: 2, + ...overrides, }); -const createTransactionV0 = ( - unsignedTx: UnsignedTransaction, - pubKey: string, - signature: string, -): TransactionV0 => ({ - V0: { - pub_key: pubKey, - signature, - ...unsignedTx, - }, -}); - -describe("MultisigTransaction", () => { - describe("fromTransactions", () => { - it("should require nonce based transactions", () => { - const unsignedTx: UnsignedTransaction = { - runtime_call: "test_call", - uniqueness: { generation: 1 }, - details: { - max_priority_fee_bips: 100, - max_fee: "1000", - gas_limit: null, - chain_id: 1, - }, - }; - const tx = createTransactionV0(unsignedTx, "pubkey1", "sig1"); - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx], - minSigners: 1, - allPubKeys: ["pubkey1"], - }), +describe("Multisig", () => { + describe("constructor", () => { + it("should reject duplicate public keys across signatures and unused pubkeys", () => { + expect( + () => + new Multisig( + createParams({ + signatures: [{ pub_key: pubkey1, signature: "aa" }], + unusedPubKeys: [pubkey1, pubkey2], + }), + ), ).toThrow(InvalidMultisigParameterError); }); - it("should require all inputs to be transaction V0", () => { - const unsignedTx = createUnsignedTx(); - const txV1: Transaction = { - V1: { - unused_pub_keys: ["pubkey2"], - signatures: [{ pub_key: "pubkey1", signature: "sig1" }], - min_signers: 1, - ...unsignedTx, - }, - }; - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [txV1], - minSigners: 1, - allPubKeys: ["pubkey1"], - }), - ).toThrow(InvalidTransactionVersionError); + it("should reject thresholds larger than the signer set", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: [pubkey1, pubkey2], + minSigners: 3, + }), + ), + ).toThrow(InvalidMultisigParameterError); }); - it("should require all inputs to match the same unsigned transaction", () => { - const unsignedTx1 = createUnsignedTx(1); - const unsignedTx2 = createUnsignedTx(2); - const tx1 = createTransactionV0(unsignedTx1, "pubkey1", "sig1"); - const tx2 = createTransactionV0(unsignedTx2, "pubkey2", "sig2"); - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx1, tx2], - minSigners: 2, - allPubKeys: ["pubkey1", "pubkey2"], - }), - ).toThrow(TransactionMismatchError); + it("should reject an empty signer set", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: [], + minSigners: 1, + }), + ), + ).toThrow(InvalidMultisigParameterError); }); - it("should return multisig with only unique signatures (no duplicate transactions)", () => { - const unsignedTx = createUnsignedTx(); - const tx1 = createTransactionV0(unsignedTx, "pubkey1", "sig1"); - const tx2 = createTransactionV0(unsignedTx, "pubkey2", "sig1"); // same signature - - const multisig = MultisigTransaction.fromTransactions({ - txns: [tx1, tx2], - minSigners: 2, - allPubKeys: ["pubkey1", "pubkey2"], - }); - - const result = multisig.asTransaction() as TransactionV1; - expect(result.V1.signatures).toHaveLength(2); - expect(result.V1.signatures).toEqual([ - { pub_key: "pubkey1", signature: "sig1" }, - { pub_key: "pubkey2", signature: "sig1" }, - ]); + it("should reject a single-signer multisig", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: [pubkey1], + minSigners: 1, + }), + ), + ).toThrow(InvalidMultisigParameterError); }); - it("should throw if an input transaction uses a pubkey not in the unused list", () => { - const unsignedTx = createUnsignedTx(); - const tx = createTransactionV0(unsignedTx, "unknown_pubkey", "sig1"); - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx], - minSigners: 1, - allPubKeys: ["pubkey1", "pubkey2"], - }), + it("should reject invalid public key hex as a multisig validation error", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: ["xyz", pubkey2], + }), + ), ).toThrow(InvalidMultisigParameterError); }); + }); - it("should throw if same pubkey is used twice", () => { - const unsignedTx = createUnsignedTx(); - const tx1 = createTransactionV0(unsignedTx, "pubkey1", "sig1"); - const tx2 = createTransactionV0(unsignedTx, "pubkey1", "sig2"); + describe("fromPubKeys", () => { + it("should create a multisig with the full signer set unused", () => { + const multisig = Multisig.fromPubKeys([pubkey1, pubkey2], 2); - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx1, tx2], - minSigners: 2, - allPubKeys: ["pubkey1", "pubkey2"], - }), - ).toThrow(InvalidMultisigParameterError); + expect(multisig.threshold).toBe(2); + expect(multisig.signaturesAndPubKeys).toEqual([]); + expect([...multisig.remainingPubKeys]).toEqual([pubkey1, pubkey2]); }); }); - describe("isComplete", () => { - it("should return true if number of unique signatures is equal to or greater than minSigners", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [ - { pub_key: "pubkey1", signature: "sig1" }, - { pub_key: "pubkey2", signature: "sig2" }, - { pub_key: "pubkey3", signature: "sig3" }, - ], - unusedPubKeys: [], - minSigners: 2, - }); - - expect(multisig.isComplete).toBe(true); + describe("addSignature", () => { + it("should add a signature and remove the signer from remaining keys", () => { + const multisig = new Multisig(createParams()); - const multisigExact = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [ - { pub_key: "pubkey1", signature: "sig1" }, - { pub_key: "pubkey2", signature: "sig2" }, - ], - unusedPubKeys: [], - minSigners: 2, - }); + multisig.addSignature("aa", pubkey1); - expect(multisigExact.isComplete).toBe(true); + expect(multisig.signaturesAndPubKeys).toEqual([ + { pub_key: pubkey1, signature: "aa" }, + ]); + expect([...multisig.remainingPubKeys]).toEqual([pubkey2, pubkey3]); }); - it("should return false if number of unique signatures is less than minSigners", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [{ pub_key: "pubkey1", signature: "sig1" }], - unusedPubKeys: ["pubkey2", "pubkey3"], - minSigners: 2, - }); + it("should reject unknown or duplicate signers", () => { + const multisig = new Multisig(createParams()); - expect(multisig.isComplete).toBe(false); - }); - }); + expect(() => multisig.addSignature("aa", "11")).toThrow( + InvalidMultisigParameterError, + ); - describe("addSignature", () => { - it("should throw if pubkey is not in unused list", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, - }); + multisig.addSignature("aa", pubkey1); - expect(() => multisig.addSignature("sig1", "unknown_pubkey")).toThrow( + expect(() => multisig.addSignature("bb", pubkey1)).toThrow( InvalidMultisigParameterError, ); }); - it("should add signature to the signatures set", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), + it("should normalize user-supplied hex strings", () => { + const multisig = new Multisig({ signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, + unusedPubKeys: [ + `0x${pubkey1.toUpperCase()}`, + `0x${pubkey2.toUpperCase()}`, + ], + minSigners: 1, }); - multisig.addSignature("sig1", "pubkey1"); + multisig.addSignature("0xAA", `0x${pubkey1.toUpperCase()}`); - const result = multisig.asTransaction() as TransactionV1; - expect(result.V1.signatures).toEqual([ - { pub_key: "pubkey1", signature: "sig1" }, + expect(multisig.signaturesAndPubKeys).toEqual([ + { pub_key: pubkey1, signature: "aa" }, ]); - expect(result.V1.signatures).toHaveLength(1); + expect([...multisig.remainingPubKeys]).toEqual([pubkey2]); }); + }); - it("should remove pubkey from unused list", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, - }); + describe("completion", () => { + it("should track whether enough signatures have been collected", () => { + const multisig = new Multisig(createParams()); + + expect(multisig.isComplete).toBe(false); - multisig.addSignature("sig1", "pubkey1"); + multisig.addSignature("aa", pubkey1); + expect(multisig.isComplete).toBe(false); - const result = multisig.asTransaction() as TransactionV1; - expect(result.V1.unused_pub_keys).not.toContain("pubkey1"); - expect(result.V1.unused_pub_keys).toContain("pubkey2"); - expect(result.V1.unused_pub_keys).toHaveLength(1); + multisig.addSignature("bb", pubkey2); + expect(multisig.isComplete).toBe(true); }); + }); - it("should throw if trying to add signature for already used pubkey", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, + describe("toTransaction", () => { + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000", + gas_limit: null, + chain_hash_fragment: "1", + }, + address_override: null, + }; + + it("should reject incomplete multisig transactions", () => { + const multisig = new Multisig(createParams()); + + expect(() => multisig.toTransaction(unsignedTx)).toThrow( + "Multisig transaction is incomplete", + ); + }); + + it("should convert a complete multisig into a V1 transaction", () => { + const multisig = new Multisig(createParams()); + + multisig.addSignature("aa", pubkey1); + multisig.addSignature("bb", pubkey2); + + expect(multisig.toTransaction(unsignedTx)).toEqual({ + V1: { + ...unsignedTx, + signatures: [ + { pub_key: pubkey1, signature: "aa" }, + { pub_key: pubkey2, signature: "bb" }, + ], + unused_pub_keys: [pubkey3], + min_signers: 2, + }, }); + }); - multisig.addSignature("sig1", "pubkey1"); + it("should preserve address overrides", () => { + const multisig = new Multisig(createParams()); + const addressOverride = + "sov1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5k2jj4"; - expect(() => multisig.addSignature("sig2", "pubkey1")).toThrow( - InvalidMultisigParameterError, - ); + multisig.addSignature("aa", pubkey1); + multisig.addSignature("bb", pubkey2); + + expect( + multisig.toTransaction({ + ...unsignedTx, + address_override: addressOverride, + }).V1.address_override, + ).toBe(addressOverride); }); }); describe("getMultisigAddress", () => { - it("should match Rust implementation output for known test vector", () => { - const multisig = MultisigTransaction.empty(createUnsignedTx(), 2, [ - "33dd646d7c43830b52289c4f277d3a5a26b3ab0a10ee05c871cb654bc046e545", - "8c7788ad88084f12ce1556703b33e673c5e450eec793c1e8e1a55b1140b4dfb8", - "74fc1e9c39b21173ef47c1cdfb37158764486c8b8ba81d8f29c6dd77800cd57f", - ]); + it("should expose the shared signer cap", () => { + expect(MAX_SIGNERS).toBe(21); + }); + + it("should match the Rust test vector", () => { + const multisig = Multisig.fromPubKeys([pubkey1, pubkey2, pubkey3], 2); + + expect(bytesToHex(multisig.getMultisigAddress())).toBe( + "814394e81dd2a682efad0fc2082272cde35a172ba7e0e2240b0a0e9d68af23ee", + ); + }); - const address = multisig.getMultisigAddress(); - const addressHex = bytesToHex(address); + it("should be invariant to current signature collection state", () => { + const multisig = new Multisig( + createParams({ + signatures: [{ pub_key: pubkey3, signature: "cc" }], + unusedPubKeys: [pubkey1, pubkey2], + }), + ); - expect(addressHex).toBe( + expect(bytesToHex(multisig.getMultisigAddress())).toBe( "814394e81dd2a682efad0fc2082272cde35a172ba7e0e2240b0a0e9d68af23ee", ); }); diff --git a/typescript/packages/multisig/src/index.ts b/typescript/packages/multisig/src/index.ts index b333e94ab8..ab5ed33299 100644 --- a/typescript/packages/multisig/src/index.ts +++ b/typescript/packages/multisig/src/index.ts @@ -1,35 +1,20 @@ import { sha256 } from "@noble/hashes/sha2"; import type { SignatureAndPubKey, - Transaction, - TransactionV0, + TransactionV1, UnsignedTransaction, } from "@sovereign-sdk/types"; import type { HexString } from "@sovereign-sdk/utils"; -import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; +import { hexToBytes, normalizeHexString } from "@sovereign-sdk/utils"; import * as borsh from "borsh"; +export const MAX_SIGNERS = 21; + /** * Base error class for multisig-related errors. */ export class MultisigError extends Error {} -/** - * Error thrown when a transaction has an unsupported version for multisig operations. - */ -export class InvalidTransactionVersionError extends MultisigError {} - -/** - * Error thrown when a provided transaction doesn't match the expected unsigned transaction. - */ -export class TransactionMismatchError extends Error { - constructor() { - super( - "Provided transaction does not match the expected unsigned transaction", - ); - } -} - /** * Error thrown when multisig is constructed or used with invalid parameters. */ @@ -40,13 +25,11 @@ export class InvalidMultisigParameterError extends MultisigError { } /** - * Parameters for constructing a MultisigTransaction. + * Parameters for constructing a Multisig. */ export type MultisigParams = { - /** The unsigned transaction to be signed by multiple parties */ - unsignedTx: UnsignedTransaction; /** Array of signatures with pubkeys already collected */ - signatures: SignatureAndPubKey[]; + signatures?: SignatureAndPubKey[]; /** Array of public keys that haven't signed yet */ unusedPubKeys: HexString[]; /** Minimum number of signatures required */ @@ -54,196 +37,99 @@ export type MultisigParams = { }; /** - * Parameters for creating a MultisigTransaction from existing signed transactions. + * Represents multisig signer membership and collected signatures. */ -export type FromTransactionsParams = { - /** Array of signed transactions to extract signatures from */ - txns: Transaction[]; - /** Minimum number of signatures required */ - minSigners: number; - /** All public keys that are members of the multisig */ - allPubKeys: HexString[]; -}; - -/** - * Represents a multisig transaction that collects signatures from multiple parties. - * Only supports nonce-based transactions and V0 transaction variants for signature collection. - */ -export class MultisigTransaction { - private unsignedTx: UnsignedTransaction; - private signatures: SignatureAndPubKey[] = []; +export class Multisig { + private signatures: SignatureAndPubKey[]; private minSigners: number; - private unusedPubKeys = new Set(); + private unusedPubKeys: Set; + + constructor({ signatures = [], unusedPubKeys, minSigners }: MultisigParams) { + const normalizedSignatures = signatures.map(normalizeSignatureAndPubKey); + const normalizedUnusedPubKeys = unusedPubKeys.map((pubKey) => + normalizeMultisigHex("public key", pubKey), + ); - /** - * Creates a new MultisigTransaction instance. - * @param params - The multisig parameters including unsigned transaction, signatures, and public keys - */ - constructor({ - unsignedTx, - signatures, - unusedPubKeys, - minSigners, - }: MultisigParams) { - this.unsignedTx = unsignedTx; - this.signatures = signatures; - this.unusedPubKeys = new Set(unusedPubKeys); + assertValidSignerSet( + normalizedSignatures, + normalizedUnusedPubKeys, + minSigners, + ); + + this.signatures = normalizedSignatures; + this.unusedPubKeys = new Set(normalizedUnusedPubKeys); this.minSigners = minSigners; } - /** - * Creates an empty MultisigTransaction with no signatures collected yet. - * @param unsignedTx - The unsigned transaction to be signed by multiple parties - * @param minSigners - Minimum number of signatures required for completion - * @param allPubKeys - All public keys that are members of the multisig - * @returns A new empty MultisigTransaction instance - */ - static empty( - unsignedTx: UnsignedTransaction, - minSigners: number, - allPubKeys: HexString[], - ): MultisigTransaction { - return new MultisigTransaction({ - unsignedTx, + static fromPubKeys(allPubKeys: HexString[], minSigners: number): Multisig { + return new Multisig({ signatures: [], unusedPubKeys: allPubKeys, minSigners, }); } - /** - * Creates a MultisigTransaction from an array of already-signed transactions. - * Extracts signatures and validates that all transactions are identical except for signatures. - * @param params - Parameters including the signed transactions, minimum signers, and all public keys - * @returns A new MultisigTransaction instance - * @throws {InvalidTransactionVersionError} If any transaction is not V0 variant - * @throws {TransactionMismatchError} If transactions don't match each other - * @throws {InvalidMultisigParameterError} If public keys are invalid or transaction is not nonce-based - */ - static fromTransactions({ - txns, - minSigners, - allPubKeys, - }: FromTransactionsParams): MultisigTransaction { - const signatures: SignatureAndPubKey[] = []; - const unsignedTx = asUnsignedTransaction(txns[0]); - const unusedPubKeys = new Set(allPubKeys); - - assertIsNonceBasedTx(unsignedTx); - - for (const tx of txns) { - assertTxVariant(tx); - assertTxMatchesUnsignedTx(tx, unsignedTx); - - const { pub_key, signature } = tx.V0; - - if (!unusedPubKeys.delete(pub_key)) { - throw new InvalidMultisigParameterError( - `Public key is not a member of the multisig or has already signed: ${pub_key}`, - ); - } - - signatures.push({ pub_key, signature }); - } - - return new MultisigTransaction({ - unsignedTx, - signatures, - unusedPubKeys: Array.from(unusedPubKeys), - minSigners, - }); - } - - /** - * Adds a signature & public key from a signed transaction to the multisig transaction. - * @param tx - The signed transaction to extract signature from - * @throws {InvalidTransactionVersionError} If the transaction is not V0 variant - * @throws {TransactionMismatchError} If the transaction doesn't match the unsigned transaction - * @throws {InvalidMultisigParameterError} If the public key is not a member or has already signed - */ - addSignedTransaction(tx: Transaction): void { - assertTxVariant(tx); - assertTxMatchesUnsignedTx(tx, this.unsignedTx); - - const { pub_key, signature } = tx.V0; - - this.addSignature(signature, pub_key); - } - - /** - * Adds a signature to the multisig transaction. - * @param signature - The signature to add - * @param pubKey - The public key corresponding to the signature - * @throws {InvalidMultisigParameterError} If the public key is not a member or has already signed - */ addSignature(signature: HexString, pubKey: HexString): void { - if (!this.unusedPubKeys.delete(pubKey)) { + const pair = normalizeSignatureAndPubKey({ signature, pub_key: pubKey }); + + if (!this.unusedPubKeys.delete(pair.pub_key)) { throw new InvalidMultisigParameterError( - `Public key is not a member of the multisig or has already signed: ${pubKey}`, + `Public key is not a member of the multisig or has already signed: ${pair.pub_key}`, ); } - this.signatures.push({ pub_key: pubKey, signature }); + this.signatures.push(pair); } - /** - * Checks if the multisig has collected enough signatures to be complete. - * @returns True if the number of signatures meets or exceeds the minimum required - */ get isComplete(): boolean { return this.signatures.length >= this.minSigners; } - /** - * Gets the unsigned transaction. - * @returns A readonly view of the unsigned transaction - */ - get unsignedTransaction(): Readonly> { - return this.unsignedTx; + get threshold(): number { + return this.minSigners; } - /** - * Gets the signatures and public keys collected so far. - * @returns A readonly view of the signatures and public keys - */ get signaturesAndPubKeys(): Readonly { - return this.signatures; + return this.signatures.map((pair) => ({ ...pair })); } - /** - * Gets the remaining public keys that haven't signed yet. - * @returns A readonly view of the remaining public keys - */ get remainingPubKeys(): Readonly> { - return this.unusedPubKeys; + return new Set(this.unusedPubKeys); } - /** - * Gets the threshold number of signatures required for the multisig to be complete. - * @returns The threshold number of signatures - */ - get threshold(): number { - return this.minSigners; + get allPubKeys(): Readonly { + return [ + ...this.signatures.map((signature) => signature.pub_key), + ...this.unusedPubKeys, + ]; + } + + toTransaction( + unsignedTx: UnsignedTransaction, + ): TransactionV1 { + if (!this.isComplete) { + throw new MultisigError("Multisig transaction is incomplete"); + } + + return { + V1: { + ...unsignedTx, + signatures: [...this.signaturesAndPubKeys], + unused_pub_keys: [...this.remainingPubKeys], + min_signers: this.threshold, + }, + }; } - /** - * Calculates the multisig address (credential ID) by hashing the threshold and sorted public keys. - * This matches the Rust implementation: hash(threshold || borsh(sorted_pubkeys)) - * @param hasher - The hash algorithm to use (currently only 'sha256' is supported) - * @returns The 32-byte multisig address as a Uint8Array - */ getMultisigAddress(hasher: "sha256" = "sha256"): Uint8Array { if (hasher !== "sha256") { throw new Error(`Unsupported hasher: ${hasher}`); } - // Collect all public keys (signatures + unused) - const allPubKeys = [ - ...this.signatures.map((s) => s.pub_key), - ...Array.from(this.unusedPubKeys), - ]; - const sortedPubKeys = allPubKeys.sort(); - const pubKeyBytes = sortedPubKeys.map((pk) => Array.from(hexToBytes(pk))); + const sortedPubKeys = [...this.allPubKeys].sort(); + const pubKeyBytes = sortedPubKeys.map((pubKey) => + Array.from(hexToBytes(pubKey)), + ); const buffer = new borsh.BinaryWriter(); buffer.writeU8(this.minSigners); @@ -253,74 +139,66 @@ export class MultisigTransaction { return sha256(buffer.toArray()); } - - /** - * Converts the multisig to a V1 transaction that can be submitted to the network. - * @returns A V1 transaction containing all collected signatures and unused public keys - */ - asTransaction(): Transaction { - return { - V1: { - runtime_call: this.unsignedTx.runtime_call, - uniqueness: this.unsignedTx.uniqueness, - details: this.unsignedTx.details, - unused_pub_keys: Array.from(this.unusedPubKeys), - signatures: this.signatures, - min_signers: this.minSigners, - }, - }; - } } -function assertTxVariant( - tx: Transaction, -): asserts tx is TransactionV0 { - if ("V1" in tx) { - throw new InvalidTransactionVersionError( - "Input transaction was an unsupported variant (V1)", +function assertValidSignerSet( + signatures: SignatureAndPubKey[], + unusedPubKeys: HexString[], + minSigners: number, +): void { + // `signatures` and `unusedPubKeys` are expected to already be normalized and + // validated hex strings by the time this runs. + const seenPubKeys = new Set(); + const allPubKeys = [ + ...signatures.map((signature) => signature.pub_key), + ...unusedPubKeys, + ]; + + if (!Number.isInteger(minSigners) || minSigners < 1) { + throw new InvalidMultisigParameterError( + `minSigners must be a positive integer, got ${minSigners}`, ); } -} -function asUnsignedTransaction( - tx: Transaction, -): UnsignedTransaction { - if ("V0" in tx) { - return { - runtime_call: tx.V0.runtime_call, - uniqueness: tx.V0.uniqueness, - details: tx.V0.details, - }; + if (allPubKeys.length < 2 || allPubKeys.length > MAX_SIGNERS) { + throw new InvalidMultisigParameterError( + `expected 2-${MAX_SIGNERS} total signers, got ${allPubKeys.length}`, + ); } - if ("V1" in tx) { - return { - runtime_call: tx.V1.runtime_call, - uniqueness: tx.V1.uniqueness, - details: tx.V1.details, - }; + if (minSigners > allPubKeys.length) { + throw new InvalidMultisigParameterError( + `minSigners cannot exceed total signer count: ${minSigners} > ${allPubKeys.length}`, + ); } - throw new InvalidTransactionVersionError( - "Transaction variant is neither V0 nor V1", - ); -} - -function assertTxMatchesUnsignedTx( - tx: Transaction, - unsignedTx: UnsignedTransaction, -): void { - const txAsUnsigned = asUnsignedTransaction(tx); + for (const pubKey of allPubKeys) { + if (seenPubKeys.has(pubKey)) { + throw new InvalidMultisigParameterError( + `Duplicate multisig public key: ${pubKey}`, + ); + } - if (JSON.stringify(txAsUnsigned) !== JSON.stringify(unsignedTx)) { - throw new TransactionMismatchError(); + seenPubKeys.add(pubKey); } } -function assertIsNonceBasedTx(unsignedTx: UnsignedTransaction): void { - if (!("nonce" in unsignedTx.uniqueness)) { +function normalizeSignatureAndPubKey( + pair: SignatureAndPubKey, +): SignatureAndPubKey { + return { + signature: normalizeMultisigHex("signature", pair.signature), + pub_key: normalizeMultisigHex("public key", pair.pub_key), + }; +} + +function normalizeMultisigHex(kind: string, value: HexString): HexString { + try { + return normalizeHexString(value); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); throw new InvalidMultisigParameterError( - "Only nonce-based transactions are supported for multisig", + `Invalid multisig ${kind} ${value}: ${message}`, ); } } diff --git a/typescript/packages/serializers/src/serializer.test.ts b/typescript/packages/serializers/src/serializer.test.ts index 11cfcecbdf..263fa6386e 100644 --- a/typescript/packages/serializers/src/serializer.test.ts +++ b/typescript/packages/serializers/src/serializer.test.ts @@ -1,5 +1,75 @@ import { describe, expect, it } from "vitest"; -import { convertUint8ArraysToArrays } from "./serializer"; +import { Serializer, convertUint8ArraysToArrays } from "./serializer"; + +class TestSerializer extends Serializer { + public lastInput: unknown; + public lastIndex?: number; + + protected jsonToBorsh(input: unknown, index: number): Uint8Array { + this.lastInput = input; + this.lastIndex = index; + return new Uint8Array([1, 2, 3]); + } +} + +describe("Serializer", () => { + it("should pass transaction signing payloads through unchanged", () => { + const serializer = new TestSerializer({ root_type_indices: [0, 177, 3] }); + const v0SigningPayload = { + V0: { + runtime_call: { bank: "transfer" }, + uniqueness: { generation: 1 }, + details: { max_fee: "1000" }, + }, + }; + const v1SigningPayload = { + V1: { + runtime_call: { bank: "transfer" }, + uniqueness: { nonce: 1 }, + details: { max_fee: "1000" }, + }, + }; + + serializer.serializeSigningPayload(v0SigningPayload); + expect(serializer.lastInput).toEqual(v0SigningPayload); + + serializer.serializeSigningPayload(v1SigningPayload); + expect(serializer.lastInput).toEqual(v1SigningPayload); + }); + + it("should convert Uint8Arrays nested in transaction signing payloads", () => { + const serializer = new TestSerializer({ root_type_indices: [0, 177, 3] }); + const signingPayload = { + V0: { + runtime_call: { + bank: { + transfer: { + to: new Uint8Array([1, 2, 3]), + }, + }, + }, + uniqueness: { generation: 1 }, + details: { max_fee: "1000" }, + }, + }; + + serializer.serializeSigningPayload(signingPayload); + + expect(serializer.lastInput).toEqual({ + V0: { + runtime_call: { + bank: { + transfer: { + to: [1, 2, 3], + }, + }, + }, + uniqueness: { generation: 1 }, + details: { max_fee: "1000" }, + }, + }); + }); +}); describe("convertUint8ArraysToArrays", () => { it("should convert a simple Uint8Array to a regular array", () => { diff --git a/typescript/packages/serializers/src/serializer.ts b/typescript/packages/serializers/src/serializer.ts index e77d12bd90..fdd39262b7 100644 --- a/typescript/packages/serializers/src/serializer.ts +++ b/typescript/packages/serializers/src/serializer.ts @@ -9,8 +9,8 @@ export type RollupSchema = Record; export enum KnownTypeId { /** The type id of the transaction. */ Transaction = 0, - /** The type id of the unsigned transaction. */ - UnsignedTransaction = 1, + /** The type id of the transaction signing payload. */ + TransactionSigningPayload = 1, /** The type id of the runtime call. */ RuntimeCall = 2, } @@ -75,15 +75,15 @@ export abstract class Serializer { } /** - * Serialize an unsigned transaction to Borsh bytes. + * Serialize a transaction signing payload to Borsh bytes. * - * @param input - The unsigned transaction to serialize. + * @param input - The transaction signing payload to serialize. * @returns The serialized Borsh bytes. */ - serializeUnsignedTx(input: unknown): Uint8Array { + serializeSigningPayload(input: unknown): Uint8Array { return this.serialize( input, - this.lookupKnownTypeIndex(KnownTypeId.UnsignedTransaction), + this.lookupKnownTypeIndex(KnownTypeId.TransactionSigningPayload), ); } diff --git a/typescript/packages/signers/src/ledger-solana/node.test.manual.ts b/typescript/packages/signers/src/ledger-solana/node.test.manual.ts index 6d2a0805a2..fbb8488fbd 100644 --- a/typescript/packages/signers/src/ledger-solana/node.test.manual.ts +++ b/typescript/packages/signers/src/ledger-solana/node.test.manual.ts @@ -65,7 +65,7 @@ const TEST_MESSAGE = Buffer.from( max_priority_fee_bips: 0, max_fee: "100000000000", gas_limit: [1000000000, 1000000000], - chain_id: 4321, + chain_hash_fragment: "795741901218843403", }, chain_name: "TestChain", }), diff --git a/typescript/packages/signers/src/wasm/eip712.test.ts b/typescript/packages/signers/src/wasm/eip712.test.ts index 816ce26749..22da31a46f 100644 --- a/typescript/packages/signers/src/wasm/eip712.test.ts +++ b/typescript/packages/signers/src/wasm/eip712.test.ts @@ -18,41 +18,44 @@ const TEST_PUBLIC_KEY = secp.getPublicKey( const schema = Schema.fromJSON(JSON.stringify(demoRollupSchema)); -// Sample unsigned transaction for testing -const sampleUnsignedTx = { - runtime_call: { - bank: { - transfer: { - to: { - Standard: "sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf", - }, - coins: { - amount: "1000", - token_id: - "token_1rwrh8gn2py0dl4vv65twgctmlwck6esm2as9dftumcw89kqqn3nqrduss6", +// Sample transaction signing payload for testing +const sampleSigningPayload = { + V0: { + runtime_call: { + bank: { + transfer: { + to: { + Standard: "sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf", + }, + coins: { + amount: "1000", + token_id: + "token_1rwrh8gn2py0dl4vv65twgctmlwck6esm2as9dftumcw89kqqn3nqrduss6", + }, }, }, }, - }, - uniqueness: { generation: "12345" }, - details: { - max_priority_fee_bips: "1000", - max_fee: "10000", - gas_limit: null, - chain_id: "1", + uniqueness: { generation: "12345" }, + details: { + max_priority_fee_bips: "1000", + max_fee: "10000", + gas_limit: null, + chain_hash_fragment: "1", + }, + address_override: null, + chain_hash: Array.from(schema.chainHash), }, }; /** * Creates the message that would be passed to signer.sign(). - * This is what rollup.signTransaction() does - serialize unsigned tx and append chain hash. + * This is what rollup.signTransaction() does: serialize the signing payload. */ function createTestMessage(): Uint8Array { - const unsignedTxBorsh = schema.jsonToBorsh( - schema.knownTypeIndex(KnownTypeId.UnsignedTransaction), - JSON.stringify(sampleUnsignedTx), + return schema.jsonToBorsh( + schema.knownTypeIndex(KnownTypeId.TransactionSigningPayload), + JSON.stringify(sampleSigningPayload), ); - return new Uint8Array([...unsignedTxBorsh, ...schema.chainHash]); } /** @@ -68,7 +71,7 @@ function parseTypedDataForViem(typedDataJson: string) { return { domain: { ...typedData.domain, chainId }, types: typesWithoutDomain, - primaryType: typedData.primaryType as "UnsignedTransaction", + primaryType: typedData.primaryType as "TransactionSigningPayload", message: typedData.message, }; } @@ -133,7 +136,7 @@ describe("Eip712Signer", () => { }); describe("sign", () => { - it("should throw error if message is too short for chain hash", async () => { + it("should throw error if message cannot be decoded", async () => { const signer = new Eip712Signer( mockProvider as any, demoRollupSchema, @@ -141,7 +144,7 @@ describe("Eip712Signer", () => { ); const shortMessage = new Uint8Array([1, 2, 3]); await expect(signer.sign(shortMessage)).rejects.toThrow( - "Message too short, expected at least 32 bytes for chain hash", + "Failed to generate EIP-712 JSON from message", ); }); diff --git a/typescript/packages/signers/src/wasm/eip712.ts b/typescript/packages/signers/src/wasm/eip712.ts index 83d4b9d0cc..c528bdcfd6 100644 --- a/typescript/packages/signers/src/wasm/eip712.ts +++ b/typescript/packages/signers/src/wasm/eip712.ts @@ -27,7 +27,7 @@ function normalizeSignature(signature: Signature): Uint8Array { /** * EIP-712 signer implementation that uses MetaMask's eth_signTypedData_v4 method. * - * This signer expects the message to be a valid UnsignedTransaction that can be + * This signer expects the message to be a valid TransactionSigningPayload that can be * parsed by the schema's eip712Json() method. * * The signer uses the provided address for signing operations. @@ -84,29 +84,20 @@ export class Eip712Signer implements Signer { } /** - * Sign an UnsignedTransaction using EIP-712 typed data signing. + * Sign a TransactionSigningPayload using EIP-712 typed data signing. */ async sign(message: Uint8Array): Promise { const address = this.address; - // Rollups invoke the signer with the chain hash appended, so drop the last 32 bytes of the message - if (message.length < 32) { - throw new SignerError( - "Message too short, expected at least 32 bytes for chain hash", - Eip712Signer.SIGNER_ID, - ); - } - const unsignedTxBytes = message.slice(0, -32); - - // Get the UnsignedTransaction type index + // Get the TransactionSigningPayload type index const typeIndex = this.schema.knownTypeIndex( - KnownTypeId.UnsignedTransaction, + KnownTypeId.TransactionSigningPayload, ); // Generate the EIP-712 JSON from the message bytes let eip712Json: string; try { - eip712Json = this.schema.eip712Json(typeIndex, unsignedTxBytes); + eip712Json = this.schema.eip712Json(typeIndex, message); } catch (error) { throw new SignerError( `Failed to generate EIP-712 JSON from message: ${error}`, @@ -117,7 +108,7 @@ export class Eip712Signer implements Signer { // Get the EIP-712 signing hash for public key recovery let signingHash: Uint8Array; try { - signingHash = this.schema.eip712SigningHash(typeIndex, unsignedTxBytes); + signingHash = this.schema.eip712SigningHash(typeIndex, message); } catch (error) { throw new SignerError( `Failed to generate EIP-712 signing hash: ${error}`, diff --git a/typescript/packages/types/README.md b/typescript/packages/types/README.md index ef11220926..2206b6e2b6 100644 --- a/typescript/packages/types/README.md +++ b/typescript/packages/types/README.md @@ -6,11 +6,11 @@ Core type definitions for Sovereign SDK blockchain interactions. This package provides TypeScript type definitions for working with "standard" Sovereign SDK rollups. While Sovereign SDK rollups are fully generic and can define custom types for transactions, blocks, and other primitives, this package contains the default type definitions used by the standard Sovereign SDK implementation. -## Standard Types - +## Standard types While Sovereign SDK supports this level of customization, most rollups will use a common set of primitives. This package provides type definitions for these standard components (and more): - `UnsignedTransaction` - Standard unsigned transaction format +- `TransactionSigningPayload` - Versioned payload serialized for signatures - `Transaction` - Standard signed transaction format These types work out-of-the-box with the default Sovereign SDK rollup configuration and are compatible with the other packages in this monorepo (`@sovereign-sdk/web3`, `@sovereign-sdk/signers`, etc.). @@ -18,15 +18,38 @@ These types work out-of-the-box with the default Sovereign SDK rollup configurat ## Usage ```typescript -import type { UnsignedTransaction, Transaction } from "@sovereign-sdk/types"; +import type { + Transaction, + TransactionSigningPayload, + UnsignedTransaction, +} from "@sovereign-sdk/types"; + +const unsignedTx: UnsignedTransaction = { + runtime_call: { + // Your rollup-specific call data + }, + uniqueness: { nonce: 1 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000000", + gas_limit: null, + chain_hash_fragment: "6654161651848106779", + }, +}; -// Use the standard transaction types -const unsignedTx: UnsignedTransaction = { - // Standard transaction fields +const signingPayload: TransactionSigningPayload = { + V0: { + ...unsignedTx, + chain_hash: Array.from(chainHash), + }, }; -const signedTx: Transaction = { - // Standard signed transaction fields +const signedTx: Transaction = { + V0: { + pub_key: "deadbeef", + signature: "cafebabe", + ...unsignedTx, + }, }; ``` @@ -39,4 +62,3 @@ If your rollup uses custom transaction or block formats that differ from the sta 3. Use the generic interfaces provided by other packages in this monorepo The Sovereign SDK's flexibility means you're never locked into these standard definitions if your use case requires something different. - diff --git a/typescript/packages/types/src/index.ts b/typescript/packages/types/src/index.ts index 9add36e7cd..35999c2aab 100644 --- a/typescript/packages/types/src/index.ts +++ b/typescript/packages/types/src/index.ts @@ -11,8 +11,8 @@ export type TxDetails = { max_fee: string; /** Optional gas limit as byte array, null for unlimited */ gas_limit: number[] | null; - /** Chain identifier for the target rollup network */ - chain_id: number; + /** 64-bit fragment of the target rollup chain hash, as a decimal string */ + chain_hash_fragment: string; }; /** Timestamp-based uniqueness mechanism using milliseconds since epoch */ @@ -28,8 +28,7 @@ export type Window = { window: number }; export type Uniqueness = Nonce | Generation | Window; /** - * Base transaction structure before signing, containing the core transaction data. - * Generic over RuntimeCall to support different rollup runtime call types. + * Consumer-facing unsigned transaction payload. */ export type UnsignedTransaction = { /** The specific runtime call/method being invoked on the rollup */ @@ -38,8 +37,43 @@ export type UnsignedTransaction = { uniqueness: Uniqueness; /** Transaction execution details including fees and gas limits */ details: TxDetails; + /** Optional address override for execution; null uses default routing */ + address_override: string | null; }; +/** + * Version 0 transaction signing payload. + * Used internally to produce single-signature signing bytes. + */ +export type TransactionSigningPayloadV0 = + UnsignedTransaction & { + /** Chain hash binding the signature to the rollup schema and metadata */ + chain_hash: number[]; + }; + +/** + * Version 1 transaction signing payload. + * Used internally for multisig signing bytes and includes the credential commitment. + */ +export type TransactionSigningPayloadV1< + RuntimeCall, + CredentialAddress = unknown, +> = TransactionSigningPayloadV0 & { + /** The multisig credential address in the rollup's native address format */ + credential_address: CredentialAddress; +}; + +/** + * Versioned transaction signing payload envelope. + * This is the exact root object serialized for signing. + */ +export type TransactionSigningPayload< + RuntimeCall, + CredentialAddress = unknown, +> = + | { V0: TransactionSigningPayloadV0 } + | { V1: TransactionSigningPayloadV1 }; + /** * Version 0 transaction format with single signature. * Used for standard single-party transactions. diff --git a/typescript/packages/universal-wallet-wasm/src/lib.rs b/typescript/packages/universal-wallet-wasm/src/lib.rs index f352c602d7..05af51e32a 100644 --- a/typescript/packages/universal-wallet-wasm/src/lib.rs +++ b/typescript/packages/universal-wallet-wasm/src/lib.rs @@ -19,8 +19,8 @@ where pub enum KnownTypeId { /// The type id of the transaction. Transaction = 0, - /// The type id of the unsigned transaction. - UnsignedTransaction = 1, + /// The type id of the transaction signing payload. + TransactionSigningPayload = 1, /// The type id of the runtime call. RuntimeCall = 2, } @@ -29,7 +29,7 @@ impl From for RollupRoots { fn from(value: KnownTypeId) -> Self { match value { KnownTypeId::Transaction => RollupRoots::Transaction, - KnownTypeId::UnsignedTransaction => RollupRoots::UnsignedTransaction, + KnownTypeId::TransactionSigningPayload => RollupRoots::TransactionSigningPayload, KnownTypeId::RuntimeCall => RollupRoots::RuntimeCall, } } diff --git a/typescript/packages/universal-wallet-wasm/tests/schema.test.ts b/typescript/packages/universal-wallet-wasm/tests/schema.test.ts index ac253c42a2..238916be3a 100644 --- a/typescript/packages/universal-wallet-wasm/tests/schema.test.ts +++ b/typescript/packages/universal-wallet-wasm/tests/schema.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { Schema, KnownTypeId } from "@sovereign-sdk/universal-wallet-wasm"; +import { KnownTypeId, Schema } from "@sovereign-sdk/universal-wallet-wasm"; +import { describe, expect, it } from "vitest"; import demoRollupSchema from "../../__fixtures__/demo-rollup-schema.json"; import { bytesToHex, hexToBytes } from "./utils"; @@ -30,7 +30,7 @@ describe("Schema", () => { describe("chainHash", () => { it("should calculate the chain hash successfully", () => { const expected = - "83cce3858536593b556abf4c469ab91b73e7a63bc3c34920bea516851b208735"; + "1bd36e60c454585ce482f322e5cf5c61f460c3448acf4b4b2a90fe6453d1dc87"; const actual = bytesToHex(schema.chainHash); expect(actual).toEqual(expected); @@ -39,7 +39,7 @@ describe("Schema", () => { describe("metadataHash", () => { it("should restore the metadata hash successfully", () => { const expected = - "292f10626cba6b48356c9e9fe284c1c576f4d7df510982d2bd188d3bbf0bb2d7"; + "0c01975266d56793a65d6482701109c352bd481f550169039016ad9b0c0a6d7b"; const actual = bytesToHex(schema.metadataHash); expect(actual).toEqual(expected); @@ -70,8 +70,8 @@ describe("Schema", () => { const actual = bytesToHex( schema.jsonToBorsh( schema.knownTypeIndex(KnownTypeId.RuntimeCall), - JSON.stringify(call) - ) + JSON.stringify(call), + ), ); const expected = "000007000000746f6b656e5f31010c204e000000000000000000000000000000f8ad2437a279e1c8932c07358c91dc4fe34864a98c6c25f298e2a0190100000000f8ad2437a279e1c8932c07358c91dc4fe34864a98c6c25f298e2a0190100e87648170000000000000000000000"; @@ -87,15 +87,15 @@ describe("Schema", () => { const doConversion = () => schema.jsonToBorsh( schema.knownTypeIndex(KnownTypeId.RuntimeCall), - JSON.stringify(call) + JSON.stringify(call), ); expect(doConversion).toThrow( - "Expected type or field __SovVirtualWallet_CallMessage_CreateToken.token_name, but it was not present" + "Expected type or field __SovVirtualWallet_CallMessage_CreateToken.token_name, but it was not present", ); }); it("should allow strings to serialize as u128", () => { const addr = hexToBytes( - "b7e23f9dc86a1547ee09d82a5c8f3610d975e2c84fb61038a719e524" + "b7e23f9dc86a1547ee09d82a5c8f3610d975e2c84fb61038a719e524", ); const call = { bank: { @@ -112,14 +112,14 @@ describe("Schema", () => { const doConversion = () => schema.jsonToBorsh( schema.knownTypeIndex(KnownTypeId.RuntimeCall), - JSON.stringify(call) + JSON.stringify(call), ); expect(doConversion).not.toThrow(); }); }); describe("eip712Json", () => { - it("should generate EIP712 JSON for UnsignedTransaction", () => { + it("should generate EIP712 JSON for TransactionSigningPayload", () => { const call = { bank: { transfer: { @@ -136,25 +136,29 @@ describe("Schema", () => { }, }; - const unsignedTransaction = { - runtime_call: call, - uniqueness: { generation: "0" }, - details: { - max_priority_fee_bips: "1000", - max_fee: "10000", - gas_limit: null, - chain_id: "1", + const transactionSigningPayload = { + V0: { + runtime_call: call, + uniqueness: { generation: "0" }, + details: { + max_priority_fee_bips: "1000", + max_fee: "10000", + gas_limit: null, + chain_hash_fragment: "1", + }, + address_override: null, + chain_hash: Array.from(schema.chainHash), }, }; const txBorsh = schema.jsonToBorsh( - schema.knownTypeIndex(KnownTypeId.UnsignedTransaction), - JSON.stringify(unsignedTransaction) + schema.knownTypeIndex(KnownTypeId.TransactionSigningPayload), + JSON.stringify(transactionSigningPayload), ); const eip712Json = schema.eip712Json( - schema.knownTypeIndex(KnownTypeId.UnsignedTransaction), - txBorsh + schema.knownTypeIndex(KnownTypeId.TransactionSigningPayload), + txBorsh, ); // Verify it's valid JSON @@ -166,15 +170,15 @@ describe("Schema", () => { expect(parsed).toHaveProperty("primaryType"); expect(parsed).toHaveProperty("types"); - expect(parsed.primaryType).toBe("UnsignedTransaction"); + expect(parsed.primaryType).toBe("TransactionSigningPayload"); expect(JSON.stringify(parsed)).toEqual( - `{"domain":{"name":"TestChain","chainId":"0x10e1","salt":"0x83cce3858536593b556abf4c469ab91b73e7a63bc3c34920bea516851b208735"},"types":{"Bank":[{"type":"Transfer","name":"Transfer"}],"Coins":[{"type":"uint128","name":"amount"},{"type":"string","name":"token_id"}],"EIP712Domain":[{"type":"string","name":"name"},{"type":"uint256","name":"chainId"},{"type":"bytes32","name":"salt"}],"MultiAddressEvmSolana":[{"type":"string","name":"Standard"}],"RuntimeCall":[{"type":"Bank","name":"Bank"}],"Transfer":[{"type":"MultiAddressEvmSolana","name":"to"},{"type":"Coins","name":"coins"}],"TxDetails":[{"type":"uint64","name":"max_priority_fee_bips"},{"type":"uint128","name":"max_fee"},{"type":"uint64","name":"chain_id"}],"UniquenessData":[{"type":"uint64","name":"Generation"}],"UnsignedTransaction":[{"type":"RuntimeCall","name":"runtime_call"},{"type":"UniquenessData","name":"uniqueness"},{"type":"TxDetails","name":"details"}]},"primaryType":"UnsignedTransaction","message":{"details":{"chain_id":"1","max_fee":"10000","max_priority_fee_bips":"1000"},"runtime_call":{"Bank":{"Transfer":{"coins":{"amount":"1000","token_id":"token_1rwrh8gn2py0dl4vv65twgctmlwck6esm2as9dftumcw89kqqn3nqrduss6"},"to":{"Standard":"sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf"}}}},"uniqueness":{"Generation":"0"}}}` + `{"domain":{"name":"TestChain","chainId":"0x10e1","salt":"0x1bd36e60c454585ce482f322e5cf5c61f460c3448acf4b4b2a90fe6453d1dc87"},"types":{"Bank":[{"type":"Transfer","name":"Transfer"}],"Coins":[{"type":"uint128","name":"amount"},{"type":"string","name":"token_id"}],"EIP712Domain":[{"type":"string","name":"name"},{"type":"uint256","name":"chainId"},{"type":"bytes32","name":"salt"}],"MultiAddressEvmSolana":[{"type":"string","name":"Standard"}],"RuntimeCall":[{"type":"Bank","name":"Bank"}],"TransactionSigningPayload":[{"type":"V0","name":"V0"}],"Transfer":[{"type":"MultiAddressEvmSolana","name":"to"},{"type":"Coins","name":"coins"}],"TxDetails":[{"type":"uint64","name":"max_priority_fee_bips"},{"type":"uint128","name":"max_fee"}],"UniquenessData":[{"type":"uint64","name":"Generation"}],"V0":[{"type":"RuntimeCall","name":"runtime_call"},{"type":"UniquenessData","name":"uniqueness"},{"type":"TxDetails","name":"details"}]},"primaryType":"TransactionSigningPayload","message":{"V0":{"details":{"max_fee":"10000","max_priority_fee_bips":"1000"},"runtime_call":{"Bank":{"Transfer":{"coins":{"amount":"1000","token_id":"token_1rwrh8gn2py0dl4vv65twgctmlwck6esm2as9dftumcw89kqqn3nqrduss6"},"to":{"Standard":"sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf"}}}},"uniqueness":{"Generation":"0"}}}}`, ); }); }); describe("eip712SigningHash", () => { - it("should generate EIP712 signing hash for UnsignedTransaction", () => { + it("should generate EIP712 signing hash for TransactionSigningPayload", () => { const call = { bank: { transfer: { @@ -191,31 +195,35 @@ describe("Schema", () => { }, }; - const unsignedTransaction = { - runtime_call: call, - uniqueness: { generation: "0" }, - details: { - max_priority_fee_bips: "1000", - max_fee: "10000", - gas_limit: null, - chain_id: "1", + const transactionSigningPayload = { + V0: { + runtime_call: call, + uniqueness: { generation: "0" }, + details: { + max_priority_fee_bips: "1000", + max_fee: "10000", + gas_limit: null, + chain_hash_fragment: "1", + }, + address_override: null, + chain_hash: Array.from(schema.chainHash), }, }; const txBorsh = schema.jsonToBorsh( - schema.knownTypeIndex(KnownTypeId.UnsignedTransaction), - JSON.stringify(unsignedTransaction) + schema.knownTypeIndex(KnownTypeId.TransactionSigningPayload), + JSON.stringify(transactionSigningPayload), ); const signingHash = schema.eip712SigningHash( - schema.knownTypeIndex(KnownTypeId.UnsignedTransaction), - txBorsh + schema.knownTypeIndex(KnownTypeId.TransactionSigningPayload), + txBorsh, ); // Should return a 32-byte hash expect(signingHash).toHaveLength(32); expect(bytesToHex(signingHash)).toEqual( - "a2f5f311607051903708529be350cf09d7042f6a6174077274c94785b81af04f" + "de93fe6c1b230c3a130c513401a324556e373362e9f0f29e6a438479b0eae85a", ); }); }); diff --git a/typescript/packages/utils/src/hex.test.ts b/typescript/packages/utils/src/hex.test.ts index 97baa4c1a9..9a8e52e21c 100644 --- a/typescript/packages/utils/src/hex.test.ts +++ b/typescript/packages/utils/src/hex.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { bytesToHex, ensureBytes, hexToBytes } from "./hex"; +import { bytesToHex, ensureBytes, hexToBytes, normalizeHexString } from "./hex"; describe("hexToBytes", () => { it("should convert a valid hex string to Uint8Array", () => { @@ -40,6 +40,22 @@ describe("bytesToHex", () => { }); }); +describe("normalizeHexString", () => { + it("should strip a 0x prefix and lowercase the value", () => { + expect(normalizeHexString("0xAABBCC")).toBe("aabbcc"); + }); + + it("should preserve already-normalized lowercase hex", () => { + expect(normalizeHexString("0a1b2c")).toBe("0a1b2c"); + }); + + it("should throw for invalid hex input", () => { + expect(() => normalizeHexString("0xZZ")).toThrow( + "Invalid hex string: contains non-hex characters", + ); + }); +}); + describe("ensureBytes", () => { it("should return Uint8Array if input is Uint8Array", () => { const arr = new Uint8Array([1, 2, 3]); diff --git a/typescript/packages/utils/src/hex.ts b/typescript/packages/utils/src/hex.ts index b3787d3858..1f0144f94d 100644 --- a/typescript/packages/utils/src/hex.ts +++ b/typescript/packages/utils/src/hex.ts @@ -45,6 +45,19 @@ export function bytesToHex(bytes: Uint8Array): HexString { return hex; } +/** + * Normalizes a hexadecimal string to the canonical form returned by {@link bytesToHex}. + * + * This strips an optional `0x` prefix, validates the input, and lowercases all digits. + * + * @param hex - The hexadecimal string to normalize. + * @returns The normalized lowercase hexadecimal string without a `0x` prefix. + * @throws {Error} If the input is not valid hexadecimal. + */ +export function normalizeHexString(hex: HexString): HexString { + return bytesToHex(hexToBytes(hex)); +} + /** * Ensures the input is a Uint8Array. If a hex string is provided, it is converted to bytes. * diff --git a/typescript/packages/utils/src/index.ts b/typescript/packages/utils/src/index.ts index 4ef64abcd3..529257d096 100644 --- a/typescript/packages/utils/src/index.ts +++ b/typescript/packages/utils/src/index.ts @@ -1,2 +1,2 @@ -export { hexToBytes, bytesToHex, ensureBytes } from "./hex"; +export { hexToBytes, bytesToHex, normalizeHexString, ensureBytes } from "./hex"; export { type HexString } from "./hex"; diff --git a/typescript/packages/web3/README.md b/typescript/packages/web3/README.md index 476335b6d9..2ba8170857 100644 --- a/typescript/packages/web3/README.md +++ b/typescript/packages/web3/README.md @@ -34,7 +34,7 @@ const rollup = new StandardRollup({ max_priority_fee_bips: 0, max_fee: 1000000, gas_limit: null, - chain_id: 4321, + chain_hash_fragment: "6654161651848106779", }, }); @@ -110,12 +110,43 @@ const simulation = await rollup.simulate( txDetails: { max_priority_fee_bips: 1000, max_fee: 1000000, - chain_id: 1, }, } ); ``` +### Multisig + +```typescript +import { Multisig } from "@sovereign-sdk/multisig"; +import type { UnsignedTransaction } from "@sovereign-sdk/types"; +import { bytesToHex } from "@sovereign-sdk/utils"; + +const unsignedTx: UnsignedTransaction = + await rollup.buildUnsignedTransaction(runtimeCall, { + overrides: { uniqueness: { nonce: 1 } }, + }); + +const multisig = Multisig.fromPubKeys( + ["pubkey1hex", "pubkey2hex", "pubkey3hex"], + 2, +); + +const signingBytes = await rollup.multisigSigningBytes(unsignedTx, multisig); +multisig.addSignature( + bytesToHex(await signer1.sign(signingBytes)), + bytesToHex(await signer1.publicKey()), +); + +multisig.addSignature( + bytesToHex(await signer2.sign(signingBytes)), + bytesToHex(await signer2.publicKey()), +); + +const tx = multisig.toTransaction(unsignedTx); +await rollup.submitTransaction(tx); +``` + ## API Reference The package exports the following main components: @@ -125,4 +156,3 @@ The package exports the following main components: - `createSerializer`: Function to create a Borsh serializer for your rollup schema For detailed API documentation, please refer to the inline TypeScript documentation in the source code. - diff --git a/typescript/packages/web3/package.json b/typescript/packages/web3/package.json index 4c81d0cc98..18d3f733f0 100644 --- a/typescript/packages/web3/package.json +++ b/typescript/packages/web3/package.json @@ -36,6 +36,7 @@ "dependencies": { "bs58": "^6.0.0", "@sovereign-sdk/client": "0.1.0-alpha.39", + "@sovereign-sdk/multisig": "workspace:^", "@sovereign-sdk/serializers": "workspace:^", "@sovereign-sdk/signers": "workspace:^", "@sovereign-sdk/universal-wallet-wasm": "workspace:^", diff --git a/typescript/packages/web3/src/rollup/chain-hash.ts b/typescript/packages/web3/src/rollup/chain-hash.ts new file mode 100644 index 0000000000..20d86cc6a2 --- /dev/null +++ b/typescript/packages/web3/src/rollup/chain-hash.ts @@ -0,0 +1,41 @@ +import type { UnsignedTransaction } from "@sovereign-sdk/types"; + +export function chainHashFragment(chainHash: Uint8Array): string { + if (chainHash.length < 8) { + throw new Error("chain hash must contain at least 8 bytes"); + } + + return new DataView( + chainHash.buffer, + chainHash.byteOffset, + chainHash.byteLength, + ) + .getBigUint64(0, true) + .toString(); +} + +export function refreshChainHashFragment( + unsignedTx: UnsignedTransaction, + chainHash: Uint8Array, +): UnsignedTransaction { + unsignedTx.details = { + ...unsignedTx.details, + chain_hash_fragment: chainHashFragment(chainHash), + }; + + return unsignedTx; +} + +export function assertChainHashFragment( + unsignedTx: UnsignedTransaction, + chainHash: Uint8Array, +): void { + const expectedFragment = chainHashFragment(chainHash); + const actualFragment = unsignedTx.details.chain_hash_fragment; + + if (actualFragment !== expectedFragment) { + throw new Error( + `Cannot sign transaction: chain_hash_fragment ${actualFragment} does not match the current chain hash fragment ${expectedFragment}`, + ); + } +} diff --git a/typescript/packages/web3/src/rollup/rollup.test.ts b/typescript/packages/web3/src/rollup/rollup.test.ts index b823a568b0..1f84374f4a 100644 --- a/typescript/packages/web3/src/rollup/rollup.test.ts +++ b/typescript/packages/web3/src/rollup/rollup.test.ts @@ -14,7 +14,7 @@ import { const mockSerializer = { serialize: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3])), serializeRuntimeCall: vi.fn().mockReturnValue(new Uint8Array([4, 5, 6])), - serializeUnsignedTx: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), + serializeSigningPayload: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), serializeTx: vi.fn().mockReturnValue(new Uint8Array([10, 11, 12])), schema: { chainHash: new Uint8Array([1, 2, 3, 4]) } as any, }; @@ -38,6 +38,7 @@ const testRollup = ( }, { unsignedTransaction: vi.fn(), + transactionSigningPayload: vi.fn().mockResolvedValue({}), transaction: vi.fn(), ...builder, }, @@ -90,7 +91,8 @@ describe("Rollup", () => { const versionMismatchError = { error: { details: { - error: "Signature verification failed", + code: "invalid_chain_hash_fragment", + error: "Authentication failed", }, }, }; @@ -181,6 +183,23 @@ describe("Rollup", () => { ); }); + it("should not identify chain hash error prose as a version mismatch", async () => { + const error = { + error: { + details: { + error: "Invalid chain hash fragment: expected one of [1], got 2", + }, + }, + }; + const { rollup, client } = testRollup(); + client.post = vi.fn().mockRejectedValue(error); + + await expect(rollup.submitTransaction({ foo: "bar" })).rejects.toEqual( + error, + ); + expect(client.rollup.schema).toHaveBeenCalledTimes(1); + }); + it("should throw VersionMismatchError when chain hash changes", async () => { const { rollup, client } = testRollup(); @@ -200,6 +219,24 @@ describe("Rollup", () => { ); }); + it("should retain schema recovery for signature verification failures", async () => { + const { rollup, client } = testRollup(); + client.post = vi.fn().mockRejectedValue({ + error: { details: { error: "Signature verification failed: stale" } }, + }); + client.rollup.schema = vi.fn().mockResolvedValue({ + schema: demoRollupSchema, + chain_hash: "0x00", + }); + vi.spyOn(rollup, "chainHash").mockResolvedValueOnce( + new Uint8Array([1, 2, 3, 4]), + ); + + await expect(rollup.submitTransaction({ foo: "bar" })).rejects.toThrow( + VersionMismatchError, + ); + }); + it("should bubble error if chain hash does not change", async () => { const { rollup, client } = testRollup(); client.post = vi.fn().mockRejectedValue(versionMismatchError); @@ -232,11 +269,15 @@ describe("Rollup", () => { }; const mockTransaction = { type: "mock-tx" }; + const mockSigningPayload = { + V0: { foo: "bar", chain_hash: [1, 2, 3, 4] }, + }; const mockTypeBuilder = { + transactionSigningPayload: vi.fn().mockResolvedValue(mockSigningPayload), transaction: vi.fn().mockResolvedValue(mockTransaction), }; - const unsignedTx = { foo: "bar" }; + const unsignedTx = { V0: { foo: "bar" } }; beforeEach(() => { vi.clearAllMocks(); @@ -248,13 +289,23 @@ describe("Rollup", () => { await rollup.signAndSubmitTransaction(unsignedTx, { signer: mockSigner }); - // should be called with (serialized tx ++ chain hash) - expect(mockSigner.sign).toHaveBeenCalledWith( - new Uint8Array([7, 8, 9, 1, 2, 3, 4]), + expect(mockSigner.sign).toHaveBeenCalledWith(new Uint8Array([7, 8, 9])); + expect(mockSerializer.serializeSigningPayload).toHaveBeenCalledWith( + mockSigningPayload, ); - expect(mockSerializer.serializeUnsignedTx).toHaveBeenCalledWith( + }); + + it("should build the transaction signing payload with the cached chain hash", async () => { + const { rollup } = testRollup({}, mockTypeBuilder); + rollup.submitTransaction = vi.fn(); + + await rollup.signAndSubmitTransaction(unsignedTx, { signer: mockSigner }); + + expect(mockTypeBuilder.transactionSigningPayload).toHaveBeenCalledWith({ unsignedTx, - ); + chainHash: new Uint8Array([1, 2, 3, 4]), + rollup, + }); }); it("should pass options to submitTransaction", async () => { @@ -329,6 +380,9 @@ describe("Rollup", () => { const mockTypeBuilder = { unsignedTransaction: vi.fn().mockResolvedValue(mockUnsignedTx), + transactionSigningPayload: vi + .fn() + .mockResolvedValue({ V0: mockUnsignedTx }), transaction: vi.fn().mockResolvedValue(mockTransaction), }; diff --git a/typescript/packages/web3/src/rollup/rollup.ts b/typescript/packages/web3/src/rollup/rollup.ts index 4ad023a83b..579734e67b 100644 --- a/typescript/packages/web3/src/rollup/rollup.ts +++ b/typescript/packages/web3/src/rollup/rollup.ts @@ -2,6 +2,7 @@ import SovereignClient from "@sovereign-sdk/client"; import type { APIError } from "@sovereign-sdk/client"; import type { RollupSchema, Serializer } from "@sovereign-sdk/serializers"; import type { Signer } from "@sovereign-sdk/signers"; +import type { HexString } from "@sovereign-sdk/utils"; import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; import { Base64 } from "js-base64"; import { VersionMismatchError } from "../errors"; @@ -33,11 +34,24 @@ export type TransactionContext< rollup: Rollup; }; +export type TransactionSigningPayloadContext< + S extends BaseTypeSpec, + C extends RollupContext, +> = { + unsignedTx: S["UnsignedTransaction"]; + chainHash: Uint8Array; + rollup: Rollup; +}; + export type TypeBuilder = { unsignedTransaction: ( context: UnsignedTransactionContext, ) => Promise; + transactionSigningPayload: ( + context: TransactionSigningPayloadContext, + ) => Promise; + transaction: (context: TransactionContext) => Promise; }; @@ -46,6 +60,11 @@ export type TypeBuilder = { */ export type RollupContext = Record; +export type CredentialIdToAddress = ( + credentialId: Uint8Array, + schema: RollupSchema, +) => string; + /** * The configuration for a rollup client. */ @@ -66,6 +85,10 @@ export type RollupConfig = { * is detected then it will be called again with the new rollup schema. */ getSerializer: (schema: RollupSchema) => Serializer; + /** + * Optional hook for converting multisig credential IDs into the rollup's native address format. + */ + credentialIdToAddress?: CredentialIdToAddress; /** * Arbitrary context that is associated with the rollup. */ @@ -143,12 +166,34 @@ export class Rollup { * TODO: How to request a specific uniqueness strategy explicitly */ async dedup(publicKey: Uint8Array): Promise { - // for public key credential id is just its bytes representation - const credentialId = bytesToHex(publicKey); + return this.dedupByCredentialId(bytesToHex(publicKey)); + } + + /** + * Retrieve dedup information about the provided credential ID. + */ + async dedupByCredentialId(credentialId: HexString): Promise { const response = await this.rollup.addresses.dedup(credentialId); return response as S["Dedup"]; } + /** + * Builds an unsigned transaction for the provided runtime call. + */ + async buildUnsignedTransaction( + runtimeCall: S["RuntimeCall"], + params?: { overrides?: DeepPartial }, + ): Promise { + const context = { + runtimeCall, + rollup: this, + overrides: + params?.overrides ?? ({} as DeepPartial), + }; + + return this._typeBuilder.unsignedTransaction(context); + } + /** * Submits a transaction to the rollup. * @@ -228,12 +273,9 @@ export class Rollup { { signer, overrides }: CallParams, options?: SovereignClient.RequestOptions, ): Promise> { - const context = { - runtimeCall, - rollup: this, - overrides: overrides ?? ({} as DeepPartial), - }; - const unsignedTx = await this._typeBuilder.unsignedTransaction(context); + const unsignedTx = await this.buildUnsignedTransaction(runtimeCall, { + overrides, + }); return this.signAndSubmitTransaction( unsignedTx, @@ -259,19 +301,16 @@ export class Rollup { runtimeCall: S["RuntimeCall"], { signer, overrides }: CallParams, ): Promise { - const context = { - runtimeCall, - rollup: this, - overrides: overrides ?? ({} as DeepPartial), - }; - const unsignedTx = await this._typeBuilder.unsignedTransaction(context); + const unsignedTx = await this.buildUnsignedTransaction(runtimeCall, { + overrides, + }); return this.signTransaction(unsignedTx, signer); } /** * Signs an unsigned transaction using the provided signer. - * Creates a signature by combining the serialized unsigned transaction with the chain hash, - * then constructs a fully signed transaction. + * Creates a signature over the serialized transaction signing payload, then constructs a fully + * signed transaction. * * @param unsignedTx - The unsigned transaction to sign. * @param signer - The signer to use for signing the transaction. @@ -282,11 +321,16 @@ export class Rollup { signer: Signer, ): Promise { const serializer = await this.serializer(); - const serializedUnsignedTx = serializer.serializeUnsignedTx(unsignedTx); - const chainHash = await this.chainHash(); - const signature = await signer.sign( - new Uint8Array([...serializedUnsignedTx, ...chainHash]), + const transactionSigningPayload = + await this._typeBuilder.transactionSigningPayload({ + unsignedTx, + chainHash: await this.chainHash(), + rollup: this, + }); + const signingBytes = serializer.serializeSigningPayload( + transactionSigningPayload, ); + const signature = await signer.sign(signingBytes); const publicKey = await signer.publicKey(); const context = { unsignedTx, @@ -391,9 +435,16 @@ export class Rollup { } function isVersionMismatchError(e: APIError): boolean { + // biome-ignore lint/suspicious/noExplicitAny: Stainless error details are untyped + const details = (e.error as any)?.details; + if (details?.code === "invalid_chain_hash_fragment") { + return true; + } + + const error = details?.error; if ( - // biome-ignore lint/suspicious/noExplicitAny: yolo - (e.error as any)?.details?.error?.includes("Signature verification failed") + typeof error === "string" && + error.includes("Signature verification failed") ) { return true; } diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts index 49295a0848..893a73d9e1 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts @@ -1,9 +1,9 @@ -import { sha256 } from "@noble/hashes/sha2"; import SovereignClient from "@sovereign-sdk/client"; +import { Multisig } from "@sovereign-sdk/multisig"; import { JsSerializer } from "@sovereign-sdk/serializers"; import { Ed25519Signer } from "@sovereign-sdk/signers"; -import { LedgerSolanaSigner } from "@sovereign-sdk/signers/ledger-solana"; -import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; +import { bytesToHex } from "@sovereign-sdk/utils"; +import bs58 from "bs58"; import { describe, expect, it, vi } from "vitest"; import demoRollupSchema from "../../../__fixtures__/demo-rollup-schema.json"; import { @@ -40,7 +40,7 @@ function createMockClient(overrides?: { function createMockSerializer(overrides?: any) { return { - serializeUnsignedTx: vi.fn().mockReturnValue(new Uint8Array(10)), + serializeSigningPayload: vi.fn().mockReturnValue(new Uint8Array(10)), serializeTx: vi.fn().mockReturnValue(new Uint8Array(10)), serializeRuntimeCall: vi.fn().mockReturnValue(new Uint8Array(10)), schema: overrides?.schema || {}, @@ -98,7 +98,7 @@ describe("SolanaSignableRollup", () => { client: mockClient, context: { defaultTxDetails: { - chain_id: 42, + chain_hash_fragment: "42", max_priority_fee_bips: 100, max_fee: "200000000", gas_limit: null, @@ -108,7 +108,7 @@ describe("SolanaSignableRollup", () => { const rollup = await createSolanaSignableRollup(customConfig); - expect(rollup.context.defaultTxDetails.chain_id).toBe(42); + expect(rollup.context.defaultTxDetails.chain_hash_fragment).toBe("42"); expect(rollup.context.defaultTxDetails.max_priority_fee_bips).toBe(100); expect(rollup.context.defaultTxDetails.max_fee).toBe("200000000"); }); @@ -119,12 +119,16 @@ describe("SolanaSignableRollup", () => { const rollup = await createSolanaSignableRollup({ client: mockClient }); expect(rollup).toBeInstanceOf(SolanaSignableRollup); - expect(rollup.context.defaultTxDetails.chain_id).toBe(1); + expect(rollup.context.defaultTxDetails.chain_hash_fragment).toBe("0"); }); it("should allow custom Solana endpoint configuration", async () => { const mockClient = createMockClient(); const customEndpoint = "/custom/solana-tx-endpoint"; + const requestOptions = { + timeout: 1234, + headers: { "x-test-header": "solana" }, + }; // Capture the endpoint and payload sent to the client let capturedEndpoint: string | undefined; @@ -157,17 +161,20 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "1000", gas_limit: null, - chain_id: 1, + chain_hash_fragment: "0", }, } as any, { signer: createMockSigner(), authenticator: "solanaSimple", }, + requestOptions, ); expect(capturedEndpoint).toBe(customEndpoint); expect(capturedPayload).toHaveProperty("body"); + expect(capturedPayload.timeout).toBe(requestOptions.timeout); + expect(capturedPayload.headers).toEqual(requestOptions.headers); }); it("should read chain_name from schema.chain_data in fixture schema", async () => { @@ -202,7 +209,7 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "1000", gas_limit: null, - chain_id: fixtureChainId, + chain_hash_fragment: "0", }, } as any, { @@ -223,6 +230,103 @@ describe("SolanaSignableRollup", () => { const message = JSON.parse(new TextDecoder().decode(jsonBytes)); expect(message.chain_name).toBe(demoRollupSchema.chain_data.chain_name); + expect(message.version).toBe(0); + }); + + it("should include address_override in Solana signed JSON when present", async () => { + const mockClient = createMockClient({ chainName: "TestChain" }); + let capturedPayload: any; + mockClient.post = vi + .fn() + .mockImplementation((_path: string, options: any) => { + capturedPayload = options; + return Promise.resolve({ id: "test-tx-hash" }); + }); + + const rollup = await createSolanaSignableRollup({ + client: mockClient, + getSerializer: (schema: any) => ({ schema }) as any, + }); + + await rollup.signAndSubmitTransaction( + { + runtime_call: { test: "call" }, + uniqueness: { generation: 123 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000", + gas_limit: null, + chain_hash_fragment: "0", + }, + address_override: "sov1target", + }, + { + signer: createMockSigner(), + authenticator: "solanaSimple", + }, + ); + + const decodedBody = Buffer.from(capturedPayload.body.body, "base64"); + const view = new DataView( + decodedBody.buffer, + decodedBody.byteOffset, + decodedBody.byteLength, + ); + const messageLength = view.getUint32(0, true); + const jsonBytes = decodedBody.slice(4, 4 + messageLength); + const message = JSON.parse(new TextDecoder().decode(jsonBytes)); + + expect(message.address_override).toBe("sov1target"); + }); + + it("should refresh stale chain hash fragments before Solana simple signing", async () => { + const mockClient = createMockClient({ + chainName: "TestChain", + chainHash: + "0x0102030405060708000000000000000000000000000000000000000000000000", + }); + let capturedPayload: any; + mockClient.post = vi + .fn() + .mockImplementation((_path: string, options: any) => { + capturedPayload = options; + return Promise.resolve({ id: "test-tx-hash" }); + }); + + const rollup = await createSolanaSignableRollup({ + client: mockClient, + getSerializer: (schema: any) => ({ schema }) as any, + }); + + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { generation: 123 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000", + gas_limit: null, + chain_hash_fragment: "stale", + }, + address_override: null, + }; + + await rollup.signAndSubmitTransaction(unsignedTx, { + signer: createMockSigner(), + authenticator: "solanaSimple", + }); + + const decodedBody = Buffer.from(capturedPayload.body.body, "base64"); + const view = new DataView( + decodedBody.buffer, + decodedBody.byteOffset, + decodedBody.byteLength, + ); + const messageLength = view.getUint32(0, true); + const jsonBytes = decodedBody.slice(4, 4 + messageLength); + const message = JSON.parse(new TextDecoder().decode(jsonBytes)); + + expect(message.details.chain_hash_fragment).toBe("578437695752307201"); + expect(unsignedTx.details.chain_hash_fragment).toBe("578437695752307201"); }); describe("byte-level compatibility with Rust implementation", () => { @@ -233,9 +337,9 @@ describe("SolanaSignableRollup", () => { // These values were generated using the test_submit_raw_signed_message_transaction() test from the sov-solana-offchain-auth crate. // The signer private key was logged, and the serde serialization of the AcceptTx was logged. const privateKeyHex = - "4096e0037e7dc13c28730b01e303ea4679a05e019f68a5ee8aec6c1968cac707"; + "2bf7a34f197040d49014e026aa35a61b094ad6b32d5ae86e769e777a51a83c5d"; const expectedJson = - '{"body":{"body":"cAEAAHsicnVudGltZV9jYWxsIjp7ImJhbmsiOnsidHJhbnNmZXIiOnsidG8iOiI0emR3SE5hRWE1bnBIdFJ0YVozUkwxbTZycHR1UVo2UkJMSEc2Y0F5VkhqTCIsImNvaW5zIjp7ImFtb3VudCI6IjEwMDAwIiwidG9rZW5faWQiOiJ0b2tlbl8xbnlsMGUweXdlcmFnZnNhdHlndDI0em1kOGpycjJ2cXR2ZGZwdHpqaHhrZ3V6Mnh4eDN2czB5MDd1NyJ9fX19LCJ1bmlxdWVuZXNzIjp7ImdlbmVyYXRpb24iOjB9LCJkZXRhaWxzIjp7Im1heF9wcmlvcml0eV9mZWVfYmlwcyI6MCwibWF4X2ZlZSI6IjEwMDAwMDAwMDAwMCIsImdhc19saW1pdCI6WzEwMDAwMDAwMDAsMTAwMDAwMDAwMF0sImNoYWluX2lkIjo0MzIxfSwiY2hhaW5fbmFtZSI6IlRlc3RDaGFpbiJ9CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwuMdhE5OjziHniAzu9qaFH0I50R93Apv2VgyONYPuLm3nN3Cr4cJwZ5ii6YYXxr7LsW3qcL0NAJfIvmUZroK+fuM18D3Hj+NsFn+nmN9jCjiWhjbQO1/79i365l424Erwg="}}'; + '{"body":{"body":"lwEAAHsicnVudGltZV9jYWxsIjp7ImJhbmsiOnsidHJhbnNmZXIiOnsidG8iOiI0emR3SE5hRWE1bnBIdFJ0YVozUkwxbTZycHR1UVo2UkJMSEc2Y0F5VkhqTCIsImNvaW5zIjp7ImFtb3VudCI6IjEwMDAwIiwidG9rZW5faWQiOiJ0b2tlbl8xbnlsMGUweXdlcmFnZnNhdHlndDI0em1kOGpycjJ2cXR2ZGZwdHpqaHhrZ3V6Mnh4eDN2czB5MDd1NyJ9fX19LCJ1bmlxdWVuZXNzIjp7ImdlbmVyYXRpb24iOjB9LCJkZXRhaWxzIjp7Im1heF9wcmlvcml0eV9mZWVfYmlwcyI6MCwibWF4X2ZlZSI6IjEwMDAwMDAwMDAwMCIsImdhc19saW1pdCI6WzEwMDAwMDAwMDAsMTAwMDAwMDAwMF0sImNoYWluX2hhc2hfZnJhZ21lbnQiOiI3OTU3NDE5MDEyMTg4NDM0MDMifSwiY2hhaW5fbmFtZSI6IlRlc3RDaGFpbiIsInZlcnNpb24iOjB9CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwt0I7oBLzTClR8+NiHHPE6LyyQfcVTjHex79N5keSD1gsvqIJOZp17q3OiVs8imQ1uYVbpGTr2eKDQZzAC9OEWF5Q0+fWsBtlViK2F9L6NO38U/7NknbOyhuXUZxh7pMQs="}}'; const mockClient = createMockClient({ chainId: 4321, @@ -284,8 +388,9 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "100000000000", gas_limit: [1000000000, 1000000000], - chain_id: 4321, + chain_hash_fragment: "795741901218843403", }, + address_override: null, }; await rollup.signAndSubmitTransaction(unsignedTx, { @@ -307,9 +412,9 @@ describe("SolanaSignableRollup", () => { const knownPubkeyHex = "70248c99a1d39769831c99706948b9851585cba907a677d112f9a3694cbcb4cd"; const knownSignatureHex = - "71204c3487b8e637cffaa5e9dc409efe2f1e98db6b557041181a368f4c487bbd407f72c43f08aa44b75f219e6fb3ce4681785dd72ee3eebe4e641ade8289370d"; + "89941ccebc40db1daf60b0b392121616868000d4799d930d18f4eaff266cd560cf61c6bf62c97955f64b33cc0640718427d0dc0180cf65e9746b7522d7f6d20e"; const expectedJson = - '{"body":{"body":"xAEAAP9zb2xhbmEgb2ZmY2hhaW4ACwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsAAXAkjJmh05dpgxyZcGlIuYUVhcupB6Z30RL5o2lMvLTNbwF7InJ1bnRpbWVfY2FsbCI6eyJiYW5rIjp7InRyYW5zZmVyIjp7InRvIjoiNHpkd0hOYUVhNW5wSHRSdGFaM1JMMW02cnB0dVFaNlJCTEhHNmNBeVZIakwiLCJjb2lucyI6eyJhbW91bnQiOiI1MDAwIiwidG9rZW5faWQiOiJ0b2tlbl8xbnlsMGUweXdlcmFnZnNhdHlndDI0em1kOGpycjJ2cXR2ZGZwdHpqaHhrZ3V6Mnh4eDN2czB5MDd1NyJ9fX19LCJ1bmlxdWVuZXNzIjp7ImdlbmVyYXRpb24iOjB9LCJkZXRhaWxzIjp7Im1heF9wcmlvcml0eV9mZWVfYmlwcyI6MCwibWF4X2ZlZSI6IjEwMDAwMDAwMDAwMCIsImdhc19saW1pdCI6WzEwMDAwMDAwMDAsMTAwMDAwMDAwMF0sImNoYWluX2lkIjo0MzIxfSwiY2hhaW5fbmFtZSI6IlRlc3RDaGFpbiJ9cSBMNIe45jfP+qXp3ECe/i8emNtrVXBBGBo2j0xIe71Af3LEPwiqRLdfIZ5vs85GgXhd1y7j7r5OZBregok3DQ=="}}'; + '{"body":{"body":"6wEAAP9zb2xhbmEgb2ZmY2hhaW4ACwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsAAXAkjJmh05dpgxyZcGlIuYUVhcupB6Z30RL5o2lMvLTNlgF7InJ1bnRpbWVfY2FsbCI6eyJiYW5rIjp7InRyYW5zZmVyIjp7InRvIjoiNHpkd0hOYUVhNW5wSHRSdGFaM1JMMW02cnB0dVFaNlJCTEhHNmNBeVZIakwiLCJjb2lucyI6eyJhbW91bnQiOiI1MDAwIiwidG9rZW5faWQiOiJ0b2tlbl8xbnlsMGUweXdlcmFnZnNhdHlndDI0em1kOGpycjJ2cXR2ZGZwdHpqaHhrZ3V6Mnh4eDN2czB5MDd1NyJ9fX19LCJ1bmlxdWVuZXNzIjp7ImdlbmVyYXRpb24iOjB9LCJkZXRhaWxzIjp7Im1heF9wcmlvcml0eV9mZWVfYmlwcyI6MCwibWF4X2ZlZSI6IjEwMDAwMDAwMDAwMCIsImdhc19saW1pdCI6WzEwMDAwMDAwMDAsMTAwMDAwMDAwMF0sImNoYWluX2hhc2hfZnJhZ21lbnQiOiI3OTU3NDE5MDEyMTg4NDM0MDMifSwiY2hhaW5fbmFtZSI6IlRlc3RDaGFpbiIsInZlcnNpb24iOjB9iZQczrxA2x2vYLCzkhIWFoaAANR5nZMNGPTq/yZs1WDPYca/Ysl5VfZLM8wGQHGEJ9DcAYDPZel0a3Ui1/bSDg=="}}'; const mockClient = createMockClient({ chainId: 4321, @@ -370,8 +475,9 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "100000000000", gas_limit: [1000000000, 1000000000], - chain_id: 4321, + chain_hash_fragment: "795741901218843403", }, + address_override: null, }; await rollup.signAndSubmitTransaction(unsignedTx, { @@ -396,7 +502,7 @@ describe("SolanaSignableRollup", () => { const key3PrivHex = "90f1cca556a78435468bb17f116a923c8eb5c6074619a9bf39f28eb673a22a50"; const expectedJson = - '{"body":{"body":"swEAAIB7InJ1bnRpbWVfY2FsbCI6eyJiYW5rIjp7InRyYW5zZmVyIjp7InRvIjoiNHpkd0hOYUVhNW5wSHRSdGFaM1JMMW02cnB0dVFaNlJCTEhHNmNBeVZIakwiLCJjb2lucyI6eyJhbW91bnQiOiI3MDAwIiwidG9rZW5faWQiOiJ0b2tlbl8xbnlsMGUweXdlcmFnZnNhdHlndDI0em1kOGpycjJ2cXR2ZGZwdHpqaHhrZ3V6Mnh4eDN2czB5MDd1NyJ9fX19LCJ1bmlxdWVuZXNzIjp7Im5vbmNlIjowfSwiZGV0YWlscyI6eyJtYXhfcHJpb3JpdHlfZmVlX2JpcHMiOjAsIm1heF9mZWUiOiIxMDAwMDAwMDAwMDAiLCJnYXNfbGltaXQiOlsxMDAwMDAwMDAwLDEwMDAwMDAwMDBdLCJjaGFpbl9pZCI6NDMyMX0sImNoYWluX25hbWUiOiJUZXN0Q2hhaW4iLCJtdWx0aXNpZ19pZCI6Ino2RHlmUGVaekN4SkVEOFlBWTltQmRKcmpnYnBCWXFMVjh0TU1OcEt2M2siLCJ2ZXJzaW9uIjoxfQsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLAgAAALGwrShO91iJLqf6Ne0Bx0Mt4DCv/hQIhiR+LegEDayRgD14q6kXbVVUrAHhyhZG2TWlr+6W9OjwY8srNbqkXgcv4nipbIUhu2N6tX9gRlwQDXaiJqLt4WPbVSxip3aoIhQ6tjQZ1t9xE30vHWb2ATKwfZLkwlcd1YUR4NL/NyCTeNelR0QRS9XubQlHpFH6gWbr7vh/c84zN46qDBOR0woVYBc1xrVz6bzFCcgAxODD5kb1GRU3v+eRUbVRZ3udIAEAAAA1/Qt6TH3bXwUlsuG8tx6Fh26y7p57mnXqrGBSp+LzBQI="}}'; + '{"body":{"body":"zgEAAIB7InJ1bnRpbWVfY2FsbCI6eyJiYW5rIjp7InRyYW5zZmVyIjp7InRvIjoiNHpkd0hOYUVhNW5wSHRSdGFaM1JMMW02cnB0dVFaNlJCTEhHNmNBeVZIakwiLCJjb2lucyI6eyJhbW91bnQiOiI3MDAwIiwidG9rZW5faWQiOiJ0b2tlbl8xbnlsMGUweXdlcmFnZnNhdHlndDI0em1kOGpycjJ2cXR2ZGZwdHpqaHhrZ3V6Mnh4eDN2czB5MDd1NyJ9fX19LCJ1bmlxdWVuZXNzIjp7Im5vbmNlIjowfSwiZGV0YWlscyI6eyJtYXhfcHJpb3JpdHlfZmVlX2JpcHMiOjAsIm1heF9mZWUiOiIxMDAwMDAwMDAwMDAiLCJnYXNfbGltaXQiOlsxMDAwMDAwMDAwLDEwMDAwMDAwMDBdLCJjaGFpbl9oYXNoX2ZyYWdtZW50IjoiNzk1NzQxOTAxMjE4ODQzNDAzIn0sImNoYWluX25hbWUiOiJUZXN0Q2hhaW4iLCJtdWx0aXNpZ19pZCI6Ino2RHlmUGVaekN4SkVEOFlBWTltQmRKcmpnYnBCWXFMVjh0TU1OcEt2M2siLCJ2ZXJzaW9uIjoxfQsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLAgAAAJ/0JLjGX1H2lXqpOX6Cdm7hhLtiGbUOtvVb9sEj9bSCWfS5gD2U+yGng1skqC5EslHi10tXa34gvGw67B3EgQIv4nipbIUhu2N6tX9gRlwQDXaiJqLt4WPbVSxip3aoIri6+DTe2fz0jV+E9R1N1Myb+psGvw8oB3UR5Pq1ScpyiXqAwUd2FO2ecZv//lhu2cCWqzzPhburzCIw11tnQg4VYBc1xrVz6bzFCcgAxODD5kb1GRU3v+eRUbVRZ3udIAEAAAA1/Qt6TH3bXwUlsuG8tx6Fh26y7p57mnXqrGBSp+LzBQI="}}'; const mockClient = createMockClient({ chainId: 4321, @@ -425,8 +531,6 @@ describe("SolanaSignableRollup", () => { const signer2 = new Ed25519Signer(key2PrivHex); const signer3 = new Ed25519Signer(key3PrivHex); - // Compute the multisig address from the 3 public keys. - // Mirrors MultisigTransaction.getMultisigAddress() from @sovereign-sdk/multisig. const pub1 = await signer1.publicKey(); const pub2 = await signer2.publicKey(); const pub3 = await signer3.publicKey(); @@ -434,16 +538,10 @@ describe("SolanaSignableRollup", () => { const pub2Hex = bytesToHex(pub2); const pub3Hex = bytesToHex(pub3); const minSigners = 2; - const multisigPubkeys = [pub1, pub2, pub3]; - - const sortedPubKeys = [pub1Hex, pub2Hex, pub3Hex].sort(); - const pubKeyBytes = sortedPubKeys.map((pk) => Array.from(hexToBytes(pk))); - const borshData = new Uint8Array(1 + 4 + 3 * 32); - const dv = new DataView(borshData.buffer); - borshData[0] = minSigners; - dv.setUint32(1, 3, true); - pubKeyBytes.forEach((pk, i) => borshData.set(pk, 5 + i * 32)); - const multisigAddress = sha256(borshData); + const multisig = Multisig.fromPubKeys( + [pub1Hex, pub2Hex, pub3Hex], + minSigners, + ); const runtimeCall = { bank: { @@ -465,45 +563,32 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "100000000000", gas_limit: [1000000000, 1000000000], - chain_id: 4321, + chain_hash_fragment: "795741901218843403", }, + address_override: null, }; - // Each signer signs independently (same order as Rust: key3, key1) - const signedTx3 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer3, - authenticator: "solanaSimple", - multisigAddress, - multisigPubkeys, - }); - const signedTx1 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer1, - authenticator: "solanaSimple", - multisigAddress, - multisigPubkeys, - }); - - // Build V1 transaction manually (avoids a cyclic dependency on @sovereign-sdk/multisig) - const v0_3 = (signedTx3 as any).V0; - const v0_1 = (signedTx1 as any).V0; - - const multisigV1 = { - V1: { - ...unsignedTx, - signatures: [ - { pub_key: v0_3.pub_key, signature: v0_3.signature }, - { pub_key: v0_1.pub_key, signature: v0_1.signature }, - ], - unused_pub_keys: [pub2Hex], - min_signers: minSigners, - }, - }; + const signer3Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solanaSimple", + ); + multisig.addSignature( + bytesToHex(await signer3.sign(signer3Bytes)), + bytesToHex(await signer3.publicKey()), + ); + const signer1Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solanaSimple", + ); + multisig.addSignature( + bytesToHex(await signer1.sign(signer1Bytes)), + bytesToHex(await signer1.publicKey()), + ); + const multisigV1 = multisig.toTransaction(unsignedTx); - await rollup.submitMultisigTransaction(multisigV1 as any, { - authenticator: "solanaSimple", - multisigAddress, - multisigPubkeys, - }); + await rollup.submitTransaction(multisigV1 as any, "solanaSimple"); const actualJson = JSON.stringify(capturedPayload); expect(actualJson).toBe(expectedJson); @@ -536,38 +621,158 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "1000", gas_limit: null, - chain_id: 1, + chain_hash_fragment: "0", }, + address_override: null, }; - const signedTx = await rollup.signTransactionForMultisig( + const pubKeyHex = bytesToHex(await signer.publicKey()); + const otherPubKeyHex = bytesToHex(new Uint8Array(32).fill(9)); + const multisig = Multisig.fromPubKeys([pubKeyHex, otherPubKeyHex], 1); + + const signingBytes = await rollup.multisigSigningBytes( unsignedTx as any, - { - signer, - authenticator: "standard", - }, + multisig, + "standard", + ); + multisig.addSignature( + bytesToHex(await signer.sign(signingBytes)), + bytesToHex(await signer.publicKey()), ); - expect(signedTx).toHaveProperty("V0"); + expect(multisig.signaturesAndPubKeys).toHaveLength(1); expect(signer.sign).toHaveBeenCalledTimes(1); - await rollup.submitMultisigTransaction( - { - V1: { - ...unsignedTx, - signatures: [], - unused_pub_keys: [], - min_signers: 0, - }, - } as any, - { - authenticator: "standard", - }, + await rollup.submitTransaction( + multisig.toTransaction(unsignedTx as any), + "standard", ); expect(capturedEndpoint).toBe("/sequencer/txs"); }); + it.each(["solanaSimple", "solana"] as const)( + "should enforce the fragment invariant without mutation for %s multisig signing", + async (authenticator) => { + const mockClient = createMockClient({ + chainName: "TestChain", + chainHash: + "0x0102030405060708000000000000000000000000000000000000000000000000", + }); + const rollup = await createSolanaSignableRollup({ + client: mockClient, + getSerializer: () => + createMockSerializer({ + schema: { chain_data: { chain_name: "TestChain" } }, + }), + }); + const multisig = Multisig.fromPubKeys( + [ + bytesToHex(new Uint8Array(32).fill(1)), + bytesToHex(new Uint8Array(32).fill(2)), + ], + 1, + ); + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 0 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000", + gas_limit: null, + chain_hash_fragment: "578437695752307201", + }, + address_override: null, + }; + const originalDetails = unsignedTx.details; + const originalUnsignedTx = { + ...unsignedTx, + details: { ...unsignedTx.details }, + }; + + await expect( + rollup.multisigSigningBytes(unsignedTx, multisig, authenticator), + ).resolves.toBeInstanceOf(Uint8Array); + expect(unsignedTx).toEqual(originalUnsignedTx); + expect(unsignedTx.details).toBe(originalDetails); + + const staleUnsignedTx = { + ...unsignedTx, + details: { + ...unsignedTx.details, + chain_hash_fragment: "stale", + }, + }; + const originalStaleDetails = staleUnsignedTx.details; + const originalStaleUnsignedTx = { + ...staleUnsignedTx, + details: { ...staleUnsignedTx.details }, + }; + + await expect( + rollup.multisigSigningBytes(staleUnsignedTx, multisig, authenticator), + ).rejects.toThrow( + "Cannot sign transaction: chain_hash_fragment stale does not match the current chain hash fragment 578437695752307201", + ); + expect(staleUnsignedTx).toEqual(originalStaleUnsignedTx); + expect(staleUnsignedTx.details).toBe(originalStaleDetails); + }, + ); + + it.each([ + ["V0", "solanaSimple"], + ["V0", "solana"], + ["V1", "solanaSimple"], + ["V1", "solana"], + ] as const)( + "should reject stale %s transactions before %s resubmission", + async (version, authenticator) => { + const mockClient = createMockClient({ + chainHash: + "0x0102030405060708000000000000000000000000000000000000000000000000", + }); + mockClient.post = vi.fn(); + const rollup = await createSolanaSignableRollup({ + client: mockClient, + getSerializer: () => + createMockSerializer({ + schema: { chain_data: { chain_name: "TestChain" } }, + }), + }); + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 0 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000", + gas_limit: null, + chain_hash_fragment: "stale", + }, + address_override: null, + }; + const pubKey = "01".repeat(32); + const signature = "02".repeat(64); + const transaction = + version === "V0" + ? { V0: { ...unsignedTx, pub_key: pubKey, signature } } + : { + V1: { + ...unsignedTx, + signatures: [{ pub_key: pubKey, signature }], + unused_pub_keys: ["03".repeat(32)], + min_signers: 1, + }, + }; + + await expect( + rollup.submitTransaction(transaction as any, authenticator), + ).rejects.toThrow( + "chain_hash_fragment stale does not match the current chain hash fragment 578437695752307201", + ); + expect(mockClient.post).not.toHaveBeenCalled(); + }, + ); + it("should match the Rust spec-compliant multisig request payload", async () => { const key1PrivHex = "d4ce78b7250da62754bd2b180aa95ecc63f2c79dd4a7cd1f104416e7039ae18b"; @@ -576,7 +781,7 @@ describe("SolanaSignableRollup", () => { const key3PrivHex = "aa52d1811235c1c02cbbcf995b9dcabc7838a0931b12e97bf4eead7e0d573414"; const expectedJson = - '{"body":"SAIAAP9zb2xhbmEgb2ZmY2hhaW4ACwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsAAxCC8hWZvytLdhSuQz4hCg8AU5iG9i7Lm5d3TsrXXseqIq7fXUxXzPzWMSb2MrMb3ZHejz5ys9PwFhFBce+qogqkMLj7Z4EBZpuFaeJrH3G98UiHtBd00SHZoG/4/YYGvLMBeyJydW50aW1lX2NhbGwiOnsiYmFuayI6eyJ0cmFuc2ZlciI6eyJ0byI6IjR6ZHdITmFFYTVucEh0UnRhWjNSTDFtNnJwdHVRWjZSQkxIRzZjQXlWSGpMIiwiY29pbnMiOnsiYW1vdW50IjoiNzAwMCIsInRva2VuX2lkIjoidG9rZW5fMW55bDBlMHl3ZXJhZ2ZzYXR5Z3QyNHptZDhqcnIydnF0dmRmcHR6amh4a2d1ejJ4eHgzdnMweTA3dTcifX19fSwidW5pcXVlbmVzcyI6eyJub25jZSI6MH0sImRldGFpbHMiOnsibWF4X3ByaW9yaXR5X2ZlZV9iaXBzIjowLCJtYXhfZmVlIjoiMTAwMDAwMDAwMDAwIiwiZ2FzX2xpbWl0IjpbMTAwMDAwMDAwMCwxMDAwMDAwMDAwXSwiY2hhaW5faWQiOjQzMjF9LCJjaGFpbl9uYW1lIjoiVGVzdENoYWluIiwibXVsdGlzaWdfaWQiOiI2NFN2N2tMZVl0VXpVdGNuTTZCQVlqQXY4WjY1R2c1aXVtUGRVZzVaTXRKbiIsInZlcnNpb24iOjF9AgAAAAksFU/XcuxSQ2WBYJoZiYQf3gikgQi3CctMHuYBX3wBukIMQPhO5X7IwojPw5NtfjrbQhBVCSaLMjP7Bvi6SAsNM01gzyzMZ00ySPuO1RnF5Y0bTEDd57o8GWNCOHyizwqygFn1pehUMpBAp5FMeI1Fz7ZRe7K7mHiu26MFwq8HBgAAAAI="}'; + '{"body":"YwIAAP9zb2xhbmEgb2ZmY2hhaW4ACwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsAAxCC8hWZvytLdhSuQz4hCg8AU5iG9i7Lm5d3TsrXXseqIq7fXUxXzPzWMSb2MrMb3ZHejz5ys9PwFhFBce+qogqkMLj7Z4EBZpuFaeJrH3G98UiHtBd00SHZoG/4/YYGvM4BeyJydW50aW1lX2NhbGwiOnsiYmFuayI6eyJ0cmFuc2ZlciI6eyJ0byI6IjR6ZHdITmFFYTVucEh0UnRhWjNSTDFtNnJwdHVRWjZSQkxIRzZjQXlWSGpMIiwiY29pbnMiOnsiYW1vdW50IjoiNzAwMCIsInRva2VuX2lkIjoidG9rZW5fMW55bDBlMHl3ZXJhZ2ZzYXR5Z3QyNHptZDhqcnIydnF0dmRmcHR6amh4a2d1ejJ4eHgzdnMweTA3dTcifX19fSwidW5pcXVlbmVzcyI6eyJub25jZSI6MH0sImRldGFpbHMiOnsibWF4X3ByaW9yaXR5X2ZlZV9iaXBzIjowLCJtYXhfZmVlIjoiMTAwMDAwMDAwMDAwIiwiZ2FzX2xpbWl0IjpbMTAwMDAwMDAwMCwxMDAwMDAwMDAwXSwiY2hhaW5faGFzaF9mcmFnbWVudCI6Ijc5NTc0MTkwMTIxODg0MzQwMyJ9LCJjaGFpbl9uYW1lIjoiVGVzdENoYWluIiwibXVsdGlzaWdfaWQiOiI2NFN2N2tMZVl0VXpVdGNuTTZCQVlqQXY4WjY1R2c1aXVtUGRVZzVaTXRKbiIsInZlcnNpb24iOjF9AgAAALqZ6CBwaWxjUG2qZcmZzTQxJ9d0+NzYgNS7g2391KSpnEq+Pcxj/YG3G9tkg4jaSXz+RgHWwgCPAbXNcnKbDQUnH8ulC3nADh3qr0/Xf9X7VRaQbgPm7NGJI6AhWW7p2aRy07HkyaoueAVpYzYhiaai7zl13usxx04dmlqkSMEJBgAAAAI="}'; const mockClient = createMockClient({ chainId: 4321, @@ -611,16 +816,10 @@ describe("SolanaSignableRollup", () => { const pub2Hex = bytesToHex(pub2); const pub3Hex = bytesToHex(pub3); const minSigners = 2; - const multisigPubkeys = [pub1, pub2, pub3]; - - const sortedPubKeys = [pub1Hex, pub2Hex, pub3Hex].sort(); - const pubKeyBytes = sortedPubKeys.map((pk) => Array.from(hexToBytes(pk))); - const borshData = new Uint8Array(1 + 4 + 3 * 32); - const dv = new DataView(borshData.buffer); - borshData[0] = minSigners; - dv.setUint32(1, 3, true); - pubKeyBytes.forEach((pk, i) => borshData.set(pk, 5 + i * 32)); - const multisigAddress = sha256(borshData); + const multisig = Multisig.fromPubKeys( + [pub1Hex, pub2Hex, pub3Hex], + minSigners, + ); const unsignedTx = { runtime_call: { @@ -640,46 +839,190 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "100000000000", gas_limit: [1000000000, 1000000000], - chain_id: 4321, + chain_hash_fragment: "795741901218843403", }, + address_override: null, }; - const signedTx3 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer3, - authenticator: "solana", - multisigAddress, - multisigPubkeys, - }); - const signedTx1 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer1, - authenticator: "solana", - multisigAddress, - multisigPubkeys, - }); + const signer3Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solana", + ); + multisig.addSignature( + bytesToHex(await signer3.sign(signer3Bytes)), + bytesToHex(await signer3.publicKey()), + ); + const signer1Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solana", + ); + multisig.addSignature( + bytesToHex(await signer1.sign(signer1Bytes)), + bytesToHex(await signer1.publicKey()), + ); + const multisigV1 = multisig.toTransaction(unsignedTx); - const v0_3 = (signedTx3 as any).V0; - const v0_1 = (signedTx1 as any).V0; + await rollup.submitTransaction(multisigV1 as any, "solana"); - const multisigV1 = { - V1: { - ...unsignedTx, - signatures: [ - { pub_key: v0_3.pub_key, signature: v0_3.signature }, - { pub_key: v0_1.pub_key, signature: v0_1.signature }, + const actualJson = JSON.stringify(capturedPayload.body); + expect(actualJson).toBe(expectedJson); + }); + + describe("V1 address_override", () => { + async function setupMultisigContext(): Promise<{ + rollup: SolanaSignableRollup; + capturedPayloadRef: { current: any }; + multisig: Multisig; + signers: { + signer1: Ed25519Signer; + signer2: Ed25519Signer; + signer3: Ed25519Signer; + }; + unsignedTx: any; + }> { + const key1PrivHex = + "09817894bf1e858df8d9bb3b931646c558ec4cacb9e4f9c05e91d0d788ec1142"; + const key2PrivHex = + "71d81253990513758c7014bec174b4405988c133fc616d6f3d633170858f3dc9"; + const key3PrivHex = + "90f1cca556a78435468bb17f116a923c8eb5c6074619a9bf39f28eb673a22a50"; + const mockClient = createMockClient({ + chainId: 4321, + chainName: "TestChain", + chainHash: + "0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + }); + const capturedPayloadRef: { current: any } = { current: undefined }; + mockClient.post = vi + .fn() + .mockImplementation((_path: string, options: any) => { + capturedPayloadRef.current = options; + return Promise.resolve({ id: "test-tx-hash" }); + }); + + const rollup = await createSolanaSignableRollup({ + client: mockClient, + getSerializer: (schema: any) => ({ schema }) as any, + }); + const signer1 = new Ed25519Signer(key1PrivHex); + const signer2 = new Ed25519Signer(key2PrivHex); + const signer3 = new Ed25519Signer(key3PrivHex); + const multisig = Multisig.fromPubKeys( + [ + bytesToHex(await signer1.publicKey()), + bytesToHex(await signer2.publicKey()), + bytesToHex(await signer3.publicKey()), ], - unused_pub_keys: [pub2Hex], - min_signers: minSigners, - }, - }; + 2, + ); + const unsignedTx = { + runtime_call: { + bank: { + transfer: { + to: "4zdwHNaEa5npHtRtaZ3RL1m6rptuQZ6RBLHG6cAyVHjL", + coins: { + amount: "7000", + token_id: + "token_1nyl0e0yweragfsatygt24zmd8jrr2vqtvdfptzjhxkguz2xxx3vs0y07u7", + }, + }, + }, + }, + uniqueness: { nonce: 0 }, + details: { + max_priority_fee_bips: 0, + max_fee: "100000000000", + gas_limit: [1000000000, 1000000000], + chain_hash_fragment: "795741901218843403", + }, + address_override: null, + }; + + return { + rollup, + capturedPayloadRef, + multisig, + signers: { signer1, signer2, signer3 }, + unsignedTx, + }; + } + + async function signMultisig( + rollup: SolanaSignableRollup, + unsignedTx: any, + multisig: Multisig, + signer: Ed25519Signer, + ): Promise { + const signingBytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solanaSimple", + ); + multisig.addSignature( + bytesToHex(await signer.sign(signingBytes)), + bytesToHex(await signer.publicKey()), + ); + } + + it("omits address_override from multisig signing bytes when it is null", async () => { + const { rollup, multisig, unsignedTx } = await setupMultisigContext(); - await rollup.submitMultisigTransaction(multisigV1 as any, { - authenticator: "solana", - multisigAddress, - multisigPubkeys, + const signingBytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solanaSimple", + ); + const signedJson = new TextDecoder().decode(signingBytes); + + expect(JSON.parse(signedJson)).not.toHaveProperty("address_override"); }); - const actualJson = JSON.stringify(capturedPayload.body); - expect(actualJson).toBe(expectedJson); + it("includes tx.address_override in multisig signing bytes before version", async () => { + const { rollup, multisig, unsignedTx } = await setupMultisigContext(); + const addressOverride = new Uint8Array(32).fill(0x42); + const addressOverrideBs58 = bs58.encode(addressOverride); + + const signingBytes = await rollup.multisigSigningBytes( + { ...unsignedTx, address_override: addressOverrideBs58 }, + multisig, + "solanaSimple", + ); + const signedJson = new TextDecoder().decode(signingBytes); + + expect(JSON.parse(signedJson).address_override).toBe(addressOverrideBs58); + expect(signedJson.indexOf('"address_override"')).toBeLessThan( + signedJson.indexOf('"version"'), + ); + }); + + it("forwards tx.address_override into submitted multisig JSON", async () => { + const { rollup, capturedPayloadRef, multisig, signers, unsignedTx } = + await setupMultisigContext(); + const addressOverride = new Uint8Array(32).fill(0x99); + const addressOverrideBs58 = bs58.encode(addressOverride); + const txWithOverride = { + ...unsignedTx, + address_override: addressOverrideBs58, + }; + + await signMultisig(rollup, txWithOverride, multisig, signers.signer3); + await signMultisig(rollup, txWithOverride, multisig, signers.signer1); + + await rollup.submitTransaction( + multisig.toTransaction(txWithOverride), + "solanaSimple", + ); + + const submittedBody = capturedPayloadRef.current.body.body as string; + const submittedText = new TextDecoder().decode( + Buffer.from(submittedBody, "base64"), + ); + expect(submittedText).toContain( + `"address_override":"${addressOverrideBs58}"`, + ); + }); }); describe("solanaAuto authenticator", () => { @@ -703,10 +1046,11 @@ describe("SolanaSignableRollup", () => { }), }); - // Create a real LedgerSolanaSigner instance and mock its methods - const ledgerSigner = new LedgerSolanaSigner(); - vi.spyOn(ledgerSigner, "publicKey").mockResolvedValue(new Uint8Array(32)); - vi.spyOn(ledgerSigner, "sign").mockResolvedValue(new Uint8Array(64)); + const ledgerSigner = { + __ledgerSolanaSigner: true as const, + publicKey: vi.fn().mockResolvedValue(new Uint8Array(32)), + sign: vi.fn().mockResolvedValue(new Uint8Array(64)), + }; await rollup.signAndSubmitTransaction( { @@ -716,7 +1060,7 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "1000", gas_limit: null, - chain_id: 1, + chain_hash_fragment: "0", }, } as any, { @@ -767,7 +1111,7 @@ describe("SolanaSignableRollup", () => { max_priority_fee_bips: 0, max_fee: "1000", gas_limit: null, - chain_id: 1, + chain_hash_fragment: "0", }, } as any, { diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts index 27a9740eba..3077a5491c 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts @@ -1,15 +1,25 @@ import type SovereignClient from "@sovereign-sdk/client"; +import { MAX_SIGNERS, Multisig } from "@sovereign-sdk/multisig"; import { type Signer, isLedgerSolanaSigner } from "@sovereign-sdk/signers"; import type { Transaction, TransactionV1, UnsignedTransaction, } from "@sovereign-sdk/types"; -import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; +import type { HexString } from "@sovereign-sdk/utils"; +import { + bytesToHex, + hexToBytes, + normalizeHexString, +} from "@sovereign-sdk/utils"; import bs58 from "bs58"; import { Base64 } from "js-base64"; import type { Subscription, SubscriptionToCallbackMap } from "../subscriptions"; import type { DeepPartial } from "../utils"; +import { + assertChainHashFragment, + refreshChainHashFragment, +} from "./chain-hash"; import type { RollupConfig, TransactionResult } from "./rollup"; import { type StandardRollup, @@ -19,30 +29,46 @@ import { standardTypeBuilder, } from "./standard-rollup"; -export type SolanaOffchainUnsignedTransaction = - UnsignedTransaction & { - chain_name: string; - }; +export type SolanaOffchainSigningPayloadV0 = Omit< + UnsignedTransaction, + "address_override" +> & { + chain_name: string; + /** + * Message format version. Must be 0 for V0 payloads. + */ + version: 0; + /** + * Signer-declared address override. + * See `AuthorizationData::address_override` (Rust) for routing semantics. + */ + address_override?: string; +}; -export type SolanaOffchainUnsignedTransactionV1 = - SolanaOffchainUnsignedTransaction & { - multisig_id: string; - version: number; - }; +export type SolanaOffchainSigningPayloadV1< + RuntimeCall, + MultisigId = unknown, +> = Omit, "version"> & { + multisig_id: MultisigId; + /** + * Message format version. Must be 1 for V1 payloads. + */ + version: 1; +}; -export type SolanaOffchainSimpleMessage = { +export type SolanaOffchainSimpleEnvelope = { signed_message: Uint8Array; chain_hash: Uint8Array; pubkey: Uint8Array; signature: Uint8Array; }; -export type SolanaOffchainSpecCompliantMessage = { +export type SolanaOffchainSpecCompliantEnvelope = { signed_message_with_preamble: Uint8Array; signature: Uint8Array; }; -export type SolanaOffchainSimpleMultisigMessage = { +export type SolanaOffchainSimpleMultisigEnvelope = { /** Wire format: [0x80][JSON payload]. The 0x80 prefix is a parsing discriminator only. */ wire_bytes: Uint8Array; chain_hash: Uint8Array; @@ -51,7 +77,7 @@ export type SolanaOffchainSimpleMultisigMessage = { min_signers: number; }; -export type SolanaOffchainSpecCompliantMultisigMessage = { +export type SolanaOffchainSpecCompliantMultisigEnvelope = { signed_message_with_preamble: Uint8Array; signatures: Uint8Array[]; signer_bitfield: number; @@ -64,22 +90,7 @@ export type Authenticator = | "solana" | "solanaAuto"; -export type SolanaMultisigAuthenticator = "solanaSimple" | "solana"; - -type SolanaMultisigParams = { - multisigAddress: Uint8Array; - multisigPubkeys: Uint8Array[]; -}; - -export type SolanaMultisigSubmitParams = - | { - authenticator: "standard"; - } - | ({ authenticator: SolanaMultisigAuthenticator } & SolanaMultisigParams); - -export type SolanaMultisigSignParams = SolanaMultisigSubmitParams & { - signer: Signer; -}; +export type SubmissionAuthenticator = Exclude; /** * Discriminator byte prepended to the wire bytes in the multisig simple format. @@ -94,7 +105,6 @@ const CHAIN_HASH_SIZE = 32; const PUBKEY_SIZE = 32; const SIGNATURE_SIZE = 64; const MULTISIG_PREAMBLE_FIXED_LENGTH = 53; -const MAX_MULTISIG_SIGNERS = 21; // TODO - is there any way we could get it from Rust rather than hard-coding here // Solana preamble constants const SIGNING_DOMAIN = new Uint8Array([ @@ -121,9 +131,9 @@ function createSolanaPreamble( chainHash: Uint8Array, messageLength: number, ): Uint8Array { - if (pubkeys.length < 1 || pubkeys.length > MAX_MULTISIG_SIGNERS) { + if (pubkeys.length < 1 || pubkeys.length > MAX_SIGNERS) { throw new Error( - `Invalid signer count: expected 1-${MAX_MULTISIG_SIGNERS} signers, got ${pubkeys.length}`, + `Invalid signer count: expected 1-${MAX_SIGNERS} signers, got ${pubkeys.length}`, ); } @@ -190,12 +200,16 @@ export class SolanaSignableRollup { */ private async submitSerializedMessage( serializedMessage: Uint8Array, + options?: SovereignClient.RequestOptions, ): Promise { + const { body: _, query: __, path, ...requestOptions } = options || {}; + return await this.inner.http.post( - this.solanaEndpoint, + path || this.solanaEndpoint, { // Match AcceptTx shape used by standard sequencer endpoints. body: { body: Base64.fromUint8Array(serializedMessage) }, + ...requestOptions, }, ); } @@ -204,45 +218,34 @@ export class SolanaSignableRollup { * Submits a Solana offchain message to the rollup. */ private async submitSolanaMessage( - solanaMessage: SolanaOffchainSimpleMessage, + solanaMessage: SolanaOffchainSimpleEnvelope, + options?: SovereignClient.RequestOptions, ): Promise { const serializedMessage = this.serializeSolanaMessage(solanaMessage); - return this.submitSerializedMessage(serializedMessage); + return this.submitSerializedMessage(serializedMessage, options); } /** * Submits a Solana spec-compliant message to the rollup. */ private async submitSolanaSpecMessage( - solanaMessage: SolanaOffchainSpecCompliantMessage, + solanaMessage: SolanaOffchainSpecCompliantEnvelope, + options?: SovereignClient.RequestOptions, ): Promise { const serializedMessage = this.serializeSolanaSpecMessage(solanaMessage); - return this.submitSerializedMessage(serializedMessage); + return this.submitSerializedMessage(serializedMessage, options); } /** * Submits a Solana spec-compliant multisig message to the rollup. */ private async submitSolanaSpecMultisigMessage( - solanaMessage: SolanaOffchainSpecCompliantMultisigMessage, + solanaMessage: SolanaOffchainSpecCompliantMultisigEnvelope, + options?: SovereignClient.RequestOptions, ): Promise { const serializedMessage = this.serializeSolanaSpecMultisigMessage(solanaMessage); - return this.submitSerializedMessage(serializedMessage); - } - - /** - * Helper to build an unsigned transaction using the standard type builder. - */ - private async buildUnsignedTransaction( - runtimeCall: RuntimeCall, - overrides?: DeepPartial>, - ): Promise> { - return this.typeBuilder.unsignedTransaction({ - runtimeCall, - overrides: overrides ?? {}, - rollup: this.inner, - }); + return this.submitSerializedMessage(serializedMessage, options); } /** @@ -281,25 +284,19 @@ export class SolanaSignableRollup { * The canonical order is lexicographic by raw pubkey bytes. */ private canonicalizeMultisigPubkeys( - multisigPubkeys?: Uint8Array[], + multisigPubkeys: HexString[], ): Uint8Array[] { - if (!multisigPubkeys) { + if (multisigPubkeys.length < 1 || multisigPubkeys.length > MAX_SIGNERS) { throw new Error( - "multisigPubkeys is required for Solana multisig transactions", - ); - } - - if ( - multisigPubkeys.length < 2 || - multisigPubkeys.length > MAX_MULTISIG_SIGNERS - ) { - throw new Error( - `Invalid multisig signer count: expected 2-${MAX_MULTISIG_SIGNERS} signers, got ${multisigPubkeys.length}`, + `Invalid multisig signer count: expected 1-${MAX_SIGNERS} signers, got ${multisigPubkeys.length}`, ); } const seenPubkeys = new Set(); - for (const pubkey of multisigPubkeys) { + const normalizedPubkeys = multisigPubkeys.map(normalizeHexString); + const pubkeyBytes = normalizedPubkeys.map((pubkey) => hexToBytes(pubkey)); + + for (const pubkey of pubkeyBytes) { if (pubkey.length !== PUBKEY_SIZE) { throw new Error( `Invalid public key length: expected ${PUBKEY_SIZE} bytes, got ${pubkey.length}`, @@ -313,11 +310,11 @@ export class SolanaSignableRollup { seenPubkeys.add(pubkeyHex); } - return [...multisigPubkeys].sort(compareByteArrays); + return pubkeyBytes.sort(compareByteArrays); } /** - * Helper to create and serialize a SolanaOffchainUnsignedTransaction to JSON bytes. + * Helper to create and serialize a SolanaOffchainSigningPayloadV0 to JSON bytes. */ private async createSolanaJsonBytes( unsignedTx: UnsignedTransaction, @@ -326,15 +323,20 @@ export class SolanaSignableRollup { const schema = serializer.schema; const chainName = schema.chain_data.chain_name || ""; - const solanaUnsignedTx: SolanaOffchainUnsignedTransaction = { + const solanaSigningPayload: SolanaOffchainSigningPayloadV0 = { runtime_call: unsignedTx.runtime_call, uniqueness: unsignedTx.uniqueness, details: unsignedTx.details, chain_name: chainName, + ...(unsignedTx.address_override !== null && + unsignedTx.address_override !== undefined && { + address_override: unsignedTx.address_override, + }), + version: 0, }; // JSON serialize the Solana unsigned transaction - return new TextEncoder().encode(JSON.stringify(solanaUnsignedTx)); + return new TextEncoder().encode(JSON.stringify(solanaSigningPayload)); } /** @@ -344,23 +346,25 @@ export class SolanaSignableRollup { private async signWithSolanaSimpleAndSubmit( unsignedTx: UnsignedTransaction, signer: Signer, + options?: SovereignClient.RequestOptions, ): Promise>> { + const chainHash = await this.inner.chainHash(); + refreshChainHashFragment(unsignedTx, chainHash); const jsonBytes = await this.createSolanaJsonBytes(unsignedTx); const pubkey = await signer.publicKey(); - const chainHash = await this.inner.chainHash(); const signature = await signer.sign(jsonBytes); // Build and submit result - const solanaMessage: SolanaOffchainSimpleMessage = { + const solanaMessage: SolanaOffchainSimpleEnvelope = { signed_message: jsonBytes, chain_hash: chainHash, pubkey: pubkey, signature: signature, }; - const response = await this.submitSolanaMessage(solanaMessage); + const response = await this.submitSolanaMessage(solanaMessage, options); return this.buildTransactionResult(response, unsignedTx, pubkey, signature); } @@ -371,11 +375,13 @@ export class SolanaSignableRollup { private async signWithSolanaSpecAndSubmit( unsignedTx: UnsignedTransaction, signer: Signer, + options?: SovereignClient.RequestOptions, ): Promise>> { + const chainHash = await this.inner.chainHash(); + refreshChainHashFragment(unsignedTx, chainHash); const jsonBytes = await this.createSolanaJsonBytes(unsignedTx); const pubkey = await signer.publicKey(); - const chainHash = await this.inner.chainHash(); // Create preamble and combine with message const preamble = createSolanaPreamble( @@ -390,12 +396,12 @@ export class SolanaSignableRollup { const signature = await signer.sign(signedMessageWithPreamble); // Build and submit result - const solanaMessage: SolanaOffchainSpecCompliantMessage = { + const solanaMessage: SolanaOffchainSpecCompliantEnvelope = { signed_message_with_preamble: signedMessageWithPreamble, signature: signature, }; - const response = await this.submitSolanaSpecMessage(solanaMessage); + const response = await this.submitSolanaSpecMessage(solanaMessage, options); return this.buildTransactionResult(response, unsignedTx, pubkey, signature); } @@ -433,18 +439,30 @@ export class SolanaSignableRollup { options, ); case "solanaSimple": { - const unsignedTx = await this.buildUnsignedTransaction( + const unsignedTx = await this.inner.buildUnsignedTransaction( runtimeCall, - params.overrides, + { + overrides: params.overrides, + }, + ); + return this.signWithSolanaSimpleAndSubmit( + unsignedTx, + params.signer, + options, ); - return this.signWithSolanaSimpleAndSubmit(unsignedTx, params.signer); } case "solana": { - const unsignedTx = await this.buildUnsignedTransaction( + const unsignedTx = await this.inner.buildUnsignedTransaction( runtimeCall, - params.overrides, + { + overrides: params.overrides, + }, + ); + return this.signWithSolanaSpecAndSubmit( + unsignedTx, + params.signer, + options, ); - return this.signWithSolanaSpecAndSubmit(unsignedTx, params.signer); } default: throw new Error(`Unsupported authenticator: ${authenticator}`); @@ -479,173 +497,176 @@ export class SolanaSignableRollup { options, ); case "solanaSimple": - return this.signWithSolanaSimpleAndSubmit(unsignedTx, params.signer); + return this.signWithSolanaSimpleAndSubmit( + unsignedTx, + params.signer, + options, + ); case "solana": - return this.signWithSolanaSpecAndSubmit(unsignedTx, params.signer); + return this.signWithSolanaSpecAndSubmit( + unsignedTx, + params.signer, + options, + ); default: throw new Error(`Unsupported authenticator: ${authenticator}`); } } + async buildUnsignedTransaction( + runtimeCall: RuntimeCall, + params?: { overrides?: DeepPartial> }, + ): Promise> { + return this.inner.buildUnsignedTransaction(runtimeCall, params); + } + /** * Creates V1 JSON bytes for a multisig transaction, including multisig_id and version. * The resulting JSON is what each signer signs directly (no discriminator prefix). */ private async createMultisigJsonBytes( unsignedTx: UnsignedTransaction, - multisigAddress: Uint8Array, + multisigId: unknown, ): Promise { const serializer = await this.inner.serializer(); const schema = serializer.schema; const chainName = schema.chain_data.chain_name || ""; - const solanaUnsignedTx: SolanaOffchainUnsignedTransactionV1 = { + // Field order matches the Rust `SolanaOffchainSigningPayloadV1` struct + // (`crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs`): + // `address_override` must sit between `multisig_id` and `version` so TS- and Rust-generated + // JSON bytes are byte-identical; multisig signatures cover these bytes directly. + const solanaSigningPayload: SolanaOffchainSigningPayloadV1 = { runtime_call: unsignedTx.runtime_call, uniqueness: unsignedTx.uniqueness, details: unsignedTx.details, chain_name: chainName, - // Hardcoded to base58 encoding, only correct for rollups using Base58Address as their - // primary address type. Will be replaced with rollup-aware address formatting once the - // SDK supports flexible address encoding (see #2673). - multisig_id: bs58.encode(multisigAddress), + multisig_id: multisigId, + ...(unsignedTx.address_override !== null && + unsignedTx.address_override !== undefined && { + address_override: unsignedTx.address_override, + }), version: 1, }; - return new TextEncoder().encode(JSON.stringify(solanaUnsignedTx)); + return new TextEncoder().encode(JSON.stringify(solanaSigningPayload)); } /** - * Creates the preamble+JSON bytes signed by every signer in a spec-compliant multisig flow. + * Formats the multisig credential in the rollup's configured address format. */ - private async createSpecCompliantMultisigSignedMessage( - unsignedTx: UnsignedTransaction, - multisigAddress: Uint8Array, - multisigPubkeys: Uint8Array[], - ): Promise { - const jsonBytes = await this.createMultisigJsonBytes( - unsignedTx, - multisigAddress, - ); - const chainHash = await this.inner.chainHash(); - const preamble = createSolanaPreamble( - multisigPubkeys, - chainHash, - jsonBytes.length, - ); + private async multisigIdFromMultisig(multisig: Multisig): Promise { + const credentialId = multisig.getMultisigAddress(); + + if (this.inner.context.credentialIdToAddress) { + const serializer = await this.inner.serializer(); + return this.inner.context.credentialIdToAddress( + credentialId, + serializer.schema, + ); + } - return this.combinePreambleAndMessage(preamble, jsonBytes); + return bs58.encode(credentialId); } /** - * Signs an unsigned transaction using Solana offchain simple multisig signing. + * Creates multisig state from a finalized transaction envelope. */ - private async signForSolanaSimpleMultisig( - unsignedTx: UnsignedTransaction, - signer: Signer, - multisigAddress: Uint8Array, - multisigPubkeys: Uint8Array[], - ): Promise> { - const pubkey = await signer.publicKey(); - const signerPubkeyHex = bytesToHex(pubkey); - const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); - - if (!multisigPubkeyHexes.includes(signerPubkeyHex)) { - throw new Error( - `Signer public key ${signerPubkeyHex} is not present in multisigPubkeys`, - ); - } - - const jsonBytes = await this.createMultisigJsonBytes( - unsignedTx, - multisigAddress, - ); + private multisigFromTransaction( + tx: TransactionV1["V1"], + ): Multisig { + return new Multisig({ + signatures: tx.signatures, + unusedPubKeys: tx.unused_pub_keys, + minSigners: tx.min_signers, + }); + } - const signature = await signer.sign(jsonBytes); + private normalizeMultisigTransaction( + tx: TransactionV1["V1"], + ): TransactionV1["V1"] { + return { + ...tx, + signatures: tx.signatures.map((signature) => ({ + signature: normalizeHexString(signature.signature), + pub_key: normalizeHexString(signature.pub_key), + })), + unused_pub_keys: tx.unused_pub_keys.map(normalizeHexString), + }; + } - return this.typeBuilder.transaction({ - unsignedTx, - sender: pubkey, - signature, - rollup: this.inner, - }); + /** + * Extracts the V0-shaped unsigned fields from a finalized multisig transaction. + */ + private unsignedTxFromTransaction( + tx: TransactionV1["V1"], + ): UnsignedTransaction { + return { + runtime_call: tx.runtime_call, + uniqueness: tx.uniqueness, + details: tx.details, + address_override: tx.address_override, + }; } /** - * Signs an unsigned transaction using the spec-compliant multisig preamble. + * Creates the preamble+JSON bytes signed by every signer in a spec-compliant multisig flow. */ - private async signForSolanaSpecMultisig( + private async createSpecCompliantMultisigSignedMessage( unsignedTx: UnsignedTransaction, - signer: Signer, - multisigAddress: Uint8Array, - multisigPubkeys: Uint8Array[], - ): Promise> { - const pubkey = await signer.publicKey(); - const signerPubkeyHex = bytesToHex(pubkey); - const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); - - if (!multisigPubkeyHexes.includes(signerPubkeyHex)) { - throw new Error( - `Signer public key ${signerPubkeyHex} is not present in multisigPubkeys`, - ); - } - - const signedMessageWithPreamble = - await this.createSpecCompliantMultisigSignedMessage( - unsignedTx, - multisigAddress, - multisigPubkeys, - ); - const signature = await signer.sign(signedMessageWithPreamble); - - return this.typeBuilder.transaction({ + multisig: Multisig, + ): Promise { + const chainHash = await this.inner.chainHash(); + assertChainHashFragment(unsignedTx, chainHash); + const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, - sender: pubkey, - signature, - rollup: this.inner, - }); + await this.multisigIdFromMultisig(multisig), + ); + const preamble = createSolanaPreamble( + this.canonicalizeMultisigPubkeys([...multisig.allPubKeys]), + chainHash, + jsonBytes.length, + ); + + return this.combinePreambleAndMessage(preamble, jsonBytes); } - /** - * Signs an unsigned transaction for use in a Solana offchain multisig. - * - * `authenticator: "standard"` delegates directly to the wrapped standard rollup. - * `authenticator: "solanaSimple"` signs the V1 JSON payload directly. - * `authenticator: "solana"` signs the spec-compliant preamble+JSON bytes. - * Solana multisig authenticators treat `multisigPubkeys` as an unordered set and - * canonicalize it before signing. - */ - async signTransactionForMultisig( + async multisigSigningBytes( unsignedTx: UnsignedTransaction, - params: SolanaMultisigSignParams, - ): Promise> { - switch (params.authenticator) { + multisig: Multisig, + authenticator: SubmissionAuthenticator, + ): Promise { + switch (authenticator) { case "standard": - return this.inner.signTransaction(unsignedTx, params.signer); - case "solanaSimple": - return this.signForSolanaSimpleMultisig( + return this.inner.multisigSigningBytes(unsignedTx, multisig); + case "solanaSimple": { + assertChainHashFragment(unsignedTx, await this.inner.chainHash()); + return this.createMultisigJsonBytes( unsignedTx, - params.signer, - params.multisigAddress, - this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + await this.multisigIdFromMultisig(multisig), ); + } case "solana": - return this.signForSolanaSpecMultisig( + return this.createSpecCompliantMultisigSignedMessage( unsignedTx, - params.signer, - params.multisigAddress, - this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + multisig, ); } + + throw new Error(`Unsupported authenticator: ${authenticator}`); } /** - * Builds a spec-compliant multisig envelope from a V1 transaction and ordered multisig pubkeys. + * Builds a spec-compliant multisig envelope from a V1 transaction. */ private buildSpecCompliantMultisigEnvelope( tx: TransactionV1["V1"], signedMessageWithPreamble: Uint8Array, - multisigPubkeys: Uint8Array[], - ): SolanaOffchainSpecCompliantMultisigMessage { + ): SolanaOffchainSpecCompliantMultisigEnvelope { + const multisigPubkeys = this.canonicalizeMultisigPubkeys([ + ...tx.signatures.map((signer) => signer.pub_key), + ...tx.unused_pub_keys, + ]); const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); const indexByPubkey = new Map(); @@ -658,41 +679,46 @@ export class SolanaSignableRollup { let signerBitfield = 0; for (const signer of tx.signatures) { - const signerIndex = indexByPubkey.get(signer.pub_key); + const normalizedSignerPubkey = normalizeHexString(signer.pub_key); + const signerIndex = indexByPubkey.get(normalizedSignerPubkey); if (signerIndex === undefined) { throw new Error( - `Signed pubkey ${signer.pub_key} is not present in multisigPubkeys`, + `Signed pubkey ${normalizedSignerPubkey} is not present in multisigPubkeys`, ); } - if (accountedPubkeys.has(signer.pub_key)) { + if (accountedPubkeys.has(normalizedSignerPubkey)) { throw new Error( - `Duplicate signed pubkey in multisig transaction: ${signer.pub_key}`, + `Duplicate signed pubkey in multisig transaction: ${normalizedSignerPubkey}`, ); } - accountedPubkeys.add(signer.pub_key); - signaturesByIndex.set(signerIndex, hexToBytes(signer.signature)); + accountedPubkeys.add(normalizedSignerPubkey); + signaturesByIndex.set( + signerIndex, + hexToBytes(normalizeHexString(signer.signature)), + ); signerBitfield = (signerBitfield | (1 << signerIndex)) >>> 0; } for (const unusedPubkey of tx.unused_pub_keys) { - if (!indexByPubkey.has(unusedPubkey)) { + const normalizedUnusedPubkey = normalizeHexString(unusedPubkey); + if (!indexByPubkey.has(normalizedUnusedPubkey)) { throw new Error( - `Unused pubkey ${unusedPubkey} is not present in multisigPubkeys`, + `Unused pubkey ${normalizedUnusedPubkey} is not present in multisigPubkeys`, ); } - if (accountedPubkeys.has(unusedPubkey)) { + if (accountedPubkeys.has(normalizedUnusedPubkey)) { throw new Error( - `Duplicate pubkey in multisig transaction payload: ${unusedPubkey}`, + `Duplicate pubkey in multisig transaction payload: ${normalizedUnusedPubkey}`, ); } - accountedPubkeys.add(unusedPubkey); + accountedPubkeys.add(normalizedUnusedPubkey); } if (accountedPubkeys.size !== multisigPubkeys.length) { throw new Error( - "multisigPubkeys must contain every signer and unused pubkey exactly once", + "Multisig transaction payload must contain every signer exactly once", ); } @@ -709,117 +735,122 @@ export class SolanaSignableRollup { } /** - * Submits a multisig transaction using the Solana offchain simple multisig format. - * - * Accepts the V1 transaction produced by `MultisigTransaction.asTransaction()`. - * `authenticator: "standard"` delegates directly to the wrapped standard rollup. - * Solana authenticators rebuild the appropriate Solana multisig envelope and - * submit it to the Solana offchain endpoint. + * Submits a finalized V1 multisig transaction using a Solana authenticator. */ - async submitMultisigTransaction( - multisigTx: TransactionV1, - params: SolanaMultisigSubmitParams, + private async submitMultisigTransactionWithAuthenticator( + transaction: TransactionV1, + authenticator: Exclude, + options?: SovereignClient.RequestOptions, ): Promise { - const { V1: tx } = multisigTx; + const finalizedTx = { + V1: this.normalizeMultisigTransaction(transaction.V1), + } as TransactionV1; + const multisig = this.multisigFromTransaction(finalizedTx.V1); - const unsignedTx: UnsignedTransaction = { - runtime_call: tx.runtime_call, - uniqueness: tx.uniqueness, - details: tx.details, - }; + if (!multisig.isComplete) { + throw new Error("Multisig transaction is incomplete"); + } - switch (params.authenticator) { - case "standard": - return this.inner.submitTransaction( - multisigTx as StandardRollupSpec["Transaction"], - ); - case "solanaSimple": { - this.canonicalizeMultisigPubkeys(params.multisigPubkeys); + const unsignedTx = this.unsignedTxFromTransaction(finalizedTx.V1); + switch (authenticator) { + case "solanaSimple": { + const chainHash = await this.inner.chainHash(); + assertChainHashFragment(unsignedTx, chainHash); const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, - params.multisigAddress, + await this.multisigIdFromMultisig(multisig), ); const wireBytes = new Uint8Array(1 + jsonBytes.length); wireBytes[0] = MULTISIG_SIMPLE_DISCRIMINATOR; wireBytes.set(jsonBytes, 1); - const chainHash = await this.inner.chainHash(); const serialized = this.serializeSolanaMultisigMessage({ wire_bytes: wireBytes, chain_hash: chainHash, - signatures: tx.signatures.map((s) => ({ + signatures: finalizedTx.V1.signatures.map((s) => ({ signature: hexToBytes(s.signature), pub_key: hexToBytes(s.pub_key), })), - unused_pub_keys: tx.unused_pub_keys.map((pk) => hexToBytes(pk)), - min_signers: tx.min_signers, + unused_pub_keys: finalizedTx.V1.unused_pub_keys.map((pk) => + hexToBytes(pk), + ), + min_signers: finalizedTx.V1.min_signers, }); - return this.submitSerializedMessage(serialized); + return this.submitSerializedMessage(serialized, options); } case "solana": { - const multisigPubkeys = this.canonicalizeMultisigPubkeys( - params.multisigPubkeys, - ); const signedMessageWithPreamble = await this.createSpecCompliantMultisigSignedMessage( unsignedTx, - params.multisigAddress, - multisigPubkeys, + multisig, ); const message = this.buildSpecCompliantMultisigEnvelope( - tx, + finalizedTx.V1, signedMessageWithPreamble, - multisigPubkeys, ); - return this.submitSolanaSpecMultisigMessage(message); + return this.submitSolanaSpecMultisigMessage(message, options); } } } /** - * Submits a standard transaction. + * Submits a finalized V0 transaction using a Solana authenticator. */ - async submitTransaction( - transaction: StandardRollupSpec["Transaction"], - authenticator: "standard", + private async submitSingleSignatureTransactionWithAuthenticator( + transaction: Extract, { V0: unknown }>["V0"], + authenticator: Exclude, options?: SovereignClient.RequestOptions, - ): Promise; - - /** - * Submits a Solana offchain message. - */ - async submitTransaction( - transaction: SolanaOffchainSimpleMessage, - authenticator: "solanaSimple", - options?: SovereignClient.RequestOptions, - ): Promise; + ): Promise { + const unsignedTx: UnsignedTransaction = { + runtime_call: transaction.runtime_call, + uniqueness: transaction.uniqueness, + details: transaction.details, + address_override: transaction.address_override, + }; + const pubkey = hexToBytes(normalizeHexString(transaction.pub_key)); + const signature = hexToBytes(normalizeHexString(transaction.signature)); + const chainHash = await this.inner.chainHash(); + assertChainHashFragment(unsignedTx, chainHash); - /** - * Submits a Solana spec-compliant message. - */ - async submitTransaction( - transaction: SolanaOffchainSpecCompliantMessage, - authenticator: "solana", - options?: SovereignClient.RequestOptions, - ): Promise; + switch (authenticator) { + case "solanaSimple": { + const signedMessage = await this.createSolanaJsonBytes(unsignedTx); + const message: SolanaOffchainSimpleEnvelope = { + signed_message: signedMessage, + chain_hash: chainHash, + pubkey, + signature, + }; + return this.submitSolanaMessage(message, options); + } + case "solana": { + const signedMessage = await this.createSolanaJsonBytes(unsignedTx); + const preamble = createSolanaPreamble( + [pubkey], + chainHash, + signedMessage.length, + ); + const message: SolanaOffchainSpecCompliantEnvelope = { + signed_message_with_preamble: this.combinePreambleAndMessage( + preamble, + signedMessage, + ), + signature, + }; + return this.submitSolanaSpecMessage(message, options); + } + } + } /** * Submits a transaction with the specified authenticator. - * - * @param transaction - Either a standard transaction or a Solana message - * @param authenticator - The authenticator type to use - * @param options - Optional request options - * @returns The transaction response */ async submitTransaction( - transaction: - | StandardRollupSpec["Transaction"] - | SolanaOffchainSimpleMessage - | SolanaOffchainSpecCompliantMessage, - authenticator: Authenticator, + transaction: Transaction, + authenticator: SubmissionAuthenticator, options?: SovereignClient.RequestOptions, ): Promise { switch (authenticator) { @@ -828,19 +859,35 @@ export class SolanaSignableRollup { transaction as StandardRollupSpec["Transaction"], options, ); - case "solanaSimple": { - // For Solana simple, we expect a SolanaOffchainSimpleMessage - const solanaMessage = transaction as SolanaOffchainSimpleMessage; - return await this.submitSolanaMessage(solanaMessage); - } - case "solana": { - // For Solana spec-compliant, we expect a SolanaOffchainSpecCompliantMessage - const solanaMessage = transaction as SolanaOffchainSpecCompliantMessage; - return await this.submitSolanaSpecMessage(solanaMessage); - } - default: - throw new Error(`Unsupported authenticator: ${authenticator}`); + case "solanaSimple": + if ("V1" in transaction) { + return this.submitMultisigTransactionWithAuthenticator( + transaction, + "solanaSimple", + options, + ); + } + return this.submitSingleSignatureTransactionWithAuthenticator( + transaction.V0, + "solanaSimple", + options, + ); + case "solana": + if ("V1" in transaction) { + return this.submitMultisigTransactionWithAuthenticator( + transaction, + "solana", + options, + ); + } + return this.submitSingleSignatureTransactionWithAuthenticator( + transaction.V0, + "solana", + options, + ); } + + throw new Error(`Unsupported authenticator: ${authenticator}`); } async simulate( @@ -854,6 +901,10 @@ export class SolanaSignableRollup { return this.inner.dedup(address); } + async dedupByCredentialId(credentialId: HexString) { + return this.inner.dedupByCredentialId(credentialId); + } + async serializer() { return this.inner.serializer(); } @@ -902,10 +953,10 @@ export class SolanaSignableRollup { } /** - * Helper method to serialize a SolanaOffchainSimpleMessage using borsh encoding. + * Helper method to serialize a SolanaOffchainSimpleEnvelope using borsh encoding. */ private serializeSolanaMessage( - message: SolanaOffchainSimpleMessage, + message: SolanaOffchainSimpleEnvelope, ): Uint8Array { // Validate message field lengths if (message.chain_hash.length !== CHAIN_HASH_SIZE) { @@ -959,10 +1010,10 @@ export class SolanaSignableRollup { } /** - * Helper method to serialize a SolanaOffchainSpecCompliantMessage using borsh encoding. + * Helper method to serialize a SolanaOffchainSpecCompliantEnvelope using borsh encoding. */ private serializeSolanaSpecMessage( - message: SolanaOffchainSpecCompliantMessage, + message: SolanaOffchainSpecCompliantEnvelope, ): Uint8Array { // Validate signature length if (message.signature.length !== SIGNATURE_SIZE) { @@ -994,7 +1045,7 @@ export class SolanaSignableRollup { } /** - * Serializes a SolanaOffchainSimpleMultisigMessage using borsh encoding. + * Serializes a SolanaOffchainSimpleMultisigEnvelope using borsh encoding. * Layout matches the Rust struct: * [u32 LE: wire_bytes.len][wire_bytes] * [32 bytes: chain_hash] @@ -1005,7 +1056,7 @@ export class SolanaSignableRollup { * [u8: min_signers] */ private serializeSolanaMultisigMessage( - message: SolanaOffchainSimpleMultisigMessage, + message: SolanaOffchainSimpleMultisigEnvelope, ): Uint8Array { if (message.chain_hash.length !== CHAIN_HASH_SIZE) { throw new Error( @@ -1080,7 +1131,7 @@ export class SolanaSignableRollup { } /** - * Serializes a SolanaOffchainSpecCompliantMultisigMessage using borsh encoding. + * Serializes a SolanaOffchainSpecCompliantMultisigEnvelope using borsh encoding. * Layout matches the Rust struct: * [u32 LE: signed_message_with_preamble.len][signed_message_with_preamble] * [u32 LE: signatures.len] @@ -1089,7 +1140,7 @@ export class SolanaSignableRollup { * [u8: min_signers] */ private serializeSolanaSpecMultisigMessage( - message: SolanaOffchainSpecCompliantMultisigMessage, + message: SolanaOffchainSpecCompliantMultisigEnvelope, ): Uint8Array { const sigCount = message.signatures.length; diff --git a/typescript/packages/web3/src/rollup/standard-rollup.test.ts b/typescript/packages/web3/src/rollup/standard-rollup.test.ts index 307ae1344e..d53fb5c2b2 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.test.ts @@ -1,12 +1,28 @@ import SovereignClient from "@sovereign-sdk/client"; +import { Multisig } from "@sovereign-sdk/multisig"; import type { RollupSchema, Serializer } from "@sovereign-sdk/serializers"; +import { bytesToHex } from "@sovereign-sdk/utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { addressFromPublicKey } from "../addresses"; +import { VersionMismatchError } from "../errors"; import { + DEFAULT_TX_DETAILS, StandardRollup, + chainHashFragment, createStandardRollup, standardTypeBuilder, } from "./standard-rollup"; +describe("chainHashFragment", () => { + it("reads the first eight bytes as a little-endian u64", () => { + const backing = Uint8Array.from([255, 1, 2, 3, 4, 5, 6, 7, 8, 255]); + + expect(chainHashFragment(backing.subarray(1, 9))).toBe( + "578437695752307201", + ); + }); +}); + describe("standardTypeBuilder", () => { const mockRollup = { dedup: vi.fn().mockResolvedValue({ nonce: 5 }), @@ -17,7 +33,7 @@ describe("standardTypeBuilder", () => { defaultTxDetails: { max_priority_fee_bips: 100, max_fee: "1000", - chain_id: 1, + chain_hash_fragment: "1", }, }, rollup: { @@ -52,8 +68,9 @@ describe("standardTypeBuilder", () => { details: { max_priority_fee_bips: 100, max_fee: "1000", - chain_id: 1, + chain_hash_fragment: "1", }, + address_override: null, }); }); @@ -72,8 +89,9 @@ describe("standardTypeBuilder", () => { details: { max_priority_fee_bips: 100, max_fee: "1000", - chain_id: 1, + chain_hash_fragment: "1", }, + address_override: null, }); }); @@ -98,8 +116,9 @@ describe("standardTypeBuilder", () => { max_priority_fee_bips: 100, max_fee: "2000", gas_limit: [1000000, 1000000], - chain_id: 1, + chain_hash_fragment: "1", }, + address_override: null, }); }); }); @@ -115,9 +134,10 @@ describe("standardTypeBuilder", () => { details: { max_priority_fee_bips: 100, max_fee: "1000", - chain_id: 1, + chain_hash_fragment: "1", gas_limit: null, }, + address_override: null, }, sender: new Uint8Array([4, 5, 6]), signature: new Uint8Array([7, 8, 9]), @@ -135,11 +155,48 @@ describe("standardTypeBuilder", () => { details: { max_priority_fee_bips: 100, max_fee: "1000", - chain_id: 1, + chain_hash_fragment: "1", gas_limit: null, }, + address_override: null, + }, + }); + }); + }); + + describe("transactionSigningPayload", () => { + it("should format a V0 signing payload with the provided chain hash", async () => { + const unsignedTx = { + runtime_call: { + value_setter: { set_value: { value: 5, gas: null } }, + }, + uniqueness: { generation: 5 }, + details: { + max_priority_fee_bips: 100, + max_fee: "1000", + chain_hash_fragment: "1", + gas_limit: null, + }, + address_override: null, + }; + + const result = await builder.transactionSigningPayload({ + unsignedTx, + chainHash: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + rollup: mockRollup as any, + }); + + expect(result).toEqual({ + V0: { + ...unsignedTx, + details: { + ...unsignedTx.details, + chain_hash_fragment: "578437695752307201", + }, + chain_hash: [1, 2, 3, 4, 5, 6, 7, 8], }, }); + expect(unsignedTx.details.chain_hash_fragment).toBe("578437695752307201"); }); }); }); @@ -147,7 +204,7 @@ describe("standardTypeBuilder", () => { const mockSerializer = { serialize: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3])), serializeRuntimeCall: vi.fn().mockReturnValue(new Uint8Array([4, 5, 6])), - serializeUnsignedTx: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), + serializeSigningPayload: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), serializeTx: vi.fn().mockReturnValue(new Uint8Array([10, 11, 12])), schema: { chainHash: new Uint8Array([1, 2, 3, 4]) } as any, }; @@ -155,6 +212,26 @@ const mockSerializer = { const getSerializer = (_schema: RollupSchema) => mockSerializer as unknown as Serializer; +function createMockStandardClient() { + const client = new SovereignClient({ fetch: vi.fn() }); + const chainHash = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + + client.rollup = { + constants: vi.fn().mockResolvedValue({ chain_id: 1 }), + schema: vi.fn().mockResolvedValue({ + schema: { chain_data: { chain_id: 1, chain_name: "TestChain" } }, + chain_hash: chainHash, + }), + addresses: { + dedup: vi.fn().mockResolvedValue({ nonce: 5 }), + }, + } as any; + client.post = vi.fn().mockResolvedValue({ status: "submitted" }); + + return client; +} + describe("createStandardRollup", () => { const mockConfig = { client: new SovereignClient({ fetch: vi.fn() }), @@ -163,7 +240,7 @@ describe("createStandardRollup", () => { defaultTxDetails: { max_priority_fee_bips: 100, max_fee: "1000", - chain_id: 1, + chain_hash_fragment: "1", gas_limit: null, }, }, @@ -212,12 +289,18 @@ describe("createStandardRollup", () => { expect(typeBuilder.unsignedTransaction).toBe(customUnsignedTransaction); expect(typeBuilder.transaction).toBeDefined(); expect(typeof typeBuilder.transaction).toBe("function"); + expect(typeBuilder.transactionSigningPayload).toBeDefined(); + expect(typeof typeBuilder.transactionSigningPayload).toBe("function"); }); it("should be created using the default context", async () => { mockConfig.client.rollup.constants = vi .fn() .mockResolvedValue({ chain_id: 55 }); + mockConfig.client.rollup.schema = vi.fn().mockResolvedValue({ + chain_hash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + }); const rollup = await createStandardRollup({ ...mockConfig, context: undefined, @@ -227,9 +310,12 @@ describe("createStandardRollup", () => { max_priority_fee_bips: 0, max_fee: "100000000", gas_limit: null, - chain_id: 55, + chain_hash_fragment: "0", }, }); + await rollup.serializer(); + await rollup.chainHash(); + expect(mockConfig.client.rollup.schema).toHaveBeenCalledTimes(1); }); it("should preserve supplied context and merge default context", async () => { @@ -241,7 +327,7 @@ describe("createStandardRollup", () => { context: { defaultTxDetails: { max_priority_fee_bips: 5, - chain_id: 1, + chain_hash_fragment: "1", }, }, }); @@ -250,8 +336,305 @@ describe("createStandardRollup", () => { max_priority_fee_bips: 5, max_fee: "100000000", gas_limit: null, - chain_id: 1, + chain_hash_fragment: "1", + }, + }); + }); + + it("should refresh default transaction details after a chain hash change", async () => { + const client = createMockStandardClient(); + const oldChainHash = `0x${"00".repeat(32)}`; + const newChainHash = `0x01${"00".repeat(31)}`; + client.rollup.schema = vi + .fn() + .mockResolvedValueOnce({ + schema: { chain_data: { chain_name: "TestChain" } }, + chain_hash: oldChainHash, + }) + .mockResolvedValueOnce({ + schema: { chain_data: { chain_name: "TestChain" } }, + chain_hash: newChainHash, + }); + client.post = vi.fn().mockRejectedValue({ + error: { + details: { + code: "invalid_chain_hash_fragment", + error: "Authentication failed", + }, + }, + }); + const rollup = await createStandardRollup({ + client, + getSerializer, + context: { + defaultTxDetails: { + ...DEFAULT_TX_DETAILS, + chain_hash_fragment: "0", + }, + }, + }); + + await rollup.chainHash(); + await expect( + rollup.submitTransaction({ foo: "bar" } as any), + ).rejects.toThrow(VersionMismatchError); + + const unsignedTx = await rollup.buildUnsignedTransaction({ test: "call" }); + expect(unsignedTx.details.chain_hash_fragment).toBe("1"); + }); + + it("should refresh default transaction details when rehydrated", async () => { + const client = createMockStandardClient(); + client.rollup.schema = vi.fn().mockResolvedValue({ + schema: { chain_data: { chain_name: "TestChain" } }, + chain_hash: `0x02${"00".repeat(31)}`, + }); + const rollup = await createStandardRollup({ + client, + getSerializer, + context: { + defaultTxDetails: { + ...DEFAULT_TX_DETAILS, + chain_hash_fragment: "0", + }, + }, + }); + + await rollup.hydrate(); + + expect(rollup.context.defaultTxDetails.chain_hash_fragment).toBe("2"); + expect(client.rollup.schema).toHaveBeenCalledTimes(1); + }); + + it("should pass optional simulation parameters to the client", async () => { + const client = new SovereignClient({ fetch: vi.fn() }); + client.rollup.simulate = vi.fn().mockResolvedValue({ outcome: "success" }); + const rollup = await createStandardRollup({ + ...mockConfig, + client, + }); + const signer = { + publicKey: vi.fn().mockResolvedValue(new Uint8Array([0xab, 0xcd])), + }; + const runtimeCall = { + bank: { + transfer: { + to: "receiver", + coins: { amount: "1", token_id: "token" }, + }, + }, + }; + const txDetails = { + max_fee: "1234", + }; + + await rollup.simulate(runtimeCall, { + signer: signer as any, + address_override: "sov1target", + tx_details: txDetails, + uniqueness: { nonce: 7 }, + }); + + expect(client.rollup.simulate).toHaveBeenCalledWith({ + sender: "abcd", + call: runtimeCall, + address_override: "sov1target", + tx_details: txDetails, + uniqueness: { nonce: 7 }, + }); + }); + + it("should serialize V0 unsigned transactions as versioned envelopes when signing", async () => { + const client = createMockStandardClient(); + const serializer = { + ...mockSerializer, + serializeSigningPayload: vi + .fn() + .mockReturnValue(new Uint8Array([7, 8, 9])), + }; + const rollup = await createStandardRollup({ + client, + getSerializer: () => serializer as unknown as Serializer, + context: mockConfig.context, + }); + const signer = { + sign: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + publicKey: vi.fn().mockResolvedValue(new Uint8Array([4, 5, 6])), + }; + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: mockConfig.context.defaultTxDetails, + address_override: null, + }; + + const tx = await rollup.signTransaction(unsignedTx, signer as any); + const expectedUnsignedTx = { + ...unsignedTx, + details: { + ...unsignedTx.details, + chain_hash_fragment: "0", }, + }; + + expect(serializer.serializeSigningPayload).toHaveBeenCalledWith({ + V0: { + ...expectedUnsignedTx, + chain_hash: new Array(32).fill(0), + }, + }); + expect(tx).toEqual({ + V0: { + pub_key: "040506", + signature: "010203", + ...expectedUnsignedTx, + }, + }); + expect(unsignedTx.details.chain_hash_fragment).toBe("0"); + }); + + it("should fetch dedup data directly by credential id", async () => { + const client = createMockStandardClient(); + const dedup = vi.fn().mockResolvedValue({ nonce: 9 }); + client.rollup.addresses = { dedup } as any; + const rollup = await createStandardRollup({ + client, + getSerializer, + context: mockConfig.context, + }); + + await expect(rollup.dedupByCredentialId("aabb")).resolves.toEqual({ + nonce: 9, }); + expect(dedup).toHaveBeenCalledWith("aabb"); + }); + + it("should create multisig signing bytes and finalize via Multisig.toTransaction()", async () => { + const client = createMockStandardClient(); + const serializer = { + ...mockSerializer, + serializeSigningPayload: vi + .fn() + .mockReturnValue(new Uint8Array([7, 8, 9])), + }; + const rollup = await createStandardRollup({ + client, + getSerializer: () => serializer as unknown as Serializer, + context: mockConfig.context, + }); + const signerPublicKey = new Uint8Array(32).fill(7); + const signer = { + sign: vi.fn().mockResolvedValue(new Uint8Array(64).fill(8)), + publicKey: vi.fn().mockResolvedValue(signerPublicKey), + }; + const otherPublicKey = new Uint8Array(32).fill(9); + const multisig = Multisig.fromPubKeys( + [bytesToHex(signerPublicKey), bytesToHex(otherPublicKey)], + 1, + ); + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: { + ...mockConfig.context.defaultTxDetails, + chain_hash_fragment: "0", + }, + address_override: null, + }; + const originalDetails = unsignedTx.details; + const originalUnsignedTx = { + ...unsignedTx, + details: { ...unsignedTx.details }, + }; + + const signingBytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + ); + + expect(serializer.serializeSigningPayload).toHaveBeenCalledWith({ + V1: { + ...unsignedTx, + chain_hash: new Array(32).fill(0), + credential_address: addressFromPublicKey( + multisig.getMultisigAddress(), + "sov", + ), + }, + }); + expect(signingBytes).toEqual(new Uint8Array([7, 8, 9])); + expect(unsignedTx).toEqual(originalUnsignedTx); + expect(unsignedTx.details).toBe(originalDetails); + + const signatureBytes = await signer.sign(signingBytes); + const signature = { + pub_key: bytesToHex(signerPublicKey), + signature: bytesToHex(signatureBytes), + }; + multisig.addSignature(signature.signature, signature.pub_key); + + expect(multisig.toTransaction(unsignedTx)).toEqual({ + V1: { + ...unsignedTx, + signatures: [signature], + unused_pub_keys: [bytesToHex(otherPublicKey)], + min_signers: 1, + }, + }); + }); + + it("should reject multisig signing when the chain hash fragment is stale without mutation", async () => { + const client = createMockStandardClient(); + const rollup = await createStandardRollup({ + client, + getSerializer, + context: mockConfig.context, + }); + const multisig = Multisig.fromPubKeys( + [ + bytesToHex(new Uint8Array(32).fill(1)), + bytesToHex(new Uint8Array(32).fill(2)), + ], + 1, + ); + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: { ...mockConfig.context.defaultTxDetails }, + address_override: null, + }; + const originalDetails = unsignedTx.details; + const originalUnsignedTx = { + ...unsignedTx, + details: { ...unsignedTx.details }, + }; + + await expect( + rollup.multisigSigningBytes(unsignedTx, multisig), + ).rejects.toThrow( + "Cannot sign transaction: chain_hash_fragment 1 does not match the current chain hash fragment 0", + ); + expect(unsignedTx).toEqual(originalUnsignedTx); + expect(unsignedTx.details).toBe(originalDetails); + }); + + it("should fail fast when converting an incomplete multisig transaction", () => { + const multisig = Multisig.fromPubKeys( + [ + bytesToHex(new Uint8Array(32).fill(1)), + bytesToHex(new Uint8Array(32).fill(2)), + ], + 2, + ); + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: mockConfig.context.defaultTxDetails, + address_override: null, + }; + + expect(() => multisig.toTransaction(unsignedTx)).toThrow( + "Multisig transaction is incomplete", + ); }); }); diff --git a/typescript/packages/web3/src/rollup/standard-rollup.ts b/typescript/packages/web3/src/rollup/standard-rollup.ts index ef16cadca2..e094f46399 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.ts @@ -1,31 +1,46 @@ import SovereignClient from "@sovereign-sdk/client"; +import type { Multisig } from "@sovereign-sdk/multisig"; import { JsSerializer } from "@sovereign-sdk/serializers"; import type { Transaction, + TransactionSigningPayload, TxDetails, UnsignedTransaction, } from "@sovereign-sdk/types"; import { bytesToHex } from "@sovereign-sdk/utils"; +import { addressFromPublicKey } from "../addresses"; +import { VersionMismatchError } from "../errors"; import type { DeepPartial } from "../utils"; import { + assertChainHashFragment, + chainHashFragment, + refreshChainHashFragment, +} from "./chain-hash"; +import { + type CredentialIdToAddress, Rollup, type RollupConfig, type SignerParams, type TransactionContext, + type TransactionSigningPayloadContext, type TypeBuilder, type UnsignedTransactionContext, } from "./rollup"; +export { chainHashFragment } from "./chain-hash"; + export type Dedup = { nonce: number; }; export type StandardRollupContext = { defaultTxDetails: TxDetails; + credentialIdToAddress?: CredentialIdToAddress; }; export type StandardRollupSpec = { UnsignedTransaction: UnsignedTransaction; + TransactionSigningPayload: TransactionSigningPayload; Transaction: Transaction; RuntimeCall: RuntimeCall; Dedup: Dedup; @@ -52,7 +67,9 @@ export function standardTypeBuilder< context: UnsignedTransactionContext, ) { const { rollup, runtimeCall } = context; - const { uniqueness: _, ...overrides } = context.overrides; + const overrides = context.overrides as DeepPartial< + UnsignedTransaction + > & { address_override?: string | null }; const uniqueness = await useOrFetchUniqueness(context); const details: TxDetails = { ...rollup.context.defaultTxDetails, @@ -63,6 +80,7 @@ export function standardTypeBuilder< runtime_call: runtimeCall, uniqueness, details, + address_override: overrides.address_override ?? null, } as S["UnsignedTransaction"]; }, async transaction({ @@ -76,24 +94,105 @@ export function standardTypeBuilder< signature: bytesToHex(signature), ...unsignedTx, }, - }; + } as S["Transaction"]; + }, + async transactionSigningPayload({ + unsignedTx, + chainHash, + }: TransactionSigningPayloadContext) { + const normalizedUnsignedTx = refreshChainHashFragment( + unsignedTx, + chainHash, + ); + + return { + V0: { + ...normalizedUnsignedTx, + chain_hash: Array.from(chainHash), + }, + } as S["TransactionSigningPayload"]; }, }; } /** * The parameters for simulating a runtime call transaction. + * + * Adds `address_override` until `@sovereign-sdk/client` is republished with it. + * As of `0.1.0-alpha.39` the generated `RollupSimulateParams` is missing the + * field even though the Rust type and OpenAPI spec ship it (added in commit + * `6e7d22a52`). Once a newer client publishes `address_override` natively, + * drop this `Omit`-extension AND the `as SovereignClient.RollupSimulateParams` + * cast in `simulate()`. */ export type SimulateParams = Omit< SovereignClient.RollupSimulateParams, "call" | "sender" > & - SignerParams; + SignerParams & { address_override?: string | null }; export class StandardRollup extends Rollup< StandardRollupSpec, StandardRollupContext > { + private updateDefaultChainHashFragment(chainHash: Uint8Array): void { + this.context.defaultTxDetails.chain_hash_fragment = + chainHashFragment(chainHash); + } + + private async credentialAddressFromId( + credentialId: Uint8Array, + ): Promise { + if (this.context.credentialIdToAddress) { + const serializer = await this.serializer(); + return this.context.credentialIdToAddress( + credentialId, + serializer.schema, + ); + } + + return addressFromPublicKey(credentialId, "sov"); + } + + async submitTransaction( + transaction: StandardRollupSpec["Transaction"], + options?: SovereignClient.RequestOptions, + ): Promise { + try { + return await super.submitTransaction(transaction, options); + } catch (error) { + if (error instanceof VersionMismatchError) { + this.updateDefaultChainHashFragment(await super.chainHash()); + } + throw error; + } + } + + async hydrate(): Promise { + await super.hydrate(); + this.updateDefaultChainHashFragment(await super.chainHash()); + } + + async multisigSigningBytes( + unsignedTx: UnsignedTransaction, + multisig: Multisig, + ): Promise { + const serializer = await this.serializer(); + const chainHash = await this.chainHash(); + assertChainHashFragment(unsignedTx, chainHash); + const signingPayload: TransactionSigningPayload = { + V1: { + ...unsignedTx, + chain_hash: Array.from(chainHash), + credential_address: await this.credentialAddressFromId( + multisig.getMultisigAddress(), + ), + }, + }; + + return serializer.serializeSigningPayload(signingPayload); + } + /** * Simulates a runtime call transaction. * @@ -103,39 +202,41 @@ export class StandardRollup extends Rollup< */ async simulate( runtimeMessage: StandardRollupSpec["RuntimeCall"], - { signer }: SimulateParams, + { signer, ...params }: SimulateParams, ): Promise { const publicKey = await signer.publicKey(); const sender = bytesToHex(publicKey); const call = runtimeMessage as { [key: string]: unknown }; - return this.rollup.simulate({ sender, call }); + // Cast bridges the `address_override` extension; see SimulateParams JSDoc. + return this.rollup.simulate({ + ...params, + sender, + call, + } as SovereignClient.RollupSimulateParams); } } -export const DEFAULT_TX_DETAILS: Omit = { +export const DEFAULT_TX_DETAILS: Omit = { max_priority_fee_bips: 0, max_fee: "100000000", gas_limit: null, }; -async function buildContext( - client: SovereignClient, +function buildContext( context?: DeepPartial, -): Promise { + credentialIdToAddress?: CredentialIdToAddress, +): C { const defaultTxDetails = { ...DEFAULT_TX_DETAILS, ...context?.defaultTxDetails, }; - if (!defaultTxDetails.chain_id) { - const { chain_id } = await client.rollup.constants(); - - defaultTxDetails.chain_id = chain_id; - } - return { + ...context, defaultTxDetails, + credentialIdToAddress: + credentialIdToAddress ?? context?.credentialIdToAddress, } as C; } @@ -152,12 +253,12 @@ export async function createStandardRollup< const client = config.client ?? new SovereignClient({ baseURL: config.url }); const getSerializer = config.getSerializer ?? ((schema) => new JsSerializer(schema)); - const context = await buildContext(client, config.context); + const context = buildContext(config.context, config.credentialIdToAddress); // Default to the standard transaction submission endpoint const txSubmissionEndpoint = config.txSubmissionEndpoint ?? "/sequencer/txs"; - return new StandardRollup( + const rollup = new StandardRollup( { ...config, client, @@ -170,4 +271,10 @@ export async function createStandardRollup< ...typeBuilderOverrides, }, ); + + if (!context.defaultTxDetails.chain_hash_fragment) { + await rollup.hydrate(); + } + + return rollup; } diff --git a/typescript/packages/web3/src/type-spec.ts b/typescript/packages/web3/src/type-spec.ts index 0968a51219..5649503db8 100644 --- a/typescript/packages/web3/src/type-spec.ts +++ b/typescript/packages/web3/src/type-spec.ts @@ -17,6 +17,12 @@ export type BaseTypeSpec = { // biome-ignore lint/suspicious/noExplicitAny: base spec, allow any as default to be overriden UnsignedTransaction: any; + /** + * The type of transaction signing payload used in the rollup. + */ + // biome-ignore lint/suspicious/noExplicitAny: base spec, allow any as default to be overriden + TransactionSigningPayload: any; + /** * The type of runtime call used in the rollup. */ diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 3e73930d1b..ccc2e9a8f6 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -203,9 +203,15 @@ importers: '@noble/hashes': specifier: ^1.5.0 version: 1.5.0 + '@sovereign-sdk/multisig': + specifier: workspace:^ + version: link:../multisig '@sovereign-sdk/signers': specifier: workspace:^ version: link:../signers + '@sovereign-sdk/utils': + specifier: workspace:^ + version: link:../utils '@sovereign-sdk/web3': specifier: workspace:^ version: link:../web3 @@ -258,9 +264,6 @@ importers: '@noble/hashes': specifier: ^1.5.0 version: 1.8.0 - '@sovereign-sdk/signers': - specifier: workspace:^ - version: link:../signers '@sovereign-sdk/utils': specifier: workspace:^ version: link:../utils @@ -271,9 +274,6 @@ importers: '@sovereign-sdk/types': specifier: workspace:^ version: link:../types - '@sovereign-sdk/web3': - specifier: workspace:^ - version: link:../web3 tsup: specifier: ^8.3.0 version: 8.3.0(@swc/core@1.7.42(@swc/helpers@0.5.17))(postcss@8.5.3)(tsx@4.20.3)(typescript@5.6.3)(yaml@2.7.1) @@ -432,6 +432,9 @@ importers: '@sovereign-sdk/client': specifier: 0.1.0-alpha.39 version: 0.1.0-alpha.39 + '@sovereign-sdk/multisig': + specifier: workspace:^ + version: link:../multisig '@sovereign-sdk/serializers': specifier: workspace:^ version: link:../serializers