diff --git a/src/routes/deposit.ts b/src/routes/deposit.ts index 25f7684..2fa14a9 100644 --- a/src/routes/deposit.ts +++ b/src/routes/deposit.ts @@ -2,7 +2,7 @@ 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 { depositForUser } from '../stellar/contract' import { formatDepositReply } from '../whatsapp/formatters' const router = Router() @@ -36,12 +36,15 @@ router.post('/', requireAuth, async (req: Request, res: Response) => { return res.status(404).json({ error: 'User not found' }) } - const onChainTransaction = await submitDeposit( + const onChainTransaction = await depositForUser( parsed.data.userId, req.auth.walletAddress, parsed.data.amount, ) + const transactionStatus = + onChainTransaction.status === 'success' ? 'CONFIRMED' : 'FAILED' + const existing = await db.transaction.findUnique({ where: { txHash: onChainTransaction.hash }, select: { id: true }, @@ -56,16 +59,20 @@ router.post('/', requireAuth, async (req: Request, res: Response) => { userId: parsed.data.userId, txHash: onChainTransaction.hash, type: 'DEPOSIT', - status: 'PENDING', + status: transactionStatus, assetSymbol: parsed.data.assetSymbol, amount: parsed.data.amount, network: user.network, protocolName: parsed.data.protocolName, memo: parsed.data.memo, + confirmedAt: + transactionStatus === 'CONFIRMED' ? new Date() : null, }, }) return res.status(201).json({ + txHash: transaction.txHash, + status: transaction.status, transaction: { id: transaction.id, txHash: transaction.txHash, diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts index d33a543..fc9147b 100644 --- a/src/routes/withdraw.ts +++ b/src/routes/withdraw.ts @@ -2,7 +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 { withdrawForUser } from '../stellar/contract' import { formatWithdrawReply } from '../whatsapp/formatters' const router = Router() @@ -36,12 +36,15 @@ router.post('/', requireAuth, async (req: Request, res: Response) => { return res.status(404).json({ error: 'User not found' }) } - const onChainTransaction = await submitWithdraw( + const onChainTransaction = await withdrawForUser( parsed.data.userId, req.auth.walletAddress, parsed.data.amount, ) + const transactionStatus = + onChainTransaction.status === 'success' ? 'CONFIRMED' : 'FAILED' + const existing = await db.transaction.findUnique({ where: { txHash: onChainTransaction.hash }, select: { id: true }, @@ -56,16 +59,20 @@ router.post('/', requireAuth, async (req: Request, res: Response) => { userId: parsed.data.userId, txHash: onChainTransaction.hash, type: 'WITHDRAWAL', - status: 'PENDING', + status: transactionStatus, assetSymbol: parsed.data.assetSymbol, amount: parsed.data.amount, network: user.network, protocolName: parsed.data.protocolName, memo: parsed.data.memo, + confirmedAt: + transactionStatus === 'CONFIRMED' ? new Date() : null, }, }) return res.status(201).json({ + txHash: transaction.txHash, + status: transaction.status, transaction: { id: transaction.id, txHash: transaction.txHash, diff --git a/src/stellar/README.md b/src/stellar/README.md index 6771af2..8ec305e 100644 --- a/src/stellar/README.md +++ b/src/stellar/README.md @@ -41,3 +41,12 @@ WALLET_ENCRYPTION_KEY=<64_hex_chars> - `triggerRebalance(protocol, apy)` - Execute rebalance - `startEventListener()` - Monitor blockchain events - `createCustodialWallet(userId)` - Generate user wallet +- `depositForUser(userId, address, amount)` - Custodial backend-signed vault deposit +- `withdrawForUser(userId, address, amount)` - Custodial backend-signed vault withdrawal + +## Signing Strategy + +Deposit and withdraw use a custodial signing model. The backend decrypts the user's +encrypted Stellar secret via `src/stellar/wallet.ts`, signs the Soroban transaction +server-side, submits it through the configured RPC server, and returns only the +resulting transaction hash/status. User secrets must never be logged or returned. diff --git a/src/stellar/contract.ts b/src/stellar/contract.ts index 6e7b491..c046947 100644 --- a/src/stellar/contract.ts +++ b/src/stellar/contract.ts @@ -16,6 +16,8 @@ import { OnChainBalance, TransactionResult } from './types'; const VAULT_CONTRACT_ID = process.env.VAULT_CONTRACT_ID || ''; const STROOPS_PER_TOKEN = 10_000_000n; +export type VaultWriteMethod = 'deposit' | 'withdraw'; + /** * Get vault contract instance */ @@ -76,6 +78,27 @@ async function executeWriteContractCall( return result; } +/** + * Execute a custodial user operation against the vault contract. + * + * Signing strategy: + * - The backend uses the encrypted user secret managed by src/stellar/wallet.ts + * - Only the public address is passed to the contract arguments + * - User secrets are never logged or returned from this module + */ +async function executeCustodialVaultOperation( + method: VaultWriteMethod, + 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(method, [userScVal, amountScVal], signer); +} + /** * Simulate and parse contract read call */ @@ -156,11 +179,15 @@ export async function deposit( userAddress: string, amount: number, ): Promise { - const signer = await getKeypairForUser(userId); - const userScVal = nativeToScVal(userAddress, { type: 'address' }); - const amountScVal = nativeToScVal(toContractAmount(amount), { type: 'i128' }); + return depositForUser(userId, userAddress, amount); +} - return executeWriteContractCall('deposit', [userScVal, amountScVal], signer); +export async function depositForUser( + userId: string, + userAddress: string, + amount: number, +): Promise { + return executeCustodialVaultOperation('deposit', userId, userAddress, amount); } /** @@ -171,9 +198,13 @@ export async function withdraw( userAddress: string, amount: number, ): Promise { - const signer = await getKeypairForUser(userId); - const userScVal = nativeToScVal(userAddress, { type: 'address' }); - const amountScVal = nativeToScVal(toContractAmount(amount), { type: 'i128' }); + return withdrawForUser(userId, userAddress, amount); +} - return executeWriteContractCall('withdraw', [userScVal, amountScVal], signer); +export async function withdrawForUser( + userId: string, + userAddress: string, + amount: number, +): Promise { + return executeCustodialVaultOperation('withdraw', userId, userAddress, amount); } diff --git a/tests/integration/api/api.test.ts b/tests/integration/api/api.test.ts index 90cf509..4a591db 100644 --- a/tests/integration/api/api.test.ts +++ b/tests/integration/api/api.test.ts @@ -26,7 +26,9 @@ jest.mock('../../../src/db', () => ({ jest.mock('../../../src/stellar/contract', () => ({ deposit: (...args: unknown[]) => mockDeposit(...args), + depositForUser: (...args: unknown[]) => mockDeposit(...args), withdraw: (...args: unknown[]) => mockWithdraw(...args), + withdrawForUser: (...args: unknown[]) => mockWithdraw(...args), getOnChainBalance: jest.fn(), getOnChainAPY: jest.fn(), getActiveProtocol: jest.fn(), @@ -167,7 +169,7 @@ describe('API integration routes', () => { mockDb.transaction.create.mockResolvedValue({ id: 'tx-2', txHash: 'server-generated-hash-0002', - status: 'PENDING', + status: 'CONFIRMED', amount: 100, assetSymbol: 'USDC', protocolName: 'Blend', @@ -188,9 +190,12 @@ describe('API integration routes', () => { expect(res.body.transaction).toEqual( expect.objectContaining({ txHash: 'server-generated-hash-0002', + status: 'CONFIRMED', amount: 100, }), ) + expect(res.body.txHash).toBe('server-generated-hash-0002') + expect(res.body.status).toBe('CONFIRMED') }) }) }) diff --git a/tests/integration/api/deposit.test.ts b/tests/integration/api/deposit.test.ts index 3544e0f..4dac722 100644 --- a/tests/integration/api/deposit.test.ts +++ b/tests/integration/api/deposit.test.ts @@ -30,7 +30,9 @@ jest.mock('../../../src/db', () => ({ jest.mock('../../../src/stellar/contract', () => ({ deposit: (...args: unknown[]) => mockDeposit(...args), + depositForUser: (...args: unknown[]) => mockDeposit(...args), withdraw: jest.fn(), + withdrawForUser: jest.fn(), getOnChainBalance: jest.fn(), getOnChainAPY: jest.fn(), getActiveProtocol: jest.fn(), @@ -76,7 +78,7 @@ describe('Deposit route', () => { mockDb.transaction.create.mockResolvedValue({ id: 'tx-new', txHash: 'chain-hash-0000000001', - status: 'PENDING', + status: 'CONFIRMED', amount: VALID_DEPOSIT.amount, assetSymbol: VALID_DEPOSIT.assetSymbol, protocolName: VALID_DEPOSIT.protocolName, @@ -176,9 +178,12 @@ describe('Deposit route', () => { expect(res.status).toBe(201); expect(res.body.transaction).toMatchObject({ txHash: 'chain-hash-0000000001', + status: 'CONFIRMED', amount: VALID_DEPOSIT.amount, assetSymbol: VALID_DEPOSIT.assetSymbol, }); + expect(res.body.txHash).toBe('chain-hash-0000000001'); + expect(res.body.status).toBe('CONFIRMED'); }); it('returns a whatsappReply string on success', async () => { @@ -191,7 +196,7 @@ describe('Deposit route', () => { expect(res.body.whatsappReply.length).toBeGreaterThan(0); }); - it('creates a transaction record with PENDING status', async () => { + it('creates a confirmed transaction record with the on-chain hash', async () => { await request(app) .post('/api/deposit') .set(authHeader()) @@ -205,9 +210,10 @@ describe('Deposit route', () => { expect.objectContaining({ data: expect.objectContaining({ type: 'DEPOSIT', - status: 'PENDING', + status: 'CONFIRMED', userId: USER_ID, txHash: 'chain-hash-0000000001', + confirmedAt: expect.any(Date), }), }), ); diff --git a/tests/integration/api/withdraw.test.ts b/tests/integration/api/withdraw.test.ts index af0a8c7..1bde313 100644 --- a/tests/integration/api/withdraw.test.ts +++ b/tests/integration/api/withdraw.test.ts @@ -23,7 +23,9 @@ jest.mock('../../../src/db', () => ({ jest.mock('../../../src/stellar/contract', () => ({ deposit: jest.fn(), + depositForUser: jest.fn(), withdraw: (...args: unknown[]) => mockWithdraw(...args), + withdrawForUser: (...args: unknown[]) => mockWithdraw(...args), getOnChainBalance: jest.fn(), getOnChainAPY: jest.fn(), getActiveProtocol: jest.fn(), @@ -69,7 +71,7 @@ describe('Withdraw route', () => { mockDb.transaction.create.mockResolvedValue({ id: 'withdraw-tx-new', txHash: 'withdraw-hash-0001', - status: 'PENDING', + status: 'CONFIRMED', amount: VALID_WITHDRAW.amount, assetSymbol: VALID_WITHDRAW.assetSymbol, protocolName: VALID_WITHDRAW.protocolName, @@ -109,17 +111,21 @@ describe('Withdraw route', () => { expect.objectContaining({ data: expect.objectContaining({ type: 'WITHDRAWAL', - status: 'PENDING', + status: 'CONFIRMED', txHash: 'withdraw-hash-0001', + confirmedAt: expect.any(Date), }), }), ) expect(res.body.transaction).toEqual( expect.objectContaining({ txHash: 'withdraw-hash-0001', + status: 'CONFIRMED', amount: VALID_WITHDRAW.amount, }), ) + expect(res.body.txHash).toBe('withdraw-hash-0001') + expect(res.body.status).toBe('CONFIRMED') }) it('returns 409 if the generated on-chain hash already exists', async () => { diff --git a/tests/unit/stellar/contract.test.ts b/tests/unit/stellar/contract.test.ts index d3a529e..4a6feb2 100644 --- a/tests/unit/stellar/contract.test.ts +++ b/tests/unit/stellar/contract.test.ts @@ -65,7 +65,12 @@ jest.mock('../../../src/stellar/wallet', () => ({ 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' +import { + deposit, + depositForUser, + withdraw, + withdrawForUser, +} from '../../../src/stellar/contract' describe('stellar contract write wrappers', () => { beforeEach(() => { @@ -75,7 +80,7 @@ describe('stellar contract write wrappers', () => { }) it('builds, signs, submits, and confirms deposit transactions with the user keypair', async () => { - const result = await deposit( + const result = await depositForUser( '550e8400-e29b-41d4-a716-446655440003', 'GUSERWALLETADDRESS', 12.5, @@ -106,7 +111,7 @@ describe('stellar contract write wrappers', () => { }) it('builds withdraw transactions against the vault contract', async () => { - const result = await withdraw( + const result = await withdrawForUser( '550e8400-e29b-41d4-a716-446655440003', 'GUSERWALLETADDRESS', 3, @@ -136,4 +141,22 @@ describe('stellar contract write wrappers', () => { ), ).rejects.toThrow('Transaction deposit failed on-chain') }) + + it('keeps the legacy deposit/withdraw exports wired to the typed helpers', async () => { + await deposit('550e8400-e29b-41d4-a716-446655440003', 'GUSERWALLETADDRESS', 2) + await withdraw('550e8400-e29b-41d4-a716-446655440003', 'GUSERWALLETADDRESS', 4) + + expect(mockCall).toHaveBeenNthCalledWith( + 1, + 'deposit', + expect.anything(), + expect.anything(), + ) + expect(mockCall).toHaveBeenNthCalledWith( + 2, + 'withdraw', + expect.anything(), + expect.anything(), + ) + }) })