Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/routes/deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 },
Expand All @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions src/routes/withdraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 },
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/stellar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
47 changes: 39 additions & 8 deletions src/stellar/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<TransactionResult> {
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
*/
Expand Down Expand Up @@ -156,11 +179,15 @@ export async function deposit(
userAddress: string,
amount: number,
): Promise<TransactionResult> {
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<TransactionResult> {
return executeCustodialVaultOperation('deposit', userId, userAddress, amount);
}

/**
Expand All @@ -171,9 +198,13 @@ export async function withdraw(
userAddress: string,
amount: number,
): Promise<TransactionResult> {
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<TransactionResult> {
return executeCustodialVaultOperation('withdraw', userId, userAddress, amount);
}
7 changes: 6 additions & 1 deletion tests/integration/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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',
Expand All @@ -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')
})
})
})
12 changes: 9 additions & 3 deletions tests/integration/api/deposit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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())
Expand All @@ -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),
}),
}),
);
Expand Down
10 changes: 8 additions & 2 deletions tests/integration/api/withdraw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
29 changes: 26 additions & 3 deletions tests/unit/stellar/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
)
})
})
Loading