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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ JWT_SECRET=

# Blockchain
RPC_URL=
TOKEN_TRANSFER_CONTRACT_ID=


# Database
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ __pycache__/
target/
.soroban/
.DS_Store
.turbo/
3 changes: 3 additions & 0 deletions contracts/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ target
# Local settings
.soroban
.stellar

# test snapshots
test_snapshots/
7 changes: 7 additions & 0 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions contracts/contracts/token_transfer/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
89 changes: 89 additions & 0 deletions contracts/contracts/token_transfer/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
20 changes: 20 additions & 0 deletions contracts/contracts/token_transfer/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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,
}
137 changes: 137 additions & 0 deletions contracts/contracts/token_transfer/src/test.rs
Original file line number Diff line number Diff line change
@@ -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));
}
8 changes: 8 additions & 0 deletions contracts/contracts/token_transfer/src/token_interface.rs
Original file line number Diff line number Diff line change
@@ -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;
}
65 changes: 65 additions & 0 deletions contracts/scripts/deploy_token_transfer.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading