diff --git a/Cargo.lock b/Cargo.lock index 9e998a56..ae71b4e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7142,6 +7142,7 @@ dependencies = [ "parity-scale-codec", "qp-high-security", "qp-scheduler", + "qp-wormhole", "scale-info", "sp-arithmetic", "sp-core", @@ -7256,6 +7257,7 @@ dependencies = [ "parity-scale-codec", "qp-high-security", "qp-scheduler", + "qp-wormhole", "scale-info", "sp-arithmetic", "sp-core", @@ -9051,6 +9053,13 @@ dependencies = [ [[package]] name = "qp-wormhole" version = "0.1.0" +dependencies = [ + "parity-scale-codec", + "qp-poseidon", + "sp-consensus-pow", + "sp-core", + "sp-runtime", +] [[package]] name = "qp-wormhole-aggregator" diff --git a/client/consensus/qpow/src/worker.rs b/client/consensus/qpow/src/worker.rs index 15834b47..f795b20c 100644 --- a/client/consensus/qpow/src/worker.rs +++ b/client/consensus/qpow/src/worker.rs @@ -139,28 +139,51 @@ where /// /// Returns `true` if the seal is valid for the current block, `false` otherwise. /// Returns `false` if there's no current build. + /// Logs detailed information on failure for debugging. pub fn verify_seal(&self, seal: &Seal) -> bool { let build = self.build.lock(); let build = match build.as_ref() { Some(b) => b, - None => return false, + None => { + warn!(target: LOG_TARGET, "verify_seal: No current build available"); + return false; + }, }; // Convert seal to nonce [u8; 64] let nonce: [u8; 64] = match seal.as_slice().try_into() { Ok(arr) => arr, Err(_) => { - warn!(target: LOG_TARGET, "Seal does not have exactly 64 bytes"); + warn!(target: LOG_TARGET, "Seal does not have exactly 64 bytes, got {}", seal.len()); return false; }, }; let pre_hash = build.metadata.pre_hash.0; let best_hash = build.metadata.best_hash; + let difficulty = build.metadata.difficulty; + let extrinsic_count = build.proposal.block.extrinsics().len(); // Verify using runtime API match self.client.runtime_api().verify_nonce_local_mining(best_hash, pre_hash, nonce) { - Ok(valid) => valid, + Ok(true) => true, + Ok(false) => { + log::error!( + target: LOG_TARGET, + "verify_seal FAILED:\n\ + pre_hash (block template): {}\n\ + best_hash (parent block): {}\n\ + difficulty: {}\n\ + nonce (seal): {}\n\ + extrinsics in block: {}", + hex::encode(pre_hash), + best_hash, + difficulty, + hex::encode(nonce), + extrinsic_count, + ); + false + }, Err(e) => { warn!(target: LOG_TARGET, "Runtime API error verifying seal: {:?}", e); false diff --git a/node/src/service.rs b/node/src/service.rs index 696d91b6..e7c916c8 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -184,25 +184,39 @@ async fn handle_external_mining( server.broadcast_job(job).await; // Wait for results from miners, retrying on invalid seals + // Track both best_hash (parent) and pre_hash (block template) to detect rebuilds let best_hash = metadata.best_hash; + let original_pre_hash = metadata.pre_hash; loop { let (miner_id, seal) = match wait_for_mining_result(server, &job_id, || { + // Interrupt if cancelled, parent block changed, OR block template was rebuilt cancellation_token.is_cancelled() || - worker_handle.metadata().map(|m| m.best_hash != best_hash).unwrap_or(true) + worker_handle + .metadata() + .map(|m| m.best_hash != best_hash || m.pre_hash != original_pre_hash) + .unwrap_or(true) }) .await { Some(result) => result, - None => return ExternalMiningOutcome::Interrupted, + None => { + // Check why we were interrupted - log if it was a rebuild + if let Some(current) = worker_handle.metadata() { + if current.best_hash == best_hash && current.pre_hash != original_pre_hash { + log::info!( + "⛏️ Block template rebuilt while mining job {}. Old pre_hash: {}, New pre_hash: {}. Rebroadcasting...", + job_id, + hex::encode(original_pre_hash.as_bytes()), + hex::encode(current.pre_hash.as_bytes()) + ); + } + } + return ExternalMiningOutcome::Interrupted; + }, }; // Verify the seal before attempting to submit (submit consumes the build) if !worker_handle.verify_seal(&seal) { - log::error!( - "🚨🚨🚨 INVALID SEAL FROM MINER {}! Job {} - seal failed verification. This may indicate a miner bug or stale work. Continuing to wait for valid seals...", - miner_id, - job_id - ); continue; } diff --git a/pallets/mining-rewards/src/lib.rs b/pallets/mining-rewards/src/lib.rs index 1eb03ff4..98c04016 100644 --- a/pallets/mining-rewards/src/lib.rs +++ b/pallets/mining-rewards/src/lib.rs @@ -16,8 +16,7 @@ pub use weights::*; #[frame_support::pallet] pub mod pallet { use super::*; - use codec::Decode; - use core::{convert::TryInto, marker::PhantomData}; + use core::marker::PhantomData; use frame_support::{ pallet_prelude::*, traits::{ @@ -27,10 +26,8 @@ pub mod pallet { }; use frame_system::pallet_prelude::*; use pallet_treasury::TreasuryProvider; - use qp_poseidon::PoseidonHasher; use qp_wormhole::TransferProofRecorder; - use sp_consensus_pow::POW_ENGINE_ID; - use sp_runtime::{generic::DigestItem, traits::Saturating, Permill}; + use sp_runtime::{traits::Saturating, Permill}; pub(crate) type BalanceOf = <::Currency as Inspect<::AccountId>>::Balance; @@ -180,34 +177,8 @@ pub mod pallet { impl Pallet { /// Extract miner wormhole address by hashing the preimage from pre-runtime digest fn extract_miner_from_digest() -> Option { - // Get the digest from the current block let digest = >::digest(); - - // Look for pre-runtime digest with POW_ENGINE_ID - for log in digest.logs.iter() { - if let DigestItem::PreRuntime(engine_id, data) = log { - if engine_id == &POW_ENGINE_ID { - // The data is a 32-byte preimage from the incoming block - if data.len() == 32 { - let preimage: [u8; 32] = match data.as_slice().try_into() { - Ok(arr) => arr, - Err(_) => continue, - }; - - // Hash the preimage with Poseidon2 to derive the wormhole address - let wormhole_address_bytes = PoseidonHasher::hash_padded(&preimage); - - // Convert to AccountId - if let Ok(miner) = - T::AccountId::decode(&mut &wormhole_address_bytes[..]) - { - return Some(miner); - } - } - } - } - } - None + qp_wormhole::extract_author_from_digest(digest.logs.iter().cloned()) } pub fn collect_transaction_fees(fees: BalanceOf) { @@ -231,7 +202,7 @@ pub mod pallet { Some(miner) => { let _ = T::Currency::mint_into(&miner, reward).defensive(); - let _ = T::ProofRecorder::record_transfer_proof( + T::ProofRecorder::record_transfer_proof( None, mint_account.clone(), miner.clone(), @@ -244,7 +215,7 @@ pub mod pallet { let treasury = T::Treasury::account_id(); let _ = T::Currency::mint_into(&treasury, reward).defensive(); - let _ = T::ProofRecorder::record_transfer_proof( + T::ProofRecorder::record_transfer_proof( None, mint_account.clone(), treasury.clone(), diff --git a/pallets/mining-rewards/src/mock.rs b/pallets/mining-rewards/src/mock.rs index e8106731..769a5b0c 100644 --- a/pallets/mining-rewards/src/mock.rs +++ b/pallets/mining-rewards/src/mock.rs @@ -4,7 +4,7 @@ use frame_support::{ parameter_types, traits::{ConstU32, Everything, Hooks}, }; -use qp_poseidon::PoseidonHasher; +use qp_wormhole::derive_wormhole_account; use sp_consensus_pow::POW_ENGINE_ID; use sp_runtime::{ app_crypto::sp_core, @@ -100,15 +100,12 @@ pub struct MockProofRecorder; impl qp_wormhole::TransferProofRecorder for MockProofRecorder { - type Error = (); - fn record_transfer_proof( _asset_id: Option, _from: sp_core::crypto::AccountId32, _to: sp_core::crypto::AccountId32, _amount: u128, - ) -> Result<(), Self::Error> { - Ok(()) + ) { } } @@ -129,12 +126,6 @@ pub fn miner_preimage(id: u8) -> [u8; 32] { [id; 32] } -/// Helper function to derive wormhole address from preimage -pub fn wormhole_address_from_preimage(preimage: [u8; 32]) -> sp_core::crypto::AccountId32 { - let hash = PoseidonHasher::hash_padded(&preimage); - sp_core::crypto::AccountId32::from(hash) -} - // Configure default miner preimages and addresses for tests pub fn miner_preimage_1() -> [u8; 32] { miner_preimage(1) @@ -145,11 +136,11 @@ pub fn miner_preimage_2() -> [u8; 32] { } pub fn miner() -> sp_core::crypto::AccountId32 { - wormhole_address_from_preimage(miner_preimage_1()) + derive_wormhole_account(miner_preimage_1()) } pub fn miner2() -> sp_core::crypto::AccountId32 { - wormhole_address_from_preimage(miner_preimage_2()) + derive_wormhole_account(miner_preimage_2()) } fn treasury_account() -> sp_core::crypto::AccountId32 { diff --git a/pallets/mining-rewards/src/tests.rs b/pallets/mining-rewards/src/tests.rs index b65dba0e..7a5cb3ed 100644 --- a/pallets/mining-rewards/src/tests.rs +++ b/pallets/mining-rewards/src/tests.rs @@ -1,5 +1,6 @@ use crate::{mock::*, weights::WeightInfo, Event}; use frame_support::traits::{Currency, Hooks}; +use qp_wormhole::derive_wormhole_account; use sp_runtime::{testing::Digest, Permill}; #[test] @@ -330,7 +331,7 @@ fn test_fees_and_rewards_to_miner() { new_test_ext().execute_with(|| { // Use a test preimage and derive the wormhole address let test_preimage = [42u8; 32]; // Use a distinct preimage for this test - let miner_wormhole_address = wormhole_address_from_preimage(test_preimage); + let miner_wormhole_address = derive_wormhole_account(test_preimage); let _ = Balances::deposit_creating(&miner_wormhole_address, 0); // Create account let actual_initial_balance_after_creation = Balances::free_balance(&miner_wormhole_address); @@ -381,6 +382,7 @@ fn test_fees_and_rewards_to_miner() { } #[test] +#[ignore] // This test takes a very long time (~120M blocks simulation), run manually with --ignored fn test_emission_simulation_120m_blocks() { new_test_ext().execute_with(|| { // Add realistic initial supply similar to genesis diff --git a/pallets/multisig/Cargo.toml b/pallets/multisig/Cargo.toml index 295d45df..669a2fca 100644 --- a/pallets/multisig/Cargo.toml +++ b/pallets/multisig/Cargo.toml @@ -40,6 +40,7 @@ pallet-scheduler = { workspace = true, default-features = true } pallet-timestamp.workspace = true pallet-utility = { workspace = true, default-features = true } qp-scheduler = { workspace = true, default-features = true } +qp-wormhole = { workspace = true, default-features = true } sp-core.workspace = true sp-io.workspace = true sp-runtime = { workspace = true, default-features = true } diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index 82f5cbbd..d881b245 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -173,8 +173,7 @@ impl Time for MockTimestamp { parameter_types! { pub const ReversibleTransfersPalletIdValue: PalletId = PalletId(*b"rtpallet"); - pub const DefaultDelay: BlockNumberOrTimestamp = - BlockNumberOrTimestamp::BlockNumber(10); + pub const DefaultDelay: BlockNumberOrTimestamp = BlockNumberOrTimestamp::BlockNumber(10); pub const MinDelayPeriodBlocks: u64 = 2; pub const MinDelayPeriodMoment: u64 = 2000; pub const MaxReversibleTransfers: u32 = 100; @@ -182,6 +181,19 @@ parameter_types! { pub const HighSecurityVolumeFee: Permill = Permill::from_percent(1); } +/// Mock proof recorder that does nothing (for tests) +pub struct MockProofRecorder; + +impl qp_wormhole::TransferProofRecorder for MockProofRecorder { + fn record_transfer_proof( + _asset_id: Option, + _from: AccountId, + _to: AccountId, + _amount: Balance, + ) { + } +} + impl pallet_reversible_transfers::Config for Test { type SchedulerOrigin = OriginCaller; type RuntimeHoldReason = RuntimeHoldReason; @@ -198,6 +210,7 @@ impl pallet_reversible_transfers::Config for Test { type TimeProvider = MockTimestamp; type MaxInterceptorAccounts = MaxInterceptorAccounts; type VolumeFee = HighSecurityVolumeFee; + type ProofRecorder = MockProofRecorder; } parameter_types! { diff --git a/pallets/reversible-transfers/Cargo.toml b/pallets/reversible-transfers/Cargo.toml index a6685469..61503547 100644 --- a/pallets/reversible-transfers/Cargo.toml +++ b/pallets/reversible-transfers/Cargo.toml @@ -23,6 +23,7 @@ pallet-balances.workspace = true pallet-recovery.workspace = true qp-high-security = { path = "../../primitives/high-security", default-features = false } qp-scheduler.workspace = true +qp-wormhole.workspace = true scale-info = { features = ["derive"], workspace = true } sp-arithmetic.workspace = true sp-runtime.workspace = true @@ -56,6 +57,7 @@ std = [ "pallet-utility/std", "qp-high-security/std", "qp-scheduler/std", + "qp-wormhole/std", "scale-info/std", "sp-core/std", "sp-io/std", diff --git a/pallets/reversible-transfers/src/lib.rs b/pallets/reversible-transfers/src/lib.rs index 45de0e47..1bb8de38 100644 --- a/pallets/reversible-transfers/src/lib.rs +++ b/pallets/reversible-transfers/src/lib.rs @@ -34,6 +34,7 @@ use frame_support::{ }; use frame_system::pallet_prelude::*; use qp_scheduler::{BlockNumberOrTimestamp, DispatchTime, ScheduleNamed}; +use qp_wormhole::TransferProofRecorder; use sp_arithmetic::Permill; use sp_runtime::traits::StaticLookup; @@ -202,6 +203,10 @@ pub mod pallet { /// fees. The fee is burned (removed from total issuance). #[pallet::constant] type VolumeFee: Get; + + /// Proof recorder for storing wormhole transfer proofs. + /// This records transfer proofs when reversible transfers are executed. + type ProofRecorder: TransferProofRecorder, BalanceOf>; } /// Maps accounts to their chosen reversibility delay period (in milliseconds). @@ -600,34 +605,51 @@ pub mod pallet { let (call, _) = T::Preimages::realize::>(&pending.call) .map_err(|_| Error::::CallDecodingFailed)?; - // If this is an assets transfer, release the held amount before dispatch - if let Ok(pallet_assets::Call::transfer_keep_alive { id, .. }) = call.clone().try_into() - { - let reason = Self::asset_hold_reason(); - let _ = as AssetsHold>>::release( - id.into(), - &reason, - &pending.from, - pending.amount, - Precision::Exact, - ); - } - - // Release the funds only for native balances holds - if let Ok(pallet_balances::Call::transfer_keep_alive { .. }) = call.clone().try_into() { - pallet_balances::Pallet::::release( - &HoldReason::ScheduledTransfer.into(), - &pending.from, - pending.amount, - Precision::Exact, - )?; - } + // Release held funds and determine asset_id for transfer proof recording + let asset_id: Option> = + if let Ok(pallet_assets::Call::transfer_keep_alive { id, .. }) = + call.clone().try_into() + { + // Assets transfer: release the held asset amount + let reason = Self::asset_hold_reason(); + let _ = as AssetsHold>>::release( + id.clone().into(), + &reason, + &pending.from, + pending.amount, + Precision::Exact, + ); + Some(id.into()) + } else if let Ok(pallet_balances::Call::transfer_keep_alive { .. }) = + call.clone().try_into() + { + // Native balance transfer: release the held balance + pallet_balances::Pallet::::release( + &HoldReason::ScheduledTransfer.into(), + &pending.from, + pending.amount, + Precision::Exact, + )?; + None + } else { + None + }; // Remove transfer from all storage (handles indexes, account count, etc.) Self::transfer_removed(&pending.from, *tx_id, &pending); - let post_info = call - .dispatch(frame_support::dispatch::RawOrigin::Signed(pending.from.clone()).into()); + let post_info = + call.dispatch(frame_system::RawOrigin::Signed(pending.from.clone()).into()); + + // Record transfer proof if dispatch was successful + if post_info.is_ok() { + T::ProofRecorder::record_transfer_proof( + asset_id, + pending.from.clone(), + pending.to.clone(), + pending.amount, + ); + } // Emit event Self::deposit_event(Event::TransactionExecuted { tx_id: *tx_id, result: post_info }); diff --git a/pallets/reversible-transfers/src/tests/mock.rs b/pallets/reversible-transfers/src/tests/mock.rs index 3ec56572..0a204ff8 100644 --- a/pallets/reversible-transfers/src/tests/mock.rs +++ b/pallets/reversible-transfers/src/tests/mock.rs @@ -198,6 +198,53 @@ parameter_types! { pub const HighSecurityVolumeFee: Permill = Permill::from_percent(1); } +/// Recorded transfer proof for testing +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordedTransferProof { + pub asset_id: Option, + pub from: AccountId, + pub to: AccountId, + pub amount: Balance, +} + +thread_local! { + /// Storage for recorded transfer proofs (for test verification) + static RECORDED_PROOFS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Mock proof recorder that tracks recorded proofs for test verification +pub struct MockProofRecorder; + +impl MockProofRecorder { + /// Get all recorded transfer proofs + pub fn get_recorded_proofs() -> Vec { + RECORDED_PROOFS.with(|proofs| proofs.borrow().clone()) + } + + /// Clear all recorded proofs (call at start of tests) + pub fn clear() { + RECORDED_PROOFS.with(|proofs| proofs.borrow_mut().clear()); + } + + /// Get the last recorded proof + pub fn last_proof() -> Option { + RECORDED_PROOFS.with(|proofs| proofs.borrow().last().cloned()) + } +} + +impl qp_wormhole::TransferProofRecorder for MockProofRecorder { + fn record_transfer_proof( + asset_id: Option, + from: AccountId, + to: AccountId, + amount: Balance, + ) { + RECORDED_PROOFS.with(|proofs| { + proofs.borrow_mut().push(RecordedTransferProof { asset_id, from, to, amount }); + }); + } +} + impl pallet_reversible_transfers::Config for Test { type SchedulerOrigin = OriginCaller; type RuntimeHoldReason = RuntimeHoldReason; @@ -214,6 +261,7 @@ impl pallet_reversible_transfers::Config for Test { type TimeProvider = MockTimestamp; type MaxInterceptorAccounts = MaxInterceptorAccounts; type VolumeFee = HighSecurityVolumeFee; + type ProofRecorder = MockProofRecorder; } parameter_types! { diff --git a/pallets/reversible-transfers/src/tests/test_reversible_transfers.rs b/pallets/reversible-transfers/src/tests/test_reversible_transfers.rs index 21ca06b5..72841f43 100644 --- a/pallets/reversible-transfers/src/tests/test_reversible_transfers.rs +++ b/pallets/reversible-transfers/src/tests/test_reversible_transfers.rs @@ -2201,3 +2201,119 @@ fn global_nonce_works() { assert_eq!(ReversibleTransfers::global_nonce(), 4); }); } + +#[test] +fn reversible_transfer_records_transfer_proof_on_execution() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + MockProofRecorder::clear(); + + let user = alice(); // Reversible, delay 10 + let dest = bob(); + let amount = 50; + let call = transfer_call(dest.clone(), amount); + let _tx_id = calculate_tx_id::(user.clone(), &call); + let HighSecurityAccountData { delay, .. } = + ReversibleTransfers::is_high_security(&user).unwrap(); + let start_block = BlockNumberOrTimestamp::BlockNumber(System::block_number()); + let execute_block = start_block.saturating_add(&delay).unwrap(); + + // No proofs recorded yet + assert!(MockProofRecorder::get_recorded_proofs().is_empty()); + + // Schedule the transfer + assert_ok!(ReversibleTransfers::schedule_transfer( + RuntimeOrigin::signed(user.clone()), + dest.clone(), + amount, + )); + + // Still no proofs (transfer not executed yet) + assert!(MockProofRecorder::get_recorded_proofs().is_empty()); + + // Run to execution block + run_to_block(execute_block.as_block_number().unwrap()); + + // Now the transfer proof should be recorded + let proofs = MockProofRecorder::get_recorded_proofs(); + assert_eq!(proofs.len(), 1, "Expected exactly one transfer proof to be recorded"); + + let proof = &proofs[0]; + assert_eq!(proof.asset_id, None, "Native transfer should have None asset_id"); + assert_eq!(proof.from, user, "Transfer proof 'from' should match sender"); + assert_eq!(proof.to, dest, "Transfer proof 'to' should match destination"); + assert_eq!(proof.amount, amount, "Transfer proof amount should match"); + }); +} + +#[test] +fn reversible_asset_transfer_records_transfer_proof_with_asset_id() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + MockProofRecorder::clear(); + + let user = alice(); // Reversible, delay 10 + let dest = charlie(); + let asset_id = 1u32; + let amount = 100; + + // Create and mint asset to user + create_asset(asset_id, user.clone(), Some(1000)); + + let HighSecurityAccountData { delay, .. } = + ReversibleTransfers::is_high_security(&user).unwrap(); + let start_block = BlockNumberOrTimestamp::BlockNumber(System::block_number()); + let execute_block = start_block.saturating_add(&delay).unwrap(); + + // Schedule asset transfer (no delay parameter - uses account's default) + assert_ok!(ReversibleTransfers::schedule_asset_transfer( + RuntimeOrigin::signed(user.clone()), + asset_id, + dest.clone(), + amount, + )); + + // Run to execution block + run_to_block(execute_block.as_block_number().unwrap()); + + // Transfer proof should be recorded with asset_id + let proofs = MockProofRecorder::get_recorded_proofs(); + assert_eq!(proofs.len(), 1, "Expected exactly one transfer proof to be recorded"); + + let proof = &proofs[0]; + assert_eq!(proof.asset_id, Some(asset_id), "Asset transfer should have Some(asset_id)"); + assert_eq!(proof.from, user, "Transfer proof 'from' should match sender"); + assert_eq!(proof.to, dest, "Transfer proof 'to' should match destination"); + assert_eq!(proof.amount, amount, "Transfer proof amount should match"); + }); +} + +#[test] +fn cancelled_reversible_transfer_does_not_record_proof() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + MockProofRecorder::clear(); + + let user = alice(); // Reversible, delay 10 + let interceptor = bob(); // interceptor from genesis config + let amount = 50; + let call = transfer_call(interceptor.clone(), amount); + let tx_id = calculate_tx_id::(user.clone(), &call); + + // Schedule the transfer + assert_ok!(ReversibleTransfers::schedule_transfer( + RuntimeOrigin::signed(user.clone()), + interceptor.clone(), + amount, + )); + + // Cancel it before execution (must be called by interceptor) + assert_ok!(ReversibleTransfers::cancel(RuntimeOrigin::signed(interceptor), tx_id)); + + // No proofs should be recorded + assert!( + MockProofRecorder::get_recorded_proofs().is_empty(), + "Cancelled transfer should not record any proof" + ); + }); +} diff --git a/pallets/wormhole/src/lib.rs b/pallets/wormhole/src/lib.rs index ef4e242b..825be095 100644 --- a/pallets/wormhole/src/lib.rs +++ b/pallets/wormhole/src/lib.rs @@ -436,19 +436,18 @@ pub mod pallet { mint_account.clone().into(), exit_account.clone().into(), *exit_balance, - )?; + ); } // Mint miner's portion of volume fee to block author // The remaining portion (fee - miner_fee) is not minted, effectively burned if !miner_fee.is_zero() { - if let Some(author) = frame_system::Pallet::::digest() - .logs - .iter() - .find_map(|item| item.as_pre_runtime()) - .and_then(|(_, data)| { - ::AccountId::decode(&mut &data[..]).ok() - }) { + let digest = frame_system::Pallet::::digest(); + if let Some(author) = qp_wormhole::extract_author_from_digest::< + ::AccountId, + _, + >(digest.logs.iter().cloned()) + { >::increase_balance( &author, miner_fee, @@ -498,7 +497,7 @@ pub mod pallet { from: ::WormholeAccountId, to: ::WormholeAccountId, amount: BalanceOf, - ) -> DispatchResult { + ) { let current_count = TransferCount::::get(&to); TransferProof::::insert( (asset_id.clone(), current_count, from.clone(), to.clone(), amount), @@ -522,8 +521,6 @@ pub mod pallet { transfer_count: current_count, }); } - - Ok(()) } } @@ -535,16 +532,14 @@ pub mod pallet { BalanceOf, > for Pallet { - type Error = DispatchError; - fn record_transfer_proof( asset_id: Option>, from: ::WormholeAccountId, to: ::WormholeAccountId, amount: BalanceOf, - ) -> Result<(), Self::Error> { + ) { let asset_id_value = asset_id.unwrap_or_default(); - Self::record_transfer(asset_id_value, from, to, amount) + Self::record_transfer(asset_id_value, from, to, amount); } } } diff --git a/pallets/wormhole/src/tests.rs b/pallets/wormhole/src/tests.rs index 0bfbaab2..10688e52 100644 --- a/pallets/wormhole/src/tests.rs +++ b/pallets/wormhole/src/tests.rs @@ -5,6 +5,8 @@ mod wormhole_tests { assert_ok, traits::fungible::{Inspect, Mutate}, }; + use qp_wormhole::derive_wormhole_account; + use sp_core::crypto::AccountId32; #[test] fn record_transfer_creates_proof_and_increments_count() { @@ -14,7 +16,7 @@ mod wormhole_tests { let amount = 10 * UNIT; let count_before = Wormhole::transfer_count(&bob); - assert_ok!(Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount)); + Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount); assert_eq!(Wormhole::transfer_count(&bob), count_before + 1); assert!(Wormhole::transfer_proof(( @@ -27,7 +29,7 @@ mod wormhole_tests { .is_some()); // Second transfer increments count again - assert_ok!(Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount)); + Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount); assert_eq!(Wormhole::transfer_count(&bob), count_before + 2); }); } @@ -40,7 +42,7 @@ mod wormhole_tests { let amount = 10 * UNIT; System::set_block_number(1); - assert_ok!(Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount)); + Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount); System::assert_last_event( crate::Event::::NativeTransferred { @@ -63,7 +65,7 @@ mod wormhole_tests { let amount = 10 * UNIT; System::set_block_number(1); - assert_ok!(Wormhole::record_transfer(asset_id, alice.clone(), bob.clone(), amount)); + Wormhole::record_transfer(asset_id, alice.clone(), bob.clone(), amount); System::assert_last_event( crate::Event::::AssetTransferred { @@ -99,7 +101,7 @@ mod wormhole_tests { // 2. Record the transfer proof let count_before = Wormhole::transfer_count(&bob); - assert_ok!(Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount)); + Wormhole::record_transfer(0u32, alice.clone(), bob.clone(), amount); assert_eq!(Balances::balance(&alice), amount); assert_eq!(Balances::balance(&bob), amount); @@ -107,4 +109,95 @@ mod wormhole_tests { assert!(Wormhole::transfer_proof((0u32, count_before, alice, bob, amount)).is_some()); }); } + + #[test] + fn known_preimage_to_wormhole_address_all_zeros() { + // Test vector: all zeros preimage + let preimage = [0u8; 32]; + let address = derive_wormhole_account(preimage); + + // This is the expected wormhole address for preimage [0; 32] + // Computed via: PoseidonHasher::hash_variable_length(preimage.to_felts()) + // SS58: 5GE628zL... + let expected_bytes: [u8; 32] = [ + 0xb8, 0x18, 0xc0, 0x2c, 0x58, 0x77, 0xcc, 0x44, 0x07, 0xf7, 0x1b, 0x9b, 0x34, 0xee, + 0x45, 0xc7, 0x99, 0x86, 0xa5, 0xaf, 0x12, 0x9b, 0xfd, 0xc9, 0xe7, 0x71, 0x51, 0x1f, + 0xb4, 0xd5, 0x20, 0x4f, + ]; + let expected = AccountId32::from(expected_bytes); + + assert_eq!( + address, expected, + "Wormhole address for all-zeros preimage should match known value" + ); + } + + #[test] + fn known_preimage_to_wormhole_address_all_ones() { + // Test vector: all ones preimage + let preimage = [1u8; 32]; + let address = derive_wormhole_account(preimage); + + // This is the expected wormhole address for preimage [1; 32] + // Computed via: PoseidonHasher::hash_variable_length(preimage.to_felts()) + // SS58: 5CRs5z8R... + let expected_bytes: [u8; 32] = [ + 0x10, 0x23, 0x39, 0x1d, 0x9b, 0xe8, 0xa3, 0x3b, 0xc5, 0xfa, 0x49, 0x65, 0xf6, 0xde, + 0x83, 0x36, 0xd5, 0xb2, 0x97, 0x2b, 0xe4, 0x95, 0x73, 0xca, 0x74, 0xf4, 0x55, 0xc8, + 0x19, 0x98, 0xa9, 0x97, + ]; + let expected = AccountId32::from(expected_bytes); + + assert_eq!( + address, expected, + "Wormhole address for all-ones preimage should match known value" + ); + } + + #[test] + fn known_preimage_to_wormhole_address_sequential() { + // Test vector: sequential bytes 0..31 + let preimage: [u8; 32] = core::array::from_fn(|i| i as u8); + let address = derive_wormhole_account(preimage); + + // This is the expected wormhole address for preimage [0, 1, 2, ..., 31] + // Computed via: PoseidonHasher::hash_variable_length(preimage.to_felts()) + // SS58: 5CZ8wxNm... + let expected_bytes: [u8; 32] = [ + 0x15, 0xaf, 0x55, 0xee, 0x62, 0xfd, 0xd5, 0xea, 0x01, 0x4a, 0x59, 0x74, 0x24, 0xe7, + 0xe5, 0xdc, 0x68, 0xd6, 0x82, 0xfd, 0x48, 0x0d, 0xf2, 0x50, 0x40, 0x1f, 0xa2, 0x15, + 0x85, 0x22, 0xec, 0xff, + ]; + let expected = AccountId32::from(expected_bytes); + + assert_eq!( + address, expected, + "Wormhole address for sequential preimage should match known value" + ); + } + + #[test] + fn preimage_to_wormhole_address_is_deterministic() { + // Same preimage should always produce the same address + let preimage = [42u8; 32]; + + let address1 = derive_wormhole_account(preimage); + let address2 = derive_wormhole_account(preimage); + + assert_eq!(address1, address2, "Same preimage should produce same wormhole address"); + } + + #[test] + fn different_preimages_produce_different_addresses() { + let preimage1 = [1u8; 32]; + let preimage2 = [2u8; 32]; + + let address1 = derive_wormhole_account(preimage1); + let address2 = derive_wormhole_account(preimage2); + + assert_ne!( + address1, address2, + "Different preimages should produce different wormhole addresses" + ); + } } diff --git a/primitives/wormhole/Cargo.toml b/primitives/wormhole/Cargo.toml index 589da4ff..f7052cb6 100644 --- a/primitives/wormhole/Cargo.toml +++ b/primitives/wormhole/Cargo.toml @@ -10,7 +10,18 @@ repository.workspace = true version = "0.1.0" [dependencies] +codec = { workspace = true, default-features = false } +qp-poseidon.workspace = true +sp-consensus-pow.workspace = true +sp-core = { workspace = true, optional = true } +sp-runtime.workspace = true [features] default = ["std"] -std = [] +std = [ + "codec/std", + "qp-poseidon/std", + "sp-consensus-pow/std", + "sp-core", + "sp-runtime/std", +] diff --git a/primitives/wormhole/src/lib.rs b/primitives/wormhole/src/lib.rs index 8bc383eb..fc10092a 100644 --- a/primitives/wormhole/src/lib.rs +++ b/primitives/wormhole/src/lib.rs @@ -3,12 +3,14 @@ extern crate alloc; +use codec::Decode; +use qp_poseidon::{PoseidonHasher, ToFelts}; +pub use sp_consensus_pow::POW_ENGINE_ID; +use sp_runtime::generic::DigestItem; + /// Trait for recording transfer proofs in the wormhole pallet. /// Other pallets can use this to record proofs when they mint/transfer tokens. pub trait TransferProofRecorder { - /// Error type for proof recording failures - type Error; - /// Record a transfer proof for native or asset tokens /// - `None` for native tokens (asset_id = 0) /// - `Some(asset_id)` for specific assets @@ -17,5 +19,55 @@ pub trait TransferProofRecorder { from: AccountId, to: AccountId, amount: Balance, - ) -> Result<(), Self::Error>; + ); +} + +/// Derive a wormhole address from a 32-byte preimage. +/// +/// This hashes the preimage using Poseidon to get the wormhole account address. +/// The preimage is the "first_hash" from wormhole derivation: `first_hash = hash(salt + secret)`. +/// The wormhole address is: `address = hash(first_hash)`. +/// +/// This function uses the same serialization as the ZK circuit: +/// - Convert 32 bytes to 4 field elements using ToFelts (8 bytes per element) +/// - Hash without padding using hash_variable_length +pub fn derive_wormhole_address(preimage: [u8; 32]) -> [u8; 32] { + let preimage_felts = preimage.to_felts(); + PoseidonHasher::hash_variable_length(preimage_felts) +} + +/// Derive a wormhole AccountId32 from a 32-byte preimage. +/// +/// This is a convenience wrapper around `derive_wormhole_address` that returns +/// an `sp_core::crypto::AccountId32` directly. Useful for tests. +#[cfg(feature = "std")] +pub fn derive_wormhole_account(preimage: [u8; 32]) -> sp_core::crypto::AccountId32 { + sp_core::crypto::AccountId32::from(derive_wormhole_address(preimage)) +} + +/// Extract the block author (miner) account from a digest. +/// +/// This looks for a pre-runtime digest entry with POW_ENGINE_ID containing +/// a 32-byte preimage, then derives the wormhole address from it and decodes +/// it as the specified AccountId type. +/// +/// Returns `None` if no valid pre-runtime digest is found or decoding fails. +pub fn extract_author_from_digest(digest: Digest) -> Option +where + AccountId: Decode, + Digest: IntoIterator, +{ + for log in digest { + if let DigestItem::PreRuntime(engine_id, data) = log { + if engine_id == POW_ENGINE_ID && data.len() == 32 { + let preimage: [u8; 32] = match data.as_slice().try_into() { + Ok(arr) => arr, + Err(_) => continue, + }; + let address_bytes = derive_wormhole_address(preimage); + return AccountId::decode(&mut &address_bytes[..]).ok(); + } + } + } + None } diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 0fe05977..77163b2a 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -483,6 +483,7 @@ impl pallet_reversible_transfers::Config for Runtime { type TimeProvider = Timestamp; type MaxInterceptorAccounts = MaxInterceptorAccounts; type VolumeFee = HighSecurityVolumeFee; + type ProofRecorder = Wormhole; } parameter_types! { @@ -646,7 +647,7 @@ impl TryFrom for pallet_assets::Call { parameter_types! { pub WormholeMintingAccount: AccountId = PalletId(*b"wormhole").into_account_truncating(); /// Minimum transfer amount for wormhole (10 QUAN = 10 * 10^12) - pub const WormholeMinimumTransferAmount: Balance = 10 * UNIT; + pub const WormholeMinimumTransferAmount: Balance = UNIT / 10; /// Volume fee rate in basis points (10 bps = 0.1%) pub const VolumeFeeRateBps: u32 = 10; /// Proportion of volume fees to burn (50% burned, 50% to miner)