diff --git a/soroban/Cargo.toml b/soroban/Cargo.toml index a3b3299a..11ee7399 100644 --- a/soroban/Cargo.toml +++ b/soroban/Cargo.toml @@ -4,4 +4,5 @@ members = [ "test-stablecoin", "predicate-registry", "predicate-client", + "example-compliant-token", ] diff --git a/soroban/example-compliant-token/Cargo.toml b/soroban/example-compliant-token/Cargo.toml new file mode 100644 index 00000000..f6a23f38 --- /dev/null +++ b/soroban/example-compliant-token/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "example-compliant-token" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = "23.5.3" +predicate-client = { path = "../predicate-client" } + +[dev-dependencies] +soroban-sdk = { version = "23.5.3", features = ["testutils"] } +predicate-registry = { path = "../predicate-registry" } +ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } +rand = "0.8.5" diff --git a/soroban/example-compliant-token/src/lib.rs b/soroban/example-compliant-token/src/lib.rs new file mode 100644 index 00000000..c052532c --- /dev/null +++ b/soroban/example-compliant-token/src/lib.rs @@ -0,0 +1,311 @@ +#![no_std] + +use predicate_client::Attestation; +use soroban_sdk::{ + contract, contracterror, contractimpl, symbol_short, Address, Bytes, Env, String, Symbol, +}; + +// Storage keys +const ADMIN: Symbol = symbol_short!("admin"); +const REGISTRY: Symbol = symbol_short!("registry"); +const POLICY: Symbol = symbol_short!("policy"); +const NETWORK: Symbol = symbol_short!("network"); + +/// Storage key for token balances: (BALANCE, address) -> i128 +const BALANCE: Symbol = symbol_short!("balance"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum TokenError { + InsufficientBalance = 1, + InvalidAmount = 2, +} + +/// A minimal token contract that requires Predicate attestation for transfers. +/// +/// This demonstrates how to integrate `predicate-client` into a Soroban contract. +/// Every transfer must be accompanied by a valid attestation from a registered attester, +/// proving the transaction satisfies the configured compliance policy. +#[contract] +pub struct CompliantTokenContract; + +#[contractimpl] +impl CompliantTokenContract { + /// Deploy the token with a Predicate Registry binding. + /// + /// # Arguments + /// * `admin` - Token admin who can mint + /// * `registry` - Address of the deployed PredicateRegistry contract + /// * `policy_id` - Policy identifier (e.g. "x-a1b2c3d4e5f6g7h8") + /// * `network` - Stellar network passphrase (e.g. "Test SDF Network ; September 2015") + pub fn __constructor( + e: &Env, + admin: Address, + registry: Address, + policy_id: String, + network: String, + ) { + e.storage().instance().set(&ADMIN, &admin); + e.storage().instance().set(®ISTRY, ®istry); + e.storage().instance().set(&POLICY, &policy_id); + e.storage().instance().set(&NETWORK, &network); + } + + /// Register this contract's policy with the Predicate Registry. + /// Call this once after deployment. The admin must authorize. + pub fn register_policy(e: &Env) { + let admin: Address = e.storage().instance().get(&ADMIN).unwrap(); + admin.require_auth(); + + let registry: Address = e.storage().instance().get(®ISTRY).unwrap(); + let policy_id: String = e.storage().instance().get(&POLICY).unwrap(); + + let args: soroban_sdk::Vec = soroban_sdk::vec![ + e, + soroban_sdk::IntoVal::into_val(&e.current_contract_address(), e), + soroban_sdk::IntoVal::into_val(&policy_id, e), + ]; + e.invoke_contract::<()>(®istry, &Symbol::new(e, "set_policy_id"), args); + } + + /// Mint tokens to an address. Admin only, no attestation required. + pub fn mint(e: &Env, to: Address, amount: i128) { + let admin: Address = e.storage().instance().get(&ADMIN).unwrap(); + admin.require_auth(); + + let key = (BALANCE, to.clone()); + let current: i128 = e.storage().persistent().get(&key).unwrap_or(0); + e.storage().persistent().set(&key, &(current + amount)); + } + + /// Transfer tokens. Requires a valid Predicate attestation. + /// + /// This is the key function: before moving tokens, it calls the Predicate + /// Registry via `predicate_client::authorize_transaction()` to verify that + /// the transfer is compliant with the configured policy. + pub fn transfer( + e: &Env, + from: Address, + to: Address, + amount: i128, + attestation: Attestation, + ) -> Result<(), TokenError> { + from.require_auth(); + + if amount <= 0 { + return Err(TokenError::InvalidAmount); + } + + // Check balance + let from_key = (BALANCE, from.clone()); + let balance: i128 = e.storage().persistent().get(&from_key).unwrap_or(0); + if balance < amount { + return Err(TokenError::InsufficientBalance); + } + + // --- Predicate compliance check --- + let registry: Address = e.storage().instance().get(®ISTRY).unwrap(); + let policy: String = e.storage().instance().get(&POLICY).unwrap(); + let network: String = e.storage().instance().get(&NETWORK).unwrap(); + + // Encode the transfer call data for the attestation + let encoded_call = Bytes::from_slice( + e, + &e.crypto() + .sha256(&soroban_sdk::Bytes::from_slice( + e, + b"transfer(address,address,i128)", + )) + .to_array(), + ); + + predicate_client::authorize_transaction( + e, + ®istry, + &attestation, + &encoded_call, + &from, + amount, + &e.current_contract_address(), + &policy, + &network, + ); + // --- End compliance check --- + + // Execute transfer + let to_key = (BALANCE, to.clone()); + let to_balance: i128 = e.storage().persistent().get(&to_key).unwrap_or(0); + e.storage().persistent().set(&from_key, &(balance - amount)); + e.storage() + .persistent() + .set(&to_key, &(to_balance + amount)); + + Ok(()) + } + + /// Query token balance. + pub fn balance(e: &Env, account: Address) -> i128 { + let key = (BALANCE, account); + e.storage().persistent().get(&key).unwrap_or(0) + } + + /// Query the registry address. + pub fn registry(e: &Env) -> Address { + e.storage().instance().get(®ISTRY).unwrap() + } + + /// Query the policy ID. + pub fn policy_id(e: &Env) -> String { + e.storage().instance().get(&POLICY).unwrap() + } +} + +#[cfg(test)] +mod test { + extern crate std; + + use super::*; + use predicate_registry::{PredicateRegistryContract, Statement}; + use soroban_sdk::{testutils::Address as _, BytesN, Env}; + + fn generate_ed25519_keypair(e: &Env) -> (ed25519_dalek::SigningKey, BytesN<32>) { + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + let sk = SigningKey::generate(&mut OsRng); + let pk_bytes = sk.verifying_key().to_bytes(); + (sk, BytesN::from_array(e, &pk_bytes)) + } + + fn sign_hash(e: &Env, sk: &ed25519_dalek::SigningKey, hash: &BytesN<32>) -> BytesN<64> { + use ed25519_dalek::Signer; + let sig = sk.sign(&hash.to_array()); + BytesN::from_array(e, &sig.to_bytes()) + } + + /// Full end-to-end: deploy registry, deploy token, mint, transfer with attestation + #[test] + fn test_compliant_transfer() { + let e = Env::default(); + e.mock_all_auths(); + + let network = String::from_str(&e, "Test SDF Network ; September 2015"); + let policy_id = String::from_str(&e, "x-example-policy"); + + // 1. Deploy the Predicate Registry + let registry_owner = Address::generate(&e); + let registry_addr = e.register(PredicateRegistryContract, (registry_owner.clone(),)); + let registry_client = + predicate_registry::PredicateRegistryContractClient::new(&e, ®istry_addr); + + // 2. Register an attester + let (attester_sk, attester_pk) = generate_ed25519_keypair(&e); + registry_client.register_attester(®istry_owner, &attester_pk); + + // 3. Deploy the compliant token + let admin = Address::generate(&e); + let token_addr = e.register( + CompliantTokenContract, + ( + admin.clone(), + registry_addr.clone(), + policy_id.clone(), + network.clone(), + ), + ); + let token = CompliantTokenContractClient::new(&e, &token_addr); + + // 4. Register policy with the registry + token.register_policy(); + + // 5. Mint tokens + let alice = Address::generate(&e); + let bob = Address::generate(&e); + token.mint(&alice, &1000); + assert_eq!(token.balance(&alice), 1000); + + // 6. Build attestation for the transfer + let transfer_amount: i128 = 250; + let encoded_call = Bytes::from_slice( + &e, + &e.crypto() + .sha256(&Bytes::from_slice(&e, b"transfer(address,address,i128)")) + .to_array(), + ); + + let statement = Statement { + uuid: String::from_str(&e, "transfer-001"), + msg_sender: alice.clone(), + target: token_addr.clone(), + msg_value: transfer_amount, + encoded_sig_and_args: encoded_call, + policy: policy_id.clone(), + expiration: e.ledger().timestamp() + 600, + }; + + // Hash and sign (this is what the Predicate API does off-chain) + let hash = registry_client.hash_statement(&statement, &network); + let signature = sign_hash(&e, &attester_sk, &hash); + + let attestation = Attestation { + uuid: statement.uuid.clone(), + expiration: statement.expiration, + attester: attester_pk, + signature, + }; + + // 7. Transfer with attestation — should succeed + token.transfer(&alice, &bob, &transfer_amount, &attestation); + + assert_eq!(token.balance(&alice), 750); + assert_eq!(token.balance(&bob), 250); + } + + #[test] + #[should_panic(expected = "HostError: Error(Contract")] + fn test_transfer_without_valid_attestation() { + let e = Env::default(); + e.mock_all_auths(); + + let network = String::from_str(&e, "Test SDF Network ; September 2015"); + let policy_id = String::from_str(&e, "x-example-policy"); + + // Deploy registry and register a real attester + let registry_owner = Address::generate(&e); + let registry_addr = e.register(PredicateRegistryContract, (registry_owner.clone(),)); + let registry_client = + predicate_registry::PredicateRegistryContractClient::new(&e, ®istry_addr); + + let (_attester_sk, attester_pk) = generate_ed25519_keypair(&e); + registry_client.register_attester(®istry_owner, &attester_pk); + + let admin = Address::generate(&e); + let token_addr = e.register( + CompliantTokenContract, + ( + admin.clone(), + registry_addr.clone(), + policy_id.clone(), + network.clone(), + ), + ); + let token = CompliantTokenContractClient::new(&e, &token_addr); + + // Register policy so the only failure is the bad attestation + token.register_policy(); + + let alice = Address::generate(&e); + let bob = Address::generate(&e); + token.mint(&alice, &1000); + + // Fake attestation with garbage signature — should fail verification + let attestation = Attestation { + uuid: String::from_str(&e, "fake-uuid"), + expiration: e.ledger().timestamp() + 600, + attester: BytesN::from_array(&e, &[0xFFu8; 32]), + signature: BytesN::from_array(&e, &[0x00u8; 64]), + }; + + token.transfer(&alice, &bob, &100, &attestation); + } +} diff --git a/soroban/predicate-client/Cargo.toml b/soroban/predicate-client/Cargo.toml index cd1c76c0..c5d118f6 100644 --- a/soroban/predicate-client/Cargo.toml +++ b/soroban/predicate-client/Cargo.toml @@ -8,9 +8,9 @@ doctest = false [dependencies] soroban-sdk = "23.5.3" -predicate-registry = { path = "../predicate-registry" } [dev-dependencies] soroban-sdk = { version = "23.5.3", features = ["testutils"] } +predicate-registry = { path = "../predicate-registry" } ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } rand = "0.8.5" diff --git a/soroban/predicate-client/src/lib.rs b/soroban/predicate-client/src/lib.rs index 127b7247..341d16c8 100644 --- a/soroban/predicate-client/src/lib.rs +++ b/soroban/predicate-client/src/lib.rs @@ -1,9 +1,52 @@ #![no_std] -/// Re-export registry types for downstream convenience. -pub use predicate_registry::{Attestation, RegistryError, Statement}; +use soroban_sdk::{ + contracterror, contracttype, vec, Address, Bytes, BytesN, Env, IntoVal, String, Symbol, Val, + Vec, +}; + +// --- Types (mirrored from predicate-registry to avoid linking the contract impl) --- + +/// Describes a transaction to be authorized. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Statement { + pub uuid: String, + pub msg_sender: Address, + pub target: Address, + pub msg_value: i128, + pub encoded_sig_and_args: Bytes, + pub policy: String, + pub expiration: u64, +} + +/// Ed25519-signed authorization from an attester. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Attestation { + pub uuid: String, + pub expiration: u64, + pub attester: BytesN<32>, + pub signature: BytesN<64>, +} -use soroban_sdk::{vec, Address, Bytes, Env, IntoVal, String, Symbol, Val, Vec}; +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum RegistryError { + Unauthorized = 1, + AttesterAlreadyRegistered = 2, + AttesterNotRegistered = 3, + AttestationExpired = 4, + UuidAlreadyUsed = 5, + UuidMismatch = 6, + ExpirationMismatch = 7, + InvalidSignature = 8, + NotInitialized = 9, + AlreadyInitialized = 10, +} + +// --- Client helper --- /// Build a Statement and validate it against the Predicate Registry. /// @@ -62,7 +105,7 @@ mod test { use super::*; use predicate_registry::PredicateRegistryContract; - use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; + use soroban_sdk::{testutils::Address as _, Address, Env}; fn setup_registry(e: &Env) -> (Address, Address) { let owner = Address::generate(e); @@ -91,22 +134,19 @@ mod test { let (owner, registry_addr) = setup_registry(&e); let network = String::from_str(&e, "Test SDF Network ; September 2015"); - // Register an attester via the registry client let registry_client = predicate_registry::PredicateRegistryContractClient::new(&e, ®istry_addr); let (sk, pub_key) = generate_ed25519_keypair(&e); registry_client.register_attester(&owner, &pub_key); - // Simulate a downstream contract calling authorize_transaction let msg_sender = Address::generate(&e); let target = Address::generate(&e); let policy = String::from_str(&e, "x-test-policy"); let encoded = Bytes::from_slice(&e, &[0xBBu8; 16]); let msg_value: i128 = 1000; - // Build the statement the same way authorize_transaction will, - // then hash+sign it so the registry can verify. - let statement = Statement { + // Build statement matching what authorize_transaction will build + let statement = predicate_registry::Statement { uuid: String::from_str(&e, "uuid-client-test"), msg_sender: msg_sender.clone(), target: target.clone(), @@ -120,8 +160,8 @@ mod test { let signature = sign_hash(&e, &sk, &hash); let attestation = Attestation { - uuid: statement.uuid.clone(), - expiration: statement.expiration, + uuid: String::from_str(&e, "uuid-client-test"), + expiration: e.ledger().timestamp() + 600, attester: pub_key, signature, }; diff --git a/soroban/scripts/deploy-compliant-token.sh b/soroban/scripts/deploy-compliant-token.sh new file mode 100755 index 00000000..6c7d6002 --- /dev/null +++ b/soroban/scripts/deploy-compliant-token.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy example-compliant-token to Stellar testnet +# +# Prerequisites: +# - stellar CLI installed +# - A PredicateRegistry already deployed (use deploy-registry.sh) +# +# Usage: +# ./deploy-compliant-token.sh +# +# Example: +# ./deploy-compliant-token.sh deployer CABC...XYZ x-a1b2c3d4e5f6g7h8 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOROBAN_DIR="$(dirname "$SCRIPT_DIR")" + +NETWORK="testnet" +NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + +if [ $# -lt 3 ]; then + echo "Usage: $0 " + echo "" + echo " identity Stellar CLI identity name" + echo " registry_contract_id Deployed PredicateRegistry contract ID (C...)" + echo " policy_id Policy identifier (e.g. x-a1b2c3d4e5f6g7h8)" + exit 1 +fi + +IDENTITY="$1" +REGISTRY_ID="$2" +POLICY_ID="$3" +ADMIN_ADDRESS=$(stellar keys address "$IDENTITY") + +# --- Build --- + +echo "Building example-compliant-token..." +cd "$SOROBAN_DIR" +cargo build --release --target wasm32-unknown-unknown -p example-compliant-token + +WASM_PATH="$SOROBAN_DIR/target/wasm32-unknown-unknown/release/example_compliant_token.wasm" + +if [ ! -f "$WASM_PATH" ]; then + echo "ERROR: WASM not found at $WASM_PATH" + exit 1 +fi + +echo "WASM size: $(wc -c < "$WASM_PATH" | tr -d ' ') bytes" + +# --- Deploy --- + +echo "" +echo "Deploying CompliantToken to $NETWORK..." +echo " Admin: $ADMIN_ADDRESS" +echo " Registry: $REGISTRY_ID" +echo " Policy: $POLICY_ID" +echo " Network: $NETWORK_PASSPHRASE" + +TOKEN_ID=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source "$IDENTITY" \ + --network "$NETWORK" \ + -- \ + --admin "$ADMIN_ADDRESS" \ + --registry "$REGISTRY_ID" \ + --policy_id "$POLICY_ID" \ + --network "$NETWORK_PASSPHRASE") + +echo "Token deployed: $TOKEN_ID" + +# --- Register policy with the registry --- + +echo "" +echo "Registering policy '$POLICY_ID' with the registry..." +stellar contract invoke \ + --id "$TOKEN_ID" \ + --source "$IDENTITY" \ + --network "$NETWORK" \ + --send=yes \ + -- \ + register_policy +echo "Policy registered." + +echo "" +echo "=== Compliant Token Deployment Complete ===" +echo " Network: $NETWORK" +echo " Token: $TOKEN_ID" +echo " Admin: $ADMIN_ADDRESS" +echo " Registry: $REGISTRY_ID" +echo " Policy: $POLICY_ID" +echo "" +echo "Usage:" +echo " # Mint tokens (admin only, no attestation needed):" +echo " stellar contract invoke --id $TOKEN_ID --source $IDENTITY --network $NETWORK --send=yes -- mint --to
--amount 1000000" +echo "" +echo " # Transfer tokens (requires attestation from Predicate API):" +echo " stellar contract invoke --id $TOKEN_ID --source $IDENTITY --network $NETWORK --send=yes -- transfer --from --to --amount --attestation '{...}'" +echo "" +echo " # Check balance:" +echo " stellar contract invoke --id $TOKEN_ID --source $IDENTITY --network $NETWORK -- balance --account
" diff --git a/soroban/scripts/deploy-registry.sh b/soroban/scripts/deploy-registry.sh new file mode 100755 index 00000000..fccd0eae --- /dev/null +++ b/soroban/scripts/deploy-registry.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy PredicateRegistry to Stellar testnet +# +# Prerequisites: +# - stellar CLI installed +# - An identity configured: stellar keys generate --network testnet +# +# Usage: +# ./deploy-registry.sh [attester_pubkey_hex] +# +# Examples: +# ./deploy-registry.sh deployer +# ./deploy-registry.sh deployer abc123... # also register an attester + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOROBAN_DIR="$(dirname "$SCRIPT_DIR")" + +NETWORK="testnet" + +if [ $# -lt 1 ]; then + echo "Usage: $0 [attester_pubkey_hex]" + echo "" + echo " identity Stellar CLI identity name (from 'stellar keys')" + echo " attester_pubkey_hex Optional: Ed25519 attester public key (64 hex chars)" + echo " to register immediately after deployment" + exit 1 +fi + +IDENTITY="$1" +ATTESTER_PK="${2:-}" +OWNER_ADDRESS=$(stellar keys address "$IDENTITY") + +# --- Build --- + +echo "Building predicate-registry..." +cd "$SOROBAN_DIR" +cargo build --release --target wasm32-unknown-unknown -p predicate-registry + +WASM_PATH="$SOROBAN_DIR/target/wasm32-unknown-unknown/release/predicate_registry.wasm" + +if [ ! -f "$WASM_PATH" ]; then + echo "ERROR: WASM not found at $WASM_PATH" + exit 1 +fi + +echo "WASM size: $(wc -c < "$WASM_PATH" | tr -d ' ') bytes" + +# --- Deploy --- + +echo "" +echo "Deploying PredicateRegistry to $NETWORK..." +echo " Owner: $OWNER_ADDRESS" + +REGISTRY_ID=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source "$IDENTITY" \ + --network "$NETWORK" \ + -- \ + --owner "$OWNER_ADDRESS") + +echo "Registry deployed: $REGISTRY_ID" + +# --- Register attester (optional) --- + +if [ -n "$ATTESTER_PK" ]; then + echo "" + echo "Registering attester: $ATTESTER_PK" + stellar contract invoke \ + --id "$REGISTRY_ID" \ + --source "$IDENTITY" \ + --network "$NETWORK" \ + --send=yes \ + -- \ + register_attester \ + --owner "$OWNER_ADDRESS" \ + --attester "$ATTESTER_PK" + echo "Attester registered." +fi + +echo "" +echo "=== Registry Deployment Complete ===" +echo " Network: $NETWORK" +echo " Registry: $REGISTRY_ID" +echo " Owner: $OWNER_ADDRESS" +if [ -n "$ATTESTER_PK" ]; then + echo " Attester: $ATTESTER_PK" +fi +echo "" +echo "Next steps:" +echo " 1. Register attesters (if not done above):" +echo " stellar contract invoke --id $REGISTRY_ID --source $IDENTITY --network $NETWORK --send=yes -- register_attester --owner $OWNER_ADDRESS --attester " +echo "" +echo " 2. Deploy a compliant contract and pass this registry address to it."