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
12 changes: 9 additions & 3 deletions src/routes/deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 },
})

Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/routes/withdraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
102 changes: 73 additions & 29 deletions src/stellar/contract.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,24 +29,53 @@ function getVaultContract(): Contract {
/**
* Build contract invocation transaction
*/
async function buildContractCall(method: string, args: xdr.ScVal[]): Promise<any> {
async function buildContractCall(
method: string,
args: xdr.ScVal[],
sourcePublicKey: string = getAgentKeypair().publicKey(),
): Promise<Transaction> {
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(),
})
.addOperation(contract.call(method, ...args))
.setTimeout(30)
.build();

return tx;
}

function toContractAmount(amount: number): bigint {
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error('Amount must be a positive number');
}

return BigInt(Math.round(amount * Number(STROOPS_PER_TOKEN)));
}

async function executeWriteContractCall(
method: string,
args: xdr.ScVal[],
signer: Keypair,
): Promise<TransactionResult> {
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
*/
Expand Down Expand Up @@ -102,34 +133,47 @@ export async function triggerRebalance(
): Promise<TransactionResult> {
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);
}

/**
* Update total assets (agent only)
*/
export async function updateTotalAssets(newTotalStroops: string): Promise<TransactionResult> {
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<TransactionResult> {
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<TransactionResult> {
const signer = await getKeypairForUser(userId);
const userScVal = nativeToScVal(userAddress, { type: 'address' });
const amountScVal = nativeToScVal(toContractAmount(amount), { type: 'i128' });

return executeWriteContractCall('withdraw', [userScVal, amountScVal], signer);
}
27 changes: 23 additions & 4 deletions tests/integration/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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,
}),
)
Expand Down
35 changes: 23 additions & 12 deletions tests/integration/api/deposit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -43,7 +53,6 @@ const SESSION = {

const VALID_DEPOSIT = {
userId: USER_ID,
txHash: 'validhash0000000001',
amount: 100,
assetSymbol: 'USDC',
protocolName: 'Blend',
Expand All @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
});
Expand All @@ -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',
}),
}),
);
Expand Down
Loading
Loading