diff --git a/README.md b/README.md
index ef9b88a..f69e29a 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,12 @@ invoices to `EXPIRED` before get, list, stats, verify, cancel, or monitor work.
Expired invoices remain visible in seller history, but they are excluded from
pending/actionable counts and cannot expose QR, pay, verify, or payment-proof
controls. The client also projects stale pending data through `expiresAt` so a
-page fails closed while it waits for the next authoritative server response.
+### Multi-Asset (XLM & USDC) Support
+
+Quittance supports multi-asset invoicing across native XLM and credit assets such as USDC on Stellar:
+- **Native XLM**: No issuer required, verified directly with native payment operations.
+- **Credit Assets (e.g. USDC)**: Verified with `asset_type`, `asset_code`, and pinned `asset_issuer`.
+- **Trustline UX**: The pay flow inspects trustline status and provides actionable guidance (`op_no_trust` handling) if the buyer wallet needs to add a trustline.
---
diff --git a/backend/src/config/stellar.ts b/backend/src/config/stellar.ts
index a82beb1..0b6e8e9 100644
--- a/backend/src/config/stellar.ts
+++ b/backend/src/config/stellar.ts
@@ -20,6 +20,11 @@ export const NETWORK_PASSPHRASE =
? StellarSdk.Networks.TESTNET
: StellarSdk.Networks.PUBLIC;
+export const STELLAR_EXPLORER_BASE =
+ STELLAR_NETWORK === 'TESTNET'
+ ? 'https://stellar.expert/explorer/testnet'
+ : 'https://stellar.expert/explorer/public';
+
/**
* The SDK refuses a plaintext Horizon URL unless `allowHttp` is set.
*
diff --git a/backend/src/routes/invoice.handlers.ts b/backend/src/routes/invoice.handlers.ts
index 83eca93..f24b31f 100644
--- a/backend/src/routes/invoice.handlers.ts
+++ b/backend/src/routes/invoice.handlers.ts
@@ -100,7 +100,7 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
stellarQrCode: await generateStellarPaymentQR(
invoice.sellerPublicKey,
invoice.amount.toString(),
- invoice.assetCode,
+ invoice.assetCode || 'XLM',
invoice.memo,
invoice.assetIssuer
),
diff --git a/backend/src/services/invoice-memory.service.ts b/backend/src/services/invoice-memory.service.ts
index 1b27bb8..a7537ae 100644
--- a/backend/src/services/invoice-memory.service.ts
+++ b/backend/src/services/invoice-memory.service.ts
@@ -22,7 +22,7 @@ export class InvoiceMemoryService {
sellerName: input.sellerName,
sellerEmail: input.sellerEmail,
amount: input.amount,
- assetCode: input.assetCode || 'XLM',
+ assetCode: (input.assetCode || 'XLM').toUpperCase(),
assetIssuer: input.assetIssuer,
memo,
description: input.description,
diff --git a/backend/src/services/invoice.service.ts b/backend/src/services/invoice.service.ts
index 988c7f2..f747585 100644
--- a/backend/src/services/invoice.service.ts
+++ b/backend/src/services/invoice.service.ts
@@ -72,7 +72,7 @@ export class InvoiceService {
input.sellerName || null,
input.sellerEmail || null,
input.amount,
- input.assetCode || 'XLM',
+ (input.assetCode || 'XLM').toUpperCase(),
input.assetIssuer || null,
memo,
input.description || null,
diff --git a/backend/src/storage/invoice-storage.ts b/backend/src/storage/invoice-storage.ts
index e104b54..580d475 100644
--- a/backend/src/storage/invoice-storage.ts
+++ b/backend/src/storage/invoice-storage.ts
@@ -18,7 +18,9 @@ export interface StoredInvoice {
sellerName?: string;
sellerEmail?: string;
amount: number;
+ /** Asset code for the invoice (e.g., 'XLM' or 'USDC'). */
assetCode: string;
+ /** Issuer public key for credit assets (omitted for native XLM). */
assetIssuer?: string;
memo: string;
description?: string;
diff --git a/backend/src/storage/memory-storage.ts b/backend/src/storage/memory-storage.ts
index 3ade755..4380e94 100644
--- a/backend/src/storage/memory-storage.ts
+++ b/backend/src/storage/memory-storage.ts
@@ -17,7 +17,7 @@ class MemoryStorage {
sellerName: data.sellerName,
sellerEmail: data.sellerEmail,
amount: data.amount!,
- assetCode: data.assetCode || 'XLM',
+ assetCode: (data.assetCode || 'XLM').toUpperCase(),
assetIssuer: data.assetIssuer,
memo: data.memo!,
description: data.description,
diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts
index ac15b23..d911637 100644
--- a/backend/src/utils/validation.ts
+++ b/backend/src/utils/validation.ts
@@ -26,7 +26,7 @@ export const stellarPublicKeySchema = z.string()
export const createInvoiceSchema = z
.object({
amount: z.number().positive().max(1000000000),
- assetCode: z.string().default('XLM').optional(),
+ assetCode: z.string().default('XLM').transform((val) => val.toUpperCase()).optional(),
assetIssuer: stellarPublicKeySchema.optional(),
description: z.string().max(500).optional(),
customerName: z.string().max(255).optional(),
diff --git a/backend/tests/invoice-handlers.test.ts b/backend/tests/invoice-handlers.test.ts
index 19c8c51..4aa1a46 100644
--- a/backend/tests/invoice-handlers.test.ts
+++ b/backend/tests/invoice-handlers.test.ts
@@ -352,6 +352,16 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage
assert.equal(res.body.data.paymentAvailable, true);
});
+ it('normalizes lowercase assetCode to uppercase on creation', async () => {
+ const res = await call(
+ handlers().createInvoice,
+ createReq({ body: invoiceBody({ assetCode: 'xlm' }) })
+ );
+
+ assert.equal(res.statusCode, 201);
+ assert.equal(res.body.data.invoice.assetCode, 'XLM');
+ });
+
it('accepts seller-selected expiry only within the 1-30 day contract', async () => {
const invoice = await createInvoice({ expiresInDays: 30 });
const lifetime = new Date(invoice.expiresAt).getTime() - new Date(invoice.createdAt).getTime();
diff --git a/backend/tests/invoice-service.test.ts b/backend/tests/invoice-service.test.ts
index df2c079..71a0dc3 100644
--- a/backend/tests/invoice-service.test.ts
+++ b/backend/tests/invoice-service.test.ts
@@ -356,4 +356,21 @@ describe('InvoiceService (Postgres) seller scoping', () => {
/Invoice not found or already processed/
);
});
+
+ it('supports multi-asset invoice creation with USDC and assetIssuer', async () => {
+ const db = new FakeInvoiceDb();
+ const service = new InvoiceService(db);
+ const created = await service.createInvoice(
+ input(SELLER_A, {
+ amount: 100,
+ assetCode: 'USDC',
+ assetIssuer: USDC_ISSUER,
+ })
+ );
+
+ assert.equal(created.assetCode, 'USDC');
+ assert.equal(created.assetIssuer, USDC_ISSUER);
+ assert.equal(db.rows[0].asset_code, 'USDC');
+ assert.equal(db.rows[0].asset_issuer, USDC_ISSUER);
+ });
});
diff --git a/backend/tests/payment-verification.test.ts b/backend/tests/payment-verification.test.ts
index e2468e9..58cf019 100644
--- a/backend/tests/payment-verification.test.ts
+++ b/backend/tests/payment-verification.test.ts
@@ -254,6 +254,30 @@ describe('verifyHorizonPayment — rejections', () => {
assert.equal(codeOf(result), 'ASSET_MISMATCH');
});
+ it('settles a valid USDC credit payment with matching issuer and amount', () => {
+ const result = verifyHorizonPayment(
+ input({
+ expected: expected({ assetCode: 'USDC', assetIssuer: USDC_ISSUER, amount: 50 }),
+ operations: [
+ paymentOp({
+ asset_type: 'credit_alphanum4',
+ asset_code: 'USDC',
+ asset_issuer: USDC_ISSUER,
+ amount: '50.0000000',
+ }),
+ ],
+ }),
+ );
+
+ assert.equal(result.ok, true);
+ if (result.ok) {
+ assert.equal(result.value.from, PAYER);
+ assert.equal(result.value.amount, '50.0000000');
+ assert.equal(result.value.assetCode, 'USDC');
+ assert.equal(result.value.assetIssuer, USDC_ISSUER);
+ }
+ });
+
it('rejects a transaction with no payment operation', () => {
assert.equal(
codeOf(verifyHorizonPayment(input({ operations: [{ type: 'create_account' }] }))),
diff --git a/db/schema.sql b/db/schema.sql
index 5f5f026..d8c7d71 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -80,6 +80,7 @@ ALTER TABLE invoices ALTER COLUMN expires_at SET NOT NULL;
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_invoices_seller ON invoices(seller_public_key);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
+CREATE INDEX IF NOT EXISTS idx_invoices_asset_code ON invoices(asset_code);
CREATE INDEX IF NOT EXISTS idx_invoices_memo ON invoices(memo);
CREATE INDEX IF NOT EXISTS idx_invoices_created_at ON invoices(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_seller_created_at ON invoices(seller_public_key, created_at DESC);
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
index e45b577..851b1a4 100644
--- a/frontend/app/dashboard/page.tsx
+++ b/frontend/app/dashboard/page.tsx
@@ -254,7 +254,7 @@ export default function DashboardPage() {
))}
) : (
-
0.00
+
0.00 XLM
)}
diff --git a/frontend/app/invoice/[id]/page.tsx b/frontend/app/invoice/[id]/page.tsx
index 49bfe23..59ae405 100644
--- a/frontend/app/invoice/[id]/page.tsx
+++ b/frontend/app/invoice/[id]/page.tsx
@@ -9,6 +9,7 @@ import PaymentStatus from '@/components/PaymentStatus';
import WalletConnect from '@/components/WalletConnect';
import UserProfile from '@/components/UserProfile';
import PaymentReceipt from '@/components/PaymentReceipt';
+import AssetLogo from '@/components/AssetLogo';
import { formatAmount, formatDate, getTimeRemaining } from '@/lib/utils';
import { MAIN_CONTENT_ID, describeAmount, statusText } from '@/lib/a11y';
import { ArrowLeft, Share2, Loader2, X } from 'lucide-react';
@@ -251,12 +252,13 @@ export default function InvoiceDetailPage() {
reader equivalent, rather than an `aria-label` on the
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 0e274cf..eefb875 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -93,7 +93,7 @@ export default function HomePage() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.15, ease: [0.22, 1, 0.36, 1] }}
>
- Invoice on Stellar. Get paid. Keep the proof.
+ Invoice on Stellar in XLM or USDC. Get paid. Keep the proof.
+ {invoice.assetCode && invoice.assetCode !== 'XLM' && view.showPaymentControls && (
+
+
+ {invoice.assetCode} Trustline Notice
+ Please ensure your Stellar wallet has established a trustline for {invoice.assetCode} before submitting payment.
+
- The amount your client pays, in the selected asset.
+ {assetCode === 'USDC'
+ ? 'The amount your client pays in USDC (requires a USDC trustline on Stellar).'
+ : 'The amount your client pays, in the selected asset.'}