From fb0634e9e9db3ba25db658e34672eedf238f86ea Mon Sep 17 00:00:00 2001 From: Dmytro Kozhevin Date: Tue, 11 Oct 2022 00:44:24 -0400 Subject: [PATCH] Example simple wallet with async multisig support. (#106) --- Cargo.lock | 10 + Cargo.toml | 1 + wallet/Cargo.toml | 24 +++ wallet/src/lib.rs | 247 +++++++++++++++++++++++++ wallet/src/test.rs | 442 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 724 insertions(+) create mode 100644 wallet/Cargo.toml create mode 100644 wallet/src/lib.rs create mode 100644 wallet/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index 0b6427dc..aef43048 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -937,6 +937,16 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "soroban-wallet-contract" +version = "0.0.0" +dependencies = [ + "ed25519-dalek", + "rand 0.7.3", + "soroban-auth", + "soroban-sdk", +] + [[package]] name = "soroban-wasmi" version = "0.16.0-soroban1" diff --git a/Cargo.toml b/Cargo.toml index 4e6aa83d..7ea977d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "token", "logging", "errors", + "wallet", ] [profile.release-with-logs] diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml new file mode 100644 index 00000000..223a507a --- /dev/null +++ b/wallet/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "soroban-wallet-contract" +version = "0.0.0" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = "0.1.0" +soroban-auth = "0.1.0" + +[dev_dependencies] +soroban-sdk = { version = "0.1.0", features = ["testutils"] } +soroban-auth = { version = "0.1.0", features = ["testutils"] } +rand = { version = "0.7.3" } +ed25519-dalek = { version = "1.0.1" } \ No newline at end of file diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs new file mode 100644 index 00000000..d081e187 --- /dev/null +++ b/wallet/src/lib.rs @@ -0,0 +1,247 @@ +//! This contract implements a simple smart wallet and mainly demonstrates more +//! complex auth scheme with multiple signers that authorize payments in immediate +//! or delayed (async) fashion. +#![no_std] +#[cfg(feature = "testutils")] +extern crate std; + +use soroban_auth::{verify, Identifier, Signature}; +use soroban_sdk::{contractimpl, contracttype, map, symbol, vec, BigInt, BytesN, Env, Map, Vec}; +mod token { + soroban_sdk::contractimport!(file = "../soroban_token_spec.wasm"); +} + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + // Weight assigned to a wallet admin. + AdminW(Identifier), + // Threshold (minimum sum of weights) for execution a transaction. + Threshold, + // `Payment`s keyed by payment identifier. + Payment(i64), + // `WeightedSigners` keyed by payment identifier. + PaySigners(i64), +} + +#[derive(Clone, PartialEq)] +#[contracttype] +pub struct Payment { + pub receiver: Identifier, + pub token: BytesN<32>, + pub amount: BigInt, +} + +#[derive(Clone)] +#[contracttype] +pub struct WeightedSigners { + pub signers: Vec, + pub weight: u32, +} + +#[derive(Clone)] +#[contracttype] +pub struct Admin { + pub id: Identifier, + pub weight: u32, +} + +const MAX_ADMINS: u32 = 20; +const MAX_WEIGHT: u32 = 100; + +pub struct WalletContract; + +// Contract usage: +// - Call `initialize` once to setup the contract admins, their weights and +// payment weight threshold. For simplicity, this setup is immutable. +// - Fund the wallet contract as needed using token contract functionality. +// - To execute the payment: +// 1. Distribute a pair of `(payment_id, Payment)` to the wallet admins for +// signing. `payment_id` should be unique for every payment. `payment_id` +// management is not implemented here for the sake of conciseness and could +// happen both off-chain or in the contract itself. +// 2. Call `pay` one or many times with arbitrary batches of admin signatures +// until enough admin weight accumulated (i.e. at least `threshold`) to +// actually execute it. +#[contractimpl] +impl WalletContract { + // Performs contract intialization. + // Call `initialize` and supply ids and weights of the admins, as well as the + // threshold needed to execute payments. The payment may only be executed when + // unique admins with combined weight exceeding `threshold` have signed it. + pub fn initialize(env: Env, admins: Vec, threshold: u32) { + check_initialization_params(&env, &admins, threshold); + + let mut weight_sum = 0; + for maybe_admin in admins.iter() { + let admin = maybe_admin.unwrap(); + if admin.weight == 0 { + panic!("weight should be non-zero"); + } + if admin.weight > MAX_WEIGHT { + panic!("too high admin weight"); + } + weight_sum += admin.weight; + // Record admin weight (and effectively admin identifier too). + env.data().set(DataKey::AdminW(admin.id), admin.weight); + } + // Do a basic sanity check to make sure we don't create a locked wallet. + if weight_sum < threshold { + panic!("admin weight is lower than threshold"); + } + env.data().set(DataKey::Threshold, threshold); + } + + // Stores a provided payment or executes it when enough signer weight is + // accumulated. + // Returns `true` when the payment was executed and `false` otherwise. + // + // Every wallet admin signs `pay` as if it was called by them only, i.e. + // they should sign `pay` function call with argument tuple of + // `(admin_id, payment_id, payment)`. Then the signatures of the wallet + // admins can be batched together in the same `pay` call. + // This allows using the same signature set in any `pay` call scenario, + // i.e. it's possible to execute the payment immediately after gathering all + // the signatures off-chain, or it's possible to call `pay` for every admin + // separately until it executes, or any combinaton of the above options. + // + // Note on replay prevention: this call doesn't need additional replay + // prevention (nonces) as only the first call of `pay` per signer is + // meaningful (all the further calls will just fail). + pub fn pay(env: Env, signatures: Vec, payment_id: i64, payment: Payment) -> bool { + let mut weight_sum = + validate_and_compute_signature_weight(&env, &signatures, payment_id, &payment); + let mut is_existing_payment = false; + let mut signer_ids = vec![&env]; + if let Some(maybe_previous_signers) = env.data().get(&DataKey::PaySigners(payment_id)) { + is_existing_payment = true; + // If there were previous signers for this payment id, we need to check that + // the payment still hasn't been executed (it should be removed on execution) + // and that it matches the payment signed by the new signers. + let stored_payment: Payment = env + .data() + .get_unchecked(&DataKey::Payment(payment_id)) + .unwrap(); + if stored_payment != payment { + panic!("stored payment doesn't match new payment with same id"); + } + let previous_signers: WeightedSigners = maybe_previous_signers.unwrap(); + signer_ids = previous_signers.signers; + // Check that no new signers have already signed this payment and + // panic if that's not the case. + // This is only one option of how to handle this; an alternative approach + // сould be to only account for weight of the new signers, but that's likely + // more error-prone (there shouldn't be a reason for an admin to + // resubmit the signature). + for maybe_signature in signatures.iter() { + let id = maybe_signature.unwrap().identifier(&env); + if signer_ids.contains(&id) { + panic!("one of the signers has already signed this payment"); + } + } + weight_sum += previous_signers.weight; + } + + for maybe_signature in signatures.iter() { + signer_ids.push_back(maybe_signature.unwrap().identifier(&env)); + } + // Update signer data. This also serves as a protection from + // re-executing the payment with the same id (a separate entry could + // serve this purpose as well). + env.data().set( + DataKey::PaySigners(payment_id), + WeightedSigners { + signers: signer_ids, + weight: weight_sum, + }, + ); + + let threshold = read_threshold(&env); + // When there is enough signature weight to authorize this payment + // execute the payment immediately. + if weight_sum >= threshold { + execute_payment(&env, payment); + // Remove the payment to mark it executed (signers are still there). + env.data().remove(&DataKey::Payment(payment_id)); + return true; + } + if !is_existing_payment { + env.data().set(DataKey::Payment(payment_id), payment); + } + + false + } +} + +fn check_initialization_params(env: &Env, admins: &Vec, threshold: u32) { + if threshold == 0 { + panic!("threshold has to be non-zero"); + } + if admins.len() == 0 { + panic!("at least one admin needs to be provided"); + } + if admins.len() > MAX_ADMINS { + panic!("too many admins"); + } + if threshold > MAX_WEIGHT * MAX_ADMINS { + panic!("threshold is too high"); + } + if env.data().has(DataKey::Threshold) { + panic!("contract has already been initialized"); + } +} + +// Performs auth and duplication check on the provided signatures and +// returns their combined weight. +fn validate_and_compute_signature_weight( + env: &Env, + signatures: &Vec, + payment_id: i64, + payment: &Payment, +) -> u32 { + let mut weight_sum = 0; + let mut unique_ids: Map = map![&env]; + + for maybe_signature in signatures.iter() { + let signature = maybe_signature.unwrap(); + let id = signature.identifier(&env); + // Accumulate the weights and take care of non-authorized accounts + // at the same time (non-authorized accounts won't have weight). + weight_sum += read_weight(env, &id); + + verify( + &env, + &signature, + symbol!("pay"), + (&id, &payment_id, payment), + ); + unique_ids.set(id, ()); + } + if unique_ids.len() != signatures.len() { + panic!("duplicate signatures provided"); + } + + weight_sum +} + +fn execute_payment(env: &Env, payment: Payment) { + let client = token::Client::new(&env, payment.token); + client.xfer( + &Signature::Invoker, + &BigInt::zero(&env), + &payment.receiver, + &payment.amount, + ); +} + +fn read_threshold(env: &Env) -> u32 { + env.data().get_unchecked(DataKey::Threshold).unwrap() +} + +fn read_weight(env: &Env, id: &Identifier) -> u32 { + env.data() + .get_unchecked(DataKey::AdminW(id.clone())) + .unwrap() +} + +mod test; diff --git a/wallet/src/test.rs b/wallet/src/test.rs new file mode 100644 index 00000000..af746705 --- /dev/null +++ b/wallet/src/test.rs @@ -0,0 +1,442 @@ +#![cfg(test)] + +use super::*; +use ed25519_dalek::Keypair; +use rand::{thread_rng, RngCore}; +use soroban_auth::{Ed25519Signature, Identifier, SignaturePayload, SignaturePayloadV0}; +use soroban_sdk::testutils::ed25519::Sign; +use soroban_sdk::testutils::Accounts; +use soroban_sdk::{vec, AccountId, Env, IntoVal, RawVal, Symbol, Vec}; +use token::{Client as TokenClient, TokenMetadata}; + +fn generate_keypair() -> Keypair { + Keypair::generate(&mut thread_rng()) +} + +fn generate_id() -> [u8; 32] { + let mut id: [u8; 32] = Default::default(); + thread_rng().fill_bytes(&mut id); + id +} + +// Note: we use `AccountId` here and `Ed25519` signers everywhere else in this +// test only for the sake of the test setup simplicity. There are no limitations +// on types of identifiers used in any contexts here. +fn create_token_contract(e: &Env, admin: &AccountId) -> (BytesN<32>, TokenClient) { + let id = e.register_contract_token(None); + let token = TokenClient::new(e, &id); + // decimals, name, symbol don't matter in tests + token.init( + &Identifier::Account(admin.clone()), + &TokenMetadata { + name: "name".into_val(e), + symbol: "symbol".into_val(e), + decimals: 7, + }, + ); + (id, token) +} + +fn create_wallet_contract(e: &Env) -> WalletContractClient { + let contract_id = BytesN::from_array(e, &generate_id()); + e.register_contract(&contract_id, WalletContract {}); + + WalletContractClient::new(e, contract_id) +} + +fn sign_args( + env: &Env, + signer: &Keypair, + fn_name: &str, + contract_id: &BytesN<32>, + args: Vec, +) -> Signature { + let msg = SignaturePayload::V0(SignaturePayloadV0 { + name: Symbol::from_str(fn_name), + contract: contract_id.clone(), + network: env.ledger().network_passphrase(), + args, + }); + sign_payload(env, signer, msg) +} + +fn sign_payload(env: &Env, signer: &Keypair, payload: SignaturePayload) -> Signature { + Signature::Ed25519(Ed25519Signature { + public_key: signer.public.to_bytes().into_val(env), + signature: signer.sign(payload).unwrap().into_val(env), + }) +} + +struct WalletTest { + env: Env, + wallet_admins: [Keypair; 3], + payment_receiver: Identifier, + token: TokenClient, + token_id: BytesN<32>, + token_2: TokenClient, + token_id_2: BytesN<32>, + token_admin: AccountId, + contract: WalletContractClient, + contract_id: Identifier, +} + +impl WalletTest { + fn setup() -> Self { + let env: Env = Default::default(); + + let wallet_admins = [generate_keypair(), generate_keypair(), generate_keypair()]; + + let token_admin = env.accounts().generate(); + let (token_id, token) = create_token_contract(&env, &token_admin); + let (token_id_2, token_2) = create_token_contract(&env, &token_admin); + + let contract = create_wallet_contract(&env); + let contract_id = Identifier::Contract(contract.contract_id.clone()); + let payment_receiver = Identifier::Ed25519(BytesN::from_array(&env, &generate_id())); + WalletTest { + env, + wallet_admins, + payment_receiver, + token, + token_id, + token_2, + token_id_2, + token_admin, + contract, + contract_id, + } + } + + fn initialize(&self, admin_weights: [u32; 3], threshold: u32) { + let mut admins = vec![&self.env]; + for i in 0..self.wallet_admins.len() { + admins.push_back(Admin { + id: self.signer_to_id(&self.wallet_admins[i]), + weight: admin_weights[i], + }); + } + + self.contract.initialize(&admins, &threshold); + } + + fn signer_to_id(&self, signer: &Keypair) -> Identifier { + Identifier::Ed25519(BytesN::<32>::from_array( + &self.env, + &signer.public.to_bytes(), + )) + } + + fn add_wallet_balance(&self, token: &TokenClient, amount: u32) { + token.with_source_account(&self.token_admin).mint( + &Signature::Invoker, + &BigInt::from_u64(&self.env, 0), + &self.contract_id, + &BigInt::from_u32(&self.env, amount), + ); + } + + fn pay(&self, signers: &[&Keypair], payment_id: i64, payment: Payment) -> bool { + let mut signatures = vec![&self.env]; + for signer in signers { + signatures.push_back(self.sign_pay(&signer, payment_id, &payment)); + } + + self.contract.pay(&signatures, &payment_id, &payment) + } + + fn sign_pay(&self, signer: &Keypair, payment_id: i64, payment: &Payment) -> Signature { + sign_args( + &self.env, + signer, + "pay", + &self.contract.contract_id, + (&self.signer_to_id(signer), payment_id, payment).into_val(&self.env), + ) + } +} + +#[test] +fn test_immediate_payment() { + let test = WalletTest::setup(); + test.initialize([50, 50, 100], 100); + + test.add_wallet_balance(&test.token, 1000); + test.add_wallet_balance(&test.token_2, 2000); + + // Multiple signers with enough combined weight. + assert_eq!( + test.pay( + &[&test.wallet_admins[0], &test.wallet_admins[1]], + 123, + Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 300), + }, + ), + true + ); + + assert_eq!( + test.token.balance(&test.payment_receiver), + BigInt::from_u32(&test.env, 300) + ); + + // Single signer with high enough weight. + assert_eq!( + test.pay( + &[&test.wallet_admins[2]], + 456, + Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id_2.clone(), + amount: BigInt::from_u32(&test.env, 1500), + }, + ), + true + ); + + assert_eq!( + test.token_2.balance(&test.payment_receiver), + BigInt::from_u32(&test.env, 1500) + ); +} + +#[test] +fn test_delayed_payment() { + let test = WalletTest::setup(); + test.initialize([30, 30, 30], 90); + + let payment = Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 300), + }; + // Initialize payment - contract is not required to have the token balance yet. + assert_eq!( + test.pay( + &[&test.wallet_admins[0], &test.wallet_admins[1]], + 123, + payment.clone() + ), + false + ); + + assert_eq!( + test.token.balance(&test.payment_receiver), + BigInt::from_u32(&test.env, 0) + ); + // Add balance and authorize the payment by the remaining signer, + // now the payment can be cleared. + test.add_wallet_balance(&test.token, 1000); + + assert_eq!( + test.pay(&[&test.wallet_admins[2]], 123, payment.clone()), + true + ); + assert_eq!( + test.token.balance(&test.payment_receiver), + BigInt::from_u32(&test.env, 300) + ); +} + +#[test] +fn test_mixed_payments() { + let test = WalletTest::setup(); + test.initialize([30, 30, 30], 90); + + let delayed_payment_1 = Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 500), + }; + assert_eq!( + test.pay(&[&test.wallet_admins[0]], 111, delayed_payment_1.clone(),), + false + ); + + test.add_wallet_balance(&test.token, 1000); + assert_eq!( + test.pay( + &[ + &test.wallet_admins[0], + &test.wallet_admins[1], + &test.wallet_admins[2] + ], + 333, + Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 1000), + }, + ), + true + ); + assert_eq!( + test.token.balance(&test.payment_receiver), + BigInt::from_u32(&test.env, 1000) + ); + + let delayed_payment_2 = Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id_2.clone(), + amount: BigInt::from_u32(&test.env, 2000), + }; + assert_eq!( + test.pay(&[&test.wallet_admins[1]], 222, delayed_payment_2.clone()), + false + ); + + assert_eq!( + test.pay(&[&test.wallet_admins[2]], 111, delayed_payment_1.clone()), + false + ); + test.add_wallet_balance(&test.token_2, 2000); + assert_eq!( + test.pay( + &[&test.wallet_admins[0], &test.wallet_admins[2]], + 222, + delayed_payment_2.clone() + ), + true + ); + assert_eq!( + test.token_2.balance(&test.payment_receiver), + BigInt::from_u32(&test.env, 2000) + ); + + test.add_wallet_balance(&test.token, 500); + assert_eq!( + test.pay(&[&test.wallet_admins[1]], 111, delayed_payment_1.clone()), + true + ); + + assert_eq!( + test.token.balance(&test.payment_receiver), + BigInt::from_u32(&test.env, 1500) + ); +} + +#[test] +#[should_panic(expected = "contract has already been initialized")] +fn test_double_initialization() { + let test = WalletTest::setup(); + test.initialize([30, 30, 30], 50); + test.initialize([30, 30, 30], 50); +} + +#[test] +#[should_panic(expected = "threshold has to be non-zero")] +fn test_non_zero_threshold() { + let test = WalletTest::setup(); + test.initialize([30, 30, 30], 0); +} + +#[test] +#[should_panic(expected = "admin weight is lower than threshold")] +fn test_too_high_threshold() { + let test = WalletTest::setup(); + test.initialize([1, 2, 3], 7); +} + +#[test] +#[should_panic(expected = "weight should be non-zero")] +fn test_zero_weight() { + let test = WalletTest::setup(); + test.initialize([1, 0, 3], 1); +} + +#[test] +#[should_panic(expected = "HostStorageError")] +fn test_unauthorized_signer() { + let test = WalletTest::setup(); + test.initialize([2, 2, 2], 2); + let non_wallet_admin = generate_keypair(); + test.pay( + &[&test.wallet_admins[1], &non_wallet_admin], + 222, + Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 300), + }, + ); +} + +#[test] +#[should_panic(expected = "stored payment doesn't match new payment with same id")] +fn test_divergent_delayed_payment() { + let test = WalletTest::setup(); + test.initialize([2, 2, 2], 4); + test.add_wallet_balance(&test.token, 1000); + + assert_eq!( + test.pay( + &[&test.wallet_admins[1]], + 222, + Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 300), + }, + ), + false + ); + + test.pay( + &[&test.wallet_admins[0]], + 222, + Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 299), + }, + ); +} + +#[test] +#[should_panic(expected = "HostStorageError")] +fn test_payment_reexecution() { + let test = WalletTest::setup(); + test.initialize([2, 2, 2], 2); + let payment = Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 300), + }; + test.add_wallet_balance(&test.token, 1000); + + assert_eq!( + test.pay(&[&test.wallet_admins[1]], 222, payment.clone()), + true + ); + + test.pay(&[&test.wallet_admins[0]], 222, payment.clone()); +} + +#[test] +#[should_panic(expected = "one of the signers has already signed this payment")] +fn test_duplicate_signers() { + let test = WalletTest::setup(); + test.initialize([2, 2, 2], 6); + let payment = Payment { + receiver: test.payment_receiver.clone(), + token: test.token_id.clone(), + amount: BigInt::from_u32(&test.env, 300), + }; + test.add_wallet_balance(&test.token, 1000); + assert_eq!( + test.pay( + &[&test.wallet_admins[0], &test.wallet_admins[1]], + 222, + payment.clone() + ), + false + ); + + test.pay( + &[&test.wallet_admins[2], &test.wallet_admins[0]], + 222, + payment.clone(), + ); +}