diff --git a/src/routes/deposit.ts b/src/routes/deposit.ts index 41c10b6..25f7684 100644 --- a/src/routes/deposit.ts +++ b/src/routes/deposit.ts @@ -2,13 +2,13 @@ import { Router, Request, Response } from 'express' import { z } from 'zod' import db from '../db' import { requireAuth } from '../middleware/auth' +import { deposit as submitDeposit } from '../stellar/contract' import { formatDepositReply } from '../whatsapp/formatters' const router = Router() const depositSchema = z.object({ userId: z.string().uuid(), - txHash: z.string().min(16), amount: z.number().positive(), assetSymbol: z.string().min(1), protocolName: z.string().min(1).optional(), @@ -36,8 +36,14 @@ router.post('/', requireAuth, async (req: Request, res: Response) => { return res.status(404).json({ error: 'User not found' }) } + const onChainTransaction = await submitDeposit( + parsed.data.userId, + req.auth.walletAddress, + parsed.data.amount, + ) + const existing = await db.transaction.findUnique({ - where: { txHash: parsed.data.txHash }, + where: { txHash: onChainTransaction.hash }, select: { id: true }, }) @@ -48,7 +54,7 @@ router.post('/', requireAuth, async (req: Request, res: Response) => { const transaction = await db.transaction.create({ data: { userId: parsed.data.userId, - txHash: parsed.data.txHash, + txHash: onChainTransaction.hash, type: 'DEPOSIT', status: 'PENDING', assetSymbol: parsed.data.assetSymbol, diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts index e451554..d33a543 100644 --- a/src/routes/withdraw.ts +++ b/src/routes/withdraw.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express' import { z } from 'zod' import db from '../db' import { requireAuth } from '../middleware/auth' +import { withdraw as submitWithdraw } from '../stellar/contract' import { formatWithdrawReply } from '../whatsapp/formatters' const router = Router() @@ -35,9 +36,25 @@ router.post('/', requireAuth, async (req: Request, res: Response) => { return res.status(404).json({ error: 'User not found' }) } + const onChainTransaction = await submitWithdraw( + parsed.data.userId, + req.auth.walletAddress, + parsed.data.amount, + ) + + const existing = await db.transaction.findUnique({ + where: { txHash: onChainTransaction.hash }, + select: { id: true }, + }) + + if (existing) { + return res.status(409).json({ error: 'Duplicate transaction hash' }) + } + const transaction = await db.transaction.create({ data: { userId: parsed.data.userId, + txHash: onChainTransaction.hash, type: 'WITHDRAWAL', status: 'PENDING', assetSymbol: parsed.data.assetSymbol, diff --git a/src/stellar/contract.ts b/src/stellar/contract.ts index 5e66f41..6e7b491 100644 --- a/src/stellar/contract.ts +++ b/src/stellar/contract.ts @@ -1,18 +1,20 @@ import { + Keypair, Contract, rpc, TransactionBuilder, - Operation, + Transaction, BASE_FEE, xdr, scValToNative, nativeToScVal, - Address, } from '@stellar/stellar-sdk'; import { getRpcServer, getNetworkPassphrase, getAgentKeypair, submitTransaction, waitForConfirmation } from './client'; +import { getKeypairForUser } from './wallet'; import { OnChainBalance, TransactionResult } from './types'; const VAULT_CONTRACT_ID = process.env.VAULT_CONTRACT_ID || ''; +const STROOPS_PER_TOKEN = 10_000_000n; /** * Get vault contract instance @@ -27,13 +29,15 @@ function getVaultContract(): Contract { /** * Build contract invocation transaction */ -async function buildContractCall(method: string, args: xdr.ScVal[]): Promise { +async function buildContractCall( + method: string, + args: xdr.ScVal[], + sourcePublicKey: string = getAgentKeypair().publicKey(), +): Promise { const server = getRpcServer(); const contract = getVaultContract(); - const keypair = getAgentKeypair(); - - const account = await server.getAccount(keypair.publicKey()); - + const account = await server.getAccount(sourcePublicKey); + const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: getNetworkPassphrase(), @@ -41,10 +45,37 @@ async function buildContractCall(method: string, args: xdr.ScVal[]): Promise { + const server = getRpcServer(); + const tx = await buildContractCall(method, args, signer.publicKey()); + const prepared = await server.prepareTransaction(tx); + prepared.sign(signer); + + const txHash = await submitTransaction(prepared); + const result = await waitForConfirmation(txHash); + + if (result.status !== 'success') { + throw new Error(`Transaction ${method} failed on-chain`); + } + + return result; +} + /** * Simulate and parse contract read call */ @@ -102,18 +133,9 @@ export async function triggerRebalance( ): Promise { const protocolScVal = nativeToScVal(protocol, { type: 'string' }); const apyScVal = nativeToScVal(expectedApyBasisPoints, { type: 'u32' }); - - const tx = await buildContractCall('rebalance', [protocolScVal, apyScVal]); - - const server = getRpcServer(); const keypair = getAgentKeypair(); - - // Prepare transaction - const prepared = await server.prepareTransaction(tx); - prepared.sign(keypair); - - const txHash = await submitTransaction(prepared); - return await waitForConfirmation(txHash); + + return executeWriteContractCall('rebalance', [protocolScVal, apyScVal], keypair); } /** @@ -121,15 +143,37 @@ export async function triggerRebalance( */ export async function updateTotalAssets(newTotalStroops: string): Promise { const amountScVal = nativeToScVal(BigInt(newTotalStroops), { type: 'i128' }); - - const tx = await buildContractCall('update_total_assets', [amountScVal]); - - const server = getRpcServer(); const keypair = getAgentKeypair(); - - const prepared = await server.prepareTransaction(tx); - prepared.sign(keypair); - - const txHash = await submitTransaction(prepared); - return await waitForConfirmation(txHash); + + return executeWriteContractCall('update_total_assets', [amountScVal], keypair); +} + +/** + * Submit a user-signed deposit transaction to the vault contract. + */ +export async function deposit( + userId: string, + userAddress: string, + amount: number, +): Promise { + const signer = await getKeypairForUser(userId); + const userScVal = nativeToScVal(userAddress, { type: 'address' }); + const amountScVal = nativeToScVal(toContractAmount(amount), { type: 'i128' }); + + return executeWriteContractCall('deposit', [userScVal, amountScVal], signer); +} + +/** + * Submit a user-signed withdrawal transaction to the vault contract. + */ +export async function withdraw( + userId: string, + userAddress: string, + amount: number, +): Promise { + const signer = await getKeypairForUser(userId); + const userScVal = nativeToScVal(userAddress, { type: 'address' }); + const amountScVal = nativeToScVal(toContractAmount(amount), { type: 'i128' }); + + return executeWriteContractCall('withdraw', [userScVal, amountScVal], signer); } diff --git a/tests/integration/api/api.test.ts b/tests/integration/api/api.test.ts index e73645c..90cf509 100644 --- a/tests/integration/api/api.test.ts +++ b/tests/integration/api/api.test.ts @@ -15,12 +15,23 @@ const mockDb = { agentLog: { findFirst: jest.fn() }, } +const mockDeposit = jest.fn() +const mockWithdraw = jest.fn() + jest.mock('../../../src/db', () => ({ __esModule: true, default: mockDb, db: mockDb, })) +jest.mock('../../../src/stellar/contract', () => ({ + deposit: (...args: unknown[]) => mockDeposit(...args), + withdraw: (...args: unknown[]) => mockWithdraw(...args), + getOnChainBalance: jest.fn(), + getOnChainAPY: jest.fn(), + getActiveProtocol: jest.fn(), +})) + import app from '../../../src/index' const userId = '550e8400-e29b-41d4-a716-446655440000' @@ -38,6 +49,16 @@ describe('API integration routes', () => { expiresAt: new Date(Date.now() + 60_000), user: { id: userId, isActive: true }, }) + mockDeposit.mockResolvedValue({ + hash: 'server-generated-hash-0002', + status: 'success', + ledger: 88, + }) + mockWithdraw.mockResolvedValue({ + hash: 'withdraw-generated-hash-0003', + status: 'success', + ledger: 89, + }) }) describe('portfolio routes', () => { @@ -131,7 +152,6 @@ describe('API integration routes', () => { .set('Authorization', `Bearer ${token}`) .send({ userId, - txHash: 'duplicate-hash-value-0001', amount: 100, assetSymbol: 'USDC', protocolName: 'Blend', @@ -146,7 +166,7 @@ describe('API integration routes', () => { mockDb.transaction.findUnique.mockResolvedValue(null) mockDb.transaction.create.mockResolvedValue({ id: 'tx-2', - txHash: 'new-hash-value-0002', + txHash: 'server-generated-hash-0002', status: 'PENDING', amount: 100, assetSymbol: 'USDC', @@ -158,7 +178,6 @@ describe('API integration routes', () => { .set('Authorization', `Bearer ${token}`) .send({ userId, - txHash: 'new-hash-value-0002', amount: 100, assetSymbol: 'USDC', protocolName: 'Blend', @@ -168,7 +187,7 @@ describe('API integration routes', () => { expect(res.body.whatsappReply).toEqual(expect.any(String)) expect(res.body.transaction).toEqual( expect.objectContaining({ - txHash: 'new-hash-value-0002', + txHash: 'server-generated-hash-0002', amount: 100, }), ) diff --git a/tests/integration/api/deposit.test.ts b/tests/integration/api/deposit.test.ts index 79f34b0..3544e0f 100644 --- a/tests/integration/api/deposit.test.ts +++ b/tests/integration/api/deposit.test.ts @@ -20,12 +20,22 @@ const mockDb = { agentLog: { findFirst: jest.fn() }, }; +const mockDeposit = jest.fn(); + jest.mock('../../../src/db', () => ({ __esModule: true, default: mockDb, db: mockDb, })); +jest.mock('../../../src/stellar/contract', () => ({ + deposit: (...args: unknown[]) => mockDeposit(...args), + withdraw: jest.fn(), + getOnChainBalance: jest.fn(), + getOnChainAPY: jest.fn(), + getActiveProtocol: jest.fn(), +})); + import request from 'supertest'; import app from '../../../src/index'; @@ -43,7 +53,6 @@ const SESSION = { const VALID_DEPOSIT = { userId: USER_ID, - txHash: 'validhash0000000001', amount: 100, assetSymbol: 'USDC', protocolName: 'Blend', @@ -59,9 +68,14 @@ describe('Deposit route', () => { mockDb.session.findUnique.mockResolvedValue(SESSION); mockDb.user.findUnique.mockResolvedValue({ id: USER_ID, network: 'TESTNET' }); mockDb.transaction.findUnique.mockResolvedValue(null); + mockDeposit.mockResolvedValue({ + hash: 'chain-hash-0000000001', + status: 'success', + ledger: 101, + }); mockDb.transaction.create.mockResolvedValue({ id: 'tx-new', - txHash: VALID_DEPOSIT.txHash, + txHash: 'chain-hash-0000000001', status: 'PENDING', amount: VALID_DEPOSIT.amount, assetSymbol: VALID_DEPOSIT.assetSymbol, @@ -113,14 +127,6 @@ describe('Deposit route', () => { expect(res.status).toBe(400); }); - it('returns 400 when txHash is too short', async () => { - const res = await request(app) - .post('/api/deposit') - .set(authHeader()) - .send({ ...VALID_DEPOSIT, txHash: 'short' }); - expect(res.status).toBe(400); - }); - it('returns 400 when amount is zero or negative', async () => { const res = await request(app) .post('/api/deposit') @@ -169,7 +175,7 @@ describe('Deposit route', () => { .send(VALID_DEPOSIT); expect(res.status).toBe(201); expect(res.body.transaction).toMatchObject({ - txHash: VALID_DEPOSIT.txHash, + txHash: 'chain-hash-0000000001', amount: VALID_DEPOSIT.amount, assetSymbol: VALID_DEPOSIT.assetSymbol, }); @@ -190,13 +196,18 @@ describe('Deposit route', () => { .post('/api/deposit') .set(authHeader()) .send(VALID_DEPOSIT); + expect(mockDeposit).toHaveBeenCalledWith( + USER_ID, + SESSION.walletAddress, + VALID_DEPOSIT.amount, + ); expect(mockDb.transaction.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ type: 'DEPOSIT', status: 'PENDING', userId: USER_ID, - txHash: VALID_DEPOSIT.txHash, + txHash: 'chain-hash-0000000001', }), }), ); diff --git a/tests/integration/api/withdraw.test.ts b/tests/integration/api/withdraw.test.ts new file mode 100644 index 0000000..af0a8c7 --- /dev/null +++ b/tests/integration/api/withdraw.test.ts @@ -0,0 +1,136 @@ +const mockDb = { + session: { findUnique: jest.fn() }, + user: { findUnique: jest.fn() }, + position: { findMany: jest.fn() }, + yieldSnapshot: { findMany: jest.fn() }, + transaction: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +} + +const mockWithdraw = jest.fn() + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})) + +jest.mock('../../../src/stellar/contract', () => ({ + deposit: jest.fn(), + withdraw: (...args: unknown[]) => mockWithdraw(...args), + getOnChainBalance: jest.fn(), + getOnChainAPY: jest.fn(), + getActiveProtocol: jest.fn(), +})) + +import request from 'supertest' +import app from '../../../src/index' + +const USER_ID = '550e8400-e29b-41d4-a716-446655440004' +const TOKEN = 'withdraw-test-token' + +const SESSION = { + id: 'session-withdraw', + userId: USER_ID, + walletAddress: 'GWITHDRAWTESTPUBKEY', + network: 'TESTNET', + expiresAt: new Date(Date.now() + 3_600_000), + user: { id: USER_ID, isActive: true }, +} + +const VALID_WITHDRAW = { + userId: USER_ID, + amount: 50, + assetSymbol: 'USDC', + protocolName: 'Blend', +} + +function authHeader() { + return { Authorization: `Bearer ${TOKEN}` } +} + +describe('Withdraw route', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDb.session.findUnique.mockResolvedValue(SESSION) + mockDb.user.findUnique.mockResolvedValue({ id: USER_ID, network: 'TESTNET' }) + mockDb.transaction.findUnique.mockResolvedValue(null) + mockWithdraw.mockResolvedValue({ + hash: 'withdraw-hash-0001', + status: 'success', + ledger: 77, + }) + mockDb.transaction.create.mockResolvedValue({ + id: 'withdraw-tx-new', + txHash: 'withdraw-hash-0001', + status: 'PENDING', + amount: VALID_WITHDRAW.amount, + assetSymbol: VALID_WITHDRAW.assetSymbol, + protocolName: VALID_WITHDRAW.protocolName, + }) + }) + + it('returns 401 without auth token', async () => { + const res = await request(app).post('/api/withdraw').send(VALID_WITHDRAW) + expect(res.status).toBe(401) + }) + + it('returns 404 when user does not exist in DB', async () => { + mockDb.user.findUnique.mockResolvedValue(null) + + const res = await request(app) + .post('/api/withdraw') + .set(authHeader()) + .send(VALID_WITHDRAW) + + expect(res.status).toBe(404) + expect(res.body.error).toBe('User not found') + }) + + it('creates an on-chain withdrawal and stores the returned hash', async () => { + const res = await request(app) + .post('/api/withdraw') + .set(authHeader()) + .send(VALID_WITHDRAW) + + expect(res.status).toBe(201) + expect(mockWithdraw).toHaveBeenCalledWith( + USER_ID, + SESSION.walletAddress, + VALID_WITHDRAW.amount, + ) + expect(mockDb.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + type: 'WITHDRAWAL', + status: 'PENDING', + txHash: 'withdraw-hash-0001', + }), + }), + ) + expect(res.body.transaction).toEqual( + expect.objectContaining({ + txHash: 'withdraw-hash-0001', + amount: VALID_WITHDRAW.amount, + }), + ) + }) + + it('returns 409 if the generated on-chain hash already exists', async () => { + mockDb.transaction.findUnique.mockResolvedValue({ id: 'existing-withdraw-tx' }) + + const res = await request(app) + .post('/api/withdraw') + .set(authHeader()) + .send(VALID_WITHDRAW) + + expect(res.status).toBe(409) + expect(res.body.error).toBe('Duplicate transaction hash') + }) +}) diff --git a/tests/unit/stellar/contract.test.ts b/tests/unit/stellar/contract.test.ts new file mode 100644 index 0000000..d3a529e --- /dev/null +++ b/tests/unit/stellar/contract.test.ts @@ -0,0 +1,139 @@ +const mockCall = jest.fn(() => ({ type: 'contract-call-op' })) +const mockBuild = jest.fn(() => mockBuiltTx) +const mockBuilder: any = {} +const mockAddOperation = jest.fn(() => mockBuilder) +const mockSetTimeout = jest.fn(() => mockBuilder) +const mockPrepareTransaction = jest.fn() +const mockGetAccount = jest.fn() +const mockScValToNative = jest.fn() +const mockNativeToScVal = jest.fn((value: unknown, options?: unknown) => ({ + value, + options, +})) + +const mockBuiltTx = { built: true } as any +const mockPreparedTx = { + sign: jest.fn(), +} as any +Object.assign(mockBuilder, { + addOperation: mockAddOperation, + setTimeout: mockSetTimeout, + build: mockBuild, +}) +const mockServer = { + getAccount: mockGetAccount, + simulateTransaction: jest.fn(), + prepareTransaction: mockPrepareTransaction, +} + +jest.mock('@stellar/stellar-sdk', () => ({ + Contract: jest.fn().mockImplementation(() => ({ + call: mockCall, + })), + rpc: { + Api: { + isSimulationError: jest.fn(() => false), + }, + }, + TransactionBuilder: jest.fn().mockImplementation(() => mockBuilder), + BASE_FEE: '100', + scValToNative: mockScValToNative, + nativeToScVal: mockNativeToScVal, +})) + +jest.mock('../../../src/stellar/client', () => ({ + getRpcServer: jest.fn(() => mockServer), + getNetworkPassphrase: jest.fn(() => 'Test SDF Network ; September 2015'), + getAgentKeypair: jest.fn(() => ({ + publicKey: () => 'GAGENTPUBLICKEY', + })), + submitTransaction: jest.fn(() => Promise.resolve('submitted-hash')), + waitForConfirmation: jest.fn(() => + Promise.resolve({ hash: 'submitted-hash', status: 'success', ledger: 77 }), + ), +})) + +jest.mock('../../../src/stellar/wallet', () => ({ + getKeypairForUser: jest.fn(() => + Promise.resolve({ + publicKey: () => 'GUSERPUBLICKEY', + sign: jest.fn(), + }), + ), +})) + +import { Contract, TransactionBuilder, nativeToScVal } from '@stellar/stellar-sdk' +import { submitTransaction, waitForConfirmation } from '../../../src/stellar/client' +import { getKeypairForUser } from '../../../src/stellar/wallet' +import { deposit, withdraw } from '../../../src/stellar/contract' + +describe('stellar contract write wrappers', () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetAccount.mockResolvedValue({ accountId: 'GACCOUNT' }) + mockPrepareTransaction.mockResolvedValue(mockPreparedTx) + }) + + it('builds, signs, submits, and confirms deposit transactions with the user keypair', async () => { + const result = await deposit( + '550e8400-e29b-41d4-a716-446655440003', + 'GUSERWALLETADDRESS', + 12.5, + ) + + expect(getKeypairForUser).toHaveBeenCalledWith( + '550e8400-e29b-41d4-a716-446655440003', + ) + expect(mockGetAccount).toHaveBeenCalledWith('GUSERPUBLICKEY') + expect(Contract).toHaveBeenCalled() + expect(mockCall).toHaveBeenCalledWith( + 'deposit', + expect.anything(), + expect.anything(), + ) + expect(nativeToScVal).toHaveBeenCalledWith('GUSERWALLETADDRESS', { + type: 'address', + }) + expect(nativeToScVal).toHaveBeenCalledWith(125000000n, { type: 'i128' }) + expect(mockPreparedTx.sign).toHaveBeenCalled() + expect(submitTransaction).toHaveBeenCalledWith(mockPreparedTx) + expect(waitForConfirmation).toHaveBeenCalledWith('submitted-hash') + expect(result).toEqual({ + hash: 'submitted-hash', + status: 'success', + ledger: 77, + }) + }) + + it('builds withdraw transactions against the vault contract', async () => { + const result = await withdraw( + '550e8400-e29b-41d4-a716-446655440003', + 'GUSERWALLETADDRESS', + 3, + ) + + expect(mockGetAccount).toHaveBeenCalledWith('GUSERPUBLICKEY') + expect(mockCall).toHaveBeenCalledWith( + 'withdraw', + expect.anything(), + expect.anything(), + ) + expect(nativeToScVal).toHaveBeenCalledWith(30000000n, { type: 'i128' }) + expect(result.hash).toBe('submitted-hash') + }) + + it('throws when confirmation returns a failed status', async () => { + ;(waitForConfirmation as jest.Mock).mockResolvedValueOnce({ + hash: 'submitted-hash', + status: 'failed', + }) + + await expect( + deposit( + '550e8400-e29b-41d4-a716-446655440003', + 'GUSERWALLETADDRESS', + 1, + ), + ).rejects.toThrow('Transaction deposit failed on-chain') + }) +})