diff --git a/.env.example b/.env.example index de2cc04..53911d1 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ JWT_SECRET= # Blockchain RPC_URL= +TOKEN_TRANSFER_CONTRACT_ID= # Database diff --git a/.gitignore b/.gitignore index dc0f27d..e3fab13 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ target/ .soroban/ .DS_Store +.turbo/ \ No newline at end of file diff --git a/contracts/.gitignore b/contracts/.gitignore index d74cc01..575891a 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -4,3 +4,6 @@ target # Local settings .soroban .stellar + +# test snapshots +test_snapshots/ \ No newline at end of file diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 7aca745..7cad153 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1480,6 +1480,13 @@ dependencies = [ "time-core", ] +[[package]] +name = "token_transfer" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/contracts/contracts/token_transfer/Cargo.toml b/contracts/contracts/token_transfer/Cargo.toml new file mode 100644 index 0000000..d1410a4 --- /dev/null +++ b/contracts/contracts/token_transfer/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "token_transfer" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/contracts/token_transfer/src/lib.rs b/contracts/contracts/token_transfer/src/lib.rs new file mode 100644 index 0000000..f389ce7 --- /dev/null +++ b/contracts/contracts/token_transfer/src/lib.rs @@ -0,0 +1,89 @@ +#![no_std] + +mod storage; +mod token_interface; +mod test; + +use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Symbol}; +use storage::{DataKey, TransferEvent}; +use token_interface::TokenClient; + +#[contract] +pub struct TokenTransferContract; + +#[contractimpl] +impl TokenTransferContract { + /// One-time initialisation. Sets the admin and the SEP-41 token this + /// contract will route transfers through. + pub fn initialize(env: Env, admin: Address, token_contract: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenContract, &token_contract); + } + + /// Transfer `amount` of the configured token from `from` to `to`. + /// `memo` is an optional opaque byte string (e.g. a message UUID) that is + /// stored in the emitted event so the backend can correlate the transfer + /// with a chat message. + pub fn transfer(env: Env, from: Address, to: Address, amount: i128, memo: Bytes) { + if amount <= 0 { + panic!("amount must be positive"); + } + + // Require the sender's authorisation + from.require_auth(); + + let token_id: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .expect("not initialized"); + + let token = TokenClient::new(&env, &token_id); + token.transfer(&from, &to, &amount); + + let event = TransferEvent { + from: from.clone(), + to: to.clone(), + amount, + memo, + }; + + env.events().publish( + (Symbol::new(&env, "transfer"),), + event, + ); + } + + /// Read the token balance of any address. + pub fn balance(env: Env, address: Address) -> i128 { + let token_id: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .expect("not initialized"); + + TokenClient::new(&env, &token_id).balance(&address) + } + + /// Returns the configured token contract address. + pub fn token_contract(env: Env) -> Address { + env.storage() + .instance() + .get(&DataKey::TokenContract) + .expect("not initialized") + } + + /// Admin-only: update the token contract (e.g. after a token migration). + pub fn set_token_contract(env: Env, new_token: Address) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + env.storage().instance().set(&DataKey::TokenContract, &new_token); + } +} diff --git a/contracts/contracts/token_transfer/src/storage.rs b/contracts/contracts/token_transfer/src/storage.rs new file mode 100644 index 0000000..e015714 --- /dev/null +++ b/contracts/contracts/token_transfer/src/storage.rs @@ -0,0 +1,20 @@ +use soroban_sdk::{contracttype, Address}; + +/// Persistent storage keys +#[contracttype] +pub enum DataKey { + /// The SEP-41 token contract this transfer contract operates on + TokenContract, + /// Admin address allowed to update the token contract + Admin, +} + +/// Event emitted after every successful transfer +#[contracttype] +pub struct TransferEvent { + pub from: Address, + pub to: Address, + pub amount: i128, + /// Optional opaque reference (e.g. chat message ID) stored as bytes + pub memo: soroban_sdk::Bytes, +} diff --git a/contracts/contracts/token_transfer/src/test.rs b/contracts/contracts/token_transfer/src/test.rs new file mode 100644 index 0000000..ac09634 --- /dev/null +++ b/contracts/contracts/token_transfer/src/test.rs @@ -0,0 +1,137 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::Address as _, + Address, Bytes, Env, +}; + +// ── Minimal mock token contract ─────────────────────────────────────────────── + +mod mock_token { + use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + + #[contracttype] + pub enum Key { + Balance(Address), + } + + #[contract] + pub struct MockToken; + + #[contractimpl] + impl MockToken { + pub fn mint(env: Env, to: Address, amount: i128) { + let key = Key::Balance(to); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(current + amount)); + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + let from_key = Key::Balance(from.clone()); + let to_key = Key::Balance(to.clone()); + let from_bal: i128 = env.storage().persistent().get(&from_key).unwrap_or(0); + assert!(from_bal >= amount, "insufficient balance"); + env.storage().persistent().set(&from_key, &(from_bal - amount)); + let to_bal: i128 = env.storage().persistent().get(&to_key).unwrap_or(0); + env.storage().persistent().set(&to_key, &(to_bal + amount)); + } + + pub fn balance(env: Env, id: Address) -> i128 { + env.storage() + .persistent() + .get(&Key::Balance(id)) + .unwrap_or(0) + } + } +} + +use mock_token::MockTokenClient; + +fn setup(env: &Env) -> (Address, Address, Address, Address) { + let admin = Address::generate(env); + let sender = Address::generate(env); + let receiver = Address::generate(env); + + // Deploy mock token + let token_id = env.register(mock_token::MockToken, ()); + let token = MockTokenClient::new(env, &token_id); + token.mint(&sender, &1_000_000); + + // Deploy transfer contract + let contract_id = env.register(TokenTransferContract, ()); + let client = TokenTransferContractClient::new(env, &contract_id); + client.initialize(&admin, &token_id); + + (contract_id, token_id, sender, receiver) +} + +#[test] +fn test_initialize() { + let env = Env::default(); + let (contract_id, token_id, _sender, _receiver) = setup(&env); + let client = TokenTransferContractClient::new(&env, &contract_id); + assert_eq!(client.token_contract(), token_id); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_double_initialize_panics() { + let env = Env::default(); + let (contract_id, token_id, _sender, _receiver) = setup(&env); + let client = TokenTransferContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin, &token_id); +} + +#[test] +fn test_transfer_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + + let (contract_id, token_id, sender, receiver) = setup(&env); + let client = TokenTransferContractClient::new(&env, &contract_id); + let token = MockTokenClient::new(&env, &token_id); + + let memo = Bytes::from_slice(&env, b"msg-uuid-1234"); + client.transfer(&sender, &receiver, &500_000, &memo); + + assert_eq!(token.balance(&sender), 500_000); + assert_eq!(token.balance(&receiver), 500_000); +} + +#[test] +fn test_balance_query() { + let env = Env::default(); + let (contract_id, _token_id, sender, _receiver) = setup(&env); + let client = TokenTransferContractClient::new(&env, &contract_id); + + assert_eq!(client.balance(&sender), 1_000_000); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_transfer_zero_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _token_id, sender, receiver) = setup(&env); + let client = TokenTransferContractClient::new(&env, &contract_id); + let memo = Bytes::from_slice(&env, b""); + client.transfer(&sender, &receiver, &0, &memo); +} + +#[test] +fn test_transfer_requires_sender_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _token_id, sender, receiver) = setup(&env); + let client = TokenTransferContractClient::new(&env, &contract_id); + let memo = Bytes::from_slice(&env, b"ref"); + + client.transfer(&sender, &receiver, &100, &memo); + + // Verify the sender's auth was required + let auths = env.auths(); + assert!(auths.iter().any(|(addr, _)| *addr == sender)); +} diff --git a/contracts/contracts/token_transfer/src/token_interface.rs b/contracts/contracts/token_transfer/src/token_interface.rs new file mode 100644 index 0000000..4016b03 --- /dev/null +++ b/contracts/contracts/token_transfer/src/token_interface.rs @@ -0,0 +1,8 @@ +use soroban_sdk::{contractclient, Address, Env}; + +/// Minimal SEP-41 token interface used to invoke external token contracts. +#[contractclient(name = "TokenClient")] +pub trait TokenInterface { + fn transfer(env: Env, from: Address, to: Address, amount: i128); + fn balance(env: Env, id: Address) -> i128; +} diff --git a/contracts/scripts/deploy_token_transfer.sh b/contracts/scripts/deploy_token_transfer.sh new file mode 100755 index 0000000..0e2e2d0 --- /dev/null +++ b/contracts/scripts/deploy_token_transfer.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Deploy token_transfer contract to Stellar testnet. +# +# Prerequisites: +# - stellar CLI installed (https://developers.stellar.org/docs/tools/stellar-cli) +# - DEPLOYER_SECRET env var set (Stellar secret key of the deployer/admin account) +# - TOKEN_CONTRACT_ID env var set (contract ID of the SEP-41 token to use) +# +# Usage: +# DEPLOYER_SECRET=S... TOKEN_CONTRACT_ID=C... ./scripts/deploy_token_transfer.sh + +set -euo pipefail + +NETWORK="testnet" +WASM_PATH="target/wasm32-unknown-unknown/release/token_transfer.wasm" + +if [[ -z "${DEPLOYER_SECRET:-}" ]]; then + echo "Error: DEPLOYER_SECRET is not set" >&2 + exit 1 +fi + +if [[ -z "${TOKEN_CONTRACT_ID:-}" ]]; then + echo "Error: TOKEN_CONTRACT_ID is not set" >&2 + exit 1 +fi + +echo "==> Building contract..." +cargo build -p token_transfer --target wasm32-unknown-unknown --release + +echo "==> Uploading WASM to testnet..." +WASM_HASH=$(stellar contract upload \ + --network "$NETWORK" \ + --source "$DEPLOYER_SECRET" \ + --wasm "$WASM_PATH") + +echo " WASM hash: $WASM_HASH" + +echo "==> Deploying contract instance..." +CONTRACT_ID=$(stellar contract deploy \ + --network "$NETWORK" \ + --source "$DEPLOYER_SECRET" \ + --wasm-hash "$WASM_HASH") + +echo " Contract ID: $CONTRACT_ID" + +# Derive the admin public key from the secret +ADMIN_PUBLIC=$(stellar keys public-key --secret-key "$DEPLOYER_SECRET") + +echo "==> Initialising contract..." +stellar contract invoke \ + --network "$NETWORK" \ + --source "$DEPLOYER_SECRET" \ + --id "$CONTRACT_ID" \ + -- initialize \ + --admin "$ADMIN_PUBLIC" \ + --token_contract "$TOKEN_CONTRACT_ID" + +echo "" +echo "✓ token_transfer deployed and initialised" +echo " Contract ID : $CONTRACT_ID" +echo " Token : $TOKEN_CONTRACT_ID" +echo " Admin : $ADMIN_PUBLIC" +echo "" +echo "Add to your .env:" +echo " TOKEN_TRANSFER_CONTRACT_ID=$CONTRACT_ID"