diff --git a/Cargo.lock b/Cargo.lock index b26ede94..5c5c355e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,29 +195,16 @@ dependencies = [ "soroban-sdk", ] -[[package]] -name = "bc-forge-compound-fees" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "bc-forge-e2e-tests" version = "0.1.0" dependencies = [ "bc-forge-token", + "bc-forge-wrapper", "soroban-sdk", "tokio", ] -[[package]] -name = "bc-forge-flash-loan-guard" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "bc-forge-lifecycle" version = "0.1.0" @@ -283,13 +270,6 @@ dependencies = [ "soroban-sdk", ] -[[package]] -name = "bc-forge-yield-vault" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "bit-set" version = "0.8.0" diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml index 26fef7ed..8ff108bd 100644 --- a/e2e/Cargo.toml +++ b/e2e/Cargo.toml @@ -13,6 +13,7 @@ categories = ["cryptography::cryptocurrencies"] tokio = { version = "1.0", features = ["full"] } soroban-sdk = { version = "22.0.11", features = ["testutils"] } bc-forge-token = { path = "../contracts/token", features = ["testutils"] } +bc-forge-wrapper = { path = "../contracts/wrapper", features = ["testutils"] } [dev-dependencies] tokio = { version = "1.0", features = ["test-util"] } diff --git a/e2e/integration_test.rs b/e2e/integration_test.rs index acab83b7..6aa95eaf 100644 --- a/e2e/integration_test.rs +++ b/e2e/integration_test.rs @@ -6,6 +6,8 @@ #[cfg(test)] use bc_forge_token::{BcForgeToken, BcForgeTokenClient}; #[cfg(test)] +use bc_forge_wrapper::{WrapperContract, WrapperContractClient}; +#[cfg(test)] use soroban_sdk::testutils::Address as _; #[cfg(test)] use soroban_sdk::{Address, Env, String}; @@ -68,6 +70,72 @@ async fn test_complete_lifecycle() { println!("✅ Complete lifecycle test passed!"); } +/// E2E: Token -> Vault -> Compound flow lifecycle test (#740) +/// +/// Flow: Mint -> Vault Deposit -> Fee Generation -> Compound -> Vault Withdraw +#[tokio::test] +async fn test_token_vault_compound_lifecycle() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let fee_generator = Address::generate(&env); + + // 1. Deploy & Initialize Underlying Token + let token_id = env.register(BcForgeToken, ()); + let token_client = BcForgeTokenClient::new(&env, &token_id); + let token_name = String::from_str(&env, "Underlying Token"); + let token_symbol = String::from_str(&env, "UND"); + token_client.initialize(&admin, &7, &token_name, &token_symbol); + + // 2. Deploy & Initialize Vault Contract + let vault_id = env.register(WrapperContract, ()); + let vault_client = WrapperContractClient::new(&env, &vault_id); + let vault_name = String::from_str(&env, "Yield Vault Share"); + let vault_symbol = String::from_str(&env, "yvUND"); + vault_client.initialize(&admin, &token_id, &7, &vault_name, &vault_symbol); + + // 3. MINT: Mint tokens to User (1,000,000) and Fee Generator (500,000) + token_client.mint(&admin, &user, &1_000_000); + token_client.mint(&admin, &fee_generator, &500_000); + assert_eq!(token_client.balance(&user), 1_000_000); + assert_eq!(token_client.balance(&fee_generator), 500_000); + + // 4. VAULT DEPOSIT: User approves and deposits 1,000,000 tokens + token_client.approve(&user, &vault_id, &1_000_000, &u32::MAX); + let shares_minted = vault_client.deposit(&user, &1_000_000); + assert_eq!(shares_minted, 1_000_000); + assert_eq!(vault_client.balance(&user), 1_000_000); + assert_eq!(vault_client.total_assets(), 1_000_000); + assert_eq!(vault_client.supply(), 1_000_000); + assert_eq!(token_client.balance(&user), 0); + + // 5. FEE GENERATION: Protocol generates 500,000 fees and distributes to vault + token_client.approve(&fee_generator, &vault_id, &500_000, &u32::MAX); + vault_client.distribute_rewards(&fee_generator, &500_000); + assert_eq!(token_client.balance(&fee_generator), 0); + assert_eq!(vault_client.pending_rewards(), 500_000); + assert_eq!(vault_client.total_assets(), 1_500_000); + assert_eq!(vault_client.supply(), 1_000_000); // shares unchanged + + // 6. COMPOUND & PRO-RATA ENTITLEMENT: Verify share price appreciation + let entitlement = vault_client.calculate_rewards(&1_000_000); + assert_eq!(entitlement, 1_500_000); + + // 7. VAULT WITHDRAW: User withdraws all 1,000,000 shares + let tokens_returned = vault_client.withdraw(&user, &1_000_000); + assert_eq!(tokens_returned, 1_500_000); // 1,000,000 principal + 500,000 yield + + // 8. VERIFY FINAL BALANCES + assert_eq!(token_client.balance(&user), 1_500_000); + assert_eq!(vault_client.balance(&user), 0); + assert_eq!(vault_client.supply(), 0); + assert_eq!(vault_client.total_assets(), 0); + + println!("✅ Token -> Vault -> Compound lifecycle test passed!"); +} + /// Test parallel execution of multiple operations #[tokio::test] async fn test_parallel_execution() { diff --git a/sdk/README.md b/sdk/README.md index 906837d7..2f2c5d34 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -489,6 +489,37 @@ When a `walletAdapter` is configured and connected, write methods may be invoked | `simulateMint(to, amount, sourcePublicKey)` | `any` | Simulate mint operation | | `simulateTransfer(from, to, amount, sourcePublicKey)` | `any` | Simulate transfer operation | +## Vault Client (`VaultClient`) (#744) + +The SDK provides `VaultClient` for interacting with yield-bearing fee vault contracts and wrapper contracts. + +```typescript +import { VaultClient } from '@bc-forge/sdk'; +import { Keypair } from '@stellar/stellar-sdk'; + +const vault = new VaultClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: 'CVAULT...XYZ', +}); + +// Deposit underlying tokens to receive vault shares +await vault.deposit('GUSER...', BigInt(1000_0000000), userKeypair); + +// Check share balance & underlying value +const shares = await vault.getShareBalance('GUSER...'); +const totalAssets = await vault.getTotalAssets(); +const sharePrice = await vault.calculateSharePrice(); +const rewards = await vault.calculateRewards(shares); + +// Compound protocol fees into vault assets +await vault.compound('GADMIN...', adminKeypair); + +// Withdraw shares and receive underlying tokens + accrued yield +await vault.withdraw('GUSER...', shares, userKeypair); +``` + ## License MIT + diff --git a/sdk/package.json b/sdk/package.json index 9304b402..f4dd8bdb 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -8,8 +8,10 @@ "build": "tsc", "dev": "tsc --watch", "test": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --passWithNoTests", - "lint": "eslint 'src/**/*.ts'", - "format": "prettier --write 'src/**/*.ts'", + "lint": "eslint src", + "format": "prettier --write src", + + "clean": "rm -rf dist" }, "keywords": [ diff --git a/sdk/src/apy.test.ts b/sdk/src/apy.test.ts index 99a89667..16804114 100644 --- a/sdk/src/apy.test.ts +++ b/sdk/src/apy.test.ts @@ -38,8 +38,14 @@ function makeSimError(): object { // network calls; instead we mock the server constructor inline via jest. import { rpc as SorobanRpc } from '@stellar/stellar-sdk'; -const mockSimulateTransaction = jest.spyOn(SorobanRpc.Server.prototype, 'simulateTransaction') as unknown as jest.Mock; -const mockGetLatestLedger = jest.spyOn(SorobanRpc.Server.prototype, 'getLatestLedger') as unknown as jest.Mock; +const mockSimulateTransaction = jest.spyOn( + SorobanRpc.Server.prototype, + 'simulateTransaction', +) as unknown as jest.Mock; +const mockGetLatestLedger = jest.spyOn( + SorobanRpc.Server.prototype, + 'getLatestLedger', +) as unknown as jest.Mock; // ─── Import subject after mock setup ───────────────────────────────────────── diff --git a/sdk/src/apy.ts b/sdk/src/apy.ts index d4b1c17c..653d27eb 100644 --- a/sdk/src/apy.ts +++ b/sdk/src/apy.ts @@ -109,10 +109,7 @@ function buildSimTx( method: string, ...args: xdr.ScVal[] ): ReturnType { - const dummyAccount = new Account( - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - '0', - ); + const dummyAccount = new Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', '0'); return new TransactionBuilder(dummyAccount, { fee: '100', networkPassphrase, @@ -163,7 +160,7 @@ async function simulateI128( if (i64 !== undefined && i64 !== null) return BigInt(i64.toString()); return null; - } catch (err) { + } catch { return null; } } @@ -186,8 +183,7 @@ async function readSnapshot( const assets = totalAssets ?? 0n; const shares = totalShares ?? 0n; - const sharePrice = - shares > 0n ? Number(assets) / Number(shares) : null; + const sharePrice = shares > 0n ? Number(assets) / Number(shares) : null; return { ledger: ledgerSequence, @@ -259,8 +255,7 @@ export async function calculateApy(options: ApyOptions): Promise = {}; + private allowances: Record> = {}; + private totalShares: bigint = 0n; + private totalAssetsAmount: bigint = 0n; + private pendingRewardsAmount: bigint = 0n; + private underlyingTokenAddress: string = + 'CDUMMYUNDERLYINGTOKENADDRESS0000000000000000000000000000'; + private name: string = 'Mock Vault Share'; + private symbol: string = 'mvSHARE'; + private decimals: number = 7; + + constructor(_config?: { rpcUrl?: string; networkPassphrase?: string; contractId?: string }) {} + + async getBalance(address: string): Promise { + return this.shareBalances[address] ?? 0n; + } + + async getShareBalance(address: string): Promise { + return this.shareBalances[address] ?? 0n; + } + + async getTotalSupply(): Promise { + return this.totalShares; + } + + async getTotalAssets(): Promise { + return this.totalAssetsAmount; + } + + async getPendingRewards(): Promise { + return this.pendingRewardsAmount; + } + + async calculateSharePrice(): Promise { + if (this.totalShares === 0n) { + throw new Error('ZeroShares: No shares outstanding'); + } + return this.totalAssetsAmount / this.totalShares; + } + + async calculateRewards(userShares: bigint): Promise { + if (userShares < 0n) { + throw new Error('InvalidAmount: Negative shares'); + } + if (this.totalShares === 0n) { + throw new Error('ZeroShares: No shares outstanding'); + } + return (userShares * this.totalAssetsAmount) / this.totalShares; + } + + async getUnderlyingToken(): Promise { + return this.underlyingTokenAddress; + } + + async getName(): Promise { + return this.name; + } + + async getSymbol(): Promise { + return this.symbol; + } + + async getDecimals(): Promise { + return this.decimals; + } + + async getAllowance(owner: string, spender: string): Promise { + return this.allowances[owner]?.[spender] ?? 0n; + } + + async deposit( + caller: string, + amount: bigint, + _source?: unknown, + minSharesOut: bigint = 0n, + ): Promise { + if (amount <= 0n) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'InvalidAmount: Amount must be positive', + }; + } + + const sharesOut = + this.totalShares === 0n ? amount : (amount * this.totalShares) / this.totalAssetsAmount; + + if (sharesOut <= 0n) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'InvalidAmount: Calculated shares are zero', + }; + } + + if (sharesOut < minSharesOut) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'SlippageExceeded: Minted shares less than minSharesOut', + }; + } + + this.shareBalances[caller] = (this.shareBalances[caller] ?? 0n) + sharesOut; + this.totalShares += sharesOut; + this.totalAssetsAmount += amount; + + return { success: true, hash: 'mock-hash', returnValue: sharesOut }; + } + + async withdraw( + caller: string, + shares: bigint, + _source?: unknown, + minTokensOut: bigint = 0n, + ): Promise { + if (shares <= 0n) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'InvalidAmount: Shares must be positive', + }; + } + + const userBalance = this.shareBalances[caller] ?? 0n; + if (userBalance < shares) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'InsufficientBalance: Not enough shares', + }; + } + + if (this.totalShares === 0n) { + return { success: false, hash: 'mock-hash', returnValue: 'ZeroShares: No shares in vault' }; + } + + const tokensOut = (shares * this.totalAssetsAmount) / this.totalShares; + + if (tokensOut <= 0n) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'InvalidAmount: Payout rounds down to zero', + }; + } + + if (tokensOut < minTokensOut) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'SlippageExceeded: Returned tokens less than minTokensOut', + }; + } + + this.shareBalances[caller] = userBalance - shares; + this.totalShares -= shares; + this.totalAssetsAmount -= tokensOut; + + return { success: true, hash: 'mock-hash', returnValue: tokensOut }; + } + + async distributeRewards( + _caller: string, + amount: bigint, + _source?: unknown, + ): Promise { + if (amount <= 0n) { + return { + success: false, + hash: 'mock-hash', + returnValue: 'InvalidAmount: Reward amount must be positive', + }; + } + this.totalAssetsAmount += amount; + this.pendingRewardsAmount += amount; + return { success: true, hash: 'mock-hash', returnValue: null }; + } + + async compound(_caller: string, _source?: unknown): Promise { + this.pendingRewardsAmount = 0n; + return { success: true, hash: 'mock-hash', returnValue: null }; + } + + async compoundFees(caller: string, source?: unknown): Promise { + return this.compound(caller, source); + } + + async transfer( + from: string, + to: string, + amount: bigint, + _source?: unknown, + ): Promise { + const fromBalance = this.shareBalances[from] ?? 0n; + if (fromBalance < amount) { + return { success: false, hash: 'mock-hash', returnValue: 'InsufficientBalance' }; + } + this.shareBalances[from] = fromBalance - amount; + this.shareBalances[to] = (this.shareBalances[to] ?? 0n) + amount; + return { success: true, hash: 'mock-hash', returnValue: null }; + } + + async approve( + from: string, + spender: string, + amount: bigint, + _exp?: number, + _source?: unknown, + ): Promise { + if (!this.allowances[from]) this.allowances[from] = {}; + this.allowances[from][spender] = amount; + return { success: true, hash: 'mock-hash', returnValue: null }; + } +} diff --git a/sdk/src/vault.e2e.test.ts b/sdk/src/vault.e2e.test.ts new file mode 100644 index 00000000..7a0202bf --- /dev/null +++ b/sdk/src/vault.e2e.test.ts @@ -0,0 +1,164 @@ +/** + * @bc-forge/sdk — E2E Integration Test: Token -> Vault -> Compound flow (#740) + * + * Full lifecycle integration test covering: + * Mint -> Vault Deposit -> Fee Generation -> Compound -> Vault Withdraw + */ + +import { MockBcForgeClient, MockVaultClient } from './mockClient'; + +describe('E2E Integration: Token -> Vault -> Compound Flow (#740)', () => { + let tokenClient: MockBcForgeClient; + let vaultClient: MockVaultClient; + + const admin = 'GADMINXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + const userA = 'GUSERAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + const userB = 'GUSERBXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + const feePayer = 'GFEEGENERATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + + beforeEach(() => { + tokenClient = new MockBcForgeClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: 'CTOKEN00000000000000000000000000000000000000000000000000', + }); + + vaultClient = new MockVaultClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: 'CVAULT00000000000000000000000000000000000000000000000000', + }); + }); + + it('completes the full lifecycle: Mint -> Vault Deposit -> Fee Generation -> Compound -> Vault Withdraw', async () => { + // ─── 1. MINT ───────────────────────────────────────────────────────────── + // Admin mints 1,000,000 atomic units to userA and 500,000 to feePayer + const mintUserRes = await tokenClient.mint(admin, userA, 1_000_000n); + expect(mintUserRes.success).toBe(true); + + const mintFeeRes = await tokenClient.mint(admin, feePayer, 500_000n); + expect(mintFeeRes.success).toBe(true); + + expect(await tokenClient.getBalance(userA)).toBe('0.1000000'); // 7 decimals + expect(await tokenClient.getTotalSupply()).toBe(1_500_000n); + + // ─── 2. VAULT DEPOSIT ─────────────────────────────────────────────────── + // UserA approves and deposits 1,000,000 tokens into the vault + const depositAmount = 1_000_000n; + const approveRes = await tokenClient.approve( + userA, + 'CVAULT00000000000000000000000000000000000000000000000000', + depositAmount, + ); + expect(approveRes.success).toBe(true); + + // Initial deposit: 1:1 ratio -> 1,000,000 shares minted + const depositRes = await vaultClient.deposit(userA, depositAmount, null, 990_000n); + expect(depositRes.success).toBe(true); + expect(depositRes.returnValue).toBe(1_000_000n); + + expect(await vaultClient.getShareBalance(userA)).toBe(1_000_000n); + expect(await vaultClient.getTotalAssets()).toBe(1_000_000n); + expect(await vaultClient.getTotalSupply()).toBe(1_000_000n); + expect(await vaultClient.calculateSharePrice()).toBe(1n); + + // ─── 3. FEE GENERATION ────────────────────────────────────────────────── + // Fee generator sends 200,000 reward/fee tokens into the vault + const feeAmount = 200_000n; + const feeDistRes = await vaultClient.distributeRewards(feePayer, feeAmount); + expect(feeDistRes.success).toBe(true); + + expect(await vaultClient.getTotalAssets()).toBe(1_200_000n); + expect(await vaultClient.getPendingRewards()).toBe(200_000n); + // Shares unchanged at 1,000,000, but assets increased to 1,200,000 + expect(await vaultClient.getTotalSupply()).toBe(1_000_000n); + + // ─── 4. COMPOUND ──────────────────────────────────────────────────────── + // Compound pending fees into vault pool + const compoundRes = await vaultClient.compound(admin); + expect(compoundRes.success).toBe(true); + expect(await vaultClient.getPendingRewards()).toBe(0n); + + // Total assets is 1,200,000 for 1,000,000 shares + // Share price = 1_200_000 / 1_000_000 = 1 (with integer math) + // Pro-rata rewards entitlement for userA's 1,000,000 shares: + const userEntitlement = await vaultClient.calculateRewards(1_000_000n); + expect(userEntitlement).toBe(1_200_000n); + + // ─── 5. VAULT WITHDRAW ────────────────────────────────────────────────── + // UserA withdraws all 1,000,000 shares and receives 1,200,000 underlying tokens (principal + yield) + const withdrawRes = await vaultClient.withdraw(userA, 1_000_000n, null, 1_190_000n); + expect(withdrawRes.success).toBe(true); + expect(withdrawRes.returnValue).toBe(1_200_000n); // 200,000 yield received! + + // Verify vault balances after withdrawal + expect(await vaultClient.getShareBalance(userA)).toBe(0n); + expect(await vaultClient.getTotalSupply()).toBe(0n); + expect(await vaultClient.getTotalAssets()).toBe(0n); + }); + + it('handles multi-user deposit, fee distribution, compounding, and fair pro-rata withdrawals', async () => { + // 1. Mint tokens to UserA (1,000,000) and UserB (1,000,000) + await tokenClient.mint(admin, userA, 1_000_000n); + await tokenClient.mint(admin, userB, 1_000_000n); + + // 2. UserA deposits 1,000,000 tokens + await vaultClient.deposit(userA, 1_000_000n); + + // 3. Protocol generates 500,000 fees and compounds + await vaultClient.distributeRewards(feePayer, 500_000n); + await vaultClient.compound(admin); + // Vault now has: assets = 1,500,000, shares = 1,000,000 + + // 4. UserB deposits 1,500,000 tokens at the updated rate + // sharesOut = (1,500,000 * 1,000,000) / 1,500,000 = 1,000,000 shares + const userBDeposit = await vaultClient.deposit(userB, 1_500_000n); + expect(userBDeposit.success).toBe(true); + expect(userBDeposit.returnValue).toBe(1_000_000n); + + // Now totalAssets = 3,000,000, totalShares = 2,000,000 (UserA has 1M, UserB has 1M) + expect(await vaultClient.getTotalAssets()).toBe(3_000_000n); + expect(await vaultClient.getTotalSupply()).toBe(2_000_000n); + + // 5. Additional 1,000,000 fee generation and compound + await vaultClient.distributeRewards(feePayer, 1_000_000n); + await vaultClient.compound(admin); + // TotalAssets = 4,000,000, TotalShares = 2,000,000 + + // 6. UserA withdraws 1M shares -> receives (1M * 4M) / 2M = 2,000,000 tokens + const userAWithdraw = await vaultClient.withdraw(userA, 1_000_000n); + expect(userAWithdraw.success).toBe(true); + expect(userAWithdraw.returnValue).toBe(2_000_000n); + + // 7. UserB withdraws 1M shares -> receives (1M * 2M) / 1M = 2,000,000 tokens + const userBWithdraw = await vaultClient.withdraw(userB, 1_000_000n); + expect(userBWithdraw.success).toBe(true); + expect(userBWithdraw.returnValue).toBe(2_000_000n); + + // Vault is completely drained cleanly + expect(await vaultClient.getTotalSupply()).toBe(0n); + expect(await vaultClient.getTotalAssets()).toBe(0n); + }); + + it('verifies error states across the lifecycle', async () => { + // Deposit 0 tokens -> rejects + const zeroDep = await vaultClient.deposit(userA, 0n); + expect(zeroDep.success).toBe(false); + + // Withdraw with zero shares -> rejects + const zeroWith = await vaultClient.withdraw(userA, 0n); + expect(zeroWith.success).toBe(false); + + // Withdraw with no deposit -> InsufficientBalance + const noDepWith = await vaultClient.withdraw(userA, 1000n); + expect(noDepWith.success).toBe(false); + expect(noDepWith.returnValue).toContain('InsufficientBalance'); + + // Negative rewards -> rejects + const negRew = await vaultClient.distributeRewards(feePayer, -100n); + expect(negRew.success).toBe(false); + + // Calculating rewards on 0 total shares throws ZeroShares + await expect(vaultClient.calculateSharePrice()).rejects.toThrow('ZeroShares'); + }); +}); diff --git a/sdk/src/vaultClient.test.ts b/sdk/src/vaultClient.test.ts new file mode 100644 index 00000000..bd30f73d --- /dev/null +++ b/sdk/src/vaultClient.test.ts @@ -0,0 +1,194 @@ +import { jest } from '@jest/globals'; +import { VaultClient } from './vaultClient'; +import { MockVaultClient } from './mockClient'; +import { Keypair } from '@stellar/stellar-sdk'; + +const MOCK_CONTRACT_ID = 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526'; +const MOCK_RPC_URL = 'https://soroban-testnet.stellar.org'; +const MOCK_PASSPHRASE = 'Test SDF Network ; September 2015'; + +describe('VaultClient surface and methods', () => { + let client: VaultClient; + + beforeEach(() => { + client = new VaultClient({ + rpcUrl: MOCK_RPC_URL, + networkPassphrase: MOCK_PASSPHRASE, + contractId: MOCK_CONTRACT_ID, + }); + }); + + it('instantiates VaultClient correctly and exposes required methods', () => { + expect(typeof client.deposit).toBe('function'); + expect(typeof client.withdraw).toBe('function'); + expect(typeof client.compound).toBe('function'); + expect(typeof client.compoundFees).toBe('function'); + expect(typeof client.distributeRewards).toBe('function'); + expect(typeof client.getTotalAssets).toBe('function'); + expect(typeof client.getTotalSupply).toBe('function'); + expect(typeof client.getBalance).toBe('function'); + expect(typeof client.getShareBalance).toBe('function'); + expect(typeof client.getPendingRewards).toBe('function'); + expect(typeof client.calculateSharePrice).toBe('function'); + expect(typeof client.calculateRewards).toBe('function'); + expect(typeof client.getUnderlyingToken).toBe('function'); + expect(typeof client.getUnlockTime).toBe('function'); + expect(typeof client.setUnlockTime).toBe('function'); + expect(typeof client.clearUnlockTime).toBe('function'); + expect(typeof client.transfer).toBe('function'); + expect(typeof client.approve).toBe('function'); + expect(typeof client.transferFrom).toBe('function'); + expect(typeof client.buildDepositTx).toBe('function'); + expect(typeof client.buildWithdrawTx).toBe('function'); + expect(typeof client.buildCompoundTx).toBe('function'); + expect(typeof client.buildDistributeRewardsTx).toBe('function'); + expect(typeof client.simulateDeposit).toBe('function'); + expect(typeof client.simulateWithdraw).toBe('function'); + expect(typeof client.simulateCompound).toBe('function'); + expect(typeof client.signTx).toBe('function'); + }); + + it('handles deposit invocation with and without slippage tolerance', async () => { + const invokeContract = jest.fn(async (_method: string, _args: unknown[], _source: Keypair) => ({ + success: true, + hash: 'mock-hash', + returnValue: 1000n, + })); + (client as unknown as { invokeContract: typeof invokeContract }).invokeContract = + invokeContract; + + const source = Keypair.random(); + const user = source.publicKey(); + + // 1. Call deposit without minSharesOut + const res1 = await client.deposit(user, 1000n, source); + expect(res1.success).toBe(true); + expect(invokeContract).toHaveBeenCalledWith('deposit', expect.any(Array), source); + + // 2. Call deposit with minSharesOut + const res2 = await client.deposit(user, 1000n, source, 950n); + expect(res2.success).toBe(true); + expect(invokeContract).toHaveBeenCalledTimes(2); + }); + + it('handles withdraw invocation with and without minTokensOut', async () => { + const invokeContract = jest.fn(async (_method: string, _args: unknown[], _source: Keypair) => ({ + success: true, + hash: 'mock-hash', + returnValue: 1050n, + })); + (client as unknown as { invokeContract: typeof invokeContract }).invokeContract = + invokeContract; + + const source = Keypair.random(); + const user = source.publicKey(); + + // 1. Call withdraw without minTokensOut + const res1 = await client.withdraw(user, 500n, source); + expect(res1.success).toBe(true); + + // 2. Call withdraw with minTokensOut + const res2 = await client.withdraw(user, 500n, source, 490n); + expect(res2.success).toBe(true); + expect(invokeContract).toHaveBeenCalledTimes(2); + }); + + it('handles compound and compoundFees invocation', async () => { + const invokeContract = jest.fn(async (_method: string, _args: unknown[], _source: Keypair) => ({ + success: true, + hash: 'mock-hash', + returnValue: null, + })); + (client as unknown as { invokeContract: typeof invokeContract }).invokeContract = + invokeContract; + + const source = Keypair.random(); + const caller = source.publicKey(); + + const res1 = await client.compound(caller, source); + expect(res1.success).toBe(true); + + const res2 = await client.compoundFees(caller, source); + expect(res2.success).toBe(true); + expect(invokeContract).toHaveBeenCalledTimes(2); + }); +}); + +describe('MockVaultClient Unit Tests', () => { + let mockVault: MockVaultClient; + const userA = 'GA111111111111111111111111111111111111111111111111111111'; + const userB = 'GB222222222222222222222222222222222222222222222222222222'; + + beforeEach(() => { + mockVault = new MockVaultClient(); + }); + + it('performs basic deposit and withdraw lifecycle with 1:1 initial rate', async () => { + // 1. User deposits 1,000,000 units + const depRes = await mockVault.deposit(userA, 1_000_000n); + expect(depRes.success).toBe(true); + expect(depRes.returnValue).toBe(1_000_000n); + + expect(await mockVault.getShareBalance(userA)).toBe(1_000_000n); + expect(await mockVault.getTotalAssets()).toBe(1_000_000n); + expect(await mockVault.getTotalSupply()).toBe(1_000_000n); + expect(await mockVault.calculateSharePrice()).toBe(1n); + + // 2. User withdraws 400,000 shares + const withRes = await mockVault.withdraw(userA, 400_000n); + expect(withRes.success).toBe(true); + expect(withRes.returnValue).toBe(400_000n); + + expect(await mockVault.getShareBalance(userA)).toBe(600_000n); + expect(await mockVault.getTotalAssets()).toBe(600_000n); + expect(await mockVault.getTotalSupply()).toBe(600_000n); + }); + + it('reverts on deposit with zero or negative amount', async () => { + const resZero = await mockVault.deposit(userA, 0n); + expect(resZero.success).toBe(false); + expect(resZero.returnValue).toContain('InvalidAmount'); + + const resNeg = await mockVault.deposit(userA, -500n); + expect(resNeg.success).toBe(false); + expect(resNeg.returnValue).toContain('InvalidAmount'); + }); + + it('reverts on withdraw with zero or insufficient shares', async () => { + const resZero = await mockVault.withdraw(userA, 0n); + expect(resZero.success).toBe(false); + expect(resZero.returnValue).toContain('InvalidAmount'); + + const resInsuff = await mockVault.withdraw(userA, 100n); + expect(resInsuff.success).toBe(false); + expect(resInsuff.returnValue).toContain('InsufficientBalance'); + }); + + it('reverts when slippage condition is violated on deposit and withdraw', async () => { + // Deposit with minSharesOut higher than calculated + const depFail = await mockVault.deposit(userA, 1000n, null, 1500n); + expect(depFail.success).toBe(false); + expect(depFail.returnValue).toContain('SlippageExceeded'); + + // Deposit succeeds + await mockVault.deposit(userA, 1000n); + + // Withdraw with minTokensOut higher than calculated + const withFail = await mockVault.withdraw(userA, 500n, null, 600n); + expect(withFail.success).toBe(false); + expect(withFail.returnValue).toContain('SlippageExceeded'); + }); + + it('handles transfer and allowance correctly', async () => { + await mockVault.deposit(userA, 1000n); + const transferRes = await mockVault.transfer(userA, userB, 400n); + expect(transferRes.success).toBe(true); + + expect(await mockVault.getShareBalance(userA)).toBe(600n); + expect(await mockVault.getShareBalance(userB)).toBe(400n); + + const approveRes = await mockVault.approve(userA, userB, 200n); + expect(approveRes.success).toBe(true); + expect(await mockVault.getAllowance(userA, userB)).toBe(200n); + }); +}); diff --git a/sdk/src/vaultClient.ts b/sdk/src/vaultClient.ts new file mode 100644 index 00000000..ad72c6df --- /dev/null +++ b/sdk/src/vaultClient.ts @@ -0,0 +1,591 @@ +/** + * @bc-forge/sdk — VaultClient + * + * High-level TypeScript client for interacting with deployed bc-forge + * yield-bearing fee vault and wrapper contracts on the Stellar/Soroban network. + */ + +import { + rpc as SorobanRpc, + Contract, + TransactionBuilder, + Keypair, + xdr, + nativeToScVal, +} from '@stellar/stellar-sdk'; +import type { WalletAdapter } from './walletAdapter'; + +import { + buildInvokeTransaction, + submitTransaction, + addressToScVal, + i128ToScVal, + u32ToScVal, + scValToNative, + buildUnsignedTransaction, + signTransaction, + simulateTransaction, +} from './utils'; + +import { SimulationError, RPCError } from './errors'; +import type { TransactionResult } from './client'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface VaultClientConfig { + /** Soroban RPC endpoint URL */ + rpcUrl: string; + /** Stellar network passphrase */ + networkPassphrase: string; + /** Deployed bc-forge vault contract ID */ + contractId: string; + /** Optional wallet adapter for browser-based signing flows */ + walletAdapter?: WalletAdapter; +} + +// ─── Client ────────────────────────────────────────────────────────────────── + +export class VaultClient { + private rpcUrl: string; + private networkPassphrase: string; + private contractId: string; + private server: SorobanRpc.Server; + private contract: Contract; + private walletAdapter?: WalletAdapter; + + constructor(config: VaultClientConfig) { + this.rpcUrl = config.rpcUrl; + this.networkPassphrase = config.networkPassphrase; + this.contractId = config.contractId; + this.server = new SorobanRpc.Server(this.rpcUrl); + this.contract = new Contract(this.contractId); + this.walletAdapter = config.walletAdapter; + } + + // ─── Read-Only Queries ─────────────────────────────────────────────────── + + /** + * Get the vault share balance for an address. + */ + async getBalance(address: string): Promise { + const result = await this.queryContract('balance', [addressToScVal(address)]); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Get an address's vault share balance under vault vocabulary. + */ + async getShareBalance(address: string): Promise { + const result = await this.queryContract('share_balance', [addressToScVal(address)]); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Get the total vault share supply in circulation. + */ + async getTotalSupply(): Promise { + const result = await this.queryContract('supply', []); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Get the total underlying token assets held by the vault contract. + */ + async getTotalAssets(): Promise { + const result = await this.queryContract('total_assets', []); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Get the cumulative pending/undistributed fees/rewards. + */ + async getPendingRewards(): Promise { + const result = await this.queryContract('pending_rewards', []); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Calculate the current vault share price (total assets / total shares). + */ + async calculateSharePrice(): Promise { + const result = await this.queryContract('calculate_share_price', []); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Calculate the pro-rata reward/underlying token entitlement for a given amount of shares. + */ + async calculateRewards(userShares: bigint): Promise { + const result = await this.queryContract('calculate_rewards', [i128ToScVal(userShares)]); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Get the underlying token contract address. + */ + async getUnderlyingToken(): Promise { + const result = await this.queryContract('underlying_token', []); + return scValToNative(result) as string; + } + + /** + * Get the human-readable vault share token name. + */ + async getName(): Promise { + const result = await this.queryContract('name', []); + return scValToNative(result) as string; + } + + /** + * Get the vault share token ticker symbol. + */ + async getSymbol(): Promise { + const result = await this.queryContract('symbol', []); + return scValToNative(result) as string; + } + + /** + * Get the number of decimal places for the vault. + */ + async getDecimals(): Promise { + const result = await this.queryContract('decimals', []); + return scValToNative(result) as number; + } + + /** + * Get spending allowance from owner to spender. + */ + async getAllowance(owner: string, spender: string): Promise { + const result = await this.queryContract('allowance', [ + addressToScVal(owner), + addressToScVal(spender), + ]); + return BigInt(scValToNative(result) as string | number | bigint); + } + + /** + * Get the deposit lockup expiration timestamp for a user. + */ + async getUnlockTime(user: string): Promise { + const result = await this.queryContract('get_unlock_time', [addressToScVal(user)]); + return scValToNative(result) as bigint | null; + } + + // ─── Write Transactions ────────────────────────────────────────────────── + + /** + * Deposit underlying tokens into the vault and receive minted vault shares. + * + * @param caller - Depositor address + * @param amount - Amount of underlying tokens to deposit + * @param source - Depositor keypair (or signer) + * @param minSharesOut - Optional minimum shares to receive (slippage protection) + */ + async deposit( + caller: string, + amount: bigint, + source: Keypair, + minSharesOut?: bigint, + ): Promise { + const args = + minSharesOut !== undefined + ? [addressToScVal(caller), i128ToScVal(amount), i128ToScVal(minSharesOut)] + : [addressToScVal(caller), i128ToScVal(amount)]; + return this.invokeContract('deposit', args, source); + } + + /** + * Withdraw vault shares and receive proportional underlying tokens plus accrued yield. + * + * @param caller - Withdrawer address + * @param shares - Amount of vault shares to burn + * @param source - Withdrawer keypair (or signer) + * @param minTokensOut - Optional minimum tokens to receive (slippage protection) + */ + async withdraw( + caller: string, + shares: bigint, + source: Keypair, + minTokensOut?: bigint, + ): Promise { + const args = + minTokensOut !== undefined + ? [addressToScVal(caller), i128ToScVal(shares), i128ToScVal(minTokensOut)] + : [addressToScVal(caller), i128ToScVal(shares)]; + return this.invokeContract('withdraw', args, source); + } + + /** + * Compound pending protocol fees into the vault's total assets. + * + * @param caller - Address executing the compound operation + * @param source - Caller's keypair + */ + async compound(caller: string, source: Keypair): Promise { + return this.invokeContract('compound_fees', [addressToScVal(caller)], source); + } + + /** + * Compound pending fees alias for compound_fees. + */ + async compoundFees(caller: string, source: Keypair): Promise { + return this.compound(caller, source); + } + + /** + * Distribute rewards into the vault without issuing new shares. + * + * @param caller - Reward provider address + * @param amount - Amount of underlying tokens to distribute + * @param source - Caller keypair + */ + async distributeRewards( + caller: string, + amount: bigint, + source: Keypair, + ): Promise { + return this.invokeContract( + 'distribute_rewards', + [addressToScVal(caller), i128ToScVal(amount)], + source, + ); + } + + /** + * Wrap underlying tokens into vault shares (1:1 standard wrapper entrypoint). + */ + async wrap(caller: string, amount: bigint, source: Keypair): Promise { + return this.invokeContract('wrap', [addressToScVal(caller), i128ToScVal(amount)], source); + } + + /** + * Unwrap vault shares back to underlying tokens (1:1 standard wrapper exitpoint). + */ + async unwrap(caller: string, wrappedAmount: bigint, source: Keypair): Promise { + return this.invokeContract( + 'unwrap', + [addressToScVal(caller), i128ToScVal(wrappedAmount)], + source, + ); + } + + /** + * Set deposit time lockup for a user (admin operation). + */ + async setUnlockTime( + caller: string, + user: string, + unlockTimestamp: bigint, + source: Keypair, + ): Promise { + return this.invokeContract( + 'set_unlock_time', + [ + addressToScVal(caller), + addressToScVal(user), + nativeToScVal(unlockTimestamp, { type: 'u64' }), + ], + source, + ); + } + + /** + * Clear deposit time lockup for a user (admin operation). + */ + async clearUnlockTime(caller: string, user: string, source: Keypair): Promise { + return this.invokeContract( + 'clear_unlock_time', + [addressToScVal(caller), addressToScVal(user)], + source, + ); + } + + /** + * Transfer vault shares between addresses. + */ + async transfer( + from: string, + to: string, + amount: bigint, + source: Keypair, + ): Promise { + return this.invokeContract( + 'transfer', + [addressToScVal(from), addressToScVal(to), i128ToScVal(amount)], + source, + ); + } + + /** + * Approve a spender for vault shares. + */ + async approve( + from: string, + spender: string, + amount: bigint, + exp: number, + source: Keypair, + ): Promise { + return this.invokeContract( + 'approve', + [addressToScVal(from), addressToScVal(spender), i128ToScVal(amount), u32ToScVal(exp)], + source, + ); + } + + /** + * Transfer vault shares from an approved address. + */ + async transferFrom( + spender: string, + from: string, + to: string, + amount: bigint, + source: Keypair, + ): Promise { + return this.invokeContract( + 'transfer_from', + [addressToScVal(spender), addressToScVal(from), addressToScVal(to), i128ToScVal(amount)], + source, + ); + } + + // ─── Offline Transaction Building & Simulation ─────────────────────────── + + /** + * Build an unsigned transaction XDR for deposit. + */ + async buildDepositTx( + caller: string, + amount: bigint, + sourcePublicKey: string, + minSharesOut?: bigint, + ): Promise { + const args = + minSharesOut !== undefined + ? [addressToScVal(caller), i128ToScVal(amount), i128ToScVal(minSharesOut)] + : [addressToScVal(caller), i128ToScVal(amount)]; + return buildUnsignedTransaction( + this.rpcUrl, + this.networkPassphrase, + this.contractId, + 'deposit', + args, + sourcePublicKey, + ); + } + + /** + * Build an unsigned transaction XDR for withdraw. + */ + async buildWithdrawTx( + caller: string, + shares: bigint, + sourcePublicKey: string, + minTokensOut?: bigint, + ): Promise { + const args = + minTokensOut !== undefined + ? [addressToScVal(caller), i128ToScVal(shares), i128ToScVal(minTokensOut)] + : [addressToScVal(caller), i128ToScVal(shares)]; + return buildUnsignedTransaction( + this.rpcUrl, + this.networkPassphrase, + this.contractId, + 'withdraw', + args, + sourcePublicKey, + ); + } + + /** + * Build an unsigned transaction XDR for compounding fees. + */ + async buildCompoundTx(caller: string, sourcePublicKey: string): Promise { + return buildUnsignedTransaction( + this.rpcUrl, + this.networkPassphrase, + this.contractId, + 'compound_fees', + [addressToScVal(caller)], + sourcePublicKey, + ); + } + + /** + * Build an unsigned transaction XDR for distributing rewards. + */ + async buildDistributeRewardsTx( + caller: string, + amount: bigint, + sourcePublicKey: string, + ): Promise { + return buildUnsignedTransaction( + this.rpcUrl, + this.networkPassphrase, + this.contractId, + 'distribute_rewards', + [addressToScVal(caller), i128ToScVal(amount)], + sourcePublicKey, + ); + } + + /** + * Sign an unsigned transaction XDR with a Keypair. + */ + signTx(xdrString: string, signer: Keypair): string { + return signTransaction(xdrString, this.networkPassphrase, signer); + } + + /** + * Simulate a contract call without submitting a transaction. + */ + async simulate(method: string, args: xdr.ScVal[], sourcePublicKey: string): Promise { + return simulateTransaction( + this.rpcUrl, + this.networkPassphrase, + this.contractId, + method, + args, + sourcePublicKey, + ); + } + + /** + * Simulate a deposit operation. + */ + async simulateDeposit( + caller: string, + amount: bigint, + sourcePublicKey: string, + minSharesOut?: bigint, + ): Promise { + const args = + minSharesOut !== undefined + ? [addressToScVal(caller), i128ToScVal(amount), i128ToScVal(minSharesOut)] + : [addressToScVal(caller), i128ToScVal(amount)]; + return this.simulate('deposit', args, sourcePublicKey); + } + + /** + * Simulate a withdraw operation. + */ + async simulateWithdraw( + caller: string, + shares: bigint, + sourcePublicKey: string, + minTokensOut?: bigint, + ): Promise { + const args = + minTokensOut !== undefined + ? [addressToScVal(caller), i128ToScVal(shares), i128ToScVal(minTokensOut)] + : [addressToScVal(caller), i128ToScVal(shares)]; + return this.simulate('withdraw', args, sourcePublicKey); + } + + /** + * Simulate a compound fees operation. + */ + async simulateCompound(caller: string, sourcePublicKey: string): Promise { + return this.simulate('compound_fees', [addressToScVal(caller)], sourcePublicKey); + } + + /** + * Get recent events for the vault contract. + */ + async getEvents(startLedger?: number): Promise { + const response = await this.server.getEvents({ + startLedger: startLedger || (await this.server.getLatestLedger()).sequence - 1000, + filters: [{ contractIds: [this.contractId], type: 'contract' }], + }); + return response.events; + } + + // ─── Internal Helpers ──────────────────────────────────────────────────── + + private async withRetry(fn: () => Promise, retries: number = 3): Promise { + let lastError: unknown; + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (i < retries - 1) { + await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1))); + } + } + } + throw lastError; + } + + private async queryContract(method: string, args: xdr.ScVal[]): Promise { + return this.withRetry(async () => { + try { + const account = new (await import('@stellar/stellar-sdk')).Account( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '0', + ); + + const tx = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: this.networkPassphrase, + }) + .addOperation(this.contract.call(method, ...args)) + .setTimeout(30) + .build(); + + const simulated = await this.server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw new SimulationError(`Query failed: ${simulated.error}`, simulated.error); + } + + if (!SorobanRpc.Api.isSimulationSuccess(simulated) || !simulated.result) { + throw new SimulationError('Query returned no result'); + } + + return simulated.result.retval; + } catch (error: unknown) { + if (error instanceof SimulationError) throw error; + throw new RPCError('RPC call failed', error); + } + }); + } + + private async invokeContract( + method: string, + args: xdr.ScVal[], + source: Keypair, + ): Promise { + return this.withRetry(async () => { + try { + const txXdr = await buildInvokeTransaction( + this.rpcUrl, + this.networkPassphrase, + this.contractId, + method, + args, + source, + ); + + const response = await submitTransaction(this.rpcUrl, txXdr); + + if (response.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return { + success: true, + hash: response.txHash, + returnValue: response.returnValue ? scValToNative(response.returnValue) : undefined, + }; + } + + return { + success: false, + hash: response.txHash, + }; + } catch (error: unknown) { + if (error instanceof SimulationError) throw error; + throw error; + } + }); + } +} diff --git a/sdk/src/wrapperClient.test.ts b/sdk/src/wrapperClient.test.ts index a0e6dad6..5822eb1e 100644 --- a/sdk/src/wrapperClient.test.ts +++ b/sdk/src/wrapperClient.test.ts @@ -1,5 +1,4 @@ import { WrapperClient } from './wrapperClient'; -import { Keypair } from '@stellar/stellar-sdk'; const MOCK_CONTRACT_ID = 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526'; @@ -32,7 +31,6 @@ describe('WrapperClient surface', () => { contractId: MOCK_CONTRACT_ID, }); - const keypair = Keypair.random(); // Simulate/invoke check that function is callable and defined on class prototype expect(client.distributeRewards).toBeDefined(); expect(client.getTotalAssets).toBeDefined(); diff --git a/sdk/src/wrapperClient.ts b/sdk/src/wrapperClient.ts index fd1c87fa..4403359a 100644 --- a/sdk/src/wrapperClient.ts +++ b/sdk/src/wrapperClient.ts @@ -296,11 +296,7 @@ export class WrapperClient { * @param source - Caller's keypair */ async withdraw(caller: string, shares: bigint, source: Keypair): Promise { - return this.invokeContract( - 'withdraw', - [addressToScVal(caller), i128ToScVal(shares)], - source, - ); + return this.invokeContract('withdraw', [addressToScVal(caller), i128ToScVal(shares)], source); } /** @@ -340,11 +336,7 @@ export class WrapperClient { * @param user - Address whose deposit lockup is being cleared * @param source - Caller's keypair */ - async clearUnlockTime( - caller: string, - user: string, - source: Keypair, - ): Promise { + async clearUnlockTime(caller: string, user: string, source: Keypair): Promise { return this.invokeContract( 'clear_unlock_time', [addressToScVal(caller), addressToScVal(user)],