diff --git a/contracts/invisible_wallet/src/lib.rs b/contracts/invisible_wallet/src/lib.rs index ee75211..2ca6259 100644 --- a/contracts/invisible_wallet/src/lib.rs +++ b/contracts/invisible_wallet/src/lib.rs @@ -43,6 +43,10 @@ pub enum WalletError { RecoveryTimelockActive = 15, /// The submitted nonce does not match the on-chain nonce (replay or out-of-order). NonceMismatch = 16, + /// The allowance is insufficient for this transfer. + InsufficientAllowance = 17, + /// The allowance has expired. + AllowanceExpired = 18, } #[contract] @@ -123,7 +127,66 @@ impl InvisibleWallet { signature: Val, _auth_contexts: Vec, ) -> Result<(), WalletError> { - let parts: Vec = Vec::from_val(&env, &signature); + // Check if the signature is actually a Spender Address claiming an allowance + if let Ok(spender) = Address::try_from_val(&env, &signature) { + spender.require_auth(); + + for context in _auth_contexts.iter() { + let Context::Contract(c) = context else { + return Err(WalletError::SignerNotAuthorized); + }; + + // We only allow token transfers via allowance + if c.fn_name != Symbol::new(&env, "transfer") { + return Err(WalletError::SignerNotAuthorized); + } + + if c.args.len() != 3 { + return Err(WalletError::SignerNotAuthorized); + } + + let from = Address::try_from_val(&env, &c.args.get(0).unwrap()) + .map_err(|_| WalletError::SignerNotAuthorized)?; + if from != env.current_contract_address() { + return Err(WalletError::SignerNotAuthorized); + } + + let amount = i128::try_from_val(&env, &c.args.get(2).unwrap()) + .map_err(|_| WalletError::SignerNotAuthorized)?; + + let token = c.contract; + + let key = storage::DataKey::Allowance { + spender: spender.clone(), + token: token.clone(), + }; + + let mut allowance: storage::Allowance = env + .storage() + .persistent() + .get(&key) + .ok_or(WalletError::InsufficientAllowance)?; + + if let Some(expiry) = allowance.expiry { + if env.ledger().timestamp() > expiry { + return Err(WalletError::AllowanceExpired); + } + } + + if amount > allowance.amount { + return Err(WalletError::InsufficientAllowance); + } + + allowance.amount -= amount; + env.storage().persistent().set(&key, &allowance); + } + + return Ok(()); + } + + // Standard WebAuthn flow + let parts: Vec = Vec::try_from_val(&env, &signature) + .map_err(|_| WalletError::InvalidSignatureFormat)?; if parts.len() != 5 { return Err(WalletError::InvalidSignatureFormat); } @@ -201,6 +264,34 @@ impl InvisibleWallet { env.invoke_contract::(&target, &func, args); } + /// Set spending limit for a specific token and spender. + /// + /// Requires passkey authorization (i.e. from the contract itself). + pub fn approve( + env: Env, + spender: Address, + token: Address, + amount: i128, + expiry: Option, + ) { + env.current_contract_address().require_auth(); + + if amount <= 0 { + panic!("Amount must be greater than 0"); + } + + let key = storage::DataKey::Allowance { spender, token }; + let allowance = storage::Allowance { amount, expiry }; + + env.storage().persistent().set(&key, &allowance); + } + + /// Get the current allowance for a spender and token. + pub fn get_allowance(env: Env, spender: Address, token: Address) -> Option { + let key = storage::DataKey::Allowance { spender, token }; + env.storage().persistent().get(&key) + } + /// Set or update the guardian address for this wallet. /// /// Only callable by the current wallet signer (authenticated via __check_auth). @@ -841,4 +932,165 @@ mod test { client.__check_auth(&BytesN::from_array(&env, &payload_2), &signature_1, &Vec::new(&env)); assert_eq!(client.get_nonce(), 2); } + + // ── Allowance tests ────────────────────────────────────────────────────── + + #[test] + fn test_allowance_approve_and_spend() { + let env = Env::default(); + let (_, pub_bytes) = test_keypair(); + 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); + + let spender = Address::generate(&env); + let token = Address::generate(&env); + + // 1. Approve 500 + env.mock_all_auths(); + client.approve(&spender, &token, &500, &None); + + let allowance = client.get_allowance(&spender, &token).unwrap(); + assert_eq!(allowance.amount, 500); + assert_eq!(allowance.expiry, None); + + // 2. Spend 200 + let context = Context::Contract(soroban_sdk::auth::ContractContext { + contract: token.clone(), + fn_name: Symbol::new(&env, "transfer"), + args: Vec::from_array(&env, [ + contract_id.to_val(), + Address::generate(&env).to_val(), + 200i128.into_val(&env), + ]), + }); + + let contexts = Vec::from_array(&env, [context]); + let signature = spender.to_val(); + + // Calling __check_auth as if the spender initiated the transfer + client.__check_auth(&BytesN::from_array(&env, &[0; 32]), &signature, &contexts); + + // Check remaining allowance + let remaining = client.get_allowance(&spender, &token).unwrap(); + assert_eq!(remaining.amount, 300); + } + + #[test] + fn test_allowance_spend_over_limit() { + let env = Env::default(); + let contract_id = env.register_contract(None, InvisibleWallet); + let client = InvisibleWalletClient::new(&env, &contract_id); + + let (_, pub_bytes) = test_keypair(); + 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); + + env.mock_all_auths(); + client.approve(&spender, &token, &100, &None); + + let context = Context::Contract(soroban_sdk::auth::ContractContext { + contract: token.clone(), + fn_name: Symbol::new(&env, "transfer"), + args: Vec::from_array(&env, [ + contract_id.to_val(), + Address::generate(&env).to_val(), + 150i128.into_val(&env), + ]), + }); + + let signature = spender.to_val(); + let res = client.try___check_auth(&BytesN::from_array(&env, &[0; 32]), &signature, &Vec::from_array(&env, [context])); + assert_eq!(res, Err(Ok(WalletError::InsufficientAllowance))); + } + + #[test] + fn test_allowance_expired() { + let env = Env::default(); + let contract_id = env.register_contract(None, InvisibleWallet); + let client = InvisibleWalletClient::new(&env, &contract_id); + + let (_, pub_bytes) = test_keypair(); + 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); + + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + // Approve with expiry in the past + client.approve(&spender, &token, &500, &Some(500)); + + let context = Context::Contract(soroban_sdk::auth::ContractContext { + contract: token.clone(), + fn_name: Symbol::new(&env, "transfer"), + args: Vec::from_array(&env, [ + contract_id.to_val(), + Address::generate(&env).to_val(), + 100i128.into_val(&env), + ]), + }); + + let signature = spender.to_val(); + let res = client.try___check_auth(&BytesN::from_array(&env, &[0; 32]), &signature, &Vec::from_array(&env, [context])); + assert_eq!(res, Err(Ok(WalletError::AllowanceExpired))); + } + + #[test] + fn test_allowance_exact_boundary() { + let env = Env::default(); + let contract_id = env.register_contract(None, InvisibleWallet); + let client = InvisibleWalletClient::new(&env, &contract_id); + + let (_, pub_bytes) = test_keypair(); + 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); + + env.mock_all_auths(); + client.approve(&spender, &token, &100, &None); + + let context = Context::Contract(soroban_sdk::auth::ContractContext { + contract: token.clone(), + fn_name: Symbol::new(&env, "transfer"), + args: Vec::from_array(&env, [ + contract_id.to_val(), + Address::generate(&env).to_val(), + 100i128.into_val(&env), + ]), + }); + + let signature = spender.to_val(); + client.__check_auth(&BytesN::from_array(&env, &[0; 32]), &signature, &Vec::from_array(&env, [context])); + + let remaining = client.get_allowance(&spender, &token).unwrap(); + assert_eq!(remaining.amount, 0); + } + + #[test] + fn test_allowance_overwrite() { + let env = Env::default(); + let contract_id = env.register_contract(None, InvisibleWallet); + let client = InvisibleWalletClient::new(&env, &contract_id); + + let (_, pub_bytes) = test_keypair(); + 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); + + env.mock_all_auths(); + client.approve(&spender, &token, &100, &None); + client.approve(&spender, &token, &300, &None); + + let remaining = client.get_allowance(&spender, &token).unwrap(); + assert_eq!(remaining.amount, 300); + } } diff --git a/contracts/invisible_wallet/src/storage.rs b/contracts/invisible_wallet/src/storage.rs index 8b847ee..a20e740 100644 --- a/contracts/invisible_wallet/src/storage.rs +++ b/contracts/invisible_wallet/src/storage.rs @@ -26,6 +26,15 @@ pub enum DataKey { RecoveryPending, /// Strictly monotonic u64 nonce to prevent signature replay attacks. Nonce, + /// Granular spending limit for a spender and token. + Allowance { spender: soroban_sdk::Address, token: soroban_sdk::Address }, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Allowance { + pub amount: i128, + pub expiry: Option, } // ── Signers (Map-based) ────────────────────────────────────────────────────── diff --git a/sdk/src/useInvisibleWallet.ts b/sdk/src/useInvisibleWallet.ts index 091326a..cb0225b 100644 --- a/sdk/src/useInvisibleWallet.ts +++ b/sdk/src/useInvisibleWallet.ts @@ -216,6 +216,25 @@ type InvisibleWallet = { * @throws {RecoveryNotPending} If no recovery is in progress. */ completeRecovery: (payerKeypair: Keypair) => Promise; + /** + * Set a spending limit for a specific token and spender. + * Requires WebAuthn authentication. + * + * @param signerKeypair Stellar Keypair used as the transaction fee source. + * @param spender Stellar address of the spender. + * @param token Stellar address of the token contract. + * @param amount Maximum amount the spender is allowed to spend. + * @param expiry Optional Unix timestamp (seconds) when the allowance expires. + */ + approve: (signerKeypair: Keypair, spender: string, token: string, amount: number, expiry?: number) => Promise; + /** + * Get the current allowance for a spender and token. + * + * @param spender Stellar address of the spender. + * @param token Stellar address of the token contract. + * @returns Object with amount and expiry, or null if no allowance exists. + */ + getAllowance: (spender: string, token: string) => Promise<{ amount: number; expiry: number | undefined } | null>; }; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -1016,7 +1035,174 @@ export function useInvisibleWallet(config: WalletConfig): InvisibleWallet { } }, [address, rpcUrl, networkPassphrase]); + // ── getAllowance ────────────────────────────────────────────────────────── + + const getAllowance = useCallback(async (spender: string, token: string): Promise<{ amount: number; expiry: number | undefined } | null> => { + setIsPending(true); + setError(null); + try { + if (!address) throw new Error('No wallet address. Call register() or login() first.'); + + const server = new SorobanRpc.Server(rpcUrl); + const walletContract = new Contract(address); + + const dummyKeypair = Keypair.random(); + const sourceAccount = new Account(dummyKeypair.publicKey(), '0'); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(walletContract.call( + 'get_allowance', + nativeToScVal(spender, { type: 'address' }), + nativeToScVal(token, { type: 'address' }) + )) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw new Error(`Simulation failed: ${sim.error}`); + } + + const result = (sim as SorobanRpc.Api.SimulateTransactionSuccessResponse).result; + if (!result || !result.retval) throw new Error('Simulation returned no result'); + + // Optional + if (result.retval.switch() === xdr.ScValType.scvVoid()) { + return null; + } + + const allowanceMap = scValToNative(result.retval); + // scValToNative converts a custom type (struct) to an object with properties + return { + amount: Number(allowanceMap.amount), + expiry: allowanceMap.expiry !== undefined ? Number(allowanceMap.expiry) : undefined, + }; + + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + throw err; + } finally { + setIsPending(false); + } + }, [address, rpcUrl, networkPassphrase]); + + // ── approve ─────────────────────────────────────────────────────────────── + + const approve = useCallback(async ( + signerKeypair: Keypair, + spender: string, + token: string, + amount: number, + expiry?: number + ): Promise => { + setIsPending(true); + setError(null); + try { + if (!address) throw new Error('No wallet address. Call register() or login() first.'); + + const server = new SorobanRpc.Server(rpcUrl); + const walletContract = new Contract(address); + const sourceAccount = await server.getAccount(signerKeypair.publicKey()); + + // Convert expiry to Option + let expiryVal: xdr.ScVal; + if (expiry !== undefined) { + expiryVal = nativeToScVal([nativeToScVal(BigInt(expiry), { type: 'u64' })], { type: 'Vec' }); + } else { + expiryVal = xdr.ScVal.scvVoid(); + } + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + walletContract.call( + 'approve', + nativeToScVal(spender, { type: 'address' }), + nativeToScVal(token, { type: 'address' }), + nativeToScVal(BigInt(amount), { type: 'i128' }), + expiryVal + ) + ) + .setTimeout(30) + .build(); + + // Simulate to discover auth entries that need WebAuthn signing + const sim = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw new Error(`Simulation failed: ${sim.error}`); + } + + const assembled = SorobanRpc.assembleTransaction(tx, sim).build(); + + // Sign auth entries that require the wallet's WebAuthn authorization. + const successSim = sim as SorobanRpc.Api.SimulateTransactionSuccessResponse; + const authEntries = successSim.result?.auth; + if (authEntries) { + for (const parsed of authEntries) { + const cred = parsed.credentials(); + if (cred.switch().value !== xdr.SorobanCredentialsType.sorobanCredentialsAddress().value) { + continue; + } + + const invocationXdr = parsed.rootInvocation().toXDR(); + const payloadHash = new Uint8Array( + await crypto.subtle.digest('SHA-256', new Uint8Array(invocationXdr)) + ); + + const webAuthnSig = await signAuthEntry(payloadHash); + if (!webAuthnSig) throw new Error('WebAuthn signing was cancelled'); + + const sigVec = xdr.ScVal.scvVec([ + nativeToScVal(webAuthnSig.publicKey, { type: 'bytes' }), + nativeToScVal(webAuthnSig.authData, { type: 'bytes' }), + nativeToScVal(webAuthnSig.clientDataJSON, { type: 'bytes' }), + nativeToScVal(webAuthnSig.signature, { type: 'bytes' }), + ]); + + const addrCred = cred.address(); + parsed.credentials( + xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: addrCred.address(), + nonce: addrCred.nonce(), + signatureExpirationLedger: addrCred.signatureExpirationLedger(), + signature: sigVec, + }) + ) + ); + } + } + + assembled.sign(signerKeypair); + + const sendResult = await server.sendTransaction(assembled); + if (sendResult.status === 'ERROR') { + throw new Error( + `Transaction rejected: ${sendResult.errorResult?.toXDR('base64') ?? 'unknown error'}` + ); + } + + const txResult = await waitForTransaction(server, sendResult.hash); + if (txResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error(`Transaction failed with status: ${txResult.status}`); + } + + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + throw err; + } finally { + setIsPending(false); + } + }, [address, rpcUrl, networkPassphrase, signAuthEntry]); + return useMemo(() => ( - { address, isDeployed, isPending, error, register, deploy, signAuthEntry, login, getNonce, addSigner, removeSigner, getSigners, setGuardian, initiateRecovery, completeRecovery } - ), [address, isDeployed, isPending, error, register, deploy, signAuthEntry, login, getNonce, addSigner, removeSigner, getSigners, setGuardian, initiateRecovery, completeRecovery]); + { address, isDeployed, isPending, error, register, deploy, signAuthEntry, login, getNonce, addSigner, removeSigner, getSigners, setGuardian, initiateRecovery, completeRecovery, approve, getAllowance } + ), [address, isDeployed, isPending, error, register, deploy, signAuthEntry, login, getNonce, addSigner, removeSigner, getSigners, setGuardian, initiateRecovery, completeRecovery, approve, getAllowance]); }