Skip to content

Commit c17c780

Browse files
authored
Merge pull request #1068 from GoodnessJohn/fix/941-940-942-943-aria-i18n-amount-stellar-dedup
fix: ARIA semantics, i18n gaps, dynamic assets, stellar.js dedup (#94
2 parents 42095ed + cb0ecde commit c17c780

11 files changed

Lines changed: 193 additions & 174 deletions

File tree

backend/src/services/stellar.js

Lines changed: 14 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { getConfig } from '../config/env.js';
55
import { getIssuer } from '../config/assets.js';
66
import logger, { withContext } from '../config/logger.js';
77
import prisma from '../db/client.js';
8-
import { getIssuer } from '../config/assets.js';
98
import { callWithCircuitBreaker } from './circuitBreaker.js';
109
import { getCachedBalance, invalidateBalanceCache } from '../cache/balanceCache.js';
1110
import { recordHorizonCall } from '../monitoring/horizonAlerter.js';
@@ -214,12 +213,6 @@ export async function createAccount(correlationId = null) {
214213
withContext(logger, { action: 'createAccount', correlationId }).info('stellar.createAccount', {
215214
publicKey,
216215
});
217-
const pair = StellarSDK.Keypair.random();
218-
const publicKey = pair.publicKey();
219-
logger.info('stellar.createAccount', { publicKey });
220-
withContext(logger, { action: 'createAccount', correlationId }).info('stellar.createAccount', {
221-
publicKey,
222-
});
223216

224217
if (isTestnet()) {
225218
const friendbotRes = await fetch(`https://friendbot.stellar.org?addr=${publicKey}`);
@@ -249,18 +242,6 @@ export async function createAccount(correlationId = null) {
249242
create: { publicKey },
250243
})
251244
.catch((err) => logger.warn('db.user.upsert.failed', { error: err.message, correlationId }));
252-
await prisma.user.upsert({
253-
where: { publicKey },
254-
update: {},
255-
create: { publicKey },
256-
}).catch(err => logger.warn('db.user.upsert.failed', { error: err.message }));
257-
await prisma.user
258-
.upsert({
259-
where: { publicKey },
260-
update: {},
261-
create: { publicKey },
262-
})
263-
.catch((err) => logger.warn('db.user.upsert.failed', { error: err.message, correlationId }));
264245

265246
return {
266247
publicKey,
@@ -269,19 +250,6 @@ export async function createAccount(correlationId = null) {
269250
});
270251
}
271252

272-
export async function getBalance(publicKey) {
273-
logger.debug('stellar.getBalance', { publicKey });
274-
const account = await getHorizonServer().loadAccount(publicKey);
275-
const balances = account.balances.map(b => ({
276-
asset: b.asset_type === 'native' ? 'XLM' : `${b.asset_code}:${b.asset_issuer}`,
277-
balance: b.balance
278-
}));
279-
280-
logger.info('stellar.balanceFetched', { publicKey, balances });
281-
await eventMonitor.publishEvent(publicKey, {
282-
type: 'BalanceChecked',
283-
data: { balances },
284-
version: 1
285253
/**
286254
* Fetch all asset balances for a Stellar account from Horizon.
287255
* @param {string} publicKey - Stellar public key of the account
@@ -335,7 +303,6 @@ export async function sendPayment(
335303
// engineers can filter the full lifecycle with a single query.
336304
const txCorrelationId = correlationId ?? randomUUID();
337305

338-
const { assetIssuer } = getConfig().stellar;
339306
const sourceKeypair = StellarSDK.Keypair.fromSecret(sourceSecret);
340307
const sourcePublicKey = sourceKeypair.publicKey();
341308
logger.info('stellar.sendPayment.start', {
@@ -348,29 +315,6 @@ export async function sendPayment(
348315
correlationId: txCorrelationId,
349316
});
350317

351-
const sourceAccount = await getHorizonServer().loadAccount(sourcePublicKey);
352-
353-
if (assetCode !== 'XLM' && !assetIssuer) {
354-
throw new Error('ASSET_ISSUER is required for non-XLM payments');
355-
}
356-
357-
const asset = assetCode === 'XLM'
358-
? StellarSDK.Asset.native()
359-
: new StellarSDK.Asset(assetCode, getIssuer(assetCode));
360-
361-
const transaction = new StellarSDK.TransactionBuilder(sourceAccount, {
362-
fee: StellarSDK.BASE_FEE,
363-
networkPassphrase: isTestnet()
364-
? StellarSDK.Networks.TESTNET
365-
: StellarSDK.Networks.PUBLIC
366-
})
367-
.addOperation(StellarSDK.Operation.payment({
368-
destination,
369-
asset,
370-
amount: amount.toString()
371-
}))
372-
.setTimeout(30)
373-
.build();
374318
// Sequence Numbers
375319
// loadAccount fetches the current on-chain sequence number for the source account.
376320
// Every Stellar transaction must include a sequence number exactly one greater than
@@ -454,7 +398,6 @@ export async function sendPayment(
454398

455399
let result;
456400
try {
457-
result = await getHorizonServer().submitTransaction(transaction);
458401
result = await withHorizonRetry(() => getHorizonServer().submitTransaction(txToSubmit));
459402
} catch (err) {
460403
logger.error('stellar.sendPayment.failed', {
@@ -497,24 +440,6 @@ export async function sendPayment(
497440
version: 1,
498441
});
499442

500-
// Persist transaction — ensure both users exist first
501-
await prisma.$transaction(async (tx) => {
502-
const [sender, recipient] = await Promise.all([
503-
tx.user.upsert({ where: { publicKey: sourcePublicKey }, update: {}, create: { publicKey: sourcePublicKey } }),
504-
tx.user.upsert({ where: { publicKey: destination }, update: {}, create: { publicKey: destination } }),
505-
]);
506-
await tx.transaction.create({
507-
data: {
508-
hash: result.hash,
509-
assetCode: assetCode || 'XLM',
510-
amount,
511-
ledger: result.ledger ?? null,
512-
successful: result.successful,
513-
senderId: sender.id,
514-
recipientId: recipient.id,
515-
},
516-
});
517-
}).catch(err => logger.warn('db.transaction.save.failed', { error: err.message }));
518443
await prisma
519444
.$transaction(async (tx) => {
520445
const [sender, recipient] = await Promise.all([
@@ -632,7 +557,6 @@ export async function createTrustline(sourceSecret, assetCode) {
632557
return { hash: result.hash, assetCode, issuer };
633558
}
634559

635-
export async function getTransactions(publicKey, { cursor, limit = 10, type, dateFrom, dateTo } = {}) {
636560
/**
637561
* Remove an existing trustline from an account. The asset balance must be zero.
638562
* @param {string} sourceSecret - Secret key of the account removing the trustline
@@ -786,7 +710,7 @@ export async function getTransactions(
786710
/**
787711
* Retrieve current network fee statistics from Horizon with an XLM/USD conversion via the SDEX.
788712
* @returns {Promise<{feeStroops: number, feeXLM: string, feeUsd: string|null, xlmUsd: string|null,
789-
* traditionalFeeUsd: number, baseFeeStroops: number, baseFeeXLM: string, surgeMultiplier: string}>}
713+
* traditionalFeeUsd: number}>}
790714
* @throws {Error} If the Horizon feeStats call fails
791715
*/
792716
export async function getFeeStats() {
@@ -806,28 +730,6 @@ export async function getFeeStats() {
806730
} catch (_) {
807731
/* non-critical: XLM/USD price lookup failure */
808732
}
809-
const stats = await getHorizonServer().feeStats();
810-
const stats = await withHorizonRetry(() => getHorizonServer().feeStats());
811-
const baseFeeStroops = parseInt(stats.last_ledger_base_fee ?? StellarSDK.BASE_FEE);
812-
const feeStroops = parseInt(stats.fee_charged?.mode ?? stats.fee_charged?.p50 ?? StellarSDK.BASE_FEE);
813-
const feeXLM = feeStroops / 1e7;
814-
const baseFeeXLM = baseFeeStroops / 1e7;
815-
const surgeMultiplier = baseFeeStroops > 0 ? feeStroops / baseFeeStroops : 1;
816-
817-
// Fetch XLM/USD price via Stellar SDEX (XLM/USDC order book)
818-
let xlmUsd = null;
819-
try {
820-
const usdc = new StellarSDK.Asset('USDC', 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN');
821-
const book = await getHorizonServer().orderbook(StellarSDK.Asset.native(), usdc).limit(1).call();
822-
const usdc = new StellarSDK.Asset('USDC', getIssuer('USDC'));
823-
const book = await withHorizonRetry(() =>
824-
getHorizonServer().orderbook(StellarSDK.Asset.native(), usdc).limit(1).call(),
825-
);
826-
const ask = parseFloat(book.asks?.[0]?.price);
827-
if (ask > 0) xlmUsd = ask;
828-
} catch (_) {
829-
/* non-critical: XLM/USD price lookup failure */
830-
}
831733

832734
const feeUsd = xlmUsd ? feeXLM * xlmUsd : null;
833735

@@ -839,17 +741,6 @@ export async function getFeeStats() {
839741
traditionalFeeUsd: 25,
840742
};
841743
});
842-
return {
843-
feeStroops,
844-
feeXLM: feeXLM.toFixed(7),
845-
feeUsd: feeUsd ? feeUsd.toFixed(6) : null,
846-
xlmUsd: xlmUsd ? xlmUsd.toFixed(4) : null,
847-
// Traditional wire transfer benchmark for comparison
848-
traditionalFeeUsd: 25,
849-
baseFeeStroops,
850-
baseFeeXLM: baseFeeXLM.toFixed(7),
851-
surgeMultiplier: surgeMultiplier.toFixed(2),
852-
};
853744
}
854745

855746
export async function getTransactionHistory(publicKey, { limit = 10, cursor } = {}) {
@@ -881,9 +772,6 @@ export async function getTransactionHistory(publicKey, { limit = 10, cursor } =
881772
export async function getExchangeRate(from, to) {
882773
if (from === to) return 1.0;
883774
try {
884-
const fromAsset = from === 'XLM' ? StellarSDK.Asset.native() : new StellarSDK.Asset(from, getIssuer(from));
885-
const toAsset = to === 'XLM' ? StellarSDK.Asset.native() : new StellarSDK.Asset(to, getIssuer(to));
886-
const orderbook = await getHorizonServer().orderbook(fromAsset, toAsset).call();
887775
const fromAsset =
888776
from === 'XLM' ? StellarSDK.Asset.native() : new StellarSDK.Asset(from, getIssuer(from));
889777
const toAsset =
@@ -928,29 +816,6 @@ export async function getNetworkStatus() {
928816
};
929817
}
930818
});
931-
const { horizonUrl } = getConfig().stellar;
932-
try {
933-
const root = await withHorizonRetry(() => getHorizonServer().root());
934-
const status = {
935-
network: isTestnet() ? 'testnet' : 'mainnet',
936-
horizonUrl,
937-
online: true,
938-
horizonVersion: root.horizon_version,
939-
networkPassphrase: root.network_passphrase,
940-
currentProtocolVersion: root.current_protocol_version,
941-
latencyMs: getLastHorizonLatency()?.latencyMs ?? null,
942-
};
943-
logger.debug('stellar.networkStatus', status);
944-
return status;
945-
} catch (err) {
946-
logger.warn('stellar.networkStatus.offline', { error: err.message });
947-
return {
948-
network: isTestnet() ? 'testnet' : 'mainnet',
949-
horizonUrl,
950-
online: false,
951-
latencyMs: null,
952-
};
953-
}
954819
}
955820

956821
// Horizon latency monitor: pings the Horizon root endpoint on an interval
@@ -988,6 +853,8 @@ export function startHorizonLatencyMonitor(intervalMs = LATENCY_PING_INTERVAL_MS
988853
export function stopHorizonLatencyMonitor() {
989854
if (latencyPingTimer) clearInterval(latencyPingTimer);
990855
latencyPingTimer = null;
856+
}
857+
991858
/**
992859
* List all non-native trustlines held by an account.
993860
* @param {string} publicKey - Stellar public key of the account
@@ -1070,17 +937,6 @@ export async function mergeAccount(sourceSecret, destination) {
1070937
};
1071938
}
1072939

1073-
/**
1074-
* Build an unsigned XDR transaction envelope for a payment without submitting it to the network.
1075-
* Useful for multisig workflows and hardware wallet signing.
1076-
* @param {string} sourceSecret - Secret key of the source account (for sequence number)
1077-
* @param {string} destination - Stellar public key of the recipient
1078-
* @param {string} amount - Amount in stroops
1079-
* @param {string} assetCode - Asset code (default: 'XLM')
1080-
* @param {string} memo - Optional memo
1081-
* @param {string} memoType - Type of memo ('text', 'id', 'hash', 'return')
1082-
* @returns {Promise<{xdr: string}>} Base64-encoded unsigned transaction envelope
1083-
*/
1084940
/**
1085941
* Simulate an account merge operation to show expected outcomes
1086942
* @param {string} sourcePublicKey - Public key of source account
@@ -1109,6 +965,17 @@ export async function simulateMergeAccount(sourcePublicKey, destinationPublicKey
1109965
};
1110966
}
1111967

968+
/**
969+
* Build an unsigned XDR transaction envelope for a payment without submitting it to the network.
970+
* Useful for multisig workflows and hardware wallet signing.
971+
* @param {string} sourceSecret - Secret key of the source account (for sequence number)
972+
* @param {string} destination - Stellar public key of the recipient
973+
* @param {string} amount - Amount in stroops
974+
* @param {string} assetCode - Asset code (default: 'XLM')
975+
* @param {string} memo - Optional memo
976+
* @param {string} memoType - Type of memo ('text', 'id', 'hash', 'return')
977+
* @returns {Promise<{xdr: string}>} Base64-encoded unsigned transaction envelope
978+
*/
1112979
export async function buildUnsignedXdr(
1113980
sourceSecret,
1114981
destination,

frontend/src/components/AmountInput.jsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
1-
import { useState } from 'react';
1+
import { useState, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { formatAssetAmount, normalizeAmountInput } from '../utils/formatAmount';
44

55
const CURRENCIES = { XLM: 'Stellar Lumens', USDC: 'USD Coin', BTC: 'Bitcoin' };
66

7+
// Default fallback assets for callers that don't pass availableAssets
8+
const DEFAULT_ASSETS = [
9+
{ code: 'XLM', name: 'Stellar Lumens' },
10+
{ code: 'USDC', name: 'USD Coin' },
11+
{ code: 'BTC', name: 'Bitcoin' },
12+
];
13+
714
/**
815
* AmountInput — numeric input with currency selector and live formatting.
9-
* Props: value, onChange, currency, onCurrencyChange, availableBalance
16+
* Props: value, onChange, currency, onCurrencyChange, availableBalance,
17+
* availableAssets – array of { code, issuer?, balance?, name? }
18+
* When provided, the currency selector is populated from this list
19+
* instead of the hardcoded CURRENCIES constant, ensuring users can
20+
* only select assets they actually hold a trustline for.
21+
* Defaults to the static three-asset list for backward compatibility.
1022
*/
11-
export function AmountInput({ value, onChange, currency = 'XLM', onCurrencyChange, availableBalance }) {
23+
export function AmountInput({ value, onChange, currency = 'XLM', onCurrencyChange, availableBalance, availableAssets }) {
1224
const { t, i18n } = useTranslation();
1325
const [focused, setFocused] = useState(false);
1426

27+
// Derive the asset list: use availableAssets prop when supplied,
28+
// otherwise fall back to the static default list for backward compatibility.
29+
const assetOptions = useMemo(() => {
30+
if (availableAssets && availableAssets.length > 0) {
31+
return availableAssets.map(a => ({ code: a.code, label: a.name ?? a.code }));
32+
}
33+
return DEFAULT_ASSETS.map(a => ({ code: a.code, label: a.name }));
34+
}, [availableAssets]);
35+
1536
const handleChange = (e) => {
1637
// Accept the locale's decimal separator (e.g. ',' in fr-FR) and
1738
// normalise it to '.' so the stored/submitted value is unambiguous.
@@ -62,8 +83,8 @@ export function AmountInput({ value, onChange, currency = 'XLM', onCurrencyChang
6283
style={selectStyle}
6384
aria-label={t('amountInput.currency')}
6485
>
65-
{Object.entries(CURRENCIES).map(([code, name]) => (
66-
<option key={code} value={code} title={name}>{code}</option>
86+
{assetOptions.map(({ code, label }) => (
87+
<option key={code} value={code} title={label}>{code}</option>
6788
))}
6889
</select>
6990
</div>

frontend/src/components/MultiSigTransactions.jsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,29 @@ import { FormField } from './FormField';
66
import { Spinner } from './Spinner';
77
import { StatusMessage } from './StatusMessage';
88

9-
const STATUS_BADGE = {
10-
PENDING: { label: 'Pending', color: 'var(--warning)' },
11-
SUBMITTED: { label: 'Submitted', color: 'var(--success)' },
12-
FAILED: { label: 'Failed', color: 'var(--danger)' },
13-
EXPIRED: { label: 'Expired', color: 'var(--muted)' },
9+
const STATUS_BADGE_COLOR = {
10+
PENDING: 'var(--warning)',
11+
SUBMITTED: 'var(--success)',
12+
FAILED: 'var(--danger)',
13+
EXPIRED: 'var(--muted)',
1414
};
1515

16+
function getStatusLabel(status, t) {
17+
switch (status) {
18+
case 'PENDING': return t('multiSig.status.pending');
19+
case 'SUBMITTED': return t('multiSig.status.submitted');
20+
case 'FAILED': return t('multiSig.status.failed');
21+
case 'EXPIRED': return t('multiSig.status.expired');
22+
default: return t('multiSig.status.unknown', { status });
23+
}
24+
}
25+
1626
function StatusBadge({ status }) {
17-
const badge = STATUS_BADGE[status] ?? { label: status, color: 'var(--muted)' };
27+
const { t } = useTranslation();
28+
const color = STATUS_BADGE_COLOR[status] ?? 'var(--muted)';
1829
return (
19-
<span style={{ background: badge.color, color: 'var(--on-primary)', borderRadius: 4, padding: '2px 8px', fontSize: '0.75rem', fontWeight: 600 }}>
20-
{badge.label}
30+
<span style={{ background: color, color: 'var(--on-primary)', borderRadius: 4, padding: '2px 8px', fontSize: '0.75rem', fontWeight: 600 }}>
31+
{getStatusLabel(status, t)}
2132
</span>
2233
);
2334
}

0 commit comments

Comments
 (0)