From cfe0593bbb17c49537314a96a0ca471783803246 Mon Sep 17 00:00:00 2001 From: smithology Date: Thu, 27 Aug 2026 17:00:51 +0100 Subject: [PATCH 1/4] implement subscription credit system and account balance --- .../__tests__/creditStore.performance.test.ts | 23 +++ app/stores/__tests__/creditStore.test.ts | 66 ++++++- app/stores/creditStore.ts | 165 +++++++++++++++++- .../credit-wallet.integration.test.ts | 30 ++++ contracts/credit/src/lib.rs | 157 +++++++++++++++-- contracts/credit/src/test.rs | 71 ++++++++ contracts/types/src/errors.rs | 4 +- contracts/types/src/lib.rs | 3 + docs/CREDIT_SYSTEM.md | 60 +++++++ jest.config.js | 1 - jest.credit.config.js | 10 ++ package.json | 6 +- pnpm-workspace.yaml | 12 ++ 13 files changed, 578 insertions(+), 30 deletions(-) create mode 100644 app/stores/__tests__/creditStore.performance.test.ts create mode 100644 app/tests/integration/credit-wallet.integration.test.ts create mode 100644 docs/CREDIT_SYSTEM.md create mode 100644 jest.credit.config.js create mode 100644 pnpm-workspace.yaml diff --git a/app/stores/__tests__/creditStore.performance.test.ts b/app/stores/__tests__/creditStore.performance.test.ts new file mode 100644 index 00000000..118c95d0 --- /dev/null +++ b/app/stores/__tests__/creditStore.performance.test.ts @@ -0,0 +1,23 @@ +import { useCreditStore } from '../creditStore'; + +describe('credit store performance', () => { + it('processes 100 credit applications within the local budget', () => { + useCreditStore.setState({ + accounts: {}, + nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, + now: () => 1_000, + }); + const store = useCreditStore.getState(); + const startedAt = performance.now(); + + for (let index = 0; index < 100; index += 1) { + store.issueCredit(`subscriber-${index}`, 100, 'benchmark'); + store.applyCredit(`subscriber-${index}`, `subscription-${index}`, 50); + } + + expect(performance.now() - startedAt).toBeLessThan(1_500); + }); +}); \ No newline at end of file diff --git a/app/stores/__tests__/creditStore.test.ts b/app/stores/__tests__/creditStore.test.ts index e076729c..dd74754b 100644 --- a/app/stores/__tests__/creditStore.test.ts +++ b/app/stores/__tests__/creditStore.test.ts @@ -2,7 +2,14 @@ import { useCreditStore } from '../creditStore'; let clock = 1000; const reset = () => - useCreditStore.setState({ accounts: {}, nextId: 0, now: () => clock }); + useCreditStore.setState({ + accounts: {}, + nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, + now: () => clock, + }); beforeEach(() => { clock = 1000; @@ -66,4 +73,61 @@ describe('useCreditStore', () => { clock = 1200; expect(s().getBalance('alice')).toBe(0); }); + + it('manages wallet deposits, withdrawals, and charge drawdowns', () => { + const walletId = s().createWallet('alice', 'sub_1', 'USD'); + expect(s().deposit('alice', walletId, 1000)).toMatchObject({ + walletId, + balance: 1000, + transactionId: 0, + }); + expect(s().drawdown('alice', walletId, 250)).toMatchObject({ + balance: 750, + transactionId: 1, + }); + expect(s().withdraw('alice', walletId, 100)).toMatchObject({ + balance: 650, + transactionId: 2, + }); + expect(s().getWallet(walletId)).toMatchObject({ + balance: 650, + totalDeposited: 1000, + totalDrawn: 250, + totalWithdrawn: 100, + transactions: [ + { id: 0, kind: 'deposit', amount: 1000, balanceAfter: 1000 }, + { id: 1, kind: 'drawdown', amount: 250, balanceAfter: 750 }, + { id: 2, kind: 'withdraw', amount: 100, balanceAfter: 650 }, + ], + }); + }); + + it('rejects unauthorized and overdrawn wallet operations', () => { + const walletId = s().createWallet('alice', 'sub_1', 'USD'); + s().deposit('alice', walletId, 100); + expect(s().drawdown('alice', walletId, 101)).toBeUndefined(); + expect(s().withdraw('bob', walletId, 1)).toBeUndefined(); + }); + + it('does not expose mutable account or wallet state', () => { + s().issueCredit('alice', 100, 'promo'); + const account = s().getAccount('alice'); + account.lots[0].remaining = 0; + expect(s().getBalance('alice')).toBe(100); + + const walletId = s().createWallet('alice', 'sub_1', 'USD'); + const wallet = s().getWallet(walletId); + if (wallet) wallet.balance = 99; + expect(s().getWallet(walletId)?.balance).toBe(0); + }); + + it('retains only the bounded recent transaction history', () => { + for (let index = 0; index < 130; index += 1) { + s().issueCredit('alice', 1, `grant-${index}`); + } + const history = s().getAccount('alice').transactions; + expect(history).toHaveLength(128); + expect(history[0].reason).toBe('grant-2'); + expect(history[127].reason).toBe('grant-129'); + }); }); diff --git a/app/stores/creditStore.ts b/app/stores/creditStore.ts index 4f877990..1797f82f 100644 --- a/app/stores/creditStore.ts +++ b/app/stores/creditStore.ts @@ -46,6 +46,40 @@ export interface CreditApplied { balanceAfter: number; } +export interface PrepaymentWallet { + id: number; + subscriber: string; + subscriptionId: string; + currency: string; + balance: number; + totalDeposited: number; + totalWithdrawn: number; + totalDrawn: number; + transactions: PrepaymentTransaction[]; + createdAt: number; + updatedAt: number; +} + +export type PrepaymentTxKind = 'deposit' | 'withdraw' | 'drawdown'; + +export interface PrepaymentTransaction { + id: number; + kind: PrepaymentTxKind; + amount: number; + balanceAfter: number; + timestamp: number; +} + +export interface PrepaymentSnapshot { + walletId: number; + balance: number; + transactionId: number; +} + +const MAX_HISTORY = 128; + +const isPositiveAmount = (amount: number): boolean => Number.isFinite(amount) && amount > 0; + const isExpired = (lot: CreditLot, now: number): boolean => lot.expiresAt !== undefined && lot.expiresAt <= now; @@ -58,6 +92,9 @@ const availableOf = (account: AccountCredit, now: number): number => interface CreditStoreState { accounts: Record; nextId: number; + wallets: Record; + nextWalletId: number; + walletTransactionIds: Record; now: () => number; issueCredit: (subscriber: string, amount: number, reason: string, expiresAt?: number) => void; @@ -67,6 +104,11 @@ interface CreditStoreState { expireCredits: (subscriber: string) => number; getBalance: (subscriber: string) => number; getAccount: (subscriber: string) => AccountCredit; + createWallet: (subscriber: string, subscriptionId: string, currency: string) => number; + getWallet: (walletId: number) => PrepaymentWallet | undefined; + deposit: (subscriber: string, walletId: number, amount: number) => PrepaymentSnapshot | undefined; + withdraw: (subscriber: string, walletId: number, amount: number) => PrepaymentSnapshot | undefined; + drawdown: (subscriber: string, walletId: number, amount: number) => PrepaymentSnapshot | undefined; } const blankAccount = (subscriber: string): AccountCredit => ({ @@ -103,7 +145,7 @@ export const useCreditStore = create()( acc.transactions = [ ...acc.transactions, { id: nextId(), kind, amount, timestamp: get().now(), reason, counterparty }, - ]; + ].slice(-MAX_HISTORY); }; const realizeExpiry = (acc: AccountCredit, now: number): number => { @@ -135,17 +177,47 @@ export const useCreditStore = create()( const cloneAccount = (acc: AccountCredit): AccountCredit => ({ ...acc, - lots: [...acc.lots], - transactions: [...acc.transactions], + lots: acc.lots.map((lot) => ({ ...lot })), + transactions: acc.transactions.map((transaction) => ({ ...transaction })), }); + const nextWalletTransactionId = (walletId: number): number => { + const id = get().walletTransactionIds[walletId] ?? 0; + set((state) => ({ + walletTransactionIds: { ...state.walletTransactionIds, [walletId]: id + 1 }, + })); + return id; + }; + + const recordWalletTransaction = ( + wallet: PrepaymentWallet, + kind: PrepaymentTxKind, + amount: number + ): PrepaymentSnapshot => { + const transactionId = nextWalletTransactionId(wallet.id); + wallet.transactions = [ + ...wallet.transactions, + { + id: transactionId, + kind, + amount, + balanceAfter: wallet.balance, + timestamp: get().now(), + }, + ].slice(-MAX_HISTORY); + return { walletId: wallet.id, balance: wallet.balance, transactionId }; + }; + return { accounts: {}, nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, now: () => Math.floor(Date.now() / 1000), issueCredit: (subscriber, amount, reason, expiresAt) => { - if (amount <= 0) return; + if (!isPositiveAmount(amount)) return; const now = get().now(); const acc = cloneAccount(account(subscriber)); realizeExpiry(acc, now); @@ -169,7 +241,8 @@ export const useCreditStore = create()( const now = get().now(); const acc = cloneAccount(account(subscriber)); realizeExpiry(acc, now); - const applied = consume(acc, now, Math.max(0, amountDue)); + const due = Number.isFinite(amountDue) ? Math.max(0, amountDue) : 0; + const applied = consume(acc, now, due); if (applied > 0) { acc.balance -= applied; record(acc, 'apply', -applied, 'charge_application'); @@ -178,13 +251,13 @@ export const useCreditStore = create()( return { subscriptionId, applied, - remainingDue: amountDue - applied, + remainingDue: due - applied, balanceAfter: acc.balance, }; }, transferCredit: (from, to, amount, reason) => { - if (amount <= 0 || from === to) return false; + if (!isPositiveAmount(amount) || from === to) return false; const now = get().now(); const sender = cloneAccount(account(from)); realizeExpiry(sender, now); @@ -216,7 +289,80 @@ export const useCreditStore = create()( }, getBalance: (subscriber) => availableOf(account(subscriber), get().now()), - getAccount: (subscriber) => account(subscriber), + getAccount: (subscriber) => cloneAccount(account(subscriber)), + + createWallet: (subscriber, subscriptionId, currency) => { + const id = get().nextWalletId; + const now = get().now(); + const wallet: PrepaymentWallet = { + id, + subscriber, + subscriptionId, + currency, + balance: 0, + totalDeposited: 0, + totalWithdrawn: 0, + totalDrawn: 0, + transactions: [], + createdAt: now, + updatedAt: now, + }; + set((state) => ({ + wallets: { ...state.wallets, [id]: wallet }, + nextWalletId: id + 1, + })); + return id; + }, + + getWallet: (walletId) => { + const wallet = get().wallets[walletId]; + return wallet ? { ...wallet } : undefined; + }, + + deposit: (subscriber, walletId, amount) => { + if (!isPositiveAmount(amount)) return undefined; + const wallet = get().wallets[walletId]; + if (!wallet || wallet.subscriber !== subscriber) return undefined; + const updated = { + ...wallet, + balance: wallet.balance + amount, + totalDeposited: wallet.totalDeposited + amount, + updatedAt: get().now(), + }; + const snapshot = recordWalletTransaction(updated, 'deposit', amount); + set((state) => ({ wallets: { ...state.wallets, [walletId]: updated } })); + return snapshot; + }, + + withdraw: (subscriber, walletId, amount) => { + if (!isPositiveAmount(amount)) return undefined; + const wallet = get().wallets[walletId]; + if (!wallet || wallet.subscriber !== subscriber || wallet.balance < amount) return undefined; + const updated = { + ...wallet, + balance: wallet.balance - amount, + totalWithdrawn: wallet.totalWithdrawn + amount, + updatedAt: get().now(), + }; + const snapshot = recordWalletTransaction(updated, 'withdraw', amount); + set((state) => ({ wallets: { ...state.wallets, [walletId]: updated } })); + return snapshot; + }, + + drawdown: (subscriber, walletId, amount) => { + if (!isPositiveAmount(amount)) return undefined; + const wallet = get().wallets[walletId]; + if (!wallet || wallet.subscriber !== subscriber || wallet.balance < amount) return undefined; + const updated = { + ...wallet, + balance: wallet.balance - amount, + totalDrawn: wallet.totalDrawn + amount, + updatedAt: get().now(), + }; + const snapshot = recordWalletTransaction(updated, 'drawdown', amount); + set((state) => ({ wallets: { ...state.wallets, [walletId]: updated } })); + return snapshot; + }, }; }, { @@ -225,6 +371,9 @@ export const useCreditStore = create()( partialize: (state) => ({ accounts: state.accounts, nextId: state.nextId, + wallets: state.wallets, + nextWalletId: state.nextWalletId, + walletTransactionIds: state.walletTransactionIds, }), } ) diff --git a/app/tests/integration/credit-wallet.integration.test.ts b/app/tests/integration/credit-wallet.integration.test.ts new file mode 100644 index 00000000..97b8ff46 --- /dev/null +++ b/app/tests/integration/credit-wallet.integration.test.ts @@ -0,0 +1,30 @@ +import { useCreditStore } from '../../stores/creditStore'; + +describe('credit wallet integration', () => { + beforeEach(() => { + useCreditStore.setState({ + accounts: {}, + nextId: 0, + wallets: {}, + nextWalletId: 0, + walletTransactionIds: {}, + now: () => 1_000, + }); + }); + + it('keeps wallet balance, receipts, and account credit independent', () => { + const store = useCreditStore.getState(); + const walletId = store.createWallet('alice', 'sub_1', 'USD'); + + store.issueCredit('alice', 500, 'refund'); + const deposit = store.deposit('alice', walletId, 1_000); + const drawdown = store.drawdown('alice', walletId, 250); + const applied = store.applyCredit('alice', 'sub_1', 200); + + expect(deposit?.balance).toBe(1_000); + expect(drawdown?.balance).toBe(750); + expect(applied).toMatchObject({ applied: 200, remainingDue: 0, balanceAfter: 300 }); + expect(useCreditStore.getState().getWallet(walletId)?.transactions).toHaveLength(2); + expect(useCreditStore.getState().getBalance('alice')).toBe(300); + }); +}); \ No newline at end of file diff --git a/contracts/credit/src/lib.rs b/contracts/credit/src/lib.rs index 72dd784b..1f031522 100644 --- a/contracts/credit/src/lib.rs +++ b/contracts/credit/src/lib.rs @@ -23,7 +23,7 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, }; -use subtrackr_types::{SubscriptionId, CoreError}; +use subtrackr_types::{CoreError, SubscriptionId}; /// Maximum retained transaction-history and lot entries per account. const MAX_HISTORY: u32 = 128; @@ -147,10 +147,30 @@ pub struct PrepaymentWallet { pub balance: i128, pub total_deposited: i128, pub total_withdrawn: i128, + pub total_drawn: i128, + pub transactions: Vec, pub created_at: u64, pub updated_at: u64, } +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PrepaymentTxKind { + Deposit, + Withdraw, + Drawdown, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PrepaymentTransaction { + pub id: u64, + pub kind: PrepaymentTxKind, + pub amount: i128, + pub balance_after: i128, + pub timestamp: u64, +} + /// Prepayment summary returned after a deposit or withdrawal. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -167,7 +187,10 @@ enum DataKey { NextId, Account(Address), Wallet(u64), + WalletTx(u64), Counter(u64), + AccountCount, + AccountIndex(Address), } #[contract] @@ -388,10 +411,14 @@ impl SubTrackrCredit { balance: 0, total_deposited: 0, total_withdrawn: 0, + total_drawn: 0, + transactions: Vec::new(&env), created_at: now, updated_at: now, }; - env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet); + env.storage() + .persistent() + .set(&DataKey::Wallet(wallet_id), &wallet); env.events() .publish((symbol_short!("wallet"), subscriber), wallet_id); wallet_id @@ -420,11 +447,15 @@ impl SubTrackrCredit { wallet.balance += amount; wallet.total_deposited += amount; wallet.updated_at = now; - env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet); + let transaction_id = + Self::record_wallet_transaction(&env, &mut wallet, PrepaymentTxKind::Deposit, amount); + env.storage() + .persistent() + .set(&DataKey::Wallet(wallet_id), &wallet); Ok(PrepaymentSnapshot { wallet_id, balance: wallet.balance, - transaction_id: Self::next_tx_id(&env, wallet_id), + transaction_id, }) } @@ -454,14 +485,61 @@ impl SubTrackrCredit { wallet.balance -= amount; wallet.total_withdrawn += amount; wallet.updated_at = now; - env.storage().persistent().set(&DataKey::Wallet(wallet_id), &wallet); + let transaction_id = + Self::record_wallet_transaction(&env, &mut wallet, PrepaymentTxKind::Withdraw, amount); + env.storage() + .persistent() + .set(&DataKey::Wallet(wallet_id), &wallet); Ok(PrepaymentSnapshot { wallet_id, balance: wallet.balance, - transaction_id: Self::next_tx_id(&env, wallet_id), + transaction_id, }) } + /// Draws funds from a prepayment wallet to settle a subscription charge. + /// The subscriber authorizes the draw and the wallet can never go negative. + pub fn drawdown( + env: Env, + caller: Address, + wallet_id: u64, + amount: i128, + ) -> Result { + caller.require_auth(); + if amount <= 0 { + return Err(CreditError::InvalidAmount); + } + let mut wallet: PrepaymentWallet = env + .storage() + .persistent() + .get(&DataKey::Wallet(wallet_id)) + .ok_or(CreditError::WalletNotFound)?; + if wallet.subscriber != caller { + return Err(CreditError::Unauthorized); + } + if wallet.balance < amount { + return Err(CreditError::InsufficientCredit); + } + wallet.balance -= amount; + wallet.total_drawn += amount; + wallet.updated_at = env.ledger().timestamp(); + let transaction_id = + Self::record_wallet_transaction(&env, &mut wallet, PrepaymentTxKind::Drawdown, amount); + env.storage() + .persistent() + .set(&DataKey::Wallet(wallet_id), &wallet); + Ok(PrepaymentSnapshot { + wallet_id, + balance: wallet.balance, + transaction_id, + }) + } + + /// Returns a wallet when it exists. + pub fn get_wallet(env: Env, wallet_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Wallet(wallet_id)) + } + /// Returns the current balance of a prepayment wallet. pub fn get_wallet_balance(env: Env, _caller: Address, wallet_id: u64) -> i128 { env.storage() @@ -475,14 +553,24 @@ impl SubTrackrCredit { /// applies credit lot expiry, and returns total expired amounts. Caller /// must be admin. pub fn expire_credits_with_cron(env: Env, admin: Address) -> Vec<(Address, i128)> { + let configured_admin = Self::require_admin(&env).expect("admin required"); + if configured_admin != admin { + panic!("unauthorized"); + } admin.require_auth(); let now = env.ledger().timestamp(); let mut results: Vec<(Address, i128)> = Vec::new(&env); + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::AccountCount) + .unwrap_or(0); let mut i: u32 = 0; - while i < MAX_HISTORY { + while i < count { let key = DataKey::Counter(i as u64); if !env.storage().persistent().has(&key) { - break; + i += 1; + continue; } let subscriber: Address = env.storage().persistent().get(&key).unwrap(); let mut account = Self::account(&env, &subscriber); @@ -521,6 +609,21 @@ impl SubTrackrCredit { } fn save(env: &Env, account: &AccountCredit) { + let account_index = DataKey::AccountIndex(account.subscriber.clone()); + if !env.storage().persistent().has(&account_index) { + let index: u32 = env + .storage() + .persistent() + .get(&DataKey::AccountCount) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::Counter(index as u64), &account.subscriber); + env.storage().persistent().set(&account_index, &index); + env.storage() + .persistent() + .set(&DataKey::AccountCount, &(index + 1)); + } env.storage() .persistent() .set(&DataKey::Account(account.subscriber.clone()), account); @@ -533,22 +636,44 @@ impl SubTrackrCredit { } fn next_wallet_id(env: &Env) -> u64 { - let base: u64 = env.storage().instance().get(&symbol_short!("NWID")).unwrap_or(0); - env.storage() - .instance() - .set(&symbol_short!("NWID"), &(base + 1)); - base - } - - fn next_tx_id(env: &Env, _wallet_id: u64) -> u64 { let base: u64 = env .storage() .instance() .get(&symbol_short!("NWID")) .unwrap_or(0); + env.storage() + .instance() + .set(&symbol_short!("NWID"), &(base + 1)); base } + fn next_wallet_tx_id(env: &Env, wallet_id: u64) -> u64 { + let key = DataKey::WalletTx(wallet_id); + let id: u64 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(id + 1)); + id + } + + fn record_wallet_transaction( + env: &Env, + wallet: &mut PrepaymentWallet, + kind: PrepaymentTxKind, + amount: i128, + ) -> u64 { + let id = Self::next_wallet_tx_id(env, wallet.id); + wallet.transactions.push_back(PrepaymentTransaction { + id, + kind, + amount, + balance_after: wallet.balance, + timestamp: env.ledger().timestamp(), + }); + while wallet.transactions.len() > MAX_HISTORY { + wallet.transactions.remove(0); + } + id + } + /// Sum of unexpired lot balances. fn available(now: u64, account: &AccountCredit) -> i128 { let mut total: i128 = 0; diff --git a/contracts/credit/src/test.rs b/contracts/credit/src/test.rs index 09656353..0654a104 100644 --- a/contracts/credit/src/test.rs +++ b/contracts/credit/src/test.rs @@ -127,3 +127,74 @@ fn expiration_policy_drives_default_expiry() { set_time(&env, 1_200); // > 1_000 + 100 assert_eq!(client.get_credit_balance(&sub), 0); } + +#[test] +fn wallet_deposit_withdraw_and_drawdown_have_unique_receipts() { + let (env, client, _admin) = setup(); + let subscriber = Address::generate(&env); + let wallet_id = client.create_wallet(&subscriber, &42, &String::from_str(&env, "USD")); + + let deposited = client.deposit(&subscriber, &wallet_id, &1_000); + assert_eq!(deposited.balance, 1_000); + assert_eq!(deposited.transaction_id, 0); + + let drawn = client.drawdown(&subscriber, &wallet_id, &250); + assert_eq!(drawn.balance, 750); + assert_eq!(drawn.transaction_id, 1); + + let withdrawn = client.withdraw(&subscriber, &wallet_id, &100); + assert_eq!(withdrawn.balance, 650); + assert_eq!(withdrawn.transaction_id, 2); + + let wallet = client.get_wallet(&wallet_id).unwrap(); + assert_eq!(wallet.balance, 650); + assert_eq!(wallet.total_deposited, 1_000); + assert_eq!(wallet.total_drawn, 250); + assert_eq!(wallet.total_withdrawn, 100); + assert_eq!(wallet.transactions.len(), 3); + assert_eq!( + wallet.transactions.get(0).unwrap().kind, + PrepaymentTxKind::Deposit + ); + assert_eq!( + wallet.transactions.get(1).unwrap().kind, + PrepaymentTxKind::Drawdown + ); + assert_eq!(wallet.transactions.get(2).unwrap().balance_after, 650); +} + +#[test] +fn wallet_rejects_unauthorized_and_overdrawn_operations() { + let (env, client, _admin) = setup(); + let subscriber = Address::generate(&env); + let other = Address::generate(&env); + let wallet_id = client.create_wallet(&subscriber, &7, &String::from_str(&env, "USD")); + client.deposit(&subscriber, &wallet_id, &100); + + assert_eq!( + client.try_drawdown(&subscriber, &wallet_id, &101), + Err(Ok(CreditError::InsufficientCredit)) + ); + assert_eq!( + client.try_withdraw(&other, &wallet_id, &1), + Err(Ok(CreditError::Unauthorized)) + ); +} + +#[test] +fn cron_expires_registered_accounts() { + let (env, client, admin) = setup(); + let subscriber = Address::generate(&env); + set_time(&env, 1_000); + client.issue_credit( + &subscriber, + &100, + &String::from_str(&env, "promo"), + &Some(1_100), + ); + set_time(&env, 1_200); + + let results = client.expire_credits_with_cron(&admin); + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().1, 100); +} diff --git a/contracts/types/src/errors.rs b/contracts/types/src/errors.rs index d93a3204..f8b0773f 100644 --- a/contracts/types/src/errors.rs +++ b/contracts/types/src/errors.rs @@ -1,6 +1,4 @@ -#![no_std] - -use soroban_sdk::{contracterror, contracttype, Env, Symbol}; +use soroban_sdk::{Env, Symbol}; /// Unified core error enum for all SubTrackr contracts. /// diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index a6c8233d..fe233dbb 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -1,5 +1,8 @@ #![no_std] +pub mod errors; +pub use errors::CoreError; + use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; /// Billing interval in seconds. diff --git a/docs/CREDIT_SYSTEM.md b/docs/CREDIT_SYSTEM.md new file mode 100644 index 00000000..b74fa2ba --- /dev/null +++ b/docs/CREDIT_SYSTEM.md @@ -0,0 +1,60 @@ +# Credit and account balances + +SubTrackr keeps subscriber credit as expiring lots. Credit is consumed +oldest-first, and the available balance never includes expired lots. Every +issuance, application, transfer, and expiry is recorded in the account ledger; +the contract and the mobile Zustand store use the same model. + +## Mobile store + +```ts +const walletId = useCreditStore.getState().createWallet('subscriber-1', 'sub-1', 'USD'); +useCreditStore.getState().deposit('subscriber-1', walletId, 10_000); + +const charge = useCreditStore.getState().applyCredit('subscriber-1', 'sub-1', 2_500); +// charge.applied === 2_500 when enough unexpired credit exists + +const remaining = useCreditStore.getState().getBalance('subscriber-1'); +``` + +Prepayment wallets are separate from promotional/refund credit and support +`deposit`, `withdraw`, and `drawdown`. Each operation returns the new balance +and a wallet-local transaction ID. Wallet transactions are retained with their +kind, amount, resulting balance, and timestamp. Invalid, unauthorized, or +overdrawn wallet operations return `undefined` in the local store. + +## Soroban contract + +The `subtrackr-credit` contract exposes the equivalent operations: + +```text +create_wallet(subscriber, subscription_id, currency) +deposit(subscriber, wallet_id, amount) +withdraw(subscriber, wallet_id, amount) +drawdown(subscriber, wallet_id, amount) +get_wallet(wallet_id) +``` + +Credit issuance is admin-authorized. Transfers require the sender's +authorization. Wallet deposits, withdrawals, and drawdowns require the wallet +subscriber's authorization. `expire_credits_with_cron` is admin-only and +enumerates accounts that have been saved by the contract. + +Amounts should be represented in the smallest unit of the selected currency, +such as cents for USD. The UI and store use JavaScript numbers, so callers +should keep values within the precise integer range supported by their payment +rail. + +## Verification + +Run the focused checks from the repository root: + +```bash +npm run credit:test:coverage +npm run credit:benchmark +npm run contracts:test -- --package subtrackr-credit +``` + +The coverage command enforces at least 80% global coverage for the credit +store. The benchmark fails if 100 issue-and-apply workflows exceed 1,500 ms on +the local test runner. This budget includes persisted Zustand state updates. \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index cdec0bc0..ee870aa1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,7 +11,6 @@ module.exports = { '/node_modules/', '/e2e/', '/src/animations/', - '/app/', '/backend/', '/developer-portal/', '/contracts/', diff --git a/jest.credit.config.js b/jest.credit.config.js new file mode 100644 index 00000000..e873fcd4 --- /dev/null +++ b/jest.credit.config.js @@ -0,0 +1,10 @@ +module.exports = { + transform: { + '^.+\\.[jt]sx?$': ['babel-jest', { configFile: './babel.config.test.js' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.(test|spec).[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], + testPathIgnorePatterns: ['/node_modules/', '/e2e/', '/contracts/'], + setupFilesAfterEnv: ['/jest.setup.js'], +}; diff --git a/package.json b/package.json index 1b5e75dc..292af14e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "lint:fix": "eslint . --ext .ts,.tsx --fix", "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"", "test:shard": "jest --runInBand", + "credit:test": "jest --config jest.credit.config.js app/stores/__tests__/creditStore.test.ts app/tests/integration/credit-wallet.integration.test.ts --runInBand", + "credit:test:coverage": "jest --config jest.credit.config.js app/stores/__tests__/creditStore.test.ts app/tests/integration/credit-wallet.integration.test.ts --runInBand --coverage --collectCoverageFrom=app/stores/creditStore.ts --coverageThreshold '{\"global\":{\"branches\":80,\"functions\":80,\"lines\":80,\"statements\":80}}'", + "credit:benchmark": "jest --config jest.credit.config.js app/stores/__tests__/creditStore.performance.test.ts --runInBand", "format:check": "prettier --check \"**/*.{ts,tsx,js,json,md}\"", "typecheck": "tsc --noEmit", "test": "jest --passWithNoTests", @@ -149,5 +152,6 @@ "*.{js,json,md}": [ "prettier --write" ] - } + }, + "packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..47f10e40 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +allowBuilds: + bufferutil: set this to true or false + detox: set this to true or false + dtrace-provider: set this to true or false + es5-ext: set this to true or false + keccak: set this to true or false + secp256k1: set this to true or false + unrs-resolver: set this to true or false + utf-8-validate: set this to true or false + web3: set this to true or false + web3-bzz: set this to true or false + web3-shh: set this to true or false From f51f59ccfb82c21a3a7831fb7f7599391cd534c7 Mon Sep 17 00:00:00 2001 From: chinedu15 Date: Thu, 27 Aug 2026 17:10:47 +0100 Subject: [PATCH 2/4] wemdoe --- pnpm-workspace.yaml | 22 +-- src/contracts/types/ERC20.ts | 168 ++++++++++-------- src/contracts/types/common.ts | 129 +++----------- .../types/factories/ERC20__factory.ts | 9 +- 4 files changed, 128 insertions(+), 200 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 47f10e40..a56c433a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,12 +1,12 @@ allowBuilds: - bufferutil: set this to true or false - detox: set this to true or false - dtrace-provider: set this to true or false - es5-ext: set this to true or false - keccak: set this to true or false - secp256k1: set this to true or false - unrs-resolver: set this to true or false - utf-8-validate: set this to true or false - web3: set this to true or false - web3-bzz: set this to true or false - web3-shh: set this to true or false + bufferutil: false + detox: false + dtrace-provider: false + es5-ext: false + keccak: false + secp256k1: false + unrs-resolver: false + utf-8-validate: false + web3: false + web3-bzz: false + web3-shh: false diff --git a/src/contracts/types/ERC20.ts b/src/contracts/types/ERC20.ts index 855f15ef..c6b0574b 100644 --- a/src/contracts/types/ERC20.ts +++ b/src/contracts/types/ERC20.ts @@ -3,32 +3,35 @@ /* eslint-disable */ import type { BaseContract, + BigNumber, BytesLike, - FunctionFragment, - Result, - Interface, - AddressLike, - ContractRunner, - ContractMethod, - Listener, + CallOverrides, + PopulatedTransaction, + Signer, + utils, } from "ethers"; +import type { FunctionFragment, Result } from "@ethersproject/abi"; +import type { Listener, Provider } from "@ethersproject/providers"; import type { - TypedContractEvent, - TypedDeferredTopicFilter, - TypedEventLog, + TypedEventFilter, + TypedEvent, TypedListener, - TypedContractMethod, + OnEvent, } from "./common"; -export interface ERC20Interface extends Interface { +export interface ERC20Interface extends utils.Interface { + functions: { + "balanceOf(address)": FunctionFragment; + "decimals()": FunctionFragment; + "name()": FunctionFragment; + "symbol()": FunctionFragment; + }; + getFunction( - nameOrSignature: "balanceOf" | "decimals" | "name" | "symbol" + nameOrSignatureOrTopic: "balanceOf" | "decimals" | "name" | "symbol" ): FunctionFragment; - encodeFunctionData( - functionFragment: "balanceOf", - values: [AddressLike] - ): string; + encodeFunctionData(functionFragment: "balanceOf", values: [string]): string; encodeFunctionData(functionFragment: "decimals", values?: undefined): string; encodeFunctionData(functionFragment: "name", values?: undefined): string; encodeFunctionData(functionFragment: "symbol", values?: undefined): string; @@ -37,75 +40,86 @@ export interface ERC20Interface extends Interface { decodeFunctionResult(functionFragment: "decimals", data: BytesLike): Result; decodeFunctionResult(functionFragment: "name", data: BytesLike): Result; decodeFunctionResult(functionFragment: "symbol", data: BytesLike): Result; + + events: {}; } export interface ERC20 extends BaseContract { - connect(runner?: ContractRunner | null): ERC20; - waitForDeployment(): Promise; + connect(signerOrProvider: Signer | Provider | string): this; + attach(addressOrName: string): this; + deployed(): Promise; interface: ERC20Interface; - queryFilter( - event: TCEvent, - fromBlockOrBlockhash?: string | number | undefined, - toBlock?: string | number | undefined - ): Promise>>; - queryFilter( - filter: TypedDeferredTopicFilter, + queryFilter( + event: TypedEventFilter, fromBlockOrBlockhash?: string | number | undefined, toBlock?: string | number | undefined - ): Promise>>; - - on( - event: TCEvent, - listener: TypedListener - ): Promise; - on( - filter: TypedDeferredTopicFilter, - listener: TypedListener - ): Promise; - - once( - event: TCEvent, - listener: TypedListener - ): Promise; - once( - filter: TypedDeferredTopicFilter, - listener: TypedListener - ): Promise; - - listeners( - event: TCEvent - ): Promise>>; - listeners(eventName?: string): Promise>; - removeAllListeners( - event?: TCEvent - ): Promise; - - balanceOf: TypedContractMethod<[account: AddressLike], [bigint], "view">; - - decimals: TypedContractMethod<[], [bigint], "view">; - - name: TypedContractMethod<[], [string], "view">; - - symbol: TypedContractMethod<[], [string], "view">; - - getFunction( - key: string | FunctionFragment - ): T; + ): Promise>; - getFunction( - nameOrSignature: "balanceOf" - ): TypedContractMethod<[account: AddressLike], [bigint], "view">; - getFunction( - nameOrSignature: "decimals" - ): TypedContractMethod<[], [bigint], "view">; - getFunction( - nameOrSignature: "name" - ): TypedContractMethod<[], [string], "view">; - getFunction( - nameOrSignature: "symbol" - ): TypedContractMethod<[], [string], "view">; + listeners( + eventFilter?: TypedEventFilter + ): Array>; + listeners(eventName?: string): Array; + removeAllListeners( + eventFilter: TypedEventFilter + ): this; + removeAllListeners(eventName?: string): this; + off: OnEvent; + on: OnEvent; + once: OnEvent; + removeListener: OnEvent; + + functions: { + balanceOf(account: string, overrides?: CallOverrides): Promise<[BigNumber]>; + + decimals(overrides?: CallOverrides): Promise<[number]>; + + name(overrides?: CallOverrides): Promise<[string]>; + + symbol(overrides?: CallOverrides): Promise<[string]>; + }; + + balanceOf(account: string, overrides?: CallOverrides): Promise; + + decimals(overrides?: CallOverrides): Promise; + + name(overrides?: CallOverrides): Promise; + + symbol(overrides?: CallOverrides): Promise; + + callStatic: { + balanceOf(account: string, overrides?: CallOverrides): Promise; + + decimals(overrides?: CallOverrides): Promise; + + name(overrides?: CallOverrides): Promise; + + symbol(overrides?: CallOverrides): Promise; + }; filters: {}; + + estimateGas: { + balanceOf(account: string, overrides?: CallOverrides): Promise; + + decimals(overrides?: CallOverrides): Promise; + + name(overrides?: CallOverrides): Promise; + + symbol(overrides?: CallOverrides): Promise; + }; + + populateTransaction: { + balanceOf( + account: string, + overrides?: CallOverrides + ): Promise; + + decimals(overrides?: CallOverrides): Promise; + + name(overrides?: CallOverrides): Promise; + + symbol(overrides?: CallOverrides): Promise; + }; } diff --git a/src/contracts/types/common.ts b/src/contracts/types/common.ts index 56b5f21e..2fc40c7f 100644 --- a/src/contracts/types/common.ts +++ b/src/contracts/types/common.ts @@ -1,65 +1,32 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { - FunctionFragment, - Typed, - EventFragment, - ContractTransaction, - ContractTransactionResponse, - DeferredTopicFilter, - EventLog, - TransactionRequest, - LogDescription, -} from "ethers"; - -export interface TypedDeferredTopicFilter<_TCEvent extends TypedContractEvent> - extends DeferredTopicFilter {} - -export interface TypedContractEvent< - InputTuple extends Array = any, - OutputTuple extends Array = any, - OutputObject = any -> { - (...args: Partial): TypedDeferredTopicFilter< - TypedContractEvent - >; - name: string; - fragment: EventFragment; - getFragment(...args: Partial): EventFragment; +import type { Listener } from "@ethersproject/providers"; +import type { Event, EventFilter } from "ethers"; + +export interface TypedEvent< + TArgsArray extends Array = any, + TArgsObject = any +> extends Event { + args: TArgsArray & TArgsObject; } -type __TypechainAOutputTuple = T extends TypedContractEvent< - infer _U, - infer W -> - ? W - : never; -type __TypechainOutputObject = T extends TypedContractEvent< - infer _U, - infer _W, - infer V -> - ? V - : never; +export interface TypedEventFilter<_TEvent extends TypedEvent> + extends EventFilter {} -export interface TypedEventLog - extends Omit { - args: __TypechainAOutputTuple & __TypechainOutputObject; +export interface TypedListener { + (...listenerArg: [...__TypechainArgsArray, TEvent]): void; } -export interface TypedLogDescription - extends Omit { - args: __TypechainAOutputTuple & __TypechainOutputObject; -} +type __TypechainArgsArray = T extends TypedEvent ? U : never; -export type TypedListener = ( - ...listenerArg: [ - ...__TypechainAOutputTuple, - TypedEventLog, - ...undefined[] - ] -) => void; +export interface OnEvent { + ( + eventFilter: TypedEventFilter, + listener: TypedListener + ): TRes; + (eventName: string, listener: Listener): TRes; +} export type MinEthersFactory = { deploy(...a: ARGS[]): Promise; @@ -71,61 +38,7 @@ export type GetContractTypeFromFactory = F extends MinEthersFactory< > ? C : never; + export type GetARGsTypeFromFactory = F extends MinEthersFactory ? Parameters : never; - -export type StateMutability = "nonpayable" | "payable" | "view"; - -export type BaseOverrides = Omit; -export type NonPayableOverrides = Omit< - BaseOverrides, - "value" | "blockTag" | "enableCcipRead" ->; -export type PayableOverrides = Omit< - BaseOverrides, - "blockTag" | "enableCcipRead" ->; -export type ViewOverrides = Omit; -export type Overrides = S extends "nonpayable" - ? NonPayableOverrides - : S extends "payable" - ? PayableOverrides - : ViewOverrides; - -export type PostfixOverrides, S extends StateMutability> = - | A - | [...A, Overrides]; -export type ContractMethodArgs< - A extends Array, - S extends StateMutability -> = PostfixOverrides<{ [I in keyof A]-?: A[I] | Typed }, S>; - -export type DefaultReturnType = R extends Array ? R[0] : R; - -// export interface ContractMethod = Array, R = any, D extends R | ContractTransactionResponse = R | ContractTransactionResponse> { -export interface TypedContractMethod< - A extends Array = Array, - R = any, - S extends StateMutability = "payable" -> { - (...args: ContractMethodArgs): S extends "view" - ? Promise> - : Promise; - - name: string; - - fragment: FunctionFragment; - - getFragment(...args: ContractMethodArgs): FunctionFragment; - - populateTransaction( - ...args: ContractMethodArgs - ): Promise; - staticCall( - ...args: ContractMethodArgs - ): Promise>; - send(...args: ContractMethodArgs): Promise; - estimateGas(...args: ContractMethodArgs): Promise; - staticCallResult(...args: ContractMethodArgs): Promise; -} diff --git a/src/contracts/types/factories/ERC20__factory.ts b/src/contracts/types/factories/ERC20__factory.ts index 85bf3612..68b4c7a6 100644 --- a/src/contracts/types/factories/ERC20__factory.ts +++ b/src/contracts/types/factories/ERC20__factory.ts @@ -2,7 +2,8 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, Interface, type ContractRunner } from "ethers"; +import { Contract, Signer, utils } from "ethers"; +import type { Provider } from "@ethersproject/providers"; import type { ERC20, ERC20Interface } from "../ERC20"; const _abi = [ @@ -72,9 +73,9 @@ const _abi = [ export class ERC20__factory { static readonly abi = _abi; static createInterface(): ERC20Interface { - return new Interface(_abi) as ERC20Interface; + return new utils.Interface(_abi) as ERC20Interface; } - static connect(address: string, runner?: ContractRunner | null): ERC20 { - return new Contract(address, _abi, runner) as unknown as ERC20; + static connect(address: string, signerOrProvider: Signer | Provider): ERC20 { + return new Contract(address, _abi, signerOrProvider) as ERC20; } } From 6ba6e94d767a62dff3910694f0cf0df49e7ca9af Mon Sep 17 00:00:00 2001 From: chinedu15 Date: Thu, 27 Aug 2026 17:32:14 +0100 Subject: [PATCH 3/4] weme --- PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md b/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md index 1f9fc18b..87523dac 100644 --- a/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md +++ b/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md @@ -21,7 +21,7 @@ ### Reviewers - At least 1 approval required for merge -- All CI checks must be green +- All CI checks must be gree --- From cb9610fdb6e96e8cc29ef1fcb6f450b148f45046 Mon Sep 17 00:00:00 2001 From: chinedu15 Date: Thu, 27 Aug 2026 17:48:00 +0100 Subject: [PATCH 4/4] weprynz --- PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md b/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md index 87523dac..1f9fc18b 100644 --- a/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md +++ b/PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md @@ -21,7 +21,7 @@ ### Reviewers - At least 1 approval required for merge -- All CI checks must be gree +- All CI checks must be green ---