Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/onchain/contracts/crowdfund_vault/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ pub enum CrowdfundError {
RefundWindowClosed = 29,
RefundWindowNotOpen = 30,
Reentrancy = 31,
InvalidSignature = 32,
}
15 changes: 15 additions & 0 deletions apps/onchain/contracts/crowdfund_vault/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
78 changes: 76 additions & 2 deletions apps/onchain/contracts/crowdfund_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,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,
};
Expand Down Expand Up @@ -579,6 +579,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,
Expand All @@ -591,6 +656,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()
Expand Down Expand Up @@ -716,7 +791,6 @@ impl CrowdfundVaultContract {
);

Ok(())
})
}

/// Add a notification subscriber (admin only)
Expand Down
1 change: 1 addition & 0 deletions apps/onchain/contracts/crowdfund_vault/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub enum DataKey {
FeeBps, // -> u32
Treasury, // -> Address
Subscribers,
DepositNonce(Address), // Address -> u64
}

#[contracttype]
Expand Down
117 changes: 117 additions & 0 deletions apps/onchain/contracts/crowdfund_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
1 change: 1 addition & 0 deletions apps/onchain/contracts/project_registry/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ pub enum RegistryError {
ContractPaused = 10,
ProjectAlreadyVerified = 11,
ProjectAlreadyRejected = 12,
InvalidSignature = 13,
}
13 changes: 13 additions & 0 deletions apps/onchain/contracts/project_registry/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
76 changes: 74 additions & 2 deletions apps/onchain/contracts/project_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -173,7 +245,7 @@ impl ProjectRegistryContract {
owner,
name,
}
.publish(&env);
.publish(env);

Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions apps/onchain/contracts/project_registry/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading