Skip to content

Commit fe49f3e

Browse files
authored
Merge pull request #77 from ayomideadeniran/feat/merchant-registry-interaction
feat: Phase 1 Merchant Registry Contract Interaction
2 parents bb022c7 + ecee44d commit fe49f3e

File tree

3 files changed

+140
-0
lines changed

3 files changed

+140
-0
lines changed

fluxapay_backend/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ RESEND_API_KEY="re_your_resend_api_key_here"
2222

2323
# Webhook
2424
WEBHOOK_SECRET="your-webhook-secret-here"
25+
26+
# Soroban Smart Contracts
27+
SOROBAN_RPC_URL="https://soroban-testnet.stellar.org"
28+
SOROBAN_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
29+
MERCHANT_REGISTRY_CONTRACT_ID="C..."
30+
ADMIN_SECRET_KEY="S..."

fluxapay_backend/src/services/merchant.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createOtp, verifyOtp as verifyOtpService } from "./otp.service";
44
import { sendOtpEmail } from "./email.service";
55
import { isDevEnv } from "../helpers/env.helper";
66
import { generateToken } from "../helpers/jwt.helper";
7+
import { merchantRegistryService } from "./merchantRegistry.service";
78

89

910
const prisma = new PrismaClient();
@@ -47,6 +48,13 @@ export async function signupMerchantService(data: {
4748
},
4849
});
4950

51+
// On-chain registration (non-blocking)
52+
merchantRegistryService.register_merchant(merchant.id, business_name, settlement_currency).catch(err => {
53+
if (isDevEnv()) {
54+
console.error("Non-blocking error during on-chain merchant registration:", err);
55+
}
56+
});
57+
5058
// Generate OTP
5159
try {
5260
const otp = await createOtp(merchant.id, "email");
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Keypair, nativeToScVal, rpc, TransactionBuilder, Networks, Contract } from '@stellar/stellar-sdk';
2+
import { isDevEnv } from '../helpers/env.helper';
3+
4+
export class MerchantRegistryService {
5+
private rpcUrl: string;
6+
private networkPassphrase: string;
7+
private contractId: string;
8+
private adminKeypair: Keypair;
9+
private server: rpc.Server;
10+
11+
constructor() {
12+
this.rpcUrl = process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org';
13+
this.networkPassphrase = process.env.SOROBAN_NETWORK_PASSPHRASE || Networks.TESTNET;
14+
this.contractId = process.env.MERCHANT_REGISTRY_CONTRACT_ID || '';
15+
16+
const adminSecret = process.env.ADMIN_SECRET_KEY;
17+
if (adminSecret) {
18+
this.adminKeypair = Keypair.fromSecret(adminSecret);
19+
} else {
20+
// Create a random one for dev/fallback if missing, though it won't actually have authorization on mainnet
21+
this.adminKeypair = Keypair.random();
22+
if (isDevEnv()) {
23+
console.warn("ADMIN_SECRET_KEY not set. Using random keypair. Contract calls will likely fail.");
24+
}
25+
}
26+
27+
this.server = new rpc.Server(this.rpcUrl);
28+
}
29+
30+
/**
31+
* Registers a merchant on-chain via the Soroban Smart Contract.
32+
* Includes an automatic retry mechanism for robustness.
33+
*/
34+
public async register_merchant(merchantId: string, businessName: string, settlementCurrency: string): Promise<boolean> {
35+
if (!this.contractId) {
36+
console.warn("MERCHANT_REGISTRY_CONTRACT_ID is not configured. Skipping on-chain registration.");
37+
return false;
38+
}
39+
40+
const MAX_RETRIES = 3;
41+
let attempt = 0;
42+
const baseDelay = 1000;
43+
44+
while (attempt < MAX_RETRIES) {
45+
try {
46+
await this.invokeRegisterContract(merchantId, businessName, settlementCurrency);
47+
if (isDevEnv()) {
48+
console.log(`Successfully registered merchant ${merchantId} on-chain.`);
49+
}
50+
return true;
51+
} catch (error) {
52+
attempt++;
53+
let errorMessage = 'Unknown error';
54+
if (error instanceof Error) errorMessage = error.message;
55+
56+
console.error(`Attempt ${attempt} to register merchant ${merchantId} on-chain failed:`, errorMessage);
57+
58+
if (attempt >= MAX_RETRIES) {
59+
// Log to manual intervention queue
60+
this.logToManualInterventionQueue(merchantId, errorMessage);
61+
return false;
62+
}
63+
64+
// Exponential backoff
65+
await new Promise(resolve => setTimeout(resolve, baseDelay * Math.pow(2, attempt - 1)));
66+
}
67+
}
68+
return false;
69+
}
70+
71+
private async invokeRegisterContract(merchantId: string, businessName: string, settlementCurrency: string) {
72+
const contract = new Contract(this.contractId);
73+
74+
// Prepare arguments: merchant_id, business_name, settlement_currency
75+
const args = [
76+
nativeToScVal(merchantId, { type: 'string' }),
77+
nativeToScVal(businessName, { type: 'string' }),
78+
nativeToScVal(settlementCurrency, { type: 'symbol' })
79+
];
80+
81+
const sourceAccount = await this.server.getAccount(this.adminKeypair.publicKey());
82+
83+
const builder = new TransactionBuilder(sourceAccount, {
84+
fee: '100000',
85+
networkPassphrase: this.networkPassphrase,
86+
});
87+
88+
const tx = builder
89+
.addOperation(contract.call('register_merchant', ...args))
90+
.setTimeout(30)
91+
.build();
92+
93+
const preparedTx = await this.server.prepareTransaction(tx) as any;
94+
95+
preparedTx.sign(this.adminKeypair);
96+
97+
const sendTxResponse = await this.server.sendTransaction(preparedTx);
98+
99+
if (sendTxResponse.status === 'ERROR') {
100+
throw new Error(`Transaction submission failed: ${JSON.stringify(sendTxResponse)}`);
101+
}
102+
103+
// Wait for the transaction to be processed
104+
let txResponse = await this.server.getTransaction(sendTxResponse.hash);
105+
106+
let retries = 0;
107+
while (txResponse.status === 'NOT_FOUND' && retries < 10) {
108+
await new Promise(resolve => setTimeout(resolve, 2000));
109+
txResponse = await this.server.getTransaction(sendTxResponse.hash);
110+
retries++;
111+
}
112+
113+
if (txResponse.status === 'FAILED') {
114+
throw new Error(`Transaction failed on-chain: ${JSON.stringify(txResponse)}`);
115+
}
116+
117+
return txResponse;
118+
}
119+
120+
private logToManualInterventionQueue(merchantId: string, reason: string) {
121+
// In a real system, this would write to a database table or message queue
122+
console.error(`[MANUAL INTERVENTION REQUIRED] Merchant ${merchantId} failed on-chain registration: ${reason}`);
123+
}
124+
}
125+
126+
export const merchantRegistryService = new MerchantRegistryService();

0 commit comments

Comments
 (0)