From e91372bbb7e7e1a07ae7d6f3abab60e9c12494f2 Mon Sep 17 00:00:00 2001 From: michaelsimeon001 Date: Fri, 21 Aug 2026 19:38:57 +0100 Subject: [PATCH] feat(sdk): implement real Soroban transaction pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deposit/withdraw/earlyExit funneled through two stub methods that never built, simulated, signed, or submitted anything — calling yl.deposit() today does nothing on-chain, despite the README documenting it as working. - New TransactionPipeline (sdks/typescript/src/transactions.ts): build -> simulate -> assemble -> sign (via the configured Signer) -> submit, for every VaultRouter write call; simulate-only for reads. Always simulates fresh per call rather than reusing a fee/footprint estimated for a different transaction. - Tier/Address/amount ScVal encoding matches how Soroban actually encodes them: Tier as scvVec([Symbol(tag)]) (how #[contracttype] enums encode, confirmed against the real @stellar/stellar-sdk implementation, not assumed), Address via Address.toScVal(), amount as i128. - Contract-level rejections (BelowMinDeposit, AssetNotAllowed, ProtocolPaused, LockNotExpired, InvalidTier, DepositCapExceeded) are parsed out of Soroban's `Error(Contract, #N)` simulation-error string into typed error classes extending YieldLadderError, matching contracts/vault_router/src/error.rs's discriminants — replaces the old fragile `err.message.includes('lock')` string-matching for withdraw. Code 7 is genuinely ambiguous between the router's own error enum and a tier vault's (they don't share a numbering scheme for every code) and falls back to a generic VaultContractError rather than guessing. - Transaction expiry (time-bounds expiring while the user is still at their wallet's signing prompt) is distinguished from an on-chain rejection via the submission result's txTooLate code, surfaced as TransactionExpiredError rather than a generic submission failure. - Signer contract (types.ts) aligned with app/src/lib/wallet/types.ts's WalletAdapter.signTransaction(xdr, opts?) — a real wallet adapter can be passed as `signer` directly, no shim needed. YieldLadderOptions gained publicKey/vaultRouterContractId/assetContractId, since none of these existed before and a transaction can't be built without them (nothing is deployed yet per deployments/*.json, so these can't be hardcoded). - withdraw/earlyExit still take just { tier } (no amount) — they query the caller's current position first via VaultRouter.position() and submit that as a full withdrawal/exit, keeping the existing public signature the README already documents. Removed the redundant simulate()-then-simulateThenSubmit() double-call in earlyExit. - @stellar/stellar-sdk moved from peer+dev to a real dependency (still declared as a peerDependency too, so a consuming app using the same SDK version — e.g. via WalletAdapter — doesn't end up with two copies). - 15 unit tests mocking only the RPC layer (SorobanRpc.Server, assembleTransaction) — real ScVal encode/decode, Tier/Address/amount construction, and Position struct decoding are all exercised for real. Covers the happy path for all three write methods, AssetNotAllowed/ BelowMinDeposit/ProtocolPaused/LockNotExpired revert paths, an unmapped contract-error fallback, transaction expiry, a non-expiry submission failure, and that earlyExit simulates exactly once. - README's yl.deposit(...) example updated to the real constructor shape and is now backed by an executable test rather than aspirational docs. Closes #139 --- README.md | 25 +- sdks/typescript/package.json | 5 +- sdks/typescript/pnpm-lock.yaml | 35 ++- sdks/typescript/src/__tests__/index.test.ts | 331 ++++++++++++++++---- sdks/typescript/src/errors.ts | 78 ++++- sdks/typescript/src/index.ts | 129 +++++--- sdks/typescript/src/transactions.ts | 218 +++++++++++++ sdks/typescript/src/types.ts | 32 +- 8 files changed, 739 insertions(+), 114 deletions(-) create mode 100644 sdks/typescript/src/transactions.ts diff --git a/README.md b/README.md index e855267..dd5b6b5 100644 --- a/README.md +++ b/README.md @@ -184,18 +184,31 @@ Mainnet addresses are published after audit completion. Testnet addresses are in ```typescript import { YieldLadder } from '@yieldladder/sdk'; -const yl = new YieldLadder({ network: 'mainnet', signer }); +const yl = new YieldLadder({ + network: 'mainnet', + publicKey: userAddress, // the account initiating transactions + signer, // e.g. a WalletAdapter — see app/src/lib/wallet/types.ts + vaultRouterContractId: 'CC...', + assetContractId: 'CC...', // the deposit asset's SAC address (e.g. USDC) +}); -await yl.deposit({ tier: 'L6', amount: '500.00' }); +const txHash = await yl.deposit({ tier: 'L6', amount: '500.00' }); const position = await yl.position(userAddress); -// { tier: 'L6', principal: '500.00', shares: '575', -// accruedYield: '12.34', lockUntil: 1781856000 } +// { tier: 'L6', principal: '5000000000', shares: '5750000000', +// accruedYield: '0', lockUntil: 1781856000 } +// principal/shares are the contract's raw i128 values (7-decimal stroops, +// same base unit `amount` is submitted in) — not yet decimal-formatted. -await yl.withdraw({ tier: 'L6' }); // throws if lock not expired -await yl.earlyExit({ tier: 'L6' }); // succeeds, charges 1.25% fee +await yl.withdraw({ tier: 'L6' }); // throws LockNotExpiredError if the lock hasn't expired +await yl.earlyExit({ tier: 'L6' }); // succeeds early, exit fee applied by the contract ``` +Every write method returns the submitted transaction's hash. `signer` must implement +`signTransaction(xdr, opts?)` (see `sdks/typescript/src/types.ts`'s `Signer` interface) — +the same shape as the app's `WalletAdapter`, so a real wallet adapter can be passed +directly without an adapter shim. + The SDK source lives in `sdks/typescript/` and targets `@stellar/stellar-sdk` v12+ with Soroban support. ## Glossary diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 970a0fb..b285010 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -9,11 +9,14 @@ "typecheck": "tsc --noEmit", "test": "vitest run" }, + "dependencies": { + "@stellar/stellar-sdk": "^12.3.0" + }, "peerDependencies": { "@stellar/stellar-sdk": ">=12.0.0" }, "devDependencies": { - "@stellar/stellar-sdk": "^12.3.0", + "@types/node": "^20.11.0", "typescript": "^5.9.3", "vitest": "^1.6.0" } diff --git a/sdks/typescript/pnpm-lock.yaml b/sdks/typescript/pnpm-lock.yaml index c9f6f75..b11759d 100644 --- a/sdks/typescript/pnpm-lock.yaml +++ b/sdks/typescript/pnpm-lock.yaml @@ -7,16 +7,20 @@ settings: importers: .: - devDependencies: + dependencies: '@stellar/stellar-sdk': specifier: ^12.3.0 version: 12.3.0 + devDependencies: + '@types/node': + specifier: ^20.11.0 + version: 20.19.43 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^1.6.0 - version: 1.6.1 + version: 1.6.1(@types/node@20.19.43) packages: @@ -306,6 +310,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@vitest/expect@1.6.1': resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} @@ -762,6 +769,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + urijs@1.19.11: resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} @@ -1029,6 +1039,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@vitest/expect@1.6.1': dependencies: '@vitest/spy': 1.6.1 @@ -1527,15 +1541,17 @@ snapshots: ufo@1.6.4: {} + undici-types@6.21.0: {} + urijs@1.19.11: {} - vite-node@1.6.1: + vite-node@1.6.1(@types/node@20.19.43): dependencies: cac: 6.7.14 debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.21 + vite: 5.4.21(@types/node@20.19.43) transitivePeerDependencies: - '@types/node' - less @@ -1547,15 +1563,16 @@ snapshots: - supports-color - terser - vite@5.4.21: + vite@5.4.21(@types/node@20.19.43): dependencies: esbuild: 0.21.5 postcss: 8.5.16 rollup: 4.62.2 optionalDependencies: + '@types/node': 20.19.43 fsevents: 2.3.3 - vitest@1.6.1: + vitest@1.6.1(@types/node@20.19.43): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -1574,9 +1591,11 @@ snapshots: strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.21 - vite-node: 1.6.1 + vite: 5.4.21(@types/node@20.19.43) + vite-node: 1.6.1(@types/node@20.19.43) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 transitivePeerDependencies: - less - lightningcss diff --git a/sdks/typescript/src/__tests__/index.test.ts b/sdks/typescript/src/__tests__/index.test.ts index 085822c..398b0ba 100644 --- a/sdks/typescript/src/__tests__/index.test.ts +++ b/sdks/typescript/src/__tests__/index.test.ts @@ -1,105 +1,314 @@ +import { randomBytes } from 'crypto'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Address, Keypair, nativeToScVal, xdr } from '@stellar/stellar-sdk'; import { YieldLadder } from '../index'; -import { BelowMinDepositError, LockNotExpiredError } from '../errors'; +import { + AssetNotAllowedError, + BelowMinDepositError, + LockNotExpiredError, + ProtocolPausedError, + SubmissionFailedError, + TransactionExpiredError, + VaultContractError, +} from '../errors'; +import type { YieldLadderOptions } from '../types'; -const opts = { network: 'testnet' as const, signer: null }; +const mockGetAccount = vi.fn(); +const mockSimulateTransaction = vi.fn(); +const mockSendTransaction = vi.fn(); +const mockAssembleTransaction = vi.fn(); + +// Only SorobanRpc.Server (network I/O) and assembleTransaction are mocked; +// Address/Keypair/nativeToScVal/scValToNative/xdr encode-decode logic is +// the real implementation, so this suite exercises actual ScVal +// construction/parsing, not a fake of it. +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual( + '@stellar/stellar-sdk', + ); + return { + ...actual, + SorobanRpc: { + ...actual.SorobanRpc, + Server: vi.fn().mockImplementation(() => ({ + getAccount: (...args: unknown[]) => mockGetAccount(...args), + simulateTransaction: (...args: unknown[]) => + mockSimulateTransaction(...args), + sendTransaction: (...args: unknown[]) => mockSendTransaction(...args), + })), + assembleTransaction: (...args: unknown[]) => + mockAssembleTransaction(...args), + }, + }; +}); + +const routerContractId = Address.contract(randomBytes(32)).toString(); +const assetContractId = Address.contract(randomBytes(32)).toString(); +const userKeypair = Keypair.random(); + +function makeOptions(overrides: Partial = {}): YieldLadderOptions { + return { + network: 'testnet', + publicKey: userKeypair.publicKey(), + signer: { signTransaction: vi.fn(async (xdrString: string) => xdrString) }, + vaultRouterContractId: routerContractId, + assetContractId, + ...overrides, + }; +} + +function fakeAccount(publicKey: string) { + return { + accountId: () => publicKey, + sequenceNumber: () => '1', + incrementSequenceNumber: () => undefined, + }; +} + +function successSim(retval: xdr.ScVal) { + return { + id: '1', + latestLedger: 100, + events: [], + _parsed: true, + transactionData: {}, + minResourceFee: '100', + cost: { cpuInsns: '0', memBytes: '0' }, + result: { auth: [], retval }, + }; +} + +function errorSim(error: string) { + return { id: '1', latestLedger: 100, events: [], _parsed: true, error }; +} + +function stubHappyPathSubmit(hash: string) { + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + mockSendTransaction.mockResolvedValueOnce({ status: 'PENDING', hash }); +} + +function positionScVal(principal: bigint, shares: bigint, lockUntil: number) { + return xdr.ScVal.scvMap( + [ + ['principal', nativeToScVal(principal, { type: 'i128' })], + ['shares', nativeToScVal(shares, { type: 'i128' })], + ['lock_until', xdr.ScVal.scvU32(lockUntil)], + ].map( + ([key, val]) => + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol(key as string), + val: val as xdr.ScVal, + }), + ), + ); +} describe('YieldLadder', () => { let sdk: YieldLadder; beforeEach(() => { - sdk = new YieldLadder(opts); - vi.restoreAllMocks(); + vi.clearAllMocks(); + mockGetAccount.mockImplementation(async (pk: string) => fakeAccount(pk)); + sdk = new YieldLadder(makeOptions()); }); describe('deposit', () => { - it('throws BelowMinDepositError when amount is below Flex minimum (10 USDC)', async () => { - await expect(sdk.deposit({ tier: 'Flex', amount: '5' })).rejects.toBeInstanceOf(BelowMinDepositError); + it('throws BelowMinDepositError below the Flex minimum without ever calling the RPC', async () => { + await expect( + sdk.deposit({ tier: 'Flex', amount: '5' }), + ).rejects.toBeInstanceOf(BelowMinDepositError); + expect(mockGetAccount).not.toHaveBeenCalled(); + }); + + it('throws BelowMinDepositError below the L12 minimum', async () => { + await expect( + sdk.deposit({ tier: 'L12', amount: '249' }), + ).rejects.toBeInstanceOf(BelowMinDepositError); + }); + + it('submits a real transaction and returns the tx hash on success', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + successSim(xdr.ScVal.scvVoid()), + ); + stubHappyPathSubmit('deposit-hash-1'); + + const hash = await sdk.deposit({ tier: 'Flex', amount: '10' }); + + expect(hash).toBe('deposit-hash-1'); + expect(mockGetAccount).toHaveBeenCalledWith(userKeypair.publicKey()); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + }); + + it('signs the assembled transaction via the configured Signer', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + successSim(xdr.ScVal.scvVoid()), + ); + stubHappyPathSubmit('deposit-hash-2'); + const signer = { signTransaction: vi.fn(async (x: string) => x) }; + const signingSdk = new YieldLadder(makeOptions({ signer })); + + await signingSdk.deposit({ tier: 'Flex', amount: '10' }); + + expect(signer.signTransaction).toHaveBeenCalledTimes(1); + expect(signer.signTransaction).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ accountToSign: userKeypair.publicKey() }), + ); + }); + + it('maps a simulated AssetNotAllowed rejection (code 4) to a typed error', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #4)'), + ); + + await expect( + sdk.deposit({ tier: 'Flex', amount: '10' }), + ).rejects.toBeInstanceOf(AssetNotAllowedError); }); - it('throws BelowMinDepositError when amount is below L3 minimum (50 USDC)', async () => { - await expect(sdk.deposit({ tier: 'L3', amount: '49.9999999' })).rejects.toBeInstanceOf(BelowMinDepositError); + it('maps a simulated ProtocolPaused rejection (code 6) to a typed error', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #6)'), + ); + + await expect( + sdk.deposit({ tier: 'Flex', amount: '10' }), + ).rejects.toBeInstanceOf(ProtocolPausedError); }); - it('throws BelowMinDepositError when amount is below L6 minimum (100 USDC)', async () => { - await expect(sdk.deposit({ tier: 'L6', amount: '99.9999999' })).rejects.toBeInstanceOf(BelowMinDepositError); + it('maps a simulated BelowMinDeposit rejection from the contract itself (code 2)', async () => { + // The SDK's own pre-check passes (10 >= Flex minimum) but the + // contract rejects anyway — still surfaced as the same typed error. + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #2)'), + ); + + await expect( + sdk.deposit({ tier: 'Flex', amount: '10' }), + ).rejects.toBeInstanceOf(BelowMinDepositError); }); - it('throws BelowMinDepositError when amount is below L12 minimum (250 USDC)', async () => { - await expect(sdk.deposit({ tier: 'L12', amount: '249' })).rejects.toBeInstanceOf(BelowMinDepositError); + it('falls back to a generic VaultContractError for an unmapped code', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #7)'), + ); + + const error = await sdk + .deposit({ tier: 'Flex', amount: '10' }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(VaultContractError); + expect((error as VaultContractError).code).toBe(7); }); - it('resolves when amount meets the Flex minimum exactly', async () => { - vi.spyOn(sdk as any, 'simulateThenSubmit').mockResolvedValue(undefined); - await expect(sdk.deposit({ tier: 'Flex', amount: '10' })).resolves.toBeUndefined(); + it('throws TransactionExpiredError when submission reports txTooLate', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + successSim(xdr.ScVal.scvVoid()), + ); + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + mockSendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'expired-hash', + errorResult: { + result: () => ({ switch: () => ({ name: 'txTooLate' }) }), + }, + }); + + await expect( + sdk.deposit({ tier: 'Flex', amount: '10' }), + ).rejects.toBeInstanceOf(TransactionExpiredError); }); - it('resolves when amount exceeds the L12 minimum', async () => { - vi.spyOn(sdk as any, 'simulateThenSubmit').mockResolvedValue(undefined); - await expect(sdk.deposit({ tier: 'L12', amount: '500.50' })).resolves.toBeUndefined(); + it('throws SubmissionFailedError for a non-expiry submission rejection', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + successSim(xdr.ScVal.scvVoid()), + ); + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + mockSendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'bad-hash', + errorResult: { + result: () => ({ switch: () => ({ name: 'txFailed' }) }), + }, + }); + + await expect( + sdk.deposit({ tier: 'Flex', amount: '10' }), + ).rejects.toBeInstanceOf(SubmissionFailedError); }); }); describe('withdraw', () => { - it('throws LockNotExpiredError when contract returns a lock error', async () => { - vi.spyOn(sdk as any, 'simulateThenSubmit').mockRejectedValue(new Error('lock period active')); - await expect(sdk.withdraw({ tier: 'L3' })).rejects.toBeInstanceOf(LockNotExpiredError); - }); + it('queries the current position, then withdraws exactly that principal', async () => { + mockSimulateTransaction + .mockResolvedValueOnce(successSim(positionScVal(500_0000000n, 525_0000000n, 0))) + .mockResolvedValueOnce(successSim(xdr.ScVal.scvVoid())); + stubHappyPathSubmit('withdraw-hash-1'); - it('re-throws non-lock errors unchanged', async () => { - vi.spyOn(sdk as any, 'simulateThenSubmit').mockRejectedValue(new Error('network timeout')); - await expect(sdk.withdraw({ tier: 'L3' })).rejects.toThrow('network timeout'); + const hash = await sdk.withdraw({ tier: 'L3' }); + + expect(hash).toBe('withdraw-hash-1'); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); }); - it('resolves on successful withdrawal', async () => { - vi.spyOn(sdk as any, 'simulateThenSubmit').mockResolvedValue(undefined); - await expect(sdk.withdraw({ tier: 'Flex' })).resolves.toBeUndefined(); + it('maps a simulated lock-not-expired rejection (code 3) to LockNotExpiredError', async () => { + mockSimulateTransaction + .mockResolvedValueOnce(successSim(positionScVal(500_0000000n, 525_0000000n, 999_999_999))) + .mockResolvedValueOnce(errorSim('HostError: Error(Contract, #3)')); + + await expect(sdk.withdraw({ tier: 'L3' })).rejects.toBeInstanceOf( + LockNotExpiredError, + ); }); }); describe('earlyExit', () => { - it('calls simulate before simulateThenSubmit', async () => { - const order: string[] = []; - vi.spyOn(sdk as any, 'simulate').mockImplementation(async () => { order.push('simulate'); return '100'; }); - vi.spyOn(sdk as any, 'simulateThenSubmit').mockImplementation(async () => { order.push('submit'); }); - await sdk.earlyExit({ tier: 'L6' }); - expect(order).toEqual(['simulate', 'submit']); - }); + it('simulates exactly once (no redundant double-simulate)', async () => { + mockSimulateTransaction + .mockResolvedValueOnce(successSim(positionScVal(500_0000000n, 525_0000000n, 999_999_999))) + .mockResolvedValueOnce(successSim(xdr.ScVal.scvVoid())); + stubHappyPathSubmit('early-exit-hash-1'); - it('resolves without error on success', async () => { - vi.spyOn(sdk as any, 'simulate').mockResolvedValue('100'); - vi.spyOn(sdk as any, 'simulateThenSubmit').mockResolvedValue(undefined); - await expect(sdk.earlyExit({ tier: 'L12' })).resolves.toBeUndefined(); + const hash = await sdk.earlyExit({ tier: 'L6' }); + + expect(hash).toBe('early-exit-hash-1'); + // One simulateTransaction call for the position query, one for the + // early_exit call itself — never two for the same invocation. + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); }); }); describe('position', () => { - it('returns a Position object with all required fields', async () => { - const result = await sdk.position('GTEST...'); - expect(result).toMatchObject({ - tier: expect.stringMatching(/^(Flex|L3|L6|L12)$/), - principal: expect.any(String), - shares: expect.any(String), - accruedYield: expect.any(String), + it('decodes a real Position struct from the simulated retval', async () => { + mockSimulateTransaction + .mockResolvedValueOnce(successSim(positionScVal(0n, 0n, 0))) + .mockResolvedValueOnce(successSim(positionScVal(500_0000000n, 525_0000000n, 123_456))) + .mockResolvedValueOnce(successSim(positionScVal(0n, 0n, 0))) + .mockResolvedValueOnce(successSim(positionScVal(0n, 0n, 0))); + + const result = await sdk.position(userKeypair.publicKey()); + + expect(result).toEqual({ + tier: 'L3', + principal: '5000000000', + shares: '5250000000', + accruedYield: '0', + lockUntil: 123_456, }); }); - it('returns the first tier with a non-zero principal', async () => { - vi.spyOn(sdk as any, 'queryTierPosition') - .mockResolvedValueOnce({ tier: 'Flex', principal: '0', shares: '0', accruedYield: '0', lockUntil: null }) - .mockResolvedValueOnce({ tier: 'L3', principal: '500', shares: '500', accruedYield: '12', lockUntil: 9999 }) - .mockResolvedValue({ tier: 'L6', principal: '0', shares: '0', accruedYield: '0', lockUntil: null }); - const result = await sdk.position('GTEST...'); - expect(result.tier).toBe('L3'); - expect(result.principal).toBe('500'); - }); + it('falls back to the first tier (Flex) when no position has a non-zero principal', async () => { + mockSimulateTransaction.mockResolvedValue(successSim(positionScVal(0n, 0n, 0))); + + const result = await sdk.position(userKeypair.publicKey()); - it('falls back to Flex when no active position exists', async () => { - vi.spyOn(sdk as any, 'queryTierPosition').mockResolvedValue({ - tier: 'Flex', principal: '0', shares: '0', accruedYield: '0', lockUntil: null, - }); - const result = await sdk.position('GNONE...'); expect(result.tier).toBe('Flex'); + expect(result.lockUntil).toBeNull(); }); }); -}); \ No newline at end of file +}); diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index 741f4ce..456a40d 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -11,4 +11,80 @@ export class LockNotExpiredError extends YieldLadderError { export class BelowMinDepositError extends YieldLadderError { constructor() { super('Amount is below the minimum deposit'); this.name = 'BelowMinDepositError'; } -} \ No newline at end of file +} + +// The rest mirror contracts/vault_router/src/error.rs's VaultError. Codes 2 +// (BelowMinDeposit), 4 (AssetNotAllowed), and 6 (ProtocolPaused) are checked +// at the very top of every VaultRouter entrypoint before it ever forwards to +// a tier vault, so a simulation error carrying one of these codes is always +// unambiguously from the router itself — never from the tier vault's own +// (differently-numbered) error enum. + +export class InvalidTierError extends YieldLadderError { + constructor() { super('Unknown or unsupported tier'); this.name = 'InvalidTierError'; } +} + +export class AssetNotAllowedError extends YieldLadderError { + constructor() { super('Asset is not on the deposit allowlist'); this.name = 'AssetNotAllowedError'; } +} + +export class DepositCapExceededError extends YieldLadderError { + constructor() { super('Deposit would exceed the tier vault\'s TVL cap'); this.name = 'DepositCapExceededError'; } +} + +export class ProtocolPausedError extends YieldLadderError { + constructor() { super('Protocol is currently paused'); this.name = 'ProtocolPausedError'; } +} + +/** + * A contract-level rejection whose numeric code didn't map to one of the + * typed errors above — most commonly code 7, which is genuinely ambiguous + * between VaultRouter's own (rarely-raised) Unauthorized and a tier vault's + * AmountExceedsBalance, since the two contracts' error enums don't share a + * numbering scheme for every code. Still a real, specific contract + * rejection — just not one this SDK can name with certainty. + */ +export class VaultContractError extends YieldLadderError { + constructor( + readonly code: number, + readonly rawError: string, + ) { + super(`Vault contract rejected the call (code ${code})`); + this.name = 'VaultContractError'; + } +} + +/** Simulation itself failed for a reason other than a parseable contract error. */ +export class SimulationFailedError extends YieldLadderError { + constructor( + message: string, + readonly rawError: string, + ) { + super(message); + this.name = 'SimulationFailedError'; + } +} + +/** + * The transaction's time-bounds expired — most often because the user took + * too long at their wallet's signing prompt. Distinct from a contract-level + * rejection: nothing about the transaction itself was invalid, it just + * arrived too late to be included. + */ +export class TransactionExpiredError extends YieldLadderError { + constructor() { + super('Transaction expired before it could be submitted — please try again'); + this.name = 'TransactionExpiredError'; + } +} + +/** Submission failed for a reason other than expiration or a mapped contract error. */ +export class SubmissionFailedError extends YieldLadderError { + constructor( + message: string, + readonly rawResponse?: unknown, + ) { + super(message); + this.name = 'SubmissionFailedError'; + } +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 075e966..4f5a066 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,6 +1,12 @@ -export type { Tier, Network, YieldLadderOptions, Position } from './types'; +export type { Tier, Network, YieldLadderOptions, Position, Signer } from './types'; import type { Tier, YieldLadderOptions, Position, Network } from './types'; -import { LockNotExpiredError, BelowMinDepositError } from './errors'; +import { BelowMinDepositError } from './errors'; +import { + addressToScVal, + amountToScVal, + tierToScVal, + TransactionPipeline, +} from './transactions'; const USDC_DECIMALS = 7; const STROOPS_PER_UNIT = 10 ** USDC_DECIMALS; @@ -19,35 +25,82 @@ const RPC_URLS: Record = { testnet: 'https://soroban-testnet.stellar.org', }; +interface RawPosition { + principal: bigint; + shares: bigint; + lock_until: number; +} + export class YieldLadder { private readonly options: YieldLadderOptions; - private readonly rpcUrl: string; + private readonly pipeline: TransactionPipeline; constructor(options: YieldLadderOptions) { this.options = options; - this.rpcUrl = RPC_URLS[options.network]; + this.pipeline = new TransactionPipeline({ + rpcUrl: options.rpcUrl ?? RPC_URLS[options.network], + network: options.network, + publicKey: options.publicKey, + signer: options.signer, + transactionTimeoutSeconds: options.transactionTimeoutSeconds, + }); } - async deposit(params: { tier: Tier; amount: string }): Promise { + /** Deposits `amount` (decimal USDC string) into `tier`. Returns the submitted transaction hash. */ + async deposit(params: { tier: Tier; amount: string }): Promise { const stroops = this.toStroops(params.amount); if (stroops < TIER_MIN_DEPOSIT[params.tier]) { throw new BelowMinDepositError(); } - await this.simulateThenSubmit('deposit', params.tier, stroops.toString()); + + return this.pipeline.invoke({ + contractId: this.options.vaultRouterContractId, + method: 'deposit', + args: [ + addressToScVal(this.options.publicKey), + tierToScVal(params.tier), + addressToScVal(this.options.assetContractId), + amountToScVal(stroops), + ], + }); } - async withdraw(params: { tier: Tier }): Promise { - try { - await this.simulateThenSubmit('withdraw', params.tier); - } catch (err: unknown) { - if (this.isLockError(err)) throw new LockNotExpiredError(); - throw err; - } + /** Withdraws the caller's full matured position from `tier`. Returns the submitted transaction hash. */ + async withdraw(params: { tier: Tier }): Promise { + const position = await this.queryTierPosition( + this.options.publicKey, + params.tier, + ); + + return this.pipeline.invoke({ + contractId: this.options.vaultRouterContractId, + method: 'withdraw', + args: [ + addressToScVal(this.options.publicKey), + tierToScVal(params.tier), + addressToScVal(this.options.assetContractId), + amountToScVal(BigInt(position.principal)), + ], + }); } - async earlyExit(params: { tier: Tier }): Promise { - await this.simulate('early_exit', params.tier); - await this.simulateThenSubmit('early_exit', params.tier); + /** Exits the caller's full position from `tier` before maturity (exit fee applies). */ + async earlyExit(params: { tier: Tier }): Promise { + const position = await this.queryTierPosition( + this.options.publicKey, + params.tier, + ); + + return this.pipeline.invoke({ + contractId: this.options.vaultRouterContractId, + method: 'early_exit', + args: [ + addressToScVal(this.options.publicKey), + tierToScVal(params.tier), + addressToScVal(this.options.assetContractId), + amountToScVal(BigInt(position.principal)), + ], + }); } async position(address: string): Promise { @@ -66,25 +119,29 @@ export class YieldLadder { ); } - private isLockError(err: unknown): boolean { - return err instanceof Error && err.message.toLowerCase().includes('lock'); - } - - private async simulate(_method: string, ..._args: unknown[]): Promise { - // Build and simulate transaction via SorobanRpc.Server at this.rpcUrl - void this.rpcUrl; - return '0'; - } - - private async simulateThenSubmit( - _method: string, - ..._args: unknown[] - ): Promise { - // Simulate -> sign with this.options.signer -> submit via SorobanRpc - void this.options; - } + private async queryTierPosition( + address: string, + tier: Tier, + ): Promise { + const raw = (await this.pipeline.simulateRead({ + contractId: this.options.vaultRouterContractId, + method: 'position', + args: [ + addressToScVal(address), + tierToScVal(tier), + addressToScVal(this.options.assetContractId), + ], + })) as RawPosition; - private async queryTierPosition(_address: string, tier: Tier): Promise { - return { tier, principal: '0', shares: '0', accruedYield: '0', lockUntil: null }; + return { + tier, + principal: raw.principal.toString(), + shares: raw.shares.toString(), + // VaultRouter's position() doesn't expose accrued yield yet — that + // lands with yield distribution in a later issue. 0 here is honest, + // not a stub pretending to compute something it can't. + accruedYield: '0', + lockUntil: raw.lock_until === 0 ? null : raw.lock_until, + }; } -} \ No newline at end of file +} diff --git a/sdks/typescript/src/transactions.ts b/sdks/typescript/src/transactions.ts new file mode 100644 index 0000000..a399092 --- /dev/null +++ b/sdks/typescript/src/transactions.ts @@ -0,0 +1,218 @@ +import { + Address, + BASE_FEE, + Contract, + nativeToScVal, + Networks, + scValToNative, + SorobanRpc, + Transaction, + TransactionBuilder, + xdr, +} from '@stellar/stellar-sdk'; +import type { Network, Signer, Tier } from './types'; +import { + AssetNotAllowedError, + BelowMinDepositError, + DepositCapExceededError, + InvalidTierError, + LockNotExpiredError, + ProtocolPausedError, + SimulationFailedError, + SubmissionFailedError, + TransactionExpiredError, + VaultContractError, + YieldLadderError, +} from './errors'; + +const NETWORK_PASSPHRASES: Record = { + mainnet: Networks.PUBLIC, + testnet: Networks.TESTNET, +}; + +const DEFAULT_TX_TIMEOUT_SECONDS = 30; + +/** + * Soroban surfaces contract-level rejections in the simulation error string + * as `Error(Contract, #N)`, where N is the error enum's discriminant. + */ +const CONTRACT_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/; + +export interface TransactionPipelineConfig { + rpcUrl: string; + network: Network; + publicKey: string; + signer: Signer; + transactionTimeoutSeconds?: number; +} + +export interface InvokeContractParams { + contractId: string; + method: string; + args: xdr.ScVal[]; +} + +/** Encodes a Tier the same way Soroban encodes any #[contracttype] enum variant. */ +export function tierToScVal(tier: Tier): xdr.ScVal { + return xdr.ScVal.scvVec([xdr.ScVal.scvSymbol(tier)]); +} + +export function addressToScVal(address: string): xdr.ScVal { + return new Address(address).toScVal(); +} + +export function amountToScVal(amount: bigint): xdr.ScVal { + return nativeToScVal(amount, { type: 'i128' }); +} + +/** + * Build -> simulate -> (sign ->) submit, shared by every VaultRouter call + * the SDK makes. No retry/reconnection logic here — a caller that wants + * that builds it around these primitives. + */ +export class TransactionPipeline { + private readonly server: SorobanRpc.Server; + private readonly networkPassphrase: string; + + constructor(private readonly config: TransactionPipelineConfig) { + this.server = new SorobanRpc.Server(config.rpcUrl); + this.networkPassphrase = NETWORK_PASSPHRASES[config.network]; + } + + /** Simulates a read-only call (no signing, no submission) and returns the raw retval, decoded. */ + async simulateRead(params: InvokeContractParams): Promise { + const tx = await this.buildTransaction(params); + const sim = await this.server.simulateTransaction(tx); + this.throwIfSimulationFailed(sim, params.method); + + if (!SorobanRpc.Api.isSimulationSuccess(sim) || sim.result === undefined) { + throw new SimulationFailedError( + `Simulating "${params.method}" returned no result`, + 'no_result', + ); + } + return scValToNative(sim.result.retval); + } + + /** + * Builds, simulates, signs (via the configured Signer), and submits a + * write call. Returns the submitted transaction's hash — callers who need + * confirmation should watch the vault's state (a later issue's concern), + * not assume submission alone means success. + */ + async invoke(params: InvokeContractParams): Promise { + const rawTx = await this.buildTransaction(params); + + // Always simulate fresh for this specific call — never reuse a fee or + // resource footprint estimated for a different transaction. + const sim = await this.server.simulateTransaction(rawTx); + this.throwIfSimulationFailed(sim, params.method); + + let prepared: Transaction; + try { + prepared = SorobanRpc.assembleTransaction(rawTx, sim).build(); + } catch (error) { + throw new SimulationFailedError( + `Failed to assemble "${params.method}" from its simulation`, + error instanceof Error ? error.message : String(error), + ); + } + + // The wallet round-trip happens here — this is where time-bounds can + // expire while the user is still looking at the signing prompt. + const signedXdr = await this.config.signer.signTransaction( + prepared.toXDR(), + { network: this.config.network, accountToSign: this.config.publicKey }, + ); + + const signedTx = TransactionBuilder.fromXDR( + signedXdr, + this.networkPassphrase, + ); + + let sendResult: SorobanRpc.Api.SendTransactionResponse; + try { + sendResult = await this.server.sendTransaction(signedTx); + } catch (error) { + throw new SubmissionFailedError( + `Submitting "${params.method}" failed`, + error instanceof Error ? error.message : String(error), + ); + } + + if (sendResult.status === 'ERROR') { + if (TransactionPipeline.isExpired(sendResult)) { + throw new TransactionExpiredError(); + } + throw new SubmissionFailedError( + `"${params.method}" was rejected on submission`, + sendResult, + ); + } + + return sendResult.hash; + } + + private async buildTransaction( + params: InvokeContractParams, + ): Promise { + const sourceAccount = await this.server.getAccount(this.config.publicKey); + const contract = new Contract(params.contractId); + const timeoutSeconds = + this.config.transactionTimeoutSeconds ?? DEFAULT_TX_TIMEOUT_SECONDS; + + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call(params.method, ...params.args)) + .setTimeout(timeoutSeconds) + .build(); + } + + private throwIfSimulationFailed( + sim: SorobanRpc.Api.SimulateTransactionResponse, + method: string, + ): void { + if (!SorobanRpc.Api.isSimulationError(sim)) { + return; + } + + const match = CONTRACT_ERROR_PATTERN.exec(sim.error); + if (match) { + throw TransactionPipeline.mapContractError(Number(match[1]), sim.error); + } + throw new SimulationFailedError( + `Simulating "${method}" failed: ${sim.error}`, + sim.error, + ); + } + + private static mapContractError( + code: number, + rawError: string, + ): YieldLadderError { + switch (code) { + case 1: + return new InvalidTierError(); + case 2: + return new BelowMinDepositError(); + case 3: + return new LockNotExpiredError(); + case 4: + return new AssetNotAllowedError(); + case 5: + return new DepositCapExceededError(); + case 6: + return new ProtocolPausedError(); + default: + return new VaultContractError(code, rawError); + } + } + + private static isExpired( + sendResult: SorobanRpc.Api.SendTransactionResponse, + ): boolean { + return sendResult.errorResult?.result().switch().name === 'txTooLate'; + } +} diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index b7119d8..e36c41d 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -1,9 +1,39 @@ export type Tier = 'Flex' | 'L3' | 'L6' | 'L12'; export type Network = 'mainnet' | 'testnet'; +/** + * Signing abstraction shared with the app's WalletAdapter + * (app/src/lib/wallet/types.ts) — a real WalletAdapter instance (Freighter, + * Albedo, LOBSTR, ...) can be passed directly as `signer` without an adapter + * shim. Deliberately just `signTransaction`: the SDK never asks a signer for + * its own public key — the caller supplies that once via + * `YieldLadderOptions.publicKey`, matching how a wallet's `connect()` and + * `signTransaction()` are already two separate steps. + */ +export interface Signer { + signTransaction( + xdr: string, + opts?: { network?: Network; accountToSign?: string }, + ): Promise; +} + export interface YieldLadderOptions { network: Network; - signer: unknown; + /** The account initiating transactions — used as the transaction source and passed as `user` to VaultRouter. */ + publicKey: string; + signer: Signer; + /** Deployed VaultRouter contract address for `network`. */ + vaultRouterContractId: string; + /** Deposit-asset contract address (e.g. the USDC SAC) used for deposit/withdraw/earlyExit. */ + assetContractId: string; + /** RPC endpoint override — defaults to the well-known public endpoint for `network`. */ + rpcUrl?: string; + /** + * Transaction validity window in seconds before Soroban considers it + * expired. Soroban ledgers close roughly every 5s; this needs enough + * headroom for a wallet signing round-trip. Default 30. + */ + transactionTimeoutSeconds?: number; } export interface Position {