Skip to content
Open
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
38 changes: 38 additions & 0 deletions apps/api/src/treasury/interfaces/redemption.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export enum RedemptionStatus {
PENDING = 'pending',
BURNING = 'burning',
WITHDRAWAL_QUEUED = 'withdrawal_queued',
FAILED = 'failed',
}

export class RedeemDto {
/** Mirror asset code to burn (e.g. "USDC", "ARS") */
asset_code!: string;
/** Amount of mirror asset to redeem */
amount!: string;
/** Merchant's Stellar address that holds the mirror assets */
merchant_wallet!: string;
/** Address to receive the underlying base currency */
destination_address!: string;
}

export interface WithdrawalJob {
withdrawal_id: string;
merchant_id: string;
asset_code: string;
amount: string;
destination_address: string;
burn_transaction_hash: string;
queued_at: string;
}

export interface RedeemResponse {
redemption_id: string;
merchant_id: string;
asset_code: string;
amount: string;
status: RedemptionStatus;
burn_transaction_hash: string;
withdrawal_id: string;
created_at: string;
}
198 changes: 198 additions & 0 deletions apps/api/src/treasury/redemption.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {
BadRequestException,
Injectable,
Logger,
UnprocessableEntityException,
} from '@nestjs/common';
import {
RedeemDto,
RedeemResponse,
RedemptionStatus,
WithdrawalJob,
} from './interfaces/redemption.interface';

@Injectable()
export class RedemptionService {
private readonly logger = new Logger(RedemptionService.name);

private readonly STELLAR_HORIZON_URL =
process.env.STELLAR_HORIZON_URL ?? 'https://horizon-testnet.stellar.org';
// TODO: pass process.env.SOROBAN_RPC_URL to SorobanRpc.Server once contract is deployed
private readonly BURN_CONTRACT_ID = process.env.BURN_CONTRACT_ID ?? '';

/** In-memory withdrawal queue — replace with a real queue (BullMQ, SQS, etc.) */
private readonly withdrawalQueue: WithdrawalJob[] = [];

async redeem(merchantId: string, dto: RedeemDto): Promise<RedeemResponse> {
const { asset_code, amount, merchant_wallet, destination_address } = dto;

// -------------------------------------------------------------------------
// Step 1: Validate merchant balance
// -------------------------------------------------------------------------
const balance = await this.getMerchantBalance(merchant_wallet, asset_code);
const requested = parseFloat(amount);

if (isNaN(requested) || requested <= 0) {
throw new BadRequestException('amount must be a positive number');
}

if (balance < requested) {
throw new BadRequestException(
`Insufficient balance: wallet holds ${balance} ${asset_code}, requested ${requested}`,
);
}

this.logger.log(
`[Redeem] Balance OK — merchant=${merchantId} wallet=${merchant_wallet} ` +
`balance=${balance} ${asset_code} requested=${requested}`,
);

// -------------------------------------------------------------------------
// Step 2: Invoke Soroban burn function
// -------------------------------------------------------------------------
const burnTxHash = await this.invokeBurn(merchant_wallet, asset_code, amount);

this.logger.log(
`[Redeem] Burn submitted — merchant=${merchantId} asset=${asset_code} ` +
`amount=${amount} burn_tx=${burnTxHash}`,
);

// -------------------------------------------------------------------------
// Step 3: Trigger underlying asset withdrawal (handled by withdrawal worker)
// -------------------------------------------------------------------------
const withdrawalId = await this.enqueueWithdrawal({
merchantId,
assetCode: asset_code,
amount,
destinationAddress: destination_address,
burnTransactionHash: burnTxHash,
});

this.logger.log(
`[Redeem] Withdrawal queued — withdrawal_id=${withdrawalId} destination=${destination_address}`,
);

return {
redemption_id: crypto.randomUUID(),
merchant_id: merchantId,
asset_code,
amount,
status: RedemptionStatus.WITHDRAWAL_QUEUED,
burn_transaction_hash: burnTxHash,
withdrawal_id: withdrawalId,
created_at: new Date().toISOString(),
};
}

// ---------------------------------------------------------------------------
// Step 1 helper: query Horizon for the mirror-asset balance
// ---------------------------------------------------------------------------
private async getMerchantBalance(
merchantWallet: string,
assetCode: string,
): Promise<number> {
const url = `${this.STELLAR_HORIZON_URL}/accounts/${merchantWallet}`;
const res = await fetch(url);

if (res.status === 404) {
throw new BadRequestException(
`Stellar account ${merchantWallet} not found on network`,
);
}
if (!res.ok) {
throw new UnprocessableEntityException(
`Horizon returned HTTP ${res.status} for account ${merchantWallet}`,
);
}

const account = (await res.json()) as {
balances: Array<{
asset_type: string;
asset_code?: string;
balance: string;
}>;
};

const entry = account.balances.find(
(b) => b.asset_code?.toUpperCase() === assetCode.toUpperCase(),
);

return entry ? parseFloat(entry.balance) : 0;
}

// ---------------------------------------------------------------------------
// Step 2 helper: call the Soroban burn function
// ---------------------------------------------------------------------------
private async invokeBurn(
_merchantWallet: string,
_assetCode: string,
_amount: string,
): Promise<string> {
if (!this.BURN_CONTRACT_ID) {
// Contract not yet deployed — return a placeholder hash in dev/test
this.logger.warn(
'[Redeem] BURN_CONTRACT_ID not configured; skipping real burn (dev mode)',
);
return `simulated_burn_${crypto.randomUUID()}`;
}

// TODO: Replace with @stellar/stellar-sdk SorobanRpc client once contract is deployed.
//
// Example using stellar-sdk (v14+):
// const server = new SorobanRpc.Server(this.SOROBAN_RPC_URL);
// const contract = new Contract(this.BURN_CONTRACT_ID);
// const account = await server.getAccount(merchantWallet);
// const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })
// .addOperation(
// contract.call('burn', ...[
// Address.fromString(merchantWallet).toScVal(),
// nativeToScVal(BigInt(Math.round(parseFloat(amount) * 1e7)), { type: 'i128' }),
// ]),
// )
// .setTimeout(30)
// .build();
// const prepared = await server.prepareTransaction(tx);
// prepared.sign(merchantKeypair);
// const result = await server.sendTransaction(prepared);
// return result.hash;

throw new UnprocessableEntityException(
'Soroban burn contract is configured but invocation is not yet implemented',
);
}

// ---------------------------------------------------------------------------
// Step 3 helper: push a job onto the withdrawal queue
// ---------------------------------------------------------------------------
private async enqueueWithdrawal(params: {
merchantId: string;
assetCode: string;
amount: string;
destinationAddress: string;
burnTransactionHash: string;
}): Promise<string> {
const job: WithdrawalJob = {
withdrawal_id: crypto.randomUUID(),
merchant_id: params.merchantId,
asset_code: params.assetCode,
amount: params.amount,
destination_address: params.destinationAddress,
burn_transaction_hash: params.burnTransactionHash,
queued_at: new Date().toISOString(),
};

this.withdrawalQueue.push(job);

// TODO: Replace in-memory queue with a durable message broker:
// - BullMQ (Redis-backed): await this.withdrawalQueue.add('process', job);
// - AWS SQS: await this.sqsClient.send(new SendMessageCommand({ ... }));
// - RabbitMQ / Kafka: await this.channel.publish(exchange, routingKey, job);

return job.withdrawal_id;
}

/** Expose the queue for inspection / testing — will be removed once a real broker is wired in. */
getPendingWithdrawals(): WithdrawalJob[] {
return [...this.withdrawalQueue];
}
}
24 changes: 20 additions & 4 deletions apps/api/src/treasury/treasury.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { TreasuryService } from './treasury.service';
import { Body, Controller, Get, Post, Request } from '@nestjs/common';
import { RedeemDto, RedeemResponse } from './interfaces/redemption.interface';
import { ProofOfReservesResponse } from './interfaces/proof-of-reserves.interface';
import { TreasuryService } from './treasury.service';
import { RedemptionService } from './redemption.service';

