Compare commits
12
Commits
0dcffb394e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a0bdcdb8 | ||
|
|
93c132b0df | ||
|
|
771d95d1cd | ||
|
|
254736356b | ||
|
|
738e406725 | ||
|
|
de4630635d | ||
|
|
6b358011b0 | ||
|
|
8729098f20 | ||
|
|
deb5f8c2c3 | ||
|
|
b3e683cd71 | ||
|
|
4458edb0d1 | ||
|
|
dcd499664a |
+2
-2
@@ -90,7 +90,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate
|
||||
|
||||
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_NETWORK=stagenet
|
||||
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
|
||||
@@ -104,7 +104,7 @@ MONERO_WALLET_NAME=shop
|
||||
MONERO_WALLET_PASSWORD=change-me
|
||||
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)
|
||||
|
||||
ELECTRUM_VERSION=4.8.1
|
||||
|
||||
@@ -34,13 +34,17 @@ describe('ElectrumWalletRpcClient', () => {
|
||||
tx_hash: 'abc123',
|
||||
height: 800_000
|
||||
},
|
||||
'50000',
|
||||
{
|
||||
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||
},
|
||||
'bc1qtest',
|
||||
800_002
|
||||
)
|
||||
).toEqual({
|
||||
txHash: 'abc123',
|
||||
amountAtomic: '50000',
|
||||
confirmations: 3
|
||||
confirmations: 3,
|
||||
inputOutpoints: []
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,13 +55,17 @@ describe('ElectrumWalletRpcClient', () => {
|
||||
tx_hash: 'abc123',
|
||||
height: 0
|
||||
},
|
||||
'50000',
|
||||
{
|
||||
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||
},
|
||||
'bc1qtest',
|
||||
800_002
|
||||
)
|
||||
).toEqual({
|
||||
txHash: 'abc123',
|
||||
amountAtomic: '50000',
|
||||
confirmations: 0
|
||||
confirmations: 0,
|
||||
inputOutpoints: []
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,11 +76,61 @@ describe('ElectrumWalletRpcClient', () => {
|
||||
tx_hash: 'abc123',
|
||||
height: 800_000
|
||||
},
|
||||
'0',
|
||||
{
|
||||
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
||||
},
|
||||
'bc1qtest',
|
||||
800_002
|
||||
)
|
||||
).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', () => {
|
||||
@@ -125,6 +183,7 @@ describe('ElectrumWalletRpcClient', () => {
|
||||
jsonrpc: '2.0',
|
||||
id: 'nullcart',
|
||||
result: {
|
||||
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
|
||||
outputs: [
|
||||
{ address: 'bc1qother', value_sats: 10_000 },
|
||||
{ address: 'bc1qtest', value_sats: 50_000 }
|
||||
@@ -137,7 +196,8 @@ describe('ElectrumWalletRpcClient', () => {
|
||||
{
|
||||
txHash: 'abc123',
|
||||
amountAtomic: '50000',
|
||||
confirmations: 3
|
||||
confirmations: 3,
|
||||
inputOutpoints: ['input123:0']
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -164,6 +224,105 @@ describe('ElectrumWalletRpcClient', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -200,21 +200,26 @@ export class ElectrumWalletRpcClient {
|
||||
|
||||
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
|
||||
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(
|
||||
entry: ElectrumWalletAddressHistoryEntry,
|
||||
amountAtomic: string,
|
||||
transaction: ElectrumWalletDeserializedTransaction,
|
||||
address: string,
|
||||
blockHeight: number | null
|
||||
): ElectrumWalletIncomingTransfer | null {
|
||||
const txHash = entry.tx_hash;
|
||||
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
|
||||
|
||||
if (!txHash || amountAtomic === '0') {
|
||||
return null;
|
||||
@@ -226,7 +231,60 @@ export class ElectrumWalletRpcClient {
|
||||
return {
|
||||
txHash,
|
||||
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 = {
|
||||
address?: string;
|
||||
value_sats: number;
|
||||
};
|
||||
import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
|
||||
import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
|
||||
|
||||
export type ElectrumWalletDeserializedTransaction = {
|
||||
inputs?: ElectrumWalletDeserializedInput[];
|
||||
outputs: ElectrumWalletDeserializedOutput[];
|
||||
};
|
||||
|
||||
@@ -2,4 +2,5 @@ export type ElectrumWalletIncomingTransfer = {
|
||||
txHash: string;
|
||||
amountAtomic: string;
|
||||
confirmations: number;
|
||||
inputOutpoints: string[];
|
||||
};
|
||||
|
||||
@@ -5,8 +5,12 @@ import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTra
|
||||
export type ElectrumWalletRpcClientTest = {
|
||||
mapIncomingTransfer: (
|
||||
entry: ElectrumWalletAddressHistoryEntry,
|
||||
amountAtomic: string,
|
||||
transaction: ElectrumWalletDeserializedTransaction,
|
||||
address: string,
|
||||
blockHeight: number | null
|
||||
) => ElectrumWalletIncomingTransfer | null;
|
||||
filterSupersededBitcoinTransfers: (
|
||||
transfers: ElectrumWalletIncomingTransfer[]
|
||||
) => ElectrumWalletIncomingTransfer[];
|
||||
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
|
||||
};
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => {
|
||||
};
|
||||
let paymentRepo: {
|
||||
update: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let insertQueryBuilder: {
|
||||
@@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => {
|
||||
|
||||
paymentRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
|
||||
};
|
||||
|
||||
@@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => {
|
||||
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());
|
||||
|
||||
await service.processInvoice('invoice-1', []);
|
||||
|
||||
expect(paymentRepo.createQueryBuilder).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 () => {
|
||||
@@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => {
|
||||
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> {
|
||||
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
|
||||
|
||||
await this.dataSource.transaction(async manager => {
|
||||
const invoiceRepo = manager.getRepository(Invoice);
|
||||
const paymentRepo = manager.getRepository(InvoicePayment);
|
||||
@@ -152,36 +150,62 @@ export class InvoicePaymentService {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
|
||||
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
|
||||
await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
|
||||
|
||||
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: invoiceId },
|
||||
txHash: transfer.txHash,
|
||||
amountAtomic: transfer.amountAtomic,
|
||||
confirmations: transfer.confirmations
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers);
|
||||
});
|
||||
}
|
||||
|
||||
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.'
|
||||
});
|
||||
|
||||
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req));
|
||||
res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
|
||||
}
|
||||
|
||||
@Post('shop/cart/product/update')
|
||||
@@ -189,4 +189,18 @@ export class StorefrontCartController {
|
||||
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-product-card-min: 11rem;
|
||||
--sf-product-card-max: 22rem;
|
||||
--sf-sticky-header-scroll-padding: 9rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -158,6 +159,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
|
||||
}
|
||||
|
||||
body.sf-body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
@@ -288,9 +293,13 @@ a:hover {
|
||||
}
|
||||
|
||||
.sf-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
margin-bottom: var(--sf-space-4);
|
||||
padding: var(--sf-space-3) 0;
|
||||
border-bottom: 1px solid var(--sf-border);
|
||||
background: var(--sf-bg);
|
||||
}
|
||||
|
||||
.sf-header__inner {
|
||||
@@ -371,6 +380,26 @@ a:hover {
|
||||
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 {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
@@ -510,6 +539,76 @@ a:hover {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -789,7 +888,7 @@ a:hover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sf-line-item__body > .sf-discount-error {
|
||||
.sf-line-item__body > .sf-cart-issue {
|
||||
margin: 0.125rem 0 0;
|
||||
}
|
||||
|
||||
@@ -866,8 +965,8 @@ a:hover {
|
||||
color: var(--sf-text-muted);
|
||||
}
|
||||
|
||||
.sf-discount-error {
|
||||
margin: 0 0 var(--sf-space-2);
|
||||
.sf-cart-issue {
|
||||
margin: var(--sf-space-2) 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--sf-error);
|
||||
line-height: 1.35;
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
{{> shop-nav}}
|
||||
{{> category-nav}}
|
||||
<main class='sf-main'>
|
||||
{{> feedback}}
|
||||
{{{body}}}
|
||||
</main>
|
||||
{{> shop-footer}}
|
||||
</div>
|
||||
{{> feedback}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{{@root.shopFiatCurrency}}</div>
|
||||
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
|
||||
{{#if stockIssueMessage}}
|
||||
<p class='sf-discount-error'>{{stockIssueMessage}}</p>
|
||||
<p class='sf-cart-issue'>{{stockIssueMessage}}</p>
|
||||
{{else}}
|
||||
{{#unless stockForSession}}
|
||||
<p class='sf-text-subtle'>No more in stock beside your order</p>
|
||||
@@ -36,7 +36,7 @@
|
||||
{{/if}}
|
||||
{{#each @root.discounts}}
|
||||
{{#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}}
|
||||
{{/each}}
|
||||
<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>
|
||||
</div>
|
||||
<p class='sf-discount-error'>{{issueMessage}}</p>
|
||||
<p class='sf-cart-issue'>{{issueMessage}}</p>
|
||||
{{else if amount}}
|
||||
<div class='sf-discount-row sf-discount-item'>
|
||||
{{> dismiss-button
|
||||
@@ -62,7 +62,7 @@
|
||||
</form>
|
||||
|
||||
{{#if cartTotalIssueMessage}}
|
||||
<p class='sf-discount-error'>{{cartTotalIssueMessage}}</p>
|
||||
<p class='sf-cart-issue'>{{cartTotalIssueMessage}}</p>
|
||||
{{/if}}
|
||||
|
||||
<div class='sf-stack sf-mt-4'>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{#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}}
|
||||
</p>
|
||||
{{/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}}
|
||||
{{#if thumbnailUrl}}
|
||||
<a class='sf-product-card__media-link' href='{{detailHref}}'>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class='sf-footer__row'>
|
||||
<div class='sf-footer__start'>
|
||||
{{#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}}
|
||||
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { resolveMinConfirmations } from './resolveMinConfirmations';
|
||||
|
||||
const tiers = [
|
||||
{ upToTotalFiat: '25', minConfirmations: 0 },
|
||||
{ upToTotalFiat: '25', minConfirmations: 1 },
|
||||
{ upToTotalFiat: '250', minConfirmations: 5 },
|
||||
{ minConfirmations: 10 }
|
||||
] as const;
|
||||
|
||||
describe('resolveMinConfirmations', () => {
|
||||
it('returns 0 for small orders (tx-detected tier)', () => {
|
||||
expect(resolveMinConfirmations(10, [...tiers])).toBe(0);
|
||||
it('returns the first tier for small orders', () => {
|
||||
expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the middle tier for medium orders', () => {
|
||||
|
||||
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
|
||||
).toBe('2/10');
|
||||
});
|
||||
|
||||
it('treats zero-confirmation tiers as confirmed', () => {
|
||||
it('returns compact progress at zero confirmations', () => {
|
||||
expect(
|
||||
formatInvoicePaymentConfirmationStatus({
|
||||
confirmations: 0,
|
||||
requiredConfirmations: 0,
|
||||
requiredConfirmations: 3,
|
||||
format: 'compact'
|
||||
})
|
||||
).toBe('Confirmed');
|
||||
).toBe('0/3');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
|
||||
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
|
||||
|
||||
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
|
||||
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
|
||||
|
||||
export const formatInvoicePaymentConfirmationStatus = ({
|
||||
confirmations,
|
||||
requiredConfirmations,
|
||||
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
|
||||
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
|
||||
}
|
||||
|
||||
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`;
|
||||
return `${confirmations}/${requiredConfirmations}`;
|
||||
};
|
||||
|
||||
@@ -463,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
|
||||
};
|
||||
|
||||
const validTiers =
|
||||
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||
|
||||
describe('IsConfirmationTiers', () => {
|
||||
it('accepts valid default tiers', () => {
|
||||
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', () => {
|
||||
expect(validateTiers('').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
|
||||
expect(validateTiers('[]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects tx-detected (0) more than once', () => {
|
||||
it('rejects minConfirmations: 0', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('rejects tx-detected (0) on catch-all tier', () => {
|
||||
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects legacy tx-detected string', () => {
|
||||
it('rejects non-numeric minConfirmations', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): 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 => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
|
||||
}
|
||||
|
||||
const tiers = parsed;
|
||||
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
|
||||
|
||||
if (txDetectedTierCount > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastTier = tiers[tiers.length - 1];
|
||||
|
||||
if (lastTier.upToTotalFiat !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastTier.minConfirmations === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < tiers.length - 1; index++) {
|
||||
const tier = tiers[index];
|
||||
|
||||
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ const submitShippingNote = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
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) {
|
||||
return `Above previous tiers → ${requirement}`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
@@ -61,20 +61,20 @@ Edit `.env.prod`. Mandatory configuration:
|
||||
| `VITE_API_BASE_URL` | `/api` |
|
||||
| `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`):
|
||||
|
||||
```json
|
||||
[
|
||||
{ "upToTotalFiat": "30", "minConfirmations": 0 },
|
||||
{ "upToTotalFiat": "30", "minConfirmations": 1 },
|
||||
{ "upToTotalFiat": "100", "minConfirmations": 3 },
|
||||
{ "upToTotalFiat": "300", "minConfirmations": 5 },
|
||||
{ "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
|
||||
|
||||
@@ -106,7 +106,7 @@ Remove the temporary bootstrap certificates under `deploy/certs/live/` (Certbot
|
||||
rm -rf deploy/certs/live/*
|
||||
```
|
||||
|
||||
Request the real certificate:
|
||||
Request the real certificate (apex + www):
|
||||
|
||||
```bash
|
||||
./deploy/scripts/issue-certs.sh --email you@example.com
|
||||
|
||||
@@ -9,9 +9,9 @@ usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") --email you@example.com
|
||||
|
||||
Obtain or renew Let's Encrypt certificates for CLEARNET_DOMAIN using the webroot
|
||||
challenge. Nginx must be running and serving /.well-known/acme-challenge/ from
|
||||
deploy/certbot/www.
|
||||
Obtain Let's Encrypt certificates for CLEARNET_DOMAIN and www.CLEARNET_DOMAIN using
|
||||
the webroot challenge. Nginx must be running and serving /.well-known/acme-challenge/
|
||||
from deploy/certbot/www.
|
||||
|
||||
Environment is read from .env.prod (CLEARNET_DOMAIN).
|
||||
EOF
|
||||
@@ -65,6 +65,7 @@ docker run --rm \
|
||||
--webroot \
|
||||
-w /var/www/certbot \
|
||||
-d "$CLEARNET_DOMAIN" \
|
||||
-d "www.${CLEARNET_DOMAIN}" \
|
||||
--email "$CERTBOT_EMAIL" \
|
||||
--agree-tos \
|
||||
--non-interactive
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${CLEARNET_DOMAIN};
|
||||
server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
@@ -13,7 +13,7 @@ server {
|
||||
|
||||
server {
|
||||
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_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem;
|
||||
|
||||
Reference in New Issue
Block a user