feat: expose settlement evidence in Greenfield API

This commit is contained in:
jwinterm
2026-07-16 08:12:53 -04:00
parent 032b414b33
commit 9393fa6a99
6 changed files with 83 additions and 3 deletions
+6
View File
@@ -152,6 +152,12 @@ docker compose --profile local-daemon up --build -d
xmrcheckout can send BTCPay-compatible webhooks when invoice status changes (e.g., payment confirmed on-chain). Configure webhooks per-merchant via the admin panel or merchant dashboard. xmrcheckout can send BTCPay-compatible webhooks when invoice status changes (e.g., payment confirmed on-chain). Configure webhooks per-merchant via the admin panel or merchant dashboard.
Greenfield invoice requests may set `checkout.confirmationsRequired` from `0` through `10000`.
The target is snapshotted on the invoice. Invoice responses and settlement webhooks include the
required and observed confirmation counts, transaction IDs, observation time, and wallet-RPC source.
Webhook registration also accepts an optional caller-generated `secret`; deliveries authenticate the
exact request bytes with `BTCPay-Sig`.
If the webhook target is on the same LAN and Docker containers cannot resolve its hostname (NAT hairpinning), add `extra_hosts` to the `reconciler` service in `docker-compose.yml`: If the webhook target is on the same LAN and Docker containers cannot resolve its hostname (NAT hairpinning), add `extra_hosts` to the `reconciler` service in `docker-compose.yml`:
```yaml ```yaml
+3
View File
@@ -6,6 +6,9 @@ COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app COPY app /app/app
COPY tests /app/tests
RUN python -m unittest discover -s /app/tests -p "test_*.py"
EXPOSE 8000 EXPOSE 8000
+28 -2
View File
@@ -316,7 +316,11 @@ def create_invoice(
subaddress_index=subaddress_index, subaddress_index=subaddress_index,
amount_xmr=amount_xmr, amount_xmr=amount_xmr,
status="pending", status="pending",
confirmation_target=user.default_confirmation_target, confirmation_target=(
checkout.confirmationsRequired
if checkout and checkout.confirmationsRequired is not None
else user.default_confirmation_target
),
total_paid_atomic=0, total_paid_atomic=0,
metadata_json=metadata, metadata_json=metadata,
expires_at=expires_at, expires_at=expires_at,
@@ -354,6 +358,11 @@ def create_invoice(
"status": status_name, "status": status_name,
"additionalStatus": additional_status, "additionalStatus": additional_status,
"availableStatusesForManualMarking": ["Invalid"], "availableStatusesForManualMarking": ["Invalid"],
"confirmationsRequired": invoice.confirmation_target,
"confirmationsObserved": invoice.confirmations or 0,
"transactionIds": [],
"observedAt": invoice.created_at.isoformat() if invoice.created_at else None,
"observationSource": "xmrcheckout wallet-rpc",
} }
@@ -383,6 +392,12 @@ def get_invoice(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found", detail="Invoice not found",
) )
transfers = (
db.query(InvoiceTransfer)
.filter(InvoiceTransfer.invoice_id == invoice.id)
.order_by(InvoiceTransfer.updated_at.desc())
.all()
)
status_name, additional_status = _btcpay_status(invoice) status_name, additional_status = _btcpay_status(invoice)
amount, currency, btcpay_data = _btcpay_amount_currency(invoice) amount, currency, btcpay_data = _btcpay_amount_currency(invoice)
expiration_time = _epoch_seconds(invoice.expires_at) expiration_time = _epoch_seconds(invoice.expires_at)
@@ -407,6 +422,15 @@ def get_invoice(
"status": status_name, "status": status_name,
"additionalStatus": additional_status, "additionalStatus": additional_status,
"availableStatusesForManualMarking": ["Invalid"], "availableStatusesForManualMarking": ["Invalid"],
"confirmationsRequired": invoice.confirmation_target,
"confirmationsObserved": invoice.confirmations or 0,
"transactionIds": [transfer.txid for transfer in transfers],
"observedAt": (
transfers[0].updated_at.isoformat()
if transfers and transfers[0].updated_at
else invoice.created_at.isoformat() if invoice.created_at else None
),
"observationSource": "xmrcheckout wallet-rpc",
} }
@@ -548,7 +572,7 @@ def create_webhook(
): ):
_require_store(store_id, user) _require_store(store_id, user)
_validate_webhook_events(payload.authorizedEvents.specificEvents) _validate_webhook_events(payload.authorizedEvents.specificEvents)
secret = generate_webhook_secret() secret = payload.secret or generate_webhook_secret()
webhook = BtcpayWebhook( webhook = BtcpayWebhook(
user_id=user.id, user_id=user.id,
url=str(payload.url), url=str(payload.url),
@@ -611,6 +635,8 @@ def update_webhook(
hook.url = str(payload.url) hook.url = str(payload.url)
if payload.authorizedEvents is not None: if payload.authorizedEvents is not None:
hook.authorized_events = payload.authorizedEvents.model_dump() hook.authorized_events = payload.authorizedEvents.model_dump()
if payload.secret is not None:
hook.secret_encrypted = encrypt_secret(payload.secret)
db.add(hook) db.add(hook)
db.commit() db.commit()
db.refresh(hook) db.refresh(hook)
+3
View File
@@ -16,6 +16,7 @@ from pydantic import (
class BtcpayCheckout(BaseModel): class BtcpayCheckout(BaseModel):
redirectURL: AnyUrl | None = None redirectURL: AnyUrl | None = None
speedPolicy: str | None = None speedPolicy: str | None = None
confirmationsRequired: conint(ge=0, le=10000) | None = None
paymentMethods: list[str] | None = None paymentMethods: list[str] | None = None
expirationMinutes: conint(ge=1, le=10080) | None = None expirationMinutes: conint(ge=1, le=10080) | None = None
monitoringMinutes: conint(ge=0, le=10080) | None = None monitoringMinutes: conint(ge=0, le=10080) | None = None
@@ -52,6 +53,7 @@ class BtcpayWebhookCreate(BaseModel):
automaticRedelivery: bool = True automaticRedelivery: bool = True
url: AnyUrl url: AnyUrl
authorizedEvents: BtcpayWebhookEvents authorizedEvents: BtcpayWebhookEvents
secret: str | None = Field(default=None, min_length=16, max_length=512)
class BtcpayWebhookUpdate(BaseModel): class BtcpayWebhookUpdate(BaseModel):
@@ -59,6 +61,7 @@ class BtcpayWebhookUpdate(BaseModel):
automaticRedelivery: bool | None = None automaticRedelivery: bool | None = None
url: AnyUrl | None = None url: AnyUrl | None = None
authorizedEvents: BtcpayWebhookEvents | None = None authorizedEvents: BtcpayWebhookEvents | None = None
secret: str | None = Field(default=None, min_length=16, max_length=512)
class BtcpayInvoiceStatusUpdate(BaseModel): class BtcpayInvoiceStatusUpdate(BaseModel):
+19 -1
View File
@@ -12,7 +12,7 @@ import requests
from requests import RequestException from requests import RequestException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import BtcpayWebhook, Invoice from .models import BtcpayWebhook, Invoice, InvoiceTransfer
from .security import decrypt_secret from .security import decrypt_secret
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,6 +39,7 @@ def dispatch_btcpay_webhooks(
if not hooks: if not hooks:
return return
payload = _build_payload( payload = _build_payload(
db=db,
event_type=event_type, event_type=event_type,
user_id=user_id, user_id=user_id,
invoice=invoice, invoice=invoice,
@@ -113,6 +114,7 @@ def _event_allowed(authorized_events: object, event_type: str) -> bool:
def _build_payload( def _build_payload(
*, *,
db: Session,
event_type: str, event_type: str,
user_id: str, user_id: str,
invoice: Invoice, invoice: Invoice,
@@ -122,6 +124,17 @@ def _build_payload(
quote = metadata.get("quote") if isinstance(metadata, dict) else None quote = metadata.get("quote") if isinstance(metadata, dict) else None
fiat_amount = quote.get("fiat_amount") if isinstance(quote, dict) else None fiat_amount = quote.get("fiat_amount") if isinstance(quote, dict) else None
fiat_currency = quote.get("fiat_currency") if isinstance(quote, dict) else None fiat_currency = quote.get("fiat_currency") if isinstance(quote, dict) else None
transfers = (
db.query(InvoiceTransfer)
.filter(InvoiceTransfer.invoice_id == invoice.id)
.order_by(InvoiceTransfer.updated_at.desc())
.all()
)
observed_at = (
transfers[0].updated_at.isoformat()
if transfers and transfers[0].updated_at
else invoice.created_at.isoformat() if invoice.created_at else None
)
return { return {
"type": event_type, "type": event_type,
"timestamp": int(time.time()), "timestamp": int(time.time()),
@@ -144,6 +157,11 @@ def _build_payload(
if getattr(invoice, "amount_xmr", None) is not None if getattr(invoice, "amount_xmr", None) is not None
else None else None
), ),
"confirmationsRequired": invoice.confirmation_target,
"confirmationsObserved": invoice.confirmations or 0,
"transactionIds": [transfer.txid for transfer in transfers],
"observedAt": observed_at,
"observationSource": "xmrcheckout wallet-rpc",
} }
+24
View File
@@ -0,0 +1,24 @@
import unittest
from pydantic import ValidationError
from app.btcpay_schemas import BtcpayCheckout, BtcpayWebhookCreate
class BtcpayContractTests(unittest.TestCase):
def test_exact_confirmation_target_is_bounded(self):
self.assertEqual(BtcpayCheckout(confirmationsRequired=10000).confirmationsRequired, 10000)
with self.assertRaises(ValidationError):
BtcpayCheckout(confirmationsRequired=10001)
def test_webhook_accepts_operator_generated_secret(self):
webhook = BtcpayWebhookCreate(
url="https://evaluetron.example/webhooks/btcpay",
secret="operator-generated-secret",
authorizedEvents={"specificEvents": ["InvoiceSettled"]},
)
self.assertEqual(webhook.secret, "operator-generated-secret")
if __name__ == "__main__":
unittest.main()