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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ members = [
"contracts/emergency_guard",
"contracts/staking_rewards",
"contracts/cross_chain_verifier",
"contracts/crucible-example-gasless",
]
11 changes: 11 additions & 0 deletions contracts/crucible-example-gasless/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "crucible-example-gasless"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
soroban-sdk = "22.0.0"

148 changes: 148 additions & 0 deletions contracts/crucible-example-gasless/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#![no_std]
#![allow(deprecated)]
use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env};

/// A meta-transaction (gasless) request signed by the user.
#[contracttype]
#[derive(Clone)]
pub struct MetaTx {
/// The user whose tokens will be transferred.
pub from: Address,
/// Recipient of the transfer.
pub to: Address,
/// Token contract address.
pub token: Address,
/// Amount to transfer.
pub amount: i128,
/// Nonce to prevent replay attacks.
pub nonce: u64,
/// Deadline (unix timestamp) after which this meta-tx is invalid.
pub deadline: u64,
}

#[contracttype]
enum DataKey {
/// Admin / relayer address.
Admin,
/// Per-user nonce counter.
Nonce(Address),
}

/// A gasless transaction (meta-transaction) contract.
///
/// A user signs a `MetaTx` off-chain. A trusted relayer submits it on-chain,
/// paying the network fee. The contract verifies the nonce and deadline, then
/// executes the token transfer on behalf of the user.
///
/// In Soroban, "signing" is handled by `require_auth` — the user's auth entry
/// is attached to the transaction by the relayer. This contract enforces:
/// - Nonce uniqueness (replay protection).
/// - Deadline enforcement (expiry protection).
/// - Relayer-only submission.
#[contract]
#[derive(Default)]
pub struct Gasless;

#[contractimpl]
impl Gasless {
/// Initialize the contract with a trusted relayer address.
pub fn initialize(env: Env, admin: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("already initialized");
}
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
}

/// Execute a meta-transaction on behalf of `meta_tx.from`.
///
/// Must be called by the registered relayer (admin).
/// The user's authorization is verified via `meta_tx.from.require_auth()`.
pub fn execute(env: Env, relayer: Address, meta_tx: MetaTx) {
// Only the registered relayer may submit.
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("not initialized");
if relayer != admin {
panic!("unauthorized relayer");
}
relayer.require_auth();

// Deadline check.
let now = env.ledger().timestamp();
if now > meta_tx.deadline {
panic!("meta-tx expired");
}

// Nonce check — must match the stored next-nonce for this user.
let expected_nonce: u64 = env
.storage()
.instance()
.get(&DataKey::Nonce(meta_tx.from.clone()))
.unwrap_or(0u64);
if meta_tx.nonce != expected_nonce {
panic!("invalid nonce");
}

// Require the user's authorization (attached by the relayer).
meta_tx.from.require_auth();

// Advance nonce.
env.storage()
.instance()
.set(&DataKey::Nonce(meta_tx.from.clone()), &(expected_nonce + 1));

// Execute the transfer.
token::Client::new(&env, &meta_tx.token).transfer(
&meta_tx.from,
&meta_tx.to,
&meta_tx.amount,
);

env.events().publish(
(symbol_short!("executed"),),
(meta_tx.from, meta_tx.to, meta_tx.amount, meta_tx.nonce),
);
}

/// Return the current nonce for `user` (the next expected nonce).
pub fn nonce(env: Env, user: Address) -> u64 {
env.storage()
.instance()
.get(&DataKey::Nonce(user))
.unwrap_or(0u64)
}

/// Return the relayer address.
pub fn relayer(env: Env) -> Address {
env.storage()
.instance()
.get(&DataKey::Admin)
.expect("not initialized")
}
}

/// Helper to build a `MetaTx` value (used in tests).
pub fn make_meta_tx(
_env: &Env,
from: Address,
to: Address,
token: Address,
amount: i128,
nonce: u64,
deadline: u64,
) -> MetaTx {
MetaTx {
from,
to,
token,
amount,
nonce,
deadline,
}
}

#[cfg(test)]
mod test;
198 changes: 198 additions & 0 deletions contracts/crucible-example-gasless/src/test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#![cfg(test)]
extern crate std;

use crucible::assert_reverts;
use crucible::prelude::*;

use crate::{make_meta_tx, Gasless, GaslessClient, MetaTx};

const AMOUNT: i128 = 1_000_000;
const BASE_TIME: u64 = 1_000_000;
const DEADLINE: u64 = BASE_TIME + 3_600; // 1 hour from now

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

struct Ctx {
pub env: MockEnv,
pub id: soroban_sdk::Address,
pub relayer: AccountHandle,
pub alice: AccountHandle,
pub bob: AccountHandle,
pub token: MockToken,
}

impl Ctx {
fn setup() -> Self {
let env = MockEnv::builder()
.at_timestamp(BASE_TIME)
.with_contract::<Gasless>()
.with_account("relayer", Stroops::xlm(100))
.with_account("alice", Stroops::xlm(100))
.with_account("bob", Stroops::xlm(100))
.build();

let id = env.contract_id::<Gasless>();
let relayer = env.account("relayer");
let alice = env.account("alice");
let bob = env.account("bob");

let token = MockToken::new(&env, "USDC", 6);
token.mint(&alice, AMOUNT * 5);

env.mock_all_auths();
GaslessClient::new(env.inner(), &id).initialize(&relayer);

Ctx {
env,
id,
relayer,
alice,
bob,
token,
}
}

fn client(&self) -> GaslessClient<'_> {
GaslessClient::new(self.env.inner(), &self.id)
}

fn meta_tx(&self, nonce: u64) -> MetaTx {
make_meta_tx(
self.env.inner(),
self.alice.clone(),
self.bob.clone(),
self.token.address(),
AMOUNT,
nonce,
DEADLINE,
)
}
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[test]
fn test_execute_transfers_tokens() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0));

assert_eq!(ctx.token.balance(&ctx.alice), AMOUNT * 4);
assert_eq!(ctx.token.balance(&ctx.bob), AMOUNT);
}

#[test]
fn test_nonce_increments_after_execute() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
assert_eq!(ctx.client().nonce(&ctx.alice), 0);
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0));
assert_eq!(ctx.client().nonce(&ctx.alice), 1);
}

#[test]
fn test_replay_attack_reverts() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0));
// Replay with same nonce.
assert_reverts!(ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)), "nonce");
}

#[test]
fn test_sequential_nonces_succeed() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0));
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(1));
assert_eq!(ctx.token.balance(&ctx.bob), AMOUNT * 2);
}

#[test]
fn test_expired_meta_tx_reverts() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
// Advance time past the deadline.
ctx.env.advance_time(Duration::seconds(3_601));
assert_reverts!(
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)),
"expired"
);
}

#[test]
fn test_unauthorized_relayer_reverts() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
// alice tries to act as relayer.
assert_reverts!(
ctx.client().execute(&ctx.alice, &ctx.meta_tx(0)),
"unauthorized relayer"
);
}

#[test]
fn test_wrong_nonce_reverts() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
// Nonce 1 is wrong when 0 is expected.
assert_reverts!(ctx.client().execute(&ctx.relayer, &ctx.meta_tx(1)), "nonce");
}

#[test]
fn test_relayer_returns_correct_address() {
let ctx = Ctx::setup();
assert_eq!(ctx.client().relayer(), ctx.relayer.clone());
}

#[test]
fn test_nonce_starts_at_zero() {
let ctx = Ctx::setup();
assert_eq!(ctx.client().nonce(&ctx.alice), 0);
assert_eq!(ctx.client().nonce(&ctx.bob), 0);
}

#[test]
fn test_execute_emits_event() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0));
let matching = ctx
.env
.events_matching((soroban_sdk::symbol_short!("executed"),));
assert!(
!matching.is_empty(),
"expected executed event to be emitted"
);
}

#[test]
fn test_multiple_users_independent_nonces() {
let ctx = Ctx::setup();
ctx.env.mock_all_auths();

// Give bob some tokens too.
ctx.token.mint(&ctx.bob, AMOUNT * 5);

// alice executes nonce 0.
ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0));

// bob's nonce is still 0 independently.
let bob_tx = make_meta_tx(
ctx.env.inner(),
ctx.bob.clone(),
ctx.alice.clone(),
ctx.token.address(),
AMOUNT,
0,
DEADLINE,
);
ctx.client().execute(&ctx.relayer, &bob_tx);

assert_eq!(ctx.client().nonce(&ctx.alice), 1);
assert_eq!(ctx.client().nonce(&ctx.bob), 1);
}
Loading