Compare commits

...
12 Commits
Author SHA1 Message Date
nobswebdev 08a0bdcdb8 Include www subdomain in clearnet TLS issuance.
Request apex and www from Let's Encrypt and serve both names in nginx.
2026-09-10 16:50:26 +02:00
nobswebdev 93c132b0df Fix SimpleX branding in shop footer link label. 2026-09-09 16:33:04 +02:00
nobswebdev 771d95d1cd Stack and center the shop header on mobile.
Column-layout logo, nav, and crypto rates with full-width wrapping for many order links.
2026-09-09 16:27:58 +02:00
nobswebdev 254736356b Show storefront feedback as fixed top-center toasts.
Use CSS-only dismiss animations, render the partial at the end of body, and add top margin to cart issue messages.
2026-09-09 16:07:58 +02:00
nobswebdev 738e406725 Rename sf-discount-error to sf-cart-issue for cart validation messages.
The class is used for stock, discount, and cart-total issues—not only discounts.
2026-09-09 15:38:15 +02:00
nobswebdev de4630635d Merge pull request 'restore scroll position after adding to cart' (#7) from slave/storefront-products-index-add-to-cart-ux-improvement into master
Reviewed-on: #7
2026-09-09 11:45:50 +00:00
nobswebdev 6b358011b0 restore scroll position after adding to cart 2026-09-09 13:44:25 +02:00
nobswebdev 8729098f20 Merge pull request 'Require min 1 confirmation in payment tier config.' (#6) from slave/get-rid-of-mempool-conf-tier into master
Reviewed-on: #6
2026-09-08 21:33:26 +00:00
nobswebdev deb5f8c2c3 Require min 1 confirmation in payment tier config.
Disallow minConfirmations: 0 for BTC and XMR tiers at startup validation, update env examples and deploy docs, and drop tx-detected UI copy and legacy tests.
2026-09-08 23:31:23 +02:00
nobswebdev b3e683cd71 Merge pull request 'Slave/btc rbf edge case fix' (#5) from slave/btc-rbf-edge-case-fix into master
Reviewed-on: #5
2026-09-08 17:02:47 +00:00
nobswebdev 4458edb0d1 Prune stale unconfirmed invoice payments during wallet polling.
Refactor processInvoice into upsert and prune helpers so absent 0-conf payments are removed when the wallet no longer reports them.
2026-09-08 18:30:16 +02:00
nobswebdev dcd499664a Filter superseded Bitcoin transfers in Electrum wallet client.
Detect RBF conflicts via shared input outpoints so invoice polling no longer ingests both the original and replacement mempool transactions.
2026-09-08 18:30:06 +02:00
28 changed files with 507 additions and 126 deletions
+2 -2
View File
@@ -90,7 +90,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate
PAYMENT_METHODS_ENABLED=xmr,btc PAYMENT_METHODS_ENABLED=xmr,btc
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]' MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
MONERO_VERSION=0.18.3.4 MONERO_VERSION=0.18.3.4
MONERO_NETWORK=stagenet MONERO_NETWORK=stagenet
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081 MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
@@ -104,7 +104,7 @@ MONERO_WALLET_NAME=shop
MONERO_WALLET_PASSWORD=change-me MONERO_WALLET_PASSWORD=change-me
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment) MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment)
BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]' BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":6}]'
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment) BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
ELECTRUM_VERSION=4.8.1 ELECTRUM_VERSION=4.8.1
@@ -34,13 +34,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123', tx_hash: 'abc123',
height: 800_000 height: 800_000
}, },
'50000', {
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002 800_002
) )
).toEqual({ ).toEqual({
txHash: 'abc123', txHash: 'abc123',
amountAtomic: '50000', amountAtomic: '50000',
confirmations: 3 confirmations: 3,
inputOutpoints: []
}); });
}); });
@@ -51,13 +55,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123', tx_hash: 'abc123',
height: 0 height: 0
}, },
'50000', {
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002 800_002
) )
).toEqual({ ).toEqual({
txHash: 'abc123', txHash: 'abc123',
amountAtomic: '50000', amountAtomic: '50000',
confirmations: 0 confirmations: 0,
inputOutpoints: []
}); });
}); });
@@ -68,11 +76,61 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123', tx_hash: 'abc123',
height: 800_000 height: 800_000
}, },
'0', {
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
},
'bc1qtest',
800_002 800_002
) )
).toBeNull(); ).toBeNull();
}); });
it('maps input outpoints from transaction inputs', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [
{ prevout_hash: 'abc123', prevout_n: 0 },
{ prevout_hash: 'def456', prevout_n: 2 }
],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['abc123:0', 'def456:2']
});
});
it('skips coinbase-like inputs without prevout data', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [{}, { prevout_hash: '', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: []
});
});
}); });
describe('sumOutputValueAtomic', () => { describe('sumOutputValueAtomic', () => {
@@ -125,6 +183,7 @@ describe('ElectrumWalletRpcClient', () => {
jsonrpc: '2.0', jsonrpc: '2.0',
id: 'nullcart', id: 'nullcart',
result: { result: {
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
outputs: [ outputs: [
{ address: 'bc1qother', value_sats: 10_000 }, { address: 'bc1qother', value_sats: 10_000 },
{ address: 'bc1qtest', value_sats: 50_000 } { address: 'bc1qtest', value_sats: 50_000 }
@@ -137,7 +196,8 @@ describe('ElectrumWalletRpcClient', () => {
{ {
txHash: 'abc123', txHash: 'abc123',
amountAtomic: '50000', amountAtomic: '50000',
confirmations: 3 confirmations: 3,
inputOutpoints: ['input123:0']
} }
]); ]);
@@ -164,6 +224,105 @@ describe('ElectrumWalletRpcClient', () => {
expect.any(Object) expect.any(Object)
); );
}); });
it('drops superseded unconfirmed transfers that share inputs with a confirmed replacement', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: [
{ tx_hash: 'original', height: 0 },
{ tx_hash: 'replacement', height: 800_000 }
]
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '01000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '02000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
});
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
{
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['shared-input:0']
}
]);
});
});
describe('filterSupersededBitcoinTransfers', () => {
it('keeps unrelated transfers unchanged', () => {
const transfers = [
{ txHash: 'a', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'b', amountAtomic: '1', confirmations: 3, inputOutpoints: ['in2:1'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
it('drops an unconfirmed transfer superseded by a confirmed replacement', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
]);
});
it('keeps the later unconfirmed transfer when both conflict before confirmation', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
]);
});
it('keeps transfers without input outpoints', () => {
const transfers = [
{ txHash: 'coinbase', amountAtomic: '1', confirmations: 0, inputOutpoints: [] },
{ txHash: 'payment', amountAtomic: '1', confirmations: 1, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
}); });
describe('createAddress', () => { describe('createAddress', () => {
@@ -200,21 +200,26 @@ export class ElectrumWalletRpcClient {
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash }); const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
const transaction = await this.deserializeTransaction(serializedTransaction); const transaction = await this.deserializeTransaction(serializedTransaction);
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight); return this.mapIncomingTransfer(entry, transaction, address, blockHeight);
}) })
); );
return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null); const resolvedTransfers = transfers.filter(
(transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null
);
return this.filterSupersededBitcoinTransfers(resolvedTransfers);
} }
private mapIncomingTransfer( private mapIncomingTransfer(
entry: ElectrumWalletAddressHistoryEntry, entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string, transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null blockHeight: number | null
): ElectrumWalletIncomingTransfer | null { ): ElectrumWalletIncomingTransfer | null {
const txHash = entry.tx_hash; const txHash = entry.tx_hash;
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
if (!txHash || amountAtomic === '0') { if (!txHash || amountAtomic === '0') {
return null; return null;
@@ -226,7 +231,60 @@ export class ElectrumWalletRpcClient {
return { return {
txHash, txHash,
amountAtomic, amountAtomic,
confirmations confirmations,
inputOutpoints: this.extractInputOutpoints(transaction)
}; };
} }
private filterSupersededBitcoinTransfers(
transfers: ElectrumWalletIncomingTransfer[]
): ElectrumWalletIncomingTransfer[] {
return transfers.filter((transfer, index) => {
const isSuperseded = transfers.some((other, otherIndex) => {
if (otherIndex === index) {
return false;
}
if (!this.sharesInputOutpoint(transfer.inputOutpoints, other.inputOutpoints)) {
return false;
}
if (other.confirmations > transfer.confirmations) {
return true;
}
return other.confirmations === transfer.confirmations && otherIndex > index;
});
return !isSuperseded;
});
}
private extractInputOutpoints(transaction: ElectrumWalletDeserializedTransaction): string[] {
if (!Array.isArray(transaction.inputs)) {
return [];
}
return transaction.inputs.flatMap(input => {
if (typeof input.prevout_hash !== 'string' || input.prevout_hash.length === 0) {
return [];
}
if (typeof input.prevout_n !== 'number') {
return [];
}
return [`${input.prevout_hash}:${input.prevout_n}`];
});
}
private sharesInputOutpoint(left: readonly string[], right: readonly string[]): boolean {
if (left.length === 0 || right.length === 0) {
return false;
}
const rightOutpoints = new Set(right);
return left.some(outpoint => rightOutpoints.has(outpoint));
}
} }
@@ -0,0 +1,4 @@
export type ElectrumWalletDeserializedInput = {
prevout_hash?: string;
prevout_n?: number;
};
@@ -0,0 +1,4 @@
export type ElectrumWalletDeserializedOutput = {
address?: string;
value_sats: number;
};
@@ -1,8 +1,7 @@
export type ElectrumWalletDeserializedOutput = { import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
address?: string; import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
value_sats: number;
};
export type ElectrumWalletDeserializedTransaction = { export type ElectrumWalletDeserializedTransaction = {
inputs?: ElectrumWalletDeserializedInput[];
outputs: ElectrumWalletDeserializedOutput[]; outputs: ElectrumWalletDeserializedOutput[];
}; };
@@ -2,4 +2,5 @@ export type ElectrumWalletIncomingTransfer = {
txHash: string; txHash: string;
amountAtomic: string; amountAtomic: string;
confirmations: number; confirmations: number;
inputOutpoints: string[];
}; };
@@ -5,8 +5,12 @@ import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTra
export type ElectrumWalletRpcClientTest = { export type ElectrumWalletRpcClientTest = {
mapIncomingTransfer: ( mapIncomingTransfer: (
entry: ElectrumWalletAddressHistoryEntry, entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string, transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null blockHeight: number | null
) => ElectrumWalletIncomingTransfer | null; ) => ElectrumWalletIncomingTransfer | null;
filterSupersededBitcoinTransfers: (
transfers: ElectrumWalletIncomingTransfer[]
) => ElectrumWalletIncomingTransfer[];
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string; sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
}; };
@@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => {
}; };
let paymentRepo: { let paymentRepo: {
update: jest.Mock; update: jest.Mock;
delete: jest.Mock;
createQueryBuilder: jest.Mock; createQueryBuilder: jest.Mock;
}; };
let insertQueryBuilder: { let insertQueryBuilder: {
@@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => {
paymentRepo = { paymentRepo = {
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder) createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
}; };
@@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => {
expect(paymentRepo.update).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled();
}); });
it('does nothing when there are no transfers to process', async () => { it('does not mutate payments when there are no transfers and no existing payments', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', []); await service.processInvoice('invoice-1', []);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.delete).not.toHaveBeenCalled();
});
it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-ghost',
txHash: 'ghost',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', []);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost');
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
}); });
it('skips transfers below the configured minimum', async () => { it('skips transfers below the configured minimum', async () => {
@@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => {
confirmations: 1 confirmations: 1
}); });
}); });
it('removes unconfirmed payments that are no longer reported', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-original',
txHash: 'original',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment,
{
id: 'payment-replacement',
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', [
buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 })
]);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original');
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
}); });
}); });
@@ -135,8 +135,6 @@ export class InvoicePaymentService {
} }
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> { private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => { await this.dataSource.transaction(async manager => {
const invoiceRepo = manager.getRepository(Invoice); const invoiceRepo = manager.getRepository(Invoice);
const paymentRepo = manager.getRepository(InvoicePayment); const paymentRepo = manager.getRepository(InvoicePayment);
@@ -152,36 +150,62 @@ export class InvoicePaymentService {
return; return;
} }
const minIncomingAtomic = minByMethod[invoice.paymentMethod]; await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
for (const transfer of transfers) { await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers);
const existing = knownByTxHash.get(transfer.txHash);
if (existing) {
if (existing.confirmations !== transfer.confirmations) {
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
}
continue;
}
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
continue;
}
await paymentRepo
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoiceId },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
})
.orIgnore()
.execute();
}
}); });
} }
private async upsertIncomingPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
for (const transfer of transfers) {
const existing = knownByTxHash.get(transfer.txHash);
if (existing) {
if (existing.confirmations !== transfer.confirmations) {
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
}
continue;
}
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
continue;
}
await paymentRepo
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoice.id },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
})
.orIgnore()
.execute();
}
}
private async pruneAbsentUnconfirmedPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash));
for (const payment of invoice.payments ?? []) {
if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) {
await paymentRepo.delete(payment.id);
}
}
}
} }
@@ -100,7 +100,7 @@ export class StorefrontCartController {
text: 'Added to cart.' text: 'Added to cart.'
}); });
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req)); res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
} }
@Post('shop/cart/product/update') @Post('shop/cart/product/update')
@@ -189,4 +189,18 @@ export class StorefrontCartController {
this.cartCookieService.setCart(req, res, cartMutation); this.cartCookieService.setCart(req, res, cartMutation);
} }
} }
private buildAddToCartRedirectPath(req: Request, variantId: string): string {
const redirectPath = safeInternalShopRedirectPath(req);
const pathname = redirectPath.split('?')[0] ?? redirectPath;
const isProductsIndexPath = pathname === '/' || pathname.startsWith('/shop/categories/');
if (!isProductsIndexPath) {
return redirectPath;
}
const anchor = `product-card-${variantId}`;
return `${redirectPath}#${anchor}`;
}
} }
@@ -49,6 +49,7 @@
--sf-split-sidebar: minmax(220px, 320px); --sf-split-sidebar: minmax(220px, 320px);
--sf-product-card-min: 11rem; --sf-product-card-min: 11rem;
--sf-product-card-max: 22rem; --sf-product-card-max: 22rem;
--sf-sticky-header-scroll-padding: 9rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -158,6 +159,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
}
body.sf-body { body.sf-body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
@@ -288,9 +293,13 @@ a:hover {
} }
.sf-header { .sf-header {
position: sticky;
top: 0;
z-index: 10;
margin-bottom: var(--sf-space-4); margin-bottom: var(--sf-space-4);
padding: var(--sf-space-3) 0; padding: var(--sf-space-3) 0;
border-bottom: 1px solid var(--sf-border); border-bottom: 1px solid var(--sf-border);
background: var(--sf-bg);
} }
.sf-header__inner { .sf-header__inner {
@@ -371,6 +380,26 @@ a:hover {
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 768px) {
.sf-header__inner {
flex-direction: column;
align-items: center;
justify-content: center;
}
.sf-header__brand-group {
flex-direction: column;
align-items: center;
}
.sf-nav,
.sf-rates {
width: 100%;
max-width: 100%;
justify-content: center;
}
}
.sf-crypto-icon { .sf-crypto-icon {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
@@ -510,6 +539,76 @@ a:hover {
border-color: var(--sf-warning-border); border-color: var(--sf-warning-border);
} }
.sf-alert--toast {
position: fixed;
top: var(--sf-space-5);
left: 50%;
z-index: 20;
display: inline-flex;
align-items: center;
gap: var(--sf-space-2);
width: max-content;
max-width: min(24rem, calc(100% - 2 * var(--sf-space-4)));
margin: 0;
padding: 0.6875rem 0.9375rem;
font-size: 0.875rem;
line-height: 1.25;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
pointer-events: none;
transform: translateX(-50%);
}
.sf-alert--toast.sf-alert--success {
animation: sf-toast-dismiss 3s ease forwards;
}
.sf-alert--toast.sf-alert--error {
animation: sf-toast-dismiss 6s ease forwards;
}
.sf-alert--toast::before {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: var(--sf-radius-full);
color: var(--sf-on-accent);
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
}
.sf-alert--toast.sf-alert--success::before {
content: '✓';
background: var(--sf-success);
}
.sf-alert--toast.sf-alert--error::before {
content: '×';
background: var(--sf-error);
}
@keyframes sf-toast-dismiss {
0%,
70% {
opacity: 1;
visibility: visible;
}
100% {
opacity: 0;
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
.sf-alert--toast {
animation: none;
}
}
.sf-btn { .sf-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -789,7 +888,7 @@ a:hover {
margin: 0; margin: 0;
} }
.sf-line-item__body > .sf-discount-error { .sf-line-item__body > .sf-cart-issue {
margin: 0.125rem 0 0; margin: 0.125rem 0 0;
} }
@@ -866,8 +965,8 @@ a:hover {
color: var(--sf-text-muted); color: var(--sf-text-muted);
} }
.sf-discount-error { .sf-cart-issue {
margin: 0 0 var(--sf-space-2); margin: var(--sf-space-2) 0;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--sf-error); color: var(--sf-error);
line-height: 1.35; line-height: 1.35;
@@ -12,10 +12,10 @@
{{> shop-nav}} {{> shop-nav}}
{{> category-nav}} {{> category-nav}}
<main class='sf-main'> <main class='sf-main'>
{{> feedback}}
{{{body}}} {{{body}}}
</main> </main>
{{> shop-footer}} {{> shop-footer}}
</div> </div>
{{> feedback}}
</body> </body>
</html> </html>
@@ -28,7 +28,7 @@
{{@root.shopFiatCurrency}}</div> {{@root.shopFiatCurrency}}</div>
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div> <div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
{{#if stockIssueMessage}} {{#if stockIssueMessage}}
<p class='sf-discount-error'>{{stockIssueMessage}}</p> <p class='sf-cart-issue'>{{stockIssueMessage}}</p>
{{else}} {{else}}
{{#unless stockForSession}} {{#unless stockForSession}}
<p class='sf-text-subtle'>No more in stock beside your order</p> <p class='sf-text-subtle'>No more in stock beside your order</p>
@@ -36,7 +36,7 @@
{{/if}} {{/if}}
{{#each @root.discounts}} {{#each @root.discounts}}
{{#if (includes ineligibleVariantIds ../id)}} {{#if (includes ineligibleVariantIds ../id)}}
<p class='sf-discount-error'>Not eligible for code {{code}}</p> <p class='sf-cart-issue'>Not eligible for code {{code}}</p>
{{/if}} {{/if}}
{{/each}} {{/each}}
<form class='sf-form-row' method='post' action='/shop/cart/product/update'> <form class='sf-form-row' method='post' action='/shop/cart/product/update'>
@@ -23,7 +23,7 @@
}} }}
<span class='sf-discount-row__code sf-text-error'>{{code}}</span> <span class='sf-discount-row__code sf-text-error'>{{code}}</span>
</div> </div>
<p class='sf-discount-error'>{{issueMessage}}</p> <p class='sf-cart-issue'>{{issueMessage}}</p>
{{else if amount}} {{else if amount}}
<div class='sf-discount-row sf-discount-item'> <div class='sf-discount-row sf-discount-item'>
{{> dismiss-button {{> dismiss-button
@@ -62,7 +62,7 @@
</form> </form>
{{#if cartTotalIssueMessage}} {{#if cartTotalIssueMessage}}
<p class='sf-discount-error'>{{cartTotalIssueMessage}}</p> <p class='sf-cart-issue'>{{cartTotalIssueMessage}}</p>
{{/if}} {{/if}}
<div class='sf-stack sf-mt-4'> <div class='sf-stack sf-mt-4'>
@@ -1,5 +1,5 @@
{{#if feedback}} {{#if feedback}}
<p class='sf-alert sf-alert--{{feedback.type}}' role='status'> <p class='sf-alert sf-alert--{{feedback.type}} sf-alert--toast' role='status'>
{{feedback.text}} {{feedback.text}}
</p> </p>
{{/if}} {{/if}}
@@ -1,4 +1,4 @@
<li class='sf-card sf-product-card'> <li class='sf-card sf-product-card' id='product-card-{{selectedVariant.id}}'>
{{#with selectedVariant}} {{#with selectedVariant}}
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
<a class='sf-product-card__media-link' href='{{detailHref}}'> <a class='sf-product-card__media-link' href='{{detailHref}}'>
@@ -2,7 +2,7 @@
<div class='sf-footer__row'> <div class='sf-footer__row'>
<div class='sf-footer__start'> <div class='sf-footer__start'>
{{#if simplexLink}} {{#if simplexLink}}
<a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via Simplex</a> <a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via SimpleX</a>
{{/if}} {{/if}}
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a> <a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
</div> </div>
@@ -1,14 +1,14 @@
import { resolveMinConfirmations } from './resolveMinConfirmations'; import { resolveMinConfirmations } from './resolveMinConfirmations';
const tiers = [ const tiers = [
{ upToTotalFiat: '25', minConfirmations: 0 }, { upToTotalFiat: '25', minConfirmations: 1 },
{ upToTotalFiat: '250', minConfirmations: 5 }, { upToTotalFiat: '250', minConfirmations: 5 },
{ minConfirmations: 10 } { minConfirmations: 10 }
] as const; ] as const;
describe('resolveMinConfirmations', () => { describe('resolveMinConfirmations', () => {
it('returns 0 for small orders (tx-detected tier)', () => { it('returns the first tier for small orders', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(0); expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
}); });
it('returns the middle tier for medium orders', () => { it('returns the middle tier for medium orders', () => {
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
).toBe('2/10'); ).toBe('2/10');
}); });
it('treats zero-confirmation tiers as confirmed', () => { it('returns compact progress at zero confirmations', () => {
expect( expect(
formatInvoicePaymentConfirmationStatus({ formatInvoicePaymentConfirmationStatus({
confirmations: 0, confirmations: 0,
requiredConfirmations: 0, requiredConfirmations: 3,
format: 'compact' format: 'compact'
}) })
).toBe('Confirmed'); ).toBe('0/3');
}); });
}); });
@@ -1,9 +1,6 @@
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo'; import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat'; import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
export const formatInvoicePaymentConfirmationStatus = ({ export const formatInvoicePaymentConfirmationStatus = ({
confirmations, confirmations,
requiredConfirmations, requiredConfirmations,
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`; return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
} }
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`; return `${confirmations}/${requiredConfirmations}`;
}; };
@@ -463,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
statusVariant: 'confirmed' statusVariant: 'confirmed'
}); });
}); });
it('treats tx-detected invoices as confirmed for payment status display', async () => {
const payment = buildPayment({ confirmations: 0 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.showRefresh).toBe(false);
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
});
}); });
describe('overpayment', () => { describe('overpayment', () => {
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
}; };
const validTiers = const validTiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
describe('IsConfirmationTiers', () => { describe('IsConfirmationTiers', () => {
it('accepts valid default tiers', () => { it('accepts valid default tiers', () => {
expect(validateTiers(validTiers)).toHaveLength(0); expect(validateTiers(validTiers)).toHaveLength(0);
}); });
it('accepts numeric-only tiers without tx-detected (0)', () => {
const tiers =
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers)).toHaveLength(0);
});
it('rejects empty string', () => { it('rejects empty string', () => {
expect(validateTiers('').length).toBeGreaterThan(0); expect(validateTiers('').length).toBeGreaterThan(0);
}); });
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
expect(validateTiers('[]').length).toBeGreaterThan(0); expect(validateTiers('[]').length).toBeGreaterThan(0);
}); });
it('rejects tx-detected (0) more than once', () => { it('rejects minConfirmations: 0', () => {
const tiers = const tiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0); expect(validateTiers(tiers).length).toBeGreaterThan(0);
}); });
it('rejects tx-detected (0) on catch-all tier', () => { it('rejects non-numeric minConfirmations', () => {
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
it('rejects legacy tx-detected string', () => {
const tiers = const tiers =
'[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":"foo"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0); expect(validateTiers(tiers).length).toBeGreaterThan(0);
}); });
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): boolean => {
}; };
const isMinConfirmations = (value: unknown): boolean => const isMinConfirmations = (value: unknown): boolean =>
typeof value === 'number' && Number.isInteger(value) && value >= 0; typeof value === 'number' && Number.isInteger(value) && value >= 1;
const isConfirmationTier = (value: unknown): value is ConfirmationTier => { const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
if (typeof value !== 'object' || value === null) { if (typeof value !== 'object' || value === null) {
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
} }
const tiers = parsed; const tiers = parsed;
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
if (txDetectedTierCount > 1) {
return false;
}
const lastTier = tiers[tiers.length - 1]; const lastTier = tiers[tiers.length - 1];
if (lastTier.upToTotalFiat !== undefined) { if (lastTier.upToTotalFiat !== undefined) {
return false; return false;
} }
if (lastTier.minConfirmations === 0) {
return false;
}
for (let index = 0; index < tiers.length - 1; index++) { for (let index = 0; index < tiers.length - 1; index++) {
const tier = tiers[index]; const tier = tiers[index];
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
} }
defaultMessage(): string { defaultMessage(): string {
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat'; return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be an integer >= 1, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
} }
} }
+1 -1
View File
@@ -468,7 +468,7 @@ const submitShippingNote = async (): Promise<void> => {
}; };
const formatConfirmationTier = (tier: ConfirmationTier, currency: string): string => { const formatConfirmationTier = (tier: ConfirmationTier, currency: string): string => {
const requirement = tier.minConfirmations === 0 ? '0 (tx-detected)' : `${tier.minConfirmations} confirmations`; const requirement = `${tier.minConfirmations} confirmations`;
if (tier.upToTotalFiat === undefined) { if (tier.upToTotalFiat === undefined) {
return `Above previous tiers → ${requirement}`; return `Above previous tiers → ${requirement}`;
+5 -5
View File
@@ -3,7 +3,7 @@
## 1. Requirements ## 1. Requirements
- Ubuntu 22.04+ or similar Linux with Docker Engine and the Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). Tested and recommended on Ubuntu 22.04 LTS. - Ubuntu 22.04+ or similar Linux with Docker Engine and the Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). Tested and recommended on Ubuntu 22.04 LTS.
- A domain name pointing at your server (A record for clearnet HTTPS) - A domain name pointing at your server (A records for apex and `www`)
## 2. Server setup ## 2. Server setup
@@ -61,20 +61,20 @@ Edit `.env.prod`. Mandatory configuration:
| `VITE_API_BASE_URL` | `/api` | | `VITE_API_BASE_URL` | `/api` |
| `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` | | `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` |
Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. At most one tier may use `minConfirmations: 0` (accept unconfirmed / mempool); that tier cannot be the catch-all. Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. Every tier must use `minConfirmations` >= 1.
Monero example (default in `.env.example`): Monero example (default in `.env.example`):
```json ```json
[ [
{ "upToTotalFiat": "30", "minConfirmations": 0 }, { "upToTotalFiat": "30", "minConfirmations": 1 },
{ "upToTotalFiat": "100", "minConfirmations": 3 }, { "upToTotalFiat": "100", "minConfirmations": 3 },
{ "upToTotalFiat": "300", "minConfirmations": 5 }, { "upToTotalFiat": "300", "minConfirmations": 5 },
{ "minConfirmations": 10 } { "minConfirmations": 10 }
] ]
``` ```
Orders up to 30 → 0 confirmations; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings. Orders up to 30 → 1 confirmation; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings.
## 5. Bootstrap TLS certificates ## 5. Bootstrap TLS certificates
@@ -106,7 +106,7 @@ Remove the temporary bootstrap certificates under `deploy/certs/live/` (Certbot
rm -rf deploy/certs/live/* rm -rf deploy/certs/live/*
``` ```
Request the real certificate: Request the real certificate (apex + www):
```bash ```bash
./deploy/scripts/issue-certs.sh --email you@example.com ./deploy/scripts/issue-certs.sh --email you@example.com
+4 -3
View File
@@ -9,9 +9,9 @@ usage() {
cat <<EOF cat <<EOF
Usage: $(basename "$0") --email you@example.com Usage: $(basename "$0") --email you@example.com
Obtain or renew Let's Encrypt certificates for CLEARNET_DOMAIN using the webroot Obtain Let's Encrypt certificates for CLEARNET_DOMAIN and www.CLEARNET_DOMAIN using
challenge. Nginx must be running and serving /.well-known/acme-challenge/ from the webroot challenge. Nginx must be running and serving /.well-known/acme-challenge/
deploy/certbot/www. from deploy/certbot/www.
Environment is read from .env.prod (CLEARNET_DOMAIN). Environment is read from .env.prod (CLEARNET_DOMAIN).
EOF EOF
@@ -65,6 +65,7 @@ docker run --rm \
--webroot \ --webroot \
-w /var/www/certbot \ -w /var/www/certbot \
-d "$CLEARNET_DOMAIN" \ -d "$CLEARNET_DOMAIN" \
-d "www.${CLEARNET_DOMAIN}" \
--email "$CERTBOT_EMAIL" \ --email "$CERTBOT_EMAIL" \
--agree-tos \ --agree-tos \
--non-interactive --non-interactive
+2 -2
View File
@@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name ${CLEARNET_DOMAIN}; server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
@@ -13,7 +13,7 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
server_name ${CLEARNET_DOMAIN}; server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
ssl_certificate /etc/nginx/certs/live/${CLEARNET_DOMAIN}/fullchain.pem; ssl_certificate /etc/nginx/certs/live/${CLEARNET_DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem; ssl_certificate_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem;