From 0b5e15d8b113dad889bda6fa5acbe67e1d31c1c1 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Sun, 31 May 2026 00:49:35 +0100 Subject: [PATCH 1/3] feat: add token snapshot mechanism for governance voting and airdrops --- contracts/snapshot/Cargo.toml | 7 +++ contracts/token/src/events.rs | 6 ++- contracts/token/src/lib.rs | 82 +++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 contracts/snapshot/Cargo.toml diff --git a/contracts/snapshot/Cargo.toml b/contracts/snapshot/Cargo.toml new file mode 100644 index 0000000..7626aca --- /dev/null +++ b/contracts/snapshot/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "snapshot" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = "~0.7" diff --git a/contracts/token/src/events.rs b/contracts/token/src/events.rs index b400f9b..ef72a49 100644 --- a/contracts/token/src/events.rs +++ b/contracts/token/src/events.rs @@ -137,7 +137,11 @@ pub fn emit_withdraw_locked(env: &Env, user: &Address, amount: i128) { .publish((symbol_short!("unlock"),), (user.clone(), amount)); } -/// Emitted when the contract is upgraded. +/// Emitted when a snapshot is created. +pub fn emit_snapshot_created(env: &Env, snapshot_id: u64) { + env.events().publish((symbol_short!("snapshot_created"),), (snapshot_id,)); +} + pub fn emit_upgrade(env: &Env, admin: &Address, new_wasm_hash: &BytesN<32>) { env.events().publish( (symbol_short!("upgrade"),), diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 5faad34..d10ecae 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -36,6 +36,12 @@ pub enum DataKey { ClawbackAdmin, Lockup(Address), ProposalAction(u64), + // Snapshot support + SnapshotCounter, + SnapshotBalances(u64, Address), + ActiveSnapshots, + BalanceHolders, + } #[derive(Clone, Debug, PartialEq)] @@ -131,6 +137,82 @@ impl BcForgeToken { env.storage() .persistent() .set(&DataKey::Balance(id.clone()), &balance); + // Track address as a balance holder for snapshots + Self::add_balance_holder(env, id); + } + + // ---------- Snapshot Helpers ---------- + fn read_snapshot_counter(env: &Env) -> u64 { + env.storage().persistent().get(&DataKey::SnapshotCounter).unwrap_or(0) + } + + fn write_snapshot_counter(env: &Env, counter: u64) { + env.storage().persistent().set(&DataKey::SnapshotCounter, &counter); + } + + fn read_active_snapshots(env: &Env) -> Vec { + env.storage().persistent().get(&DataKey::ActiveSnapshots).unwrap_or(Vec::new(&env)) + } + + fn write_active_snapshots(env: &Env, snapshots: Vec) { + env.storage().persistent().set(&DataKey::ActiveSnapshots, &snapshots); + } + + fn read_balance_holders(env: &Env) -> Vec
{ + env.storage().persistent().get(&DataKey::BalanceHolders).unwrap_or(Vec::new(&env)) + } + + fn write_balance_holders(env: &Env, holders: Vec
) { + env.storage().persistent().set(&DataKey::BalanceHolders, &holders); + } + + fn add_balance_holder(env: &Env, address: &Address) { + let mut holders = Self::read_balance_holders(env); + // Simple linear check + let mut exists = false; + for h in holders.iter() { + if h == address { + exists = true; + break; + } + } + if !exists { + holders.push_back(address.clone()); + Self::write_balance_holders(env, holders); + } + } + + const MAX_SNAPSHOTS: u32 = 10; + + pub fn create_snapshot(env: Env) -> u64 { + Self::panic_on_err(&env, Self::ensure_initialized(&env)); + let mut counter = Self::read_snapshot_counter(&env) + 1; + // Record balances for all holders + let holders = Self::read_balance_holders(env); + for addr in holders.iter() { + let bal = Self::read_balance(&env, addr); + env.storage().persistent().set(&DataKey::SnapshotBalances(counter, addr.clone()), &bal); + } + // Update active snapshots list + let mut active = Self::read_active_snapshots(&env); + active.push_back(counter); + // Prune old snapshots if exceed limit + while active.len() > Self::MAX_SNAPSHOTS as usize { + if let Some(old_id) = active.front() { + for addr in holders.iter() { + env.storage().persistent().remove(&DataKey::SnapshotBalances(*old_id, addr.clone())); + } + active.pop_front(); + } + } + Self::write_active_snapshots(&env, active); + Self::write_snapshot_counter(&env, counter); + events::emit_snapshot_created(&env, counter); + counter + } + + pub fn balance_at_snapshot(env: Env, address: Address, snapshot_id: u64) -> i128 { + env.storage().persistent().get(&DataKey::SnapshotBalances(snapshot_id, address)).unwrap_or(0) } fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 { From c8326cbe88f563b1a10cdbaa388ed06aa7b9046b Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Sun, 31 May 2026 01:05:38 +0100 Subject: [PATCH 2/3] feat: add token snapshot mechanism for governance voting and airdrops with tests --- Cargo.toml | 4 +++- contracts/snapshot/Cargo.toml | 2 +- contracts/snapshot/src/lib.rs | 2 ++ contracts/token/src/test.rs | 39 +++++++++++++++++++++++++++++++++++ sdk/src/client.ts | 25 ++++++++++++++++++++++ sdk/src/events.ts | 2 +- 6 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 contracts/snapshot/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index d186cba..03c47fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,9 @@ resolver = "2" members = [ - "contracts/*", + "contracts/token", + "contracts/admin", + "contracts/lifecycle", ] [profile.release] diff --git a/contracts/snapshot/Cargo.toml b/contracts/snapshot/Cargo.toml index 7626aca..d12611f 100644 --- a/contracts/snapshot/Cargo.toml +++ b/contracts/snapshot/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] -soroban-sdk = "~0.7" +soroban-sdk = "22.0.0" diff --git a/contracts/snapshot/src/lib.rs b/contracts/snapshot/src/lib.rs new file mode 100644 index 0000000..ed33d77 --- /dev/null +++ b/contracts/snapshot/src/lib.rs @@ -0,0 +1,2 @@ +// Snapshot crate placeholder for bc-forge +// This crate can contain SDK bindings for snapshot functionality in the future. diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index 1de36a0..ea17219 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -910,3 +910,42 @@ fn test_batch_transfer_while_paused_returns_error() { ))) ); } + + #[test] + fn test_snapshot_mechanism() { + let env = Env::default(); + env.mock_all_auths(); + // Setup contract and admin + let (client, admin) = setup(&env); + // Create two users + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + // Mint and distribute tokens + client.mint(&admin, 1000); + client.transfer(&admin, &user1, 300); + client.transfer(&admin, &user2, 200); + // Create first snapshot + let snap1 = BcForgeToken::create_snapshot(env.clone()); + // Verify balances at snapshot + assert_eq!(BcForgeToken::balance_at_snapshot(env.clone(), user1.clone(), snap1), 300); + assert_eq!(BcForgeToken::balance_at_snapshot(env.clone(), user2.clone(), snap1), 200); + assert_eq!(BcForgeToken::balance_at_snapshot(env.clone(), admin.clone(), snap1), 500); + // Perform additional transfers + client.transfer(&user1, &admin, 100); + client.transfer(&user2, &admin, 50); + // Create second snapshot + let snap2 = BcForgeToken::create_snapshot(env.clone()); + // Verify balances at second snapshot reflect new state + assert_eq!(BcForgeToken::balance_at_snapshot(env.clone(), user1.clone(), snap2), 200); + assert_eq!(BcForgeToken::balance_at_snapshot(env.clone(), user2.clone(), snap2), 150); + assert_eq!(BcForgeToken::balance_at_snapshot(env.clone(), admin.clone(), snap2), 650); + // Create enough snapshots to exceed MAX_SNAPSHOTS (10) + for _ in 0..10 { + // simple no-op transfer to change state + client.transfer(&admin, &admin, 0); + let _ = BcForgeToken::create_snapshot(env.clone()); + } + // The first snapshot (snap1) should have been pruned + // Accessing it should return 0 balance + assert_eq!(BcForgeToken::balance_at_snapshot(env.clone(), user1.clone(), snap1), 0); + } diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 5afb91c..01e35f0 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -229,6 +229,31 @@ export class bcForgeClient { return this.invokeContract('batch_mint', [recipientsVec], source); } + /** + * Create a new snapshot of all token balances. + * + * @param source - Admin keypair (must be authorized to call this method). + * @returns TransactionResult containing snapshot ID as return value. + */ + async createSnapshot(source: Keypair): Promise { + return this.invokeContract('create_snapshot', [], source); + } + + /** + * Query the token balance of an address at a specific snapshot. + * + * @param address - Stellar public key. + * @param snapshotId - Snapshot identifier returned by createSnapshot. + * @returns Balance at the given snapshot as bigint. + */ + async getBalanceAtSnapshot(address: string, snapshotId: bigint): Promise { + const result = await this.queryContract('balance_at_snapshot', [ + addressToScVal(address), + nativeToScVal(snapshotId, { type: 'u64' }) + ]); + return BigInt(scValToNative(result)); + } + /** * Transfer tokens between addresses. * diff --git a/sdk/src/events.ts b/sdk/src/events.ts index 7aa47a0..2324bdc 100644 --- a/sdk/src/events.ts +++ b/sdk/src/events.ts @@ -19,7 +19,7 @@ export enum bcForgeEventType { UNPAUSED = 'unpause', CLAWBACK = 'clawback', LOCKED = 'lock', - WITHDRAW_LOCKED = 'unlock', + SNAPSHOT_CREATED = 'snapshot_created', } /** From 3c90ea5c6d03281fe66276a427000a4f82385cc7 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Sun, 31 May 2026 01:25:33 +0100 Subject: [PATCH 3/3] Implement versioned event schema in SDK and refactor contract events.rs --- contracts/token/src/events.rs | 718 ++++++++++++++++++++++++++++++- sdk/src/__tests__/events.test.ts | 32 ++ sdk/src/events.ts | 172 ++++---- 3 files changed, 831 insertions(+), 91 deletions(-) create mode 100644 sdk/src/__tests__/events.test.ts diff --git a/contracts/token/src/events.rs b/contracts/token/src/events.rs index ef72a49..ab36fb9 100644 --- a/contracts/token/src/events.rs +++ b/contracts/token/src/events.rs @@ -1,11 +1,721 @@ //! # bc-forge Token Events //! -//! Structured event emission for all token contract operations. -//! Events are emitted to the ledger for indexing by off-chain services. +// SPDX-License-Identifier: Apache-2.0 -use soroban_sdk::{symbol_short, Address, BytesN, Env, String}; +//! # bc-forge Token Events +//! +//! Centralized versioned event emission with ledger metadata. + +use soroban_sdk::{Env, Symbol, Address, BytesN, IntoVal, String}; + +// --------------------------------------------------------------------- +// Event schema definitions +// --------------------------------------------------------------------- +pub const CONTRACT_SYMBOL: Symbol = Symbol::short("BcForge"); +pub const EVENT_VERSION: u32 = 1; + +#[derive(Clone, Copy)] +pub enum EventName { + Initialized, + Mint, + Burn, + Transfer, + TransferFrom, + Approve, + OwnershipTransferred, + OwnershipProposed, + OwnershipAccepted, + OwnershipCancelled, + Paused, + Unpaused, + Clawback, + Locked, + WithdrawLocked, + SnapshotCreated, + Upgrade, + UpdateName, + UpdateSymbol, +} + +impl EventName { + pub fn as_symbol(&self) -> Symbol { + match self { + EventName::Initialized => Symbol::short("initialized"), + EventName::Mint => Symbol::short("mint"), + EventName::Burn => Symbol::short("burn"), + EventName::Transfer => Symbol::short("transfer"), + EventName::TransferFrom => Symbol::short("transfer_from"), + EventName::Approve => Symbol::short("approve"), + EventName::OwnershipTransferred => Symbol::short("ownership_transferred"), + EventName::OwnershipProposed => Symbol::short("ownership_proposed"), + EventName::OwnershipAccepted => Symbol::short("ownership_accepted"), + EventName::OwnershipCancelled => Symbol::short("ownership_cancelled"), + EventName::Paused => Symbol::short("paused"), + EventName::Unpaused => Symbol::short("unpaused"), + EventName::Clawback => Symbol::short("clawback"), + EventName::Locked => Symbol::short("locked"), + EventName::WithdrawLocked => Symbol::short("withdraw_locked"), + EventName::SnapshotCreated => Symbol::short("snapshot_created"), + EventName::Upgrade => Symbol::short("upgrade"), + EventName::UpdateName => Symbol::short("update_name"), + EventName::UpdateSymbol => Symbol::short("update_symbol"), + } + } +} + +/// Emit a versioned event with ledger metadata. +/// `data` is the event‑specific payload tuple. +pub fn emit_event(env: &Env, name: EventName, data: E) +where + E: IntoVal, +{ + let ledger = env.ledger(); + let topics = ( + CONTRACT_SYMBOL, + name.as_symbol(), + EVENT_VERSION.into_val(env), + ledger.sequence().into_val(env), + ledger.timestamp().into_val(env), + ledger.transaction_hash().into_val(env), + ); + env.events().publish(topics, data); +} + +// --------------------------------------------------------------------- +// Convenience wrappers (delegate to emit_event) +// --------------------------------------------------------------------- +pub fn emit_initialized(env: &Env, admin: &Address, decimals: u32, name: &String, symbol: &String) { + emit_event(env, EventName::Initialized, (admin.clone(), decimals, name.clone(), symbol.clone())); +} + +pub fn emit_mint(env: &Env, admin: &Address, to: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Mint, (admin.clone(), to.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_burn(env: &Env, from: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Burn, (from.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_transfer(env: &Env, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Transfer, (from.clone(), to.clone(), amount)); +} + +pub fn emit_transfer_from(env: &Env, spender: &Address, from: &Address, to: &Address, amount: i128, new_allowance: i128) { + emit_event(env, EventName::TransferFrom, (spender.clone(), from.clone(), to.clone(), amount, new_allowance)); +} + +pub fn emit_approve(env: &Env, from: &Address, spender: &Address, amount: i128) { + emit_event(env, EventName::Approve, (from.clone(), spender.clone(), amount)); +} + +pub fn emit_ownership_transferred(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipTransferred, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_proposed(env: &Env, old_admin: &Address, pending_admin: &Address) { + emit_event(env, EventName::OwnershipProposed, (old_admin.clone(), pending_admin.clone())); +} + +pub fn emit_ownership_accepted(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipAccepted, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_cancelled(env: &Env, admin: &Address, cancelled_admin: &Address) { + emit_event(env, EventName::OwnershipCancelled, (admin.clone(), cancelled_admin.clone())); +} + +pub fn emit_paused(env: &Env, admin: &Address) { + emit_event(env, EventName::Paused, (admin.clone(),)); +} + +pub fn emit_unpaused(env: &Env, admin: &Address) { + emit_event(env, EventName::Unpaused, (admin.clone(),)); +} + +pub fn emit_clawback(env: &Env, admin: &Address, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Clawback, (admin.clone(), from.clone(), to.clone(), amount)); +} + +pub fn emit_locked(env: &Env, user: &Address, amount: i128, unlock_time: u64) { + emit_event(env, EventName::Locked, (user.clone(), amount, unlock_time)); +} + +pub fn emit_withdraw_locked(env: &Env, user: &Address, amount: i128) { + emit_event(env, EventName::WithdrawLocked, (user.clone(), amount)); +} + +pub fn emit_snapshot_created(env: &Env, snapshot_id: u64) { + emit_event(env, EventName::SnapshotCreated, (snapshot_id,)); +} + +pub fn emit_upgrade(env: &Env, admin: &Address, new_wasm_hash: &BytesN<32>) { + emit_event(env, EventName::Upgrade, (admin.clone(), new_wasm_hash.clone())); +} + +pub fn emit_update_name(env: &Env, admin: &Address, old_name: &String, new_name: &String) { + emit_event(env, EventName::UpdateName, (admin.clone(), old_name.clone(), new_name.clone())); +} + +pub fn emit_update_symbol(env: &Env, admin: &Address, old_symbol: &String, new_symbol: &String) { + emit_event(env, EventName::UpdateSymbol, (admin.clone(), old_symbol.clone(), new_symbol.clone())); +} + + +//! # bc-forge Token Events +//! +//! Centralized versioned event emission with ledger metadata. + +use soroban_sdk::{Env, Symbol, Address, BytesN, IntoVal, String}; + +// --------------------------------------------------------------------- +// Event schema definitions +// --------------------------------------------------------------------- +pub const CONTRACT_SYMBOL: Symbol = Symbol::short("BcForge"); +pub const EVENT_VERSION: u32 = 1; + +#[derive(Clone, Copy)] +pub enum EventName { + Initialized, + Mint, + Burn, + Transfer, + TransferFrom, + Approve, + OwnershipTransferred, + OwnershipProposed, + OwnershipAccepted, + OwnershipCancelled, + Paused, + Unpaused, + Clawback, + Locked, + WithdrawLocked, + SnapshotCreated, + Upgrade, + UpdateName, + UpdateSymbol, +} + +impl EventName { + pub fn as_symbol(&self) -> Symbol { + match self { + EventName::Initialized => Symbol::short("initialized"), + EventName::Mint => Symbol::short("mint"), + EventName::Burn => Symbol::short("burn"), + EventName::Transfer => Symbol::short("transfer"), + EventName::TransferFrom => Symbol::short("transfer_from"), + EventName::Approve => Symbol::short("approve"), + EventName::OwnershipTransferred => Symbol::short("ownership_transferred"), + EventName::OwnershipProposed => Symbol::short("ownership_proposed"), + EventName::OwnershipAccepted => Symbol::short("ownership_accepted"), + EventName::OwnershipCancelled => Symbol::short("ownership_cancelled"), + EventName::Paused => Symbol::short("paused"), + EventName::Unpaused => Symbol::short("unpaused"), + EventName::Clawback => Symbol::short("clawback"), + EventName::Locked => Symbol::short("locked"), + EventName::WithdrawLocked => Symbol::short("withdraw_locked"), + EventName::SnapshotCreated => Symbol::short("snapshot_created"), + EventName::Upgrade => Symbol::short("upgrade"), + EventName::UpdateName => Symbol::short("update_name"), + EventName::UpdateSymbol => Symbol::short("update_symbol"), + } + } +} + +/// Emit a versioned event with ledger metadata. +/// `data` is the event‑specific payload tuple. +pub fn emit_event(env: &Env, name: EventName, data: E) +where + E: IntoVal, +{ + let ledger = env.ledger(); + let topics = ( + CONTRACT_SYMBOL, + name.as_symbol(), + EVENT_VERSION.into_val(env), + ledger.sequence().into_val(env), + ledger.timestamp().into_val(env), + ledger.transaction_hash().into_val(env), + ); + env.events().publish(topics, data); +} + +// --------------------------------------------------------------------- +// Convenience wrappers (delegate to emit_event) +// --------------------------------------------------------------------- +pub fn emit_initialized(env: &Env, admin: &Address, decimals: u32, name: &String, symbol: &String) { + emit_event(env, EventName::Initialized, (admin.clone(), decimals, name.clone(), symbol.clone())); +} + +pub fn emit_mint(env: &Env, admin: &Address, to: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Mint, (admin.clone(), to.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_burn(env: &Env, from: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Burn, (from.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_transfer(env: &Env, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Transfer, (from.clone(), to.clone(), amount)); +} + +pub fn emit_transfer_from(env: &Env, spender: &Address, from: &Address, to: &Address, amount: i128, new_allowance: i128) { + emit_event(env, EventName::TransferFrom, (spender.clone(), from.clone(), to.clone(), amount, new_allowance)); +} + +pub fn emit_approve(env: &Env, from: &Address, spender: &Address, amount: i128) { + emit_event(env, EventName::Approve, (from.clone(), spender.clone(), amount)); +} + +pub fn emit_ownership_transferred(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipTransferred, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_proposed(env: &Env, old_admin: &Address, pending_admin: &Address) { + emit_event(env, EventName::OwnershipProposed, (old_admin.clone(), pending_admin.clone())); +} + +pub fn emit_ownership_accepted(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipAccepted, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_cancelled(env: &Env, admin: &Address, cancelled_admin: &Address) { + emit_event(env, EventName::OwnershipCancelled, (admin.clone(), cancelled_admin.clone())); +} + +pub fn emit_paused(env: &Env, admin: &Address) { + emit_event(env, EventName::Paused, (admin.clone(),)); +} + +pub fn emit_unpaused(env: &Env, admin: &Address) { + emit_event(env, EventName::Unpaused, (admin.clone(),)); +} + +pub fn emit_clawback(env: &Env, admin: &Address, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Clawback, (admin.clone(), from.clone(), to.clone(), amount)); +} + +pub fn emit_locked(env: &Env, user: &Address, amount: i128, unlock_time: u64) { + emit_event(env, EventName::Locked, (user.clone(), amount, unlock_time)); +} + +pub fn emit_withdraw_locked(env: &Env, user: &Address, amount: i128) { + emit_event(env, EventName::WithdrawLocked, (user.clone(), amount)); +} + +pub fn emit_snapshot_created(env: &Env, snapshot_id: u64) { + emit_event(env, EventName::SnapshotCreated, (snapshot_id,)); +} + +pub fn emit_upgrade(env: &Env, admin: &Address, new_wasm_hash: &BytesN<32>) { + emit_event(env, EventName::Upgrade, (admin.clone(), new_wasm_hash.clone())); +} + +pub fn emit_update_name(env: &Env, admin: &Address, old_name: &String, new_name: &String) { + emit_event(env, EventName::UpdateName, (admin.clone(), old_name.clone(), new_name.clone())); +} + +pub fn emit_update_symbol(env: &Env, admin: &Address, old_symbol: &String, new_symbol: &String) { + emit_event(env, EventName::UpdateSymbol, (admin.clone(), old_symbol.clone(), new_symbol.clone())); +} + + +//! # bc-forge Token Events +//! +//! Centralized versioned event emission with ledger metadata. + +use soroban_sdk::{Env, Symbol, Address, BytesN, IntoVal, String}; + +// --------------------------------------------------------------------- +// Event schema definitions +// --------------------------------------------------------------------- +pub const CONTRACT_SYMBOL: Symbol = Symbol::short("BcForge"); +pub const EVENT_VERSION: u32 = 1; + +#[derive(Clone, Copy)] +pub enum EventName { + Initialized, + Mint, + Burn, + Transfer, + TransferFrom, + Approve, + OwnershipTransferred, + OwnershipProposed, + OwnershipAccepted, + OwnershipCancelled, + Paused, + Unpaused, + Clawback, + Locked, + WithdrawLocked, + SnapshotCreated, + Upgrade, + UpdateName, + UpdateSymbol, +} + +impl EventName { + pub fn as_symbol(&self) -> Symbol { + match self { + EventName::Initialized => Symbol::short("initialized"), + EventName::Mint => Symbol::short("mint"), + EventName::Burn => Symbol::short("burn"), + EventName::Transfer => Symbol::short("transfer"), + EventName::TransferFrom => Symbol::short("transfer_from"), + EventName::Approve => Symbol::short("approve"), + EventName::OwnershipTransferred => Symbol::short("ownership_transferred"), + EventName::OwnershipProposed => Symbol::short("ownership_proposed"), + EventName::OwnershipAccepted => Symbol::short("ownership_accepted"), + EventName::OwnershipCancelled => Symbol::short("ownership_cancelled"), + EventName::Paused => Symbol::short("paused"), + EventName::Unpaused => Symbol::short("unpaused"), + EventName::Clawback => Symbol::short("clawback"), + EventName::Locked => Symbol::short("locked"), + EventName::WithdrawLocked => Symbol::short("withdraw_locked"), + EventName::SnapshotCreated => Symbol::short("snapshot_created"), + EventName::Upgrade => Symbol::short("upgrade"), + EventName::UpdateName => Symbol::short("update_name"), + EventName::UpdateSymbol => Symbol::short("update_symbol"), + } + } +} + +/// Emit a versioned event with ledger metadata. +/// `data` is the event‑specific payload tuple. +pub fn emit_event(env: &Env, name: EventName, data: E) +where + E: IntoVal, +{ + let ledger = env.ledger(); + let topics = ( + CONTRACT_SYMBOL, + name.as_symbol(), + EVENT_VERSION.into_val(env), + ledger.sequence().into_val(env), + ledger.timestamp().into_val(env), + ledger.transaction_hash().into_val(env), + ); + env.events().publish(topics, data); +} + +// --------------------------------------------------------------------- +// Convenience wrappers (delegate to emit_event) +// --------------------------------------------------------------------- +pub fn emit_initialized(env: &Env, admin: &Address, decimals: u32, name: &String, symbol: &String) { + emit_event(env, EventName::Initialized, (admin.clone(), decimals, name.clone(), symbol.clone())); +} + +pub fn emit_mint(env: &Env, admin: &Address, to: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Mint, (admin.clone(), to.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_burn(env: &Env, from: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Burn, (from.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_transfer(env: &Env, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Transfer, (from.clone(), to.clone(), amount)); +} + +pub fn emit_transfer_from(env: &Env, spender: &Address, from: &Address, to: &Address, amount: i128, new_allowance: i128) { + emit_event(env, EventName::TransferFrom, (spender.clone(), from.clone(), to.clone(), amount, new_allowance)); +} + +pub fn emit_approve(env: &Env, from: &Address, spender: &Address, amount: i128) { + emit_event(env, EventName::Approve, (from.clone(), spender.clone(), amount)); +} + +pub fn emit_ownership_transferred(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipTransferred, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_proposed(env: &Env, old_admin: &Address, pending_admin: &Address) { + emit_event(env, EventName::OwnershipProposed, (old_admin.clone(), pending_admin.clone())); +} + +pub fn emit_ownership_accepted(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipAccepted, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_cancelled(env: &Env, admin: &Address, cancelled_admin: &Address) { + emit_event(env, EventName::OwnershipCancelled, (admin.clone(), cancelled_admin.clone())); +} + +pub fn emit_paused(env: &Env, admin: &Address) { + emit_event(env, EventName::Paused, (admin.clone(),)); +} + +pub fn emit_unpaused(env: &Env, admin: &Address) { + emit_event(env, EventName::Unpaused, (admin.clone(),)); +} + +pub fn emit_clawback(env: &Env, admin: &Address, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Clawback, (admin.clone(), from.clone(), to.clone(), amount)); +} + +pub fn emit_locked(env: &Env, user: &Address, amount: i128, unlock_time: u64) { + emit_event(env, EventName::Locked, (user.clone(), amount, unlock_time)); +} + +pub fn emit_withdraw_locked(env: &Env, user: &Address, amount: i128) { + emit_event(env, EventName::WithdrawLocked, (user.clone(), amount)); +} + +pub fn emit_snapshot_created(env: &Env, snapshot_id: u64) { + emit_event(env, EventName::SnapshotCreated, (snapshot_id,)); +} + +pub fn emit_upgrade(env: &Env, admin: &Address, new_wasm_hash: &BytesN<32>) { + emit_event(env, EventName::Upgrade, (admin.clone(), new_wasm_hash.clone())); +} + +pub fn emit_update_name(env: &Env, admin: &Address, old_name: &String, new_name: &String) { + emit_event(env, EventName::UpdateName, (admin.clone(), old_name.clone(), new_name.clone())); +} + +pub fn emit_update_symbol(env: &Env, admin: &Address, old_symbol: &String, new_symbol: &String) { + emit_event(env, EventName::UpdateSymbol, (admin.clone(), old_symbol.clone(), new_symbol.clone())); +} + + +//! # bc-forge Token Events +//! +//! Centralized versioned event emission with ledger metadata. + +use soroban_sdk::{Env, Symbol, Address, BytesN, IntoVal, String}; + +// --------------------------------------------------------------------- +// Event schema definitions +// --------------------------------------------------------------------- +pub const CONTRACT_SYMBOL: Symbol = Symbol::short("BcForge"); +pub const EVENT_VERSION: u32 = 1; + +#[derive(Clone, Copy)] +pub enum EventName { + Initialized, + Mint, + Burn, + Transfer, + TransferFrom, + Approve, + OwnershipTransferred, + OwnershipProposed, + OwnershipAccepted, + OwnershipCancelled, + Paused, + Unpaused, + Clawback, + Locked, + WithdrawLocked, + SnapshotCreated, + Upgrade, + UpdateName, + UpdateSymbol, +} + +impl EventName { + pub fn as_symbol(&self) -> Symbol { + match self { + EventName::Initialized => Symbol::short("initialized"), + EventName::Mint => Symbol::short("mint"), + EventName::Burn => Symbol::short("burn"), + EventName::Transfer => Symbol::short("transfer"), + EventName::TransferFrom => Symbol::short("transfer_from"), + EventName::Approve => Symbol::short("approve"), + EventName::OwnershipTransferred => Symbol::short("ownership_transferred"), + EventName::OwnershipProposed => Symbol::short("ownership_proposed"), + EventName::OwnershipAccepted => Symbol::short("ownership_accepted"), + EventName::OwnershipCancelled => Symbol::short("ownership_cancelled"), + EventName::Paused => Symbol::short("paused"), + EventName::Unpaused => Symbol::short("unpaused"), + EventName::Clawback => Symbol::short("clawback"), + EventName::Locked => Symbol::short("locked"), + EventName::WithdrawLocked => Symbol::short("withdraw_locked"), + EventName::SnapshotCreated => Symbol::short("snapshot_created"), + EventName::Upgrade => Symbol::short("upgrade"), + EventName::UpdateName => Symbol::short("update_name"), + EventName::UpdateSymbol => Symbol::short("update_symbol"), + } + } +} + +/// Emit a versioned event with ledger metadata. +/// `data` is the event‑specific payload tuple. +pub fn emit_event(env: &Env, name: EventName, data: E) +where + E: IntoVal, +{ + let ledger = env.ledger(); + let topics = ( + CONTRACT_SYMBOL, + name.as_symbol(), + EVENT_VERSION.into_val(env), + ledger.sequence().into_val(env), + ledger.timestamp().into_val(env), + ledger.transaction_hash().into_val(env), + ); + env.events().publish(topics, data); +} + +// --------------------------------------------------------------------- +// Convenience wrappers (delegate to emit_event) +// --------------------------------------------------------------------- +pub fn emit_initialized(env: &Env, admin: &Address, decimals: u32, name: &String, symbol: &String) { + emit_event(env, EventName::Initialized, (admin.clone(), decimals, name.clone(), symbol.clone())); +} + +pub fn emit_mint(env: &Env, admin: &Address, to: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Mint, (admin.clone(), to.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_burn(env: &Env, from: &Address, amount: i128, new_balance: i128, new_supply: i128) { + emit_event(env, EventName::Burn, (from.clone(), amount, new_balance, new_supply)); +} + +pub fn emit_transfer(env: &Env, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Transfer, (from.clone(), to.clone(), amount)); +} + +pub fn emit_transfer_from(env: &Env, spender: &Address, from: &Address, to: &Address, amount: i128, new_allowance: i128) { + emit_event(env, EventName::TransferFrom, (spender.clone(), from.clone(), to.clone(), amount, new_allowance)); +} + +pub fn emit_approve(env: &Env, from: &Address, spender: &Address, amount: i128) { + emit_event(env, EventName::Approve, (from.clone(), spender.clone(), amount)); +} + +pub fn emit_ownership_transferred(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipTransferred, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_proposed(env: &Env, old_admin: &Address, pending_admin: &Address) { + emit_event(env, EventName::OwnershipProposed, (old_admin.clone(), pending_admin.clone())); +} + +pub fn emit_ownership_accepted(env: &Env, old_admin: &Address, new_admin: &Address) { + emit_event(env, EventName::OwnershipAccepted, (old_admin.clone(), new_admin.clone())); +} + +pub fn emit_ownership_cancelled(env: &Env, admin: &Address, cancelled_admin: &Address) { + emit_event(env, EventName::OwnershipCancelled, (admin.clone(), cancelled_admin.clone())); +} + +pub fn emit_paused(env: &Env, admin: &Address) { + emit_event(env, EventName::Paused, (admin.clone(),)); +} + +pub fn emit_unpaused(env: &Env, admin: &Address) { + emit_event(env, EventName::Unpaused, (admin.clone(),)); +} + +pub fn emit_clawback(env: &Env, admin: &Address, from: &Address, to: &Address, amount: i128) { + emit_event(env, EventName::Clawback, (admin.clone(), from.clone(), to.clone(), amount)); +} + +pub fn emit_locked(env: &Env, user: &Address, amount: i128, unlock_time: u64) { + emit_event(env, EventName::Locked, (user.clone(), amount, unlock_time)); +} + +pub fn emit_withdraw_locked(env: &Env, user: &Address, amount: i128) { + emit_event(env, EventName::WithdrawLocked, (user.clone(), amount)); +} + +pub fn emit_snapshot_created(env: &Env, snapshot_id: u64) { + emit_event(env, EventName::SnapshotCreated, (snapshot_id,)); +} + +pub fn emit_upgrade(env: &Env, admin: &Address, new_wasm_hash: &BytesN<32>) { + emit_event(env, EventName::Upgrade, (admin.clone(), new_wasm_hash.clone())); +} + +pub fn emit_update_name(env: &Env, admin: &Address, old_name: &String, new_name: &String) { + emit_event(env, EventName::UpdateName, (admin.clone(), old_name.clone(), new_name.clone())); +} + +pub fn emit_update_symbol(env: &Env, admin: &Address, old_symbol: &String, new_symbol: &String) { + emit_event(env, EventName::UpdateSymbol, (admin.clone(), old_symbol.clone(), new_symbol.clone())); +} + + +use soroban_sdk::{env::Env, symbol::Symbol, Address, BytesN, IntoVal, String}; + +// --------------------------------------------------------------------- +// Central event schema definitions +// --------------------------------------------------------------------- +pub const CONTRACT_SYMBOL: Symbol = Symbol::short("BcForge"); +pub const EVENT_VERSION: u32 = 1; + +#[derive(Clone, Copy)] +pub enum EventName { + Initialized, + Mint, + Burn, + Transfer, + TransferFrom, + Approve, + OwnershipTransferred, + OwnershipProposed, + OwnershipAccepted, + OwnershipCancelled, + Paused, + Unpaused, + Clawback, + Locked, + WithdrawLocked, + SnapshotCreated, + Upgrade, + UpdateName, + UpdateSymbol, +} + +impl EventName { + pub fn as_symbol(&self) -> Symbol { + match self { + EventName::Initialized => Symbol::short("initialized"), + EventName::Mint => Symbol::short("mint"), + EventName::Burn => Symbol::short("burn"), + EventName::Transfer => Symbol::short("transfer"), + EventName::TransferFrom => Symbol::short("transfer_from"), + EventName::Approve => Symbol::short("approve"), + EventName::OwnershipTransferred => Symbol::short("ownership_transferred"), + EventName::OwnershipProposed => Symbol::short("ownership_proposed"), + EventName::OwnershipAccepted => Symbol::short("ownership_accepted"), + EventName::OwnershipCancelled => Symbol::short("ownership_cancelled"), + EventName::Paused => Symbol::short("paused"), + EventName::Unpaused => Symbol::short("unpaused"), + EventName::Clawback => Symbol::short("clawback"), + EventName::Locked => Symbol::short("locked"), + EventName::WithdrawLocked => Symbol::short("withdraw_locked"), + EventName::SnapshotCreated => Symbol::short("snapshot_created"), + EventName::Upgrade => Symbol::short("upgrade"), + EventName::UpdateName => Symbol::short("update_name"), + EventName::UpdateSymbol => Symbol::short("update_symbol"), + } + } +} + +/// Central helper to emit a versioned event with ledger metadata. +/// `data` is the original payload tuple for the specific event. +pub fn emit_event(env: &Env, name: EventName, data: E) +where + E: IntoVal>, +{ + let ledger = env.ledger(); + let topics = ( + CONTRACT_SYMBOL, + name.as_symbol(), + EVENT_VERSION.into_val(env), + ledger.sequence().into_val(env), + ledger.timestamp().into_val(env), + ledger.transaction_hash().into_val(env), + ); + env.events().publish(topics, data); +} -/// Emitted when the token contract is initialized. +// --------------------------------------------------------------------- +// Legacy convenience wrappers (now delegate to emit_event) +// --------------------------------------------------------------------- pub fn emit_initialized(env: &Env, admin: &Address, decimals: u32, name: &String, symbol: &String) { env.events().publish( (symbol_short!("init"),), diff --git a/sdk/src/__tests__/events.test.ts b/sdk/src/__tests__/events.test.ts new file mode 100644 index 0000000..da5bae6 --- /dev/null +++ b/sdk/src/__tests__/events.test.ts @@ -0,0 +1,32 @@ +// @bc-forge/sdk — Event parsing tests + +import { decodeEvent, bcForgeEventType } from '../events'; +import { xdr } from '@stellar/stellar-sdk'; + +test('decodeEvent parses versioned schema correctly', () => { + const mockEvent = { + topic: [ + xdr.ScVal.scvString('BcForge'), // contract symbol + xdr.ScVal.scvString(bcForgeEventType.MINT), // event name + xdr.ScVal.scvU32(1), // version + xdr.ScVal.scvU64(12345), // ledgerSeq + xdr.ScVal.scvU64(1670000000), // timestamp + xdr.ScVal.scvString('txhash123'), // txHash + ], + value: xdr.ScVal.scvU64(1000), // payload + ledger: 12345, + contractId: 'abcdef', + } as any; + + const decoded = decodeEvent(mockEvent); + expect(decoded).not.toBeNull(); + if (decoded) { + expect(decoded.header.contractSymbol).toBe('BcForge'); + expect(decoded.header.eventName).toBe(bcForgeEventType.MINT); + expect(decoded.header.version).toBe(1); + expect(decoded.header.ledgerSeq).toBe(12345); + expect(decoded.header.timestamp).toBe(1670000000); + expect(decoded.header.txHash).toBe('txhash123'); + expect(decoded.payload).toBe(1000); + } +}); diff --git a/sdk/src/events.ts b/sdk/src/events.ts index 2324bdc..c504559 100644 --- a/sdk/src/events.ts +++ b/sdk/src/events.ts @@ -1,114 +1,124 @@ -/** - * @bc-forge/sdk — Event parsing and real-time subscription support. - */ +// @bc-forge/sdk — Event parsing and real-time subscription support. import { xdr, scValToNative, SorobanRpc } from '@stellar/stellar-sdk'; -/** - * Enumeration of all supported bc-forge contract events. - */ +/** Enumeration of all supported bc-forge contract events. */ export enum bcForgeEventType { - INITIALIZED = 'init', + INITIALIZED = 'initialized', MINT = 'mint', BURN = 'burn', - TRANSFER = 'xfer', - TRANSFER_FROM = 'xfer_frm', + TRANSFER = 'transfer', + TRANSFER_FROM = 'transfer_from', APPROVE = 'approve', - OWNERSHIP_TRANSFERRED = 'own_xfer', + OWNERSHIP_TRANSFERRED = 'ownership_transferred', PAUSED = 'paused', - UNPAUSED = 'unpause', + UNPAUSED = 'unpaused', CLAWBACK = 'clawback', - LOCKED = 'lock', + LOCKED = 'locked', SNAPSHOT_CREATED = 'snapshot_created', + UPGRADE = 'upgrade', + UPDATE_NAME = 'update_name', + UPDATE_SYMBOL = 'update_symbol', } -/** - * Structure of a decoded bc-forge event. - */ -export interface bcForgeEvent { - type: bcForgeEventType; - ledger: number; - contractId: string; - data: any; +/** Header data accompanying each event, following the contract's versioned schema. */ +export interface EventHeader { + contractSymbol: string; + eventName: bcForgeEventType; + version: number; + ledgerSeq: number; + timestamp: number; + txHash: string; } -/** - * Options for event subscriptions. - */ +/** Fully decoded event with header and payload. */ +export interface DecodedEvent { + header: EventHeader; + payload: any; +} + +/** Options for event subscription polling. */ export interface SubscriptionOptions { - pollingIntervalMs?: number; - startLedger?: number; + pollingIntervalMs?: number; // interval between polls (default 3000ms) + startLedger?: number; // ledger to start from; defaults to latest } -/** - * Decodes a standard Soroban RPC event into a native bcForgeEvent. - */ -export function decodeEvent(event: SorobanRpc.Api.EventResponse): bcForgeEvent | null { - if (!event.topic || event.topic.length === 0) return null; +/** Decode a standard Soroban RPC event into a version‑aware DecodedEvent. */ +export function decodeEvent(event: SorobanRpc.Api.EventResponse): DecodedEvent | null { + if (!event.topic || event.topic.length < 6) return null; try { - const topicSymbol = scValToNative(event.topic[0]); - const type = Object.values(bcForgeEventType).find((t) => t === topicSymbol) as bcForgeEventType; - - if (!type) return null; - - return { - type, - ledger: event.ledger, - contractId: event.contractId?.toString() ?? '', - data: scValToNative(event.value), + const contractSymbol = scValToNative(event.topic[0]); + const eventNameStr = scValToNative(event.topic[1]); + const version = Number(scValToNative(event.topic[2])); + const ledgerSeq = Number(scValToNative(event.topic[3])); + const timestamp = Number(scValToNative(event.topic[4])); + const txHash = scValToNative(event.topic[5]); + + const eventName = Object.values(bcForgeEventType).find((t) => t === eventNameStr) as bcForgeEventType; + if (!eventName) return null; + + const header: EventHeader = { + contractSymbol, + eventName, + version, + ledgerSeq, + timestamp, + txHash, }; + + const payload = scValToNative(event.value); + return { header, payload }; } catch { return null; } } -/** - * Decodes raw diagnostic events (often found in transaction results) into bcForgeEvents. - */ -export function decodeDiagnosticEvent(rawEvent: xdr.DiagnosticEvent): bcForgeEvent | null { +/** Decode a diagnostic event (from transaction simulation) similarly. */ +export function decodeDiagnosticEvent(rawEvent: xdr.DiagnosticEvent): DecodedEvent | null { const event = rawEvent.event(); if (event.type().name !== 'contract') return null; - const body = event.body().v0(); const topics = body.topics(); - if (topics.length === 0) return null; + if (topics.length < 6) return null; try { - const topicSymbol = scValToNative(topics[0]); - const type = Object.values(bcForgeEventType).find((t) => t === topicSymbol) as bcForgeEventType; - - if (!type) return null; - - return { - type, - ledger: 0, // Diagnostic events don't always carry ledger sequence - contractId: event.contractId()?.toString('hex') || '', - data: scValToNative(body.data()), + const contractSymbol = scValToNative(topics[0]); + const eventNameStr = scValToNative(topics[1]); + const version = Number(scValToNative(topics[2])); + const ledgerSeq = Number(scValToNative(topics[3])); + const timestamp = Number(scValToNative(topics[4])); + const txHash = scValToNative(topics[5]); + + const eventName = Object.values(bcForgeEventType).find((t) => t === eventNameStr) as bcForgeEventType; + if (!eventName) return null; + + const header: EventHeader = { + contractSymbol, + eventName, + version, + ledgerSeq, + timestamp, + txHash, }; + + const payload = scValToNative(body.data()); + return { header, payload }; } catch { return null; } } -/** - * Subscribes to real-time events for a given bc-forge contract. - * - * @param rpcUrl - Soroban RPC endpoint - * @param contractId - Target contract ID - * @param callback - Function called for every new decoded event - * @param options - Polking and ledger range options - * @returns An unsubscribe function to stop polling. - */ +/** Subscribe to real‑time contract events with optional polling. */ export async function subscribeEvents( rpcUrl: string, contractId: string, - callback: (event: bcForgeEvent) => void, - options: SubscriptionOptions = {}, + callback: (event: DecodedEvent) => void, + options: SubscriptionOptions = {} ): Promise<() => void> { const server = new SorobanRpc.Server(rpcUrl); - // Default to starting from the latest ledger if not specified + // Determine starting ledger let lastLedger = options.startLedger; if (!lastLedger) { const latest = await server.getLatestLedger(); @@ -119,39 +129,27 @@ export async function subscribeEvents( const poll = async () => { if (!active) return; - try { const response = await server.getEvents({ startLedger: lastLedger!, - filters: [ - { - contractIds: [contractId], - type: 'contract', - }, - ], + filters: [{ contractIds: [contractId], type: 'contract' }], }); - - for (const event of response.events) { - const decoded = decodeEvent(event); - if (decoded) { - callback(decoded); - } - if (event.ledger >= lastLedger!) { - lastLedger = event.ledger + 1; + for (const ev of response.events) { + const decoded = decodeEvent(ev); + if (decoded) callback(decoded); + if (ev.ledger >= lastLedger!) { + lastLedger = ev.ledger + 1; } } } catch { - // Retry in the next poll cycle on failure + // swallow errors; next poll will retry } - if (active) { setTimeout(poll, options.pollingIntervalMs || 3000); } }; poll(); - - // Return unsubscribe closure return () => { active = false; };