@Controller('treasury')
export class TreasuryController {
constructor(private readonly treasuryService: TreasuryService) {}
constructor(
private readonly treasuryService: TreasuryService,
private readonly redemptionService: RedemptionService,
) {}

@Get('reserves')
async getProofOfReserves(): Promise<ProofOfReservesResponse> {
// TODO: Get supported assets from config service
// const supportedAssets = await this.configService.getSupportedAssets();
const supportedAssets = (process.env.SUPPORTED_ASSETS ?? 'USDC,ARS').split(',');

const reserves = await Promise.all(
Expand All @@ -22,4 +26,16 @@ export class TreasuryController {
reserves,
};
}

/**
* Redeem mirror assets back to the underlying base currency.
* Requires a valid JWT (merchant_id extracted from token).
* Body: RedeemDto — asset_code, amount, merchant_wallet, destination_address
*/
@Post('redeem')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async redeem(@Request() req: any, @Body() dto: RedeemDto): Promise<RedeemResponse> {
const { merchant_id } = req.user as { merchant_id: string };
return this.redemptionService.redeem(merchant_id, dto);
}
}
3 changes: 2 additions & 1 deletion apps/api/src/treasury/treasury.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { TreasuryController } from './treasury.controller';
import { TreasuryService } from './treasury.service';
import { RedemptionService } from './redemption.service';

@Module({
controllers: [TreasuryController],
providers: [TreasuryService],
providers: [TreasuryService, RedemptionService],
})
export class TreasuryModule {}