Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/Console/Commands/ResetPayeeIncidents.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

namespace App\Console\Commands;

use App\Models\AuditLog;
use App\Models\UserMessage;
use App\Models\WalletConnection;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;

/**
* Close payee-attestation incidents raised by mistake (e.g. the first
* reconcile after deploy judged historical payments against today's wallet)
* and remove the in-app security messages they produced. E-mails that were
* already sent cannot be recalled.
*/
class ResetPayeeIncidents extends Command
{
protected $signature = 'wallet-connections:reset-payee-incidents
{--since= : Only incidents/messages created at or after this time (e.g. "2026-09-07 04:40")}
{--purge-messages : Also delete the merchant/admin security messages of those incidents}
{--dry-run : Report what would change without changing anything}';

protected $description = 'Clear payee mismatch incidents (and optionally their security messages) - for false positives';

public function handle(): int
{
$since = $this->option('since') ? Carbon::parse((string) $this->option('since')) : null;
$dry = (bool) $this->option('dry-run');

$rows = WalletConnection::query()
->whereNotNull('payee_mismatch_at')
->when($since, fn ($q) => $q->where('payee_mismatch_at', '>=', $since))
->get();

foreach ($rows as $connection) {
$this->line(sprintf('incident store %s since %s node %s%s', $connection->store_id, $connection->payee_mismatch_at, $connection->payee_mismatch_details['pubkey'] ?? '?', $dry ? ' (dry-run)' : ''));
if ($dry) {
continue;
}
$details = $connection->payee_mismatch_details;
$connection->forceFill(['payee_mismatch_at' => null, 'payee_mismatch_details' => null])->save();
AuditLog::log('wallet_connection.payee_incident_reset', 'wallet_connection', $connection->id, [
'store_id' => $connection->store_id,
'reset_details' => $details,
], null);
}

$deleted = 0;
if ($this->option('purge-messages')) {
// Only the messages of the incidents reset above. Messages written
// before the wallet_connection_id column existed carry no id and are
// matched by the time window instead.
$connectionIds = $rows->pluck('id')->all();
$messages = UserMessage::query()
->where('type', 'security')
->where(function ($q) {
$q->where('title', 'like', 'Payment received by an unknown wallet%')
->orWhere('title', 'like', 'Payee mismatch:%');
})
->where(function ($q) use ($connectionIds, $since) {
$q->whereIn('wallet_connection_id', $connectionIds ?: ['00000000-0000-0000-0000-000000000000'])
->orWhere(function ($legacy) use ($since) {
$legacy->whereNull('wallet_connection_id')
->when($since, fn ($qq) => $qq->where('created_at', '>=', $since));
});
});
$deleted = $dry ? $messages->count() : $messages->delete();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

$this->info(sprintf('%s %d incident(s), %d security message(s)%s', $dry ? 'Would reset' : 'Reset', $rows->count(), $deleted, $dry ? ' (dry-run)' : ''));

return self::SUCCESS;
}
}
1 change: 1 addition & 0 deletions app/Http/Controllers/Admin/WalletChangeLogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class WalletChangeLogController extends Controller
'wallet_connection.payee_learned',
'wallet_connection.payee_mismatch',
'wallet_connection.payee_accepted',
'wallet_connection.payee_incident_reset',
'store.cashu_fallback_configured',
];

Expand Down
6 changes: 5 additions & 1 deletion app/Models/UserMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class UserMessage extends Model
protected $fillable = [
'user_id',
'type',
'wallet_connection_id',
'title',
'body',
'link',
Expand Down Expand Up @@ -55,18 +56,21 @@ public function markAsRead(): void
* @param string $type info|success|warning|invoice|subscription|support
* @param string|null $link Optional URL
* @param string|null $linkText Optional link label
* @param string|null $walletConnectionId Wallet connection a security message is about
*/
public static function createForUser(
int $userId,
string $title,
?string $body = null,
string $type = 'info',
?string $link = null,
?string $linkText = null
?string $linkText = null,
?string $walletConnectionId = null
): self {
return self::create([
'user_id' => $userId,
'type' => $type,
'wallet_connection_id' => $walletConnectionId,
'title' => $title,
'body' => $body,
'link' => $link,
Expand Down
35 changes: 24 additions & 11 deletions app/Services/Boltz/SettlementLedgerService.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,6 @@ public function syncInvoice(Store $store, string $invoiceId, bool $forgetCache =
$invoice = $this->invoiceService->getInvoice($btcpayStoreId, $invoiceId, $userApiKey);
$methods = $this->invoiceService->getInvoicePaymentMethods($btcpayStoreId, $invoiceId, $userApiKey);

// Security: who signed the Lightning invoices this store got paid on
// (PayeeAttestationService). Never lets a failure break the ledger.
try {
app(PayeeAttestationService::class)->attestInvoice($store, $invoiceId, $methods);
} catch (\Throwable $e) {
Log::error('Payee attestation failed', ['store_id' => $store->id, 'invoice_id' => $invoiceId, 'error' => $e->getMessage()]);
}

$count = 0;
foreach ($methods as $method) {
if (! is_array($method)) {
Expand Down Expand Up @@ -144,7 +136,9 @@ protected function syncPaymentMethod(Store $store, string $invoiceId, array $inv

$estimate = $this->estimateNetSettlement($category, $grossSats);

StoreSettlement::updateOrCreate(
$paidAt = isset($payment['receivedDate']) ? Carbon::parse($payment['receivedDate']) : null;
$destination = isset($payment['destination']) ? (string) $payment['destination'] : null;
$row = StoreSettlement::updateOrCreate(
[
'store_id' => $store->id,
'btcpay_invoice_id' => $invoiceId,
Expand All @@ -153,9 +147,9 @@ protected function syncPaymentMethod(Store $store, string $invoiceId, array $inv
],
[
'category' => $category,
'destination' => isset($payment['destination']) ? (string) $payment['destination'] : null,
'destination' => $destination,
'payment_status' => isset($payment['status']) ? (string) $payment['status'] : null,
'paid_at' => isset($payment['receivedDate']) ? Carbon::parse($payment['receivedDate']) : null,
'paid_at' => $paidAt,
'gross_sats' => $grossSats,
'invoice_currency' => isset($invoice['currency']) ? strtoupper((string) $invoice['currency']) : null,
'invoice_amount' => isset($invoice['amount']) && is_numeric($invoice['amount']) ? (string) $invoice['amount'] : null,
Expand All @@ -165,6 +159,25 @@ protected function syncPaymentMethod(Store $store, string $invoiceId, array $inv
]
);
$count++;

// Security: who signed the Lightning invoice this payment settled
// on (PayeeAttestationService). Only payments the ledger sees for
// the first time - the daily reconcile re-reads history and must
// not judge old invoices against today's wallet. Never lets a
// failure break the ledger.
if ($row->wasRecentlyCreated && in_array($methodId, PayeeAttestationService::LIGHTNING_METHODS, true)) {
try {
app(PayeeAttestationService::class)->attestPayment(
$store,
$invoiceId,
$methodId,
$destination ?: (isset($method['destination']) ? (string) $method['destination'] : null),
$paidAt,
);
} catch (\Throwable $e) {
Log::error('Payee attestation failed', ['store_id' => $store->id, 'invoice_id' => $invoiceId, 'error' => $e->getMessage()]);
}
}
}

return $count;
Expand Down
134 changes: 109 additions & 25 deletions app/Services/WalletSecurity/PayeeAttestationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use App\Models\User;
use App\Models\WalletConnection;
use App\Services\BtcPay\InvoiceService;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;

/**
Expand All @@ -28,6 +30,9 @@ class PayeeAttestationService

public const LIGHTNING_METHODS = ['BTC-LN', 'BTC-LNURL'];

/** Multi-node providers may answer from a different node per invoice. */
public const CANARY_RECONFIRM_ATTEMPTS = 3;

public function __construct(
protected InvoiceService $invoices,
protected WalletSecurityNotifier $notifier,
Expand All @@ -41,10 +46,29 @@ public function __construct(
public function learn(WalletConnection $connection, ?User $by = null, string $reason = 'connected'): bool
{
$store = $connection->store;
$owner = $store instanceof Store ? $store->user : null;
if (! $store instanceof Store || ! $owner instanceof User || ! filled($owner->btcpay_api_key)) {
if (! $store instanceof Store) {
return false;
}
$payee = $this->canaryPayee($store, $reason);
if ($payee === null) {
return false;
}

$this->setAllowlist($connection, [$payee], 'canary', $by, $reason);

return true;
}

/**
* Node id that signs an invoice the store hands out RIGHT NOW: create a
* canary invoice, read its Lightning destination, archive it.
*/
public function canaryPayee(Store $store, string $reason = 'probe'): ?string
{
$owner = $store->user;
if (! $owner instanceof User || ! filled($owner->btcpay_api_key)) {
return null;
}
$apiKey = (string) $owner->btcpay_api_key;
$btcpayStoreId = (string) $store->btcpay_store_id;

Expand All @@ -64,14 +88,13 @@ public function learn(WalletConnection $connection, ?User $by = null, string $re
$methods = $this->invoices->getInvoicePaymentMethods($btcpayStoreId, $invoiceId, $apiKey);
} catch (\Throwable $e) {
Log::warning('Payee canary skipped', [
'connection_id' => $connection->id,
'store_id' => $store->id,
'reason' => $reason,
'error' => $e->getMessage(),
]);
$this->archiveQuietly($btcpayStoreId, $invoiceId, $apiKey);

return false;
return null;
}

$payee = null;
Expand All @@ -84,42 +107,72 @@ public function learn(WalletConnection $connection, ?User $by = null, string $re
$this->archiveQuietly($btcpayStoreId, $invoiceId, $apiKey);

if ($payee === null) {
Log::info('Payee canary produced no Lightning invoice', [
'connection_id' => $connection->id,
'store_id' => $store->id,
]);

return false;
Log::info('Payee canary produced no Lightning invoice', ['store_id' => $store->id, 'reason' => $reason]);
}

$this->setAllowlist($connection, [$payee], 'canary', $by, $reason);

return true;
return $payee;
}

/**
* Check every settled Lightning payment of an invoice (called from the
* settlement ledger sync with the payment methods it already fetched).
* Check every Lightning payment of an invoice payload (manual / admin use;
* the ledger calls attestPayment() per newly recorded payment instead).
*
* @param list<mixed> $methods Greenfield invoice payment-methods payload
* @return array<string, string> bolt11 => outcome
*/
public function attestInvoice(Store $store, string $invoiceId, array $methods): array
{
$results = [];
foreach ($methods as $method) {
if (! is_array($method)) {
continue;
}
$id = strtoupper((string) ($method['paymentMethodId'] ?? $method['paymentMethod'] ?? ''));
if (! in_array($id, self::LIGHTNING_METHODS, true)) {
continue;
}
$payments = is_array($method['payments'] ?? null) ? $method['payments'] : [];
foreach ($payments as $payment) {
if (! is_array($payment)) {
continue;
}
$bolt11 = $payment['destination'] ?? ($method['destination'] ?? null);
if (! is_string($bolt11)) {
continue;
}
$paidAt = isset($payment['receivedDate']) ? Carbon::parse((string) $payment['receivedDate']) : null;
$results[$bolt11] = $this->attestPayment($store, $invoiceId, $id, $bolt11, $paidAt);
}
}

return $results;
}

/**
* One settled Lightning payment. Payments received before the wallet was
* (re)connected or before the allow-list was learned are history and are
* neither judged nor learned from.
*
* @return 'ok'|'learned'|'mismatch'|'unparsed'|'skipped'|'historical'
*/
public function attestPayment(Store $store, string $invoiceId, string $methodId, ?string $bolt11, ?CarbonInterface $paidAt): string
{
$connection = $store->walletConnection;
if (! $connection instanceof WalletConnection || $connection->status !== 'connected') {
return [];
return 'skipped';
}

$results = [];
foreach (self::lightningDestinations($methods, requirePayments: true) as $method => $bolt11) {
$results[$bolt11] = $this->attestBolt11($connection, $bolt11, [
'invoice_id' => $invoiceId,
'method' => preg_replace('/#\d+$/', '', (string) $method),
]);
if ($bolt11 === null || ! str_starts_with(strtolower($bolt11), 'ln')) {
return 'skipped';
}
$since = $connection->secret_updated_at ?? $connection->created_at;
if ($connection->payee_learned_at && (! $since || $connection->payee_learned_at->gt($since))) {
$since = $connection->payee_learned_at;
}
if ($paidAt !== null && $since !== null && $paidAt->lt($since)) {
return 'historical';
}

return $results;
return $this->attestBolt11($connection, $bolt11, ['invoice_id' => $invoiceId, 'method' => $methodId]);
}

/**
Expand Down Expand Up @@ -153,6 +206,20 @@ public function attestBolt11(WalletConnection $connection, string $bolt11, array
}

$store = $connection->store;
if (! $store instanceof Store) {
return 'skipped';
}

// Providers run several nodes (Blink: lnd1/lnd2/..., Boltz: LND + CLN)
// and move between them. Before raising an incident, ask the connected
// wallet for fresh invoices: if it signs with this node right now, the
// payment is consistent with the wallet Satflux connected.
if ($this->canaryConfirms($store, $payee)) {
$this->setAllowlist($connection, [...$allowed, $payee], $connection->payee_learn_source ?? 'canary', null, 'canary_reconfirm', $context);

return 'learned';
}

$details = [
'pubkey' => $payee,
'invoice_id' => $context['invoice_id'] ?? null,
Expand All @@ -170,7 +237,7 @@ public function attestBolt11(WalletConnection $connection, string $bolt11, array
->update(['payee_mismatch_at' => now()]) === 1;
$connection->refresh();

if ($first && $store instanceof Store) {
if ($first) {
AuditLog::log('wallet_connection.payee_mismatch', 'wallet_connection', $connection->id, [
'store_id' => $store->id,
...$details,
Expand All @@ -182,6 +249,22 @@ public function attestBolt11(WalletConnection $connection, string $bolt11, array
return 'mismatch';
}

/** Up to CANARY_RECONFIRM_ATTEMPTS fresh invoices: does the wallet sign with $payee now? */
protected function canaryConfirms(Store $store, string $payee): bool
{
for ($i = 0; $i < self::CANARY_RECONFIRM_ATTEMPTS; $i++) {
$current = $this->canaryPayee($store, 'reconfirm');
if ($current === null) {
return false;
}
if ($current === $payee) {
return true;
}
}

return false;
}

/** Admin accepts a node after investigating: add it to the allow-list and close the incident. */
public function accept(WalletConnection $connection, string $pubkey, User $admin): void
{
Expand Down Expand Up @@ -212,6 +295,7 @@ public function accept(WalletConnection $connection, string $pubkey, User $admin
*/
protected function setAllowlist(WalletConnection $connection, array $pubkeys, string $source, ?User $by, string $reason, array $context = []): void
{
$pubkeys = array_values(array_unique($pubkeys));
$connection->forceFill([
'payee_pubkeys' => $pubkeys,
'payee_learn_source' => $source,
Expand Down
Loading
Loading