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
6 changes: 6 additions & 0 deletions fluxapay_backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ RESEND_API_KEY="re_your_resend_api_key_here"

# Webhook
WEBHOOK_SECRET="your-webhook-secret-here"

# Soroban Smart Contracts
SOROBAN_RPC_URL="https://soroban-testnet.stellar.org"
SOROBAN_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
MERCHANT_REGISTRY_CONTRACT_ID="C..."
ADMIN_SECRET_KEY="S..."
8 changes: 8 additions & 0 deletions fluxapay_backend/src/services/merchant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createOtp, verifyOtp as verifyOtpService } from "./otp.service";
import { sendOtpEmail } from "./email.service";
import { isDevEnv } from "../helpers/env.helper";
import { generateToken } from "../helpers/jwt.helper";
import { merchantRegistryService } from "./merchantRegistry.service";


const prisma = new PrismaClient();
Expand Down Expand Up @@ -47,6 +48,13 @@ export async function signupMerchantService(data: {
},
});

// On-chain registration (non-blocking)
merchantRegistryService.register_merchant(merchant.id, business_name, settlement_currency).catch(err => {
if (isDevEnv()) {
console.error("Non-blocking error during on-chain merchant registration:", err);
}
});

// Generate OTP
try {
const otp = await createOtp(merchant.id, "email");
Expand Down
126 changes: 126 additions & 0 deletions fluxapay_backend/src/services/merchantRegistry.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Keypair, nativeToScVal, rpc, TransactionBuilder, Networks, Contract } from '@stellar/stellar-sdk';
import { isDevEnv } from '../helpers/env.helper';

export class MerchantRegistryService {
private rpcUrl: string;
private networkPassphrase: string;
private contractId: string;
private adminKeypair: Keypair;
private server: rpc.Server;

constructor() {
this.rpcUrl = process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org';
this.networkPassphrase = process.env.SOROBAN_NETWORK_PASSPHRASE || Networks.TESTNET;
this.contractId = process.env.MERCHANT_REGISTRY_CONTRACT_ID || '';

const adminSecret = process.env.ADMIN_SECRET_KEY;
if (adminSecret) {
this.adminKeypair = Keypair.fromSecret(adminSecret);
} else {
// Create a random one for dev/fallback if missing, though it won't actually have authorization on mainnet
this.adminKeypair = Keypair.random();
if (isDevEnv()) {
console.warn("ADMIN_SECRET_KEY not set. Using random keypair. Contract calls will likely fail.");
}
}

this.server = new rpc.Server(this.rpcUrl);
}

/**
* Registers a merchant on-chain via the Soroban Smart Contract.
* Includes an automatic retry mechanism for robustness.
*/
public async register_merchant(merchantId: string, businessName: string, settlementCurrency: string): Promise<boolean> {
if (!this.contractId) {
console.warn("MERCHANT_REGISTRY_CONTRACT_ID is not configured. Skipping on-chain registration.");
return false;
}

const MAX_RETRIES = 3;
let attempt = 0;
const baseDelay = 1000;

while (attempt < MAX_RETRIES) {
try {
await this.invokeRegisterContract(merchantId, businessName, settlementCurrency);
if (isDevEnv()) {
console.log(`Successfully registered merchant ${merchantId} on-chain.`);
}
return true;
} catch (error) {
attempt++;
let errorMessage = 'Unknown error';
if (error instanceof Error) errorMessage = error.message;

console.error(`Attempt ${attempt} to register merchant ${merchantId} on-chain failed:`, errorMessage);

if (attempt >= MAX_RETRIES) {
// Log to manual intervention queue
this.logToManualInterventionQueue(merchantId, errorMessage);
return false;
}

// Exponential backoff
await new Promise(resolve => setTimeout(resolve, baseDelay * Math.pow(2, attempt - 1)));
}
}
return false;
}

private async invokeRegisterContract(merchantId: string, businessName: string, settlementCurrency: string) {
const contract = new Contract(this.contractId);

// Prepare arguments: merchant_id, business_name, settlement_currency
const args = [
nativeToScVal(merchantId, { type: 'string' }),
nativeToScVal(businessName, { type: 'string' }),
nativeToScVal(settlementCurrency, { type: 'symbol' })
];

const sourceAccount = await this.server.getAccount(this.adminKeypair.publicKey());

const builder = new TransactionBuilder(sourceAccount, {
fee: '100000',
networkPassphrase: this.networkPassphrase,
});

const tx = builder
.addOperation(contract.call('register_merchant', ...args))
.setTimeout(30)
.build();

const preparedTx = await this.server.prepareTransaction(tx) as any;

preparedTx.sign(this.adminKeypair);

const sendTxResponse = await this.server.sendTransaction(preparedTx);

if (sendTxResponse.status === 'ERROR') {
throw new Error(`Transaction submission failed: ${JSON.stringify(sendTxResponse)}`);
}

// Wait for the transaction to be processed
let txResponse = await this.server.getTransaction(sendTxResponse.hash);

let retries = 0;
while (txResponse.status === 'NOT_FOUND' && retries < 10) {
await new Promise(resolve => setTimeout(resolve, 2000));
txResponse = await this.server.getTransaction(sendTxResponse.hash);
retries++;
}

if (txResponse.status === 'FAILED') {
throw new Error(`Transaction failed on-chain: ${JSON.stringify(txResponse)}`);
}

return txResponse;
}

private logToManualInterventionQueue(merchantId: string, reason: string) {
// In a real system, this would write to a database table or message queue
console.error(`[MANUAL INTERVENTION REQUIRED] Merchant ${merchantId} failed on-chain registration: ${reason}`);
}
}

export const merchantRegistryService = new MerchantRegistryService();
Loading