From e0c61fdfc07fc7a714a13b045b1304207778c4ce Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Wed, 29 Apr 2026 01:23:51 +0100 Subject: [PATCH] feat: implement Gas-less Transaction Support (EIP-712 Style Signatures) --- apps/onchain/Cargo.lock | 2 +- .../contracts/crowdfund_vault/src/errors.rs | 1 + .../contracts/crowdfund_vault/src/events.rs | 15 +++ .../contracts/crowdfund_vault/src/lib.rs | 78 +++++++++++- .../contracts/crowdfund_vault/src/storage.rs | 1 + .../contracts/crowdfund_vault/src/test.rs | 117 ++++++++++++++++++ .../contracts/project_registry/src/errors.rs | 1 + .../contracts/project_registry/src/events.rs | 13 ++ .../contracts/project_registry/src/lib.rs | 76 +++++++++++- .../contracts/project_registry/src/storage.rs | 1 + .../contracts/project_registry/src/test.rs | 85 +++++++++++++ 11 files changed, 385 insertions(+), 5 deletions(-) diff --git a/apps/onchain/Cargo.lock b/apps/onchain/Cargo.lock index 3280d2fd..1b5bc1c7 100644 --- a/apps/onchain/Cargo.lock +++ b/apps/onchain/Cargo.lock @@ -1219,7 +1219,7 @@ dependencies = [ name = "project_registry" version = "0.0.0" dependencies = [ - "soroban-sdk", + "soroban-sdk 23.5.2", ] [[package]] diff --git a/apps/onchain/contracts/crowdfund_vault/src/errors.rs b/apps/onchain/contracts/crowdfund_vault/src/errors.rs index aa0b3174..9ab53de2 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/errors.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/errors.rs @@ -35,4 +35,5 @@ pub enum CrowdfundError { RefundWindowClosed = 29, RefundWindowNotOpen = 30, Reentrancy = 31, + InvalidSignature = 32, } diff --git a/apps/onchain/contracts/crowdfund_vault/src/events.rs b/apps/onchain/contracts/crowdfund_vault/src/events.rs index e4a7a221..30542030 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/events.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/events.rs @@ -212,3 +212,18 @@ pub struct StorageMigratedEvent { pub admin: Address, pub storage_version: u32, } + +/// Emitted when a contribution (deposit) is submitted via a gasless +/// meta-transaction relayed on behalf of the user. +/// Relayers and indexers can use this to track gasless deposits separately. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GaslessDepositEvent { + #[topic] + pub user: Address, + #[topic] + pub project_id: u64, + pub amount: i128, + /// The nonce consumed by this gasless deposit. The next valid nonce is `consumed_nonce + 1`. + pub consumed_nonce: u64, +} diff --git a/apps/onchain/contracts/crowdfund_vault/src/lib.rs b/apps/onchain/contracts/crowdfund_vault/src/lib.rs index 2faffa9c..351083f3 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/lib.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/lib.rs @@ -13,7 +13,7 @@ use notification_interface::{Notification, NotificationReceiverClient}; use reentrancy_guard::{acquire as acquire_reentrancy, release as release_reentrancy}; use soroban_sdk::token::TokenClient; use soroban_sdk::xdr::ToXdr; -use soroban_sdk::{contract, contractimpl, vec, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, vec, Address, BytesN, Env, IntoVal, Symbol, Vec}; use storage::{ DataKey, MilestoneDispute, ProjectData, ProtocolStats, LEDGER_BUMP, LEDGER_THRESHOLD, }; @@ -578,6 +578,71 @@ impl CrowdfundVaultContract { }) } + /// Returns the current deposit nonce for the given address. + /// Relayers must call this to determine the nonce to include in the user's off-chain authorization. + pub fn get_deposit_nonce(env: Env, address: Address) -> u64 { + Self::deposit_nonce_of(&env, &address) + } + + fn deposit_nonce_of(env: &Env, address: &Address) -> u64 { + let key = DataKey::DepositNonce(address.clone()); + let nonce = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); + } + nonce + } + + pub fn deposit_with_sig( + env: Env, + user: Address, + project_id: u64, + amount: i128, + signature: soroban_sdk::Bytes, + ) -> Result<(), CrowdfundError> { + Self::with_reentrancy_guard(&env, || { + Self::require_current_storage_version(&env)?; + if signature.is_empty() { + return Err(CrowdfundError::InvalidSignature); + } + + let nonce = Self::deposit_nonce_of(&env, &user); + + user.require_auth_for_args( + ( + Symbol::new(&env, "deposit_with_sig"), + user.clone(), + project_id, + amount, + nonce, + ) + .into_val(&env), + ); + + let new_nonce = nonce + 1; + env.storage() + .persistent() + .set(&DataKey::DepositNonce(user.clone()), &new_nonce); + env.storage().persistent().extend_ttl( + &DataKey::DepositNonce(user.clone()), + LEDGER_THRESHOLD, + LEDGER_BUMP, + ); + + events::GaslessDepositEvent { + user: user.clone(), + project_id, + amount, + consumed_nonce: nonce, + } + .publish(&env); + + Self::deposit_internal(&env, user, project_id, amount) + }) + } + /// Deposit funds into a project pub fn deposit( env: Env, @@ -590,6 +655,16 @@ impl CrowdfundVaultContract { user.require_auth(); + Self::deposit_internal(&env, user, project_id, amount) + }) + } + + fn deposit_internal( + env: &Env, + user: Address, + project_id: u64, + amount: i128, + ) -> Result<(), CrowdfundError> { let is_paused: bool = env .storage() .instance() @@ -715,7 +790,6 @@ impl CrowdfundVaultContract { ); Ok(()) - }) } /// Add a notification subscriber (admin only) diff --git a/apps/onchain/contracts/crowdfund_vault/src/storage.rs b/apps/onchain/contracts/crowdfund_vault/src/storage.rs index 06259b8f..057caaae 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/storage.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/storage.rs @@ -38,6 +38,7 @@ pub enum DataKey { FeeBps, // -> u32 Treasury, // -> Address Subscribers, + DepositNonce(Address), // Address -> u64 } #[contracttype] diff --git a/apps/onchain/contracts/crowdfund_vault/src/test.rs b/apps/onchain/contracts/crowdfund_vault/src/test.rs index 0e16594c..f393d413 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/test.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/test.rs @@ -2527,3 +2527,120 @@ fn test_withdraw_cei_state_written_before_balance_assertion() { assert_eq!(client.get_balance(&project_id), 300_000); assert_eq!(token_client.balance(&owner), 200_000); } + +// ── Gas-less deposit (EIP-712 style) ───────────────────────────────────────── + +/// The relayer-submitted deposit path must work with mock_all_auths just like +/// the regular deposit path: funds move, the project balance grows, and the +/// per-address deposit nonce is incremented. +#[test] +fn test_deposit_with_sig_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, owner, user, token_client) = setup_test(&env); + + client.initialize(&admin); + + let project_id = client.create_project( + &owner, + &symbol_short!("GasTest"), + &1_000_000, + &token_client.address, + ); + + // Nonce must start at 0 before any gasless deposit. + assert_eq!(client.get_deposit_nonce(&user), 0u64); + + let signature = soroban_sdk::Bytes::from_slice(&env, &[1u8; 64]); + client.deposit_with_sig(&user, &project_id, &300_000, &signature); + + // Balance updated. + assert_eq!(client.get_balance(&project_id), 300_000); + // Project total_deposited updated. + assert_eq!(client.get_project(&project_id).total_deposited, 300_000); + // Nonce incremented to 1. + assert_eq!(client.get_deposit_nonce(&user), 1u64); +} + +/// Each successful gasless deposit must increment the nonce, preventing +/// replay across multiple calls. +#[test] +fn test_deposit_with_sig_nonce_increments() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, owner, user, token_client) = setup_test(&env); + client.initialize(&admin); + + let project_id = client.create_project( + &owner, + &symbol_short!("NonceT"), + &2_000_000, + &token_client.address, + ); + + let sig = soroban_sdk::Bytes::from_slice(&env, &[2u8; 64]); + client.deposit_with_sig(&user, &project_id, &100_000, &sig); + assert_eq!(client.get_deposit_nonce(&user), 1u64); + + let sig2 = soroban_sdk::Bytes::from_slice(&env, &[3u8; 64]); + client.deposit_with_sig(&user, &project_id, &200_000, &sig2); + assert_eq!(client.get_deposit_nonce(&user), 2u64); +} + +/// An empty signature must be rejected with InvalidSignature. +#[test] +fn test_deposit_with_sig_rejects_empty_signature() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, owner, user, token_client) = setup_test(&env); + client.initialize(&admin); + + let project_id = client.create_project( + &owner, + &symbol_short!("SigFail"), + &1_000_000, + &token_client.address, + ); + + let empty_sig = soroban_sdk::Bytes::new(&env); + let result = client.try_deposit_with_sig(&user, &project_id, &100_000, &empty_sig); + assert_eq!(result, Err(Ok(crate::errors::CrowdfundError::InvalidSignature))); +} + +/// deposit_with_sig on a non-existent project must fail with ProjectNotFound. +#[test] +fn test_deposit_with_sig_project_not_found() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, _, user, _) = setup_test(&env); + client.initialize(&admin); + + let sig = soroban_sdk::Bytes::from_slice(&env, &[1u8; 64]); + let result = client.try_deposit_with_sig(&user, &999, &100_000, &sig); + assert_eq!(result, Err(Ok(crate::errors::CrowdfundError::ProjectNotFound))); +} + +/// deposit_with_sig with amount <= 0 must fail with InvalidAmount. +#[test] +fn test_deposit_with_sig_invalid_amount() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, owner, user, token_client) = setup_test(&env); + client.initialize(&admin); + + let project_id = client.create_project( + &owner, + &symbol_short!("AmtFail"), + &1_000_000, + &token_client.address, + ); + + let sig = soroban_sdk::Bytes::from_slice(&env, &[1u8; 64]); + let result = client.try_deposit_with_sig(&user, &project_id, &0, &sig); + assert_eq!(result, Err(Ok(crate::errors::CrowdfundError::InvalidAmount))); +} diff --git a/apps/onchain/contracts/project_registry/src/errors.rs b/apps/onchain/contracts/project_registry/src/errors.rs index 45c076d2..8d772373 100644 --- a/apps/onchain/contracts/project_registry/src/errors.rs +++ b/apps/onchain/contracts/project_registry/src/errors.rs @@ -16,4 +16,5 @@ pub enum RegistryError { ContractPaused = 10, ProjectAlreadyVerified = 11, ProjectAlreadyRejected = 12, + InvalidSignature = 13, } diff --git a/apps/onchain/contracts/project_registry/src/events.rs b/apps/onchain/contracts/project_registry/src/events.rs index b382940d..ecefd41e 100644 --- a/apps/onchain/contracts/project_registry/src/events.rs +++ b/apps/onchain/contracts/project_registry/src/events.rs @@ -45,3 +45,16 @@ pub struct VerificationOverriddenEvent { pub admin: Address, pub verified: bool, } + +/// Emitted when a project is registered via a gasless meta-transaction +/// relayed on behalf of the owner. Relayers and indexers can use this +/// to track gasless registrations separately from direct ones. +#[contractevent] +pub struct GaslessProjectRegisteredEvent { + #[topic] + pub project_id: u64, + pub owner: Address, + pub name: Symbol, + /// The nonce consumed by this registration. The next valid nonce is `consumed_nonce + 1`. + pub consumed_nonce: u64, +} diff --git a/apps/onchain/contracts/project_registry/src/lib.rs b/apps/onchain/contracts/project_registry/src/lib.rs index 5f13721b..c0b89c60 100644 --- a/apps/onchain/contracts/project_registry/src/lib.rs +++ b/apps/onchain/contracts/project_registry/src/lib.rs @@ -6,7 +6,7 @@ mod storage; use errors::RegistryError; use soroban_sdk::token::TokenClient; -use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, IntoVal, Symbol}; +use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, IntoVal, Symbol}; use storage::{DataKey, ProjectEntry, RegistryConfig, VerificationStatus, WeightMode}; #[contract] @@ -134,6 +134,69 @@ impl ProjectRegistryContract { // ── Project registration ────────────────────────────────────────────────── + /// Returns the current registration nonce for the given owner address. + /// Relayers must call this to determine the nonce to include in the + /// user's off-chain `SorobanAuthorizationEntry`. + pub fn get_registration_nonce(env: Env, address: Address) -> u64 { + Self::registration_nonce_of(&env, &address) + } + + fn registration_nonce_of(env: &Env, address: &Address) -> u64 { + let key = DataKey::RegistrationNonce(address.clone()); + let nonce: u64 = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + // Bump TTL using reasonable defaults (~30 days at 5s/ledger) + env.storage() + .persistent() + .extend_ttl(&key, 100_000u32, 518_400u32); + } + nonce + } + + pub fn register_project_with_sig( + env: Env, + owner: Address, + project_id: u64, + name: Symbol, + signature: Bytes, + ) -> Result<(), RegistryError> { + Self::require_not_paused(&env)?; + if signature.is_empty() { + return Err(RegistryError::InvalidSignature); + } + + let nonce = Self::registration_nonce_of(&env, &owner); + + owner.require_auth_for_args( + ( + Symbol::new(&env, "register_project_with_sig"), + owner.clone(), + project_id, + name.clone(), + nonce, + ) + .into_val(&env), + ); + + let new_nonce = nonce + 1; + env.storage() + .persistent() + .set(&DataKey::RegistrationNonce(owner.clone()), &new_nonce); + env.storage() + .persistent() + .extend_ttl(&DataKey::RegistrationNonce(owner.clone()), 100_000u32, 518_400u32); + + events::GaslessProjectRegisteredEvent { + project_id, + owner: owner.clone(), + name: name.clone(), + consumed_nonce: nonce, + } + .publish(&env); + + Self::register_project_internal(&env, owner, project_id, name) + } + /// Register a project for community verification. /// Anyone can register a project they own. pub fn register_project( @@ -145,6 +208,15 @@ impl ProjectRegistryContract { Self::require_not_paused(&env)?; owner.require_auth(); + Self::register_project_internal(&env, owner, project_id, name) + } + + fn register_project_internal( + env: &Env, + owner: Address, + project_id: u64, + name: Symbol, + ) -> Result<(), RegistryError> { if env .storage() .persistent() @@ -173,7 +245,7 @@ impl ProjectRegistryContract { owner, name, } - .publish(&env); + .publish(env); Ok(()) } diff --git a/apps/onchain/contracts/project_registry/src/storage.rs b/apps/onchain/contracts/project_registry/src/storage.rs index e50720f9..0481c005 100644 --- a/apps/onchain/contracts/project_registry/src/storage.rs +++ b/apps/onchain/contracts/project_registry/src/storage.rs @@ -60,4 +60,5 @@ pub enum DataKey { Project(u64), // project_id -> ProjectEntry VoteCast(u64, Address), // (project_id, voter) -> bool VoterWeight(u64, Address), // (project_id, voter) -> i128 (recorded at vote time) + RegistrationNonce(Address), // Address -> u64 } diff --git a/apps/onchain/contracts/project_registry/src/test.rs b/apps/onchain/contracts/project_registry/src/test.rs index 3d2c0100..721e8cd0 100644 --- a/apps/onchain/contracts/project_registry/src/test.rs +++ b/apps/onchain/contracts/project_registry/src/test.rs @@ -338,3 +338,88 @@ fn test_pause_blocks_votes() { Err(Ok(RegistryError::ContractPaused)) ); } + +// ── Gas-less project registration (EIP-712 style) ───────────────────────────── + +/// A relayer-submitted registration must succeed and produce the same on-chain +/// state as a regular registration: project entry stored, status Pending. +#[test] +fn test_register_project_with_sig_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup(&env, 100, WeightMode::Flat); + + let owner = Address::generate(&env); + + // Nonce must start at 0. + assert_eq!(client.get_registration_nonce(&owner), 0u64); + + let sig = soroban_sdk::Bytes::from_slice(&env, &[1u8; 64]); + client.register_project_with_sig(&owner, &42u64, &symbol_short!("GSig"), &sig); + + // Project entry created with Pending status. + let entry = client.get_project(&42u64); + assert_eq!(entry.project_id, 42); + assert_eq!(entry.owner, owner); + assert_eq!(entry.status, VerificationStatus::Pending); + + // Nonce incremented. + assert_eq!(client.get_registration_nonce(&owner), 1u64); +} + +/// Nonce must increment on each successful gasless registration (different +/// project IDs since same project_id would fail as AlreadyRegistered). +#[test] +fn test_register_project_with_sig_nonce_increments() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup(&env, 100, WeightMode::Flat); + + let owner = Address::generate(&env); + let sig1 = soroban_sdk::Bytes::from_slice(&env, &[1u8; 64]); + client.register_project_with_sig(&owner, &1u64, &symbol_short!("P1"), &sig1); + assert_eq!(client.get_registration_nonce(&owner), 1u64); + + let sig2 = soroban_sdk::Bytes::from_slice(&env, &[2u8; 64]); + client.register_project_with_sig(&owner, &2u64, &symbol_short!("P2"), &sig2); + assert_eq!(client.get_registration_nonce(&owner), 2u64); +} + +/// An empty signature must be rejected with InvalidSignature. +#[test] +fn test_register_project_with_sig_rejects_empty_sig() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup(&env, 100, WeightMode::Flat); + + let owner = Address::generate(&env); + let empty_sig = soroban_sdk::Bytes::new(&env); + assert_eq!( + client.try_register_project_with_sig( + &owner, + &10u64, + &symbol_short!("Fail"), + &empty_sig + ), + Err(Ok(RegistryError::InvalidSignature)) + ); +} + +/// Duplicate project registration via gasless path must fail with +/// ProjectAlreadyRegistered just like the regular path. +#[test] +fn test_register_project_with_sig_duplicate_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup(&env, 100, WeightMode::Flat); + + let owner = Address::generate(&env); + let sig1 = soroban_sdk::Bytes::from_slice(&env, &[1u8; 64]); + client.register_project_with_sig(&owner, &5u64, &symbol_short!("Dup"), &sig1); + + let sig2 = soroban_sdk::Bytes::from_slice(&env, &[2u8; 64]); + assert_eq!( + client.try_register_project_with_sig(&owner, &5u64, &symbol_short!("Dup"), &sig2), + Err(Ok(RegistryError::ProjectAlreadyRegistered)) + ); +}