Skip to content
Merged
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
127 changes: 126 additions & 1 deletion contracts/invisible_wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#[cfg(test)]
extern crate alloc;
use soroban_sdk::{
contract, contractimpl, contracterror,
contract, contractimpl, contracterror, contracttype,
Env, Address, Bytes, BytesN, Vec, Symbol, Val,
auth::Context, FromVal, TryFromVal, TryIntoVal, symbol_short, Map};

Expand All @@ -20,6 +20,14 @@ use storage::{DataKey, AllowanceKey, PendingRecovery};
/// Recovery timelock duration: 3 days in seconds.
const RECOVERY_DELAY_SECONDS: u64 = 259_200;

#[contracttype]
#[derive(Clone)]
pub struct BatchInvocation {
pub target: Address,
pub func: Symbol,
pub args: Vec<Val>,
}


#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
Expand Down Expand Up @@ -452,6 +460,15 @@ impl InvisibleWallet {
env.invoke_contract::<Val>(&target, &func, args);
}

/// Execute several contract invocations under one wallet authorization.
/// Soroban rolls back every nested invocation if any invocation fails.
pub fn batch(env: Env, invocations: Vec<BatchInvocation>) {
env.current_contract_address().require_auth();
for invocation in invocations.iter() {
env.invoke_contract::<Val>(&invocation.target, &invocation.func, invocation.args);
}
}

/// Set spending limit for a specific token and spender.
///
/// Requires passkey authorization (i.e. from the contract itself).
Expand Down Expand Up @@ -1542,4 +1559,112 @@ mod test {
let remaining = client.get_allowance(&spender, &token).unwrap();
assert_eq!(remaining.amount, 300);
}

#[test]
fn test_batch_rolls_back_when_later_invocation_fails() {
let env = Env::default();
let (_, pub_bytes) = test_keypair();
let contract_id = env.register_contract(None, InvisibleWallet);
let client = InvisibleWalletClient::new(&env, &contract_id);
client.init(
&BytesN::from_array(&env, &pub_bytes),
&bytes_from_str(&env, "localhost"),
&bytes_from_str(&env, "https://test.example"),
);

let spender = Address::generate(&env);
let token = Address::generate(&env);
let first = BatchInvocation {
target: contract_id.clone(),
func: Symbol::new(&env, "approve"),
args: Vec::from_array(&env, [
spender.clone().into_val(&env),
token.clone().into_val(&env),
500i128.into_val(&env),
().into_val(&env),
]),
};
let second = BatchInvocation {
target: contract_id.clone(),
func: Symbol::new(&env, "approve"),
args: Vec::from_array(&env, [
spender.clone().into_val(&env),
token.clone().into_val(&env),
0i128.into_val(&env),
().into_val(&env),
]),
};

env.mock_all_auths();
assert!(client.try_batch(&Vec::from_array(&env, [first, second])).is_err());
assert!(client.get_allowance(&spender, &token).is_none());
}


// __check_auth multi-context test
//
// Verifies that __check_auth correctly processes multiple auth contexts in
// a single call -- the scenario that arises when batch() is invoked. The
// WebAuthn branch sums the i128 amount across all Contract contexts to
// enforce the per-key spend limit, so two contexts each spending 300 must
// be rejected against a 500 limit, even though each individually would pass.

#[test]
fn test_check_auth_multi_context_spend_limit_enforced() {
let env = Env::default();
let (signing_key, pub_bytes) = test_keypair();
let payload = [7u8; 32];

let contract_id = env.register_contract(None, InvisibleWallet);
let client = InvisibleWalletClient::new(&env, &contract_id);
let rp_id = bytes_from_str(&env, "localhost");
let origin = bytes_from_str(&env, "https://test.example");
client.init(&BytesN::from_array(&env, &pub_bytes), &rp_id, &origin);

// Set a per-key spend limit of 500 for this signer.
env.mock_all_auths();
client.set_key_spend_limit(&BytesN::from_array(&env, &pub_bytes), &500);

// Build two contract-call contexts, each spending 300 (total 600 > 500).
let token = Address::generate(&env);
let recipient = Address::generate(&env);
let ctx1 = Context::Contract(soroban_sdk::auth::ContractContext {
contract: token.clone(),
fn_name: Symbol::new(&env, "transfer"),
args: Vec::from_array(&env, [
contract_id.to_val(),
recipient.to_val(),
300i128.into_val(&env),
]),
});
let ctx2 = Context::Contract(soroban_sdk::auth::ContractContext {
contract: token.clone(),
fn_name: Symbol::new(&env, "transfer"),
args: Vec::from_array(&env, [
contract_id.to_val(),
recipient.to_val(),
300i128.into_val(&env),
]),
});
let contexts = Vec::from_array(&env, [ctx1, ctx2]);

// Build a valid WebAuthn signature for this payload.
let (auth_data_raw, challenge_b64, sig_bytes) =
make_webauthn_fixture(&signing_key, &payload, b"localhost");
let signature = Vec::<Val>::from_array(&env, [
BytesN::from_array(&env, &pub_bytes).into_val(&env),
Bytes::from_array(&env, &auth_data_raw).into_val(&env),
build_client_data_json(&env, &challenge_b64).into_val(&env),
BytesN::from_array(&env, &sig_bytes).into_val(&env),
0u64.into_val(&env),
]).into_val(&env);

// Two contexts at 300 each exceed the 500 limit -> rejected.
let res = client.try___check_auth(
&BytesN::from_array(&env, &payload),
&signature,
&contexts,
);
assert_eq!(res, Err(Ok(WalletError::SpendLimitExceeded)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
{
"generators": {
"address": 3,
"nonce": 0
},
"auth": [
[],
[],
[],
[]
],
"ledger": {
"protocol_version": 22,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
"base_reserve": 0,
"min_persistent_entry_ttl": 4096,
"min_temp_entry_ttl": 16,
"max_entry_ttl": 6312000,
"ledger_entries": [
[
{
"contract_data": {
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"key": "ledger_key_contract_instance",
"durability": "persistent"
}
},
[
{
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"key": "ledger_key_contract_instance",
"durability": "persistent",
"val": {
"contract_instance": {
"executable": {
"wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"storage": [
{
"key": {
"vec": [
{
"symbol": "Nonce"
}
]
},
"val": {
"u64": 0
}
},
{
"key": {
"vec": [
{
"symbol": "Origin"
}
]
},
"val": {
"bytes": "68747470733a2f2f746573742e6578616d706c65"
}
},
{
"key": {
"vec": [
{
"symbol": "RpId"
}
]
},
"val": {
"bytes": "6c6f63616c686f7374"
}
},
{
"key": {
"vec": [
{
"symbol": "Signers"
}
]
},
"val": {
"map": [
{
"key": {
"u32": 0
},
"val": {
"bytes": "040c901d423c831ca85e27c73c263ba132721bb9d7a84c4f0380b2a6756fd601331c8870234dec878504c174144fa4b14b66a651691606d8173e55bd37e381569e"
}
}
]
}
}
]
}
}
}
},
"ext": "v0"
},
4095
]
],
[
{
"contract_code": {
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
},
[
{
"last_modified_ledger_seq": 0,
"data": {
"contract_code": {
"ext": "v0",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"code": ""
}
},
"ext": "v0"
},
4095
]
]
]
},
"events": []
}
Loading
Loading