From ad661ebad99e714566d0b9cf9b6765a817071aed Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 16 Jul 2026 08:13:02 -0400 Subject: [PATCH] feat: expose settlement evidence in Greenfield API --- .env.example | 5 ++--- README.md | 13 ++++++++++--- api/Dockerfile | 3 +++ api/app/btcpay_routes.py | 30 ++++++++++++++++++++++++++++-- api/app/btcpay_schemas.py | 3 +++ api/app/btcpay_webhooks.py | 20 +++++++++++++++++++- api/tests/test_btcpay_contract.py | 24 ++++++++++++++++++++++++ docker-compose.yml | 10 +++++----- 8 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 api/tests/test_btcpay_contract.py diff --git a/.env.example b/.env.example index aaf2512..0492986 100644 --- a/.env.example +++ b/.env.example @@ -26,9 +26,8 @@ MONERO_WALLET_RPC_WALLET_PASSWORD= MONERO_WALLET_RPC_WALLET_DIR=/wallets # Wownero daemon -# - Default: uses a local daemon -# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568 -MONERO_DAEMON_URL=http://192.168.88.235:34568 +# Required. Use an operator-controlled daemon URL, or run the bundled `wownerod` service and use: +MONERO_DAEMON_URL=http://wownerod:34568 # Admin panel (generate with: openssl rand -base64 32 | tr -d '=' | head -c 32) ADMIN_API_KEY= diff --git a/README.md b/README.md index bf68600..c3229cc 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,10 @@ cp .env.example .env - Wownero view-only wallet settings (`MONERO_WALLET_RPC_*`) - `MONERO_DAEMON_URL` (choose one of the options below) -3. Choose a Wownero daemon source: -- **Use an existing daemon (default):** - - Leave `MONERO_DAEMON_URL` as-is (the default points at a local Wownero daemon). +3. Choose a Wownero daemon source. `MONERO_DAEMON_URL` is required and has no tracked deployment + default: +- **Use an existing daemon:** + - Set `MONERO_DAEMON_URL` to an operator-controlled endpoint reachable by the wallet RPC containers. - **Run your own daemon via Docker Compose:** - Set `MONERO_DAEMON_URL=http://wownerod:34568` - Start the stack with the `local-daemon` profile (see step 5) @@ -175,6 +176,12 @@ docker compose --profile local-daemon up --build -d wowcheckout 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`: ```yaml diff --git a/api/Dockerfile b/api/Dockerfile index 2ff277f..c4323af 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -6,6 +6,9 @@ COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt COPY app /app/app +COPY tests /app/tests + +RUN python -m unittest discover -s /app/tests -p "test_*.py" EXPOSE 8000 diff --git a/api/app/btcpay_routes.py b/api/app/btcpay_routes.py index eefeaeb..46d8b85 100644 --- a/api/app/btcpay_routes.py +++ b/api/app/btcpay_routes.py @@ -325,7 +325,11 @@ def create_invoice( subaddress_index=subaddress_index, amount_xmr=amount_xmr, 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, metadata_json=metadata, expires_at=expires_at, @@ -363,6 +367,11 @@ def create_invoice( "status": status_name, "additionalStatus": additional_status, "availableStatusesForManualMarking": ["Invalid"], + "confirmationsRequired": invoice.confirmation_target, + "confirmationsObserved": invoice.confirmations or 0, + "transactionIds": [], + "observedAt": invoice.created_at.isoformat() if invoice.created_at else None, + "observationSource": "wowcheckout wallet-rpc", } @@ -392,6 +401,12 @@ def get_invoice( status_code=status.HTTP_404_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) amount, currency, btcpay_data = _btcpay_amount_currency(invoice) expiration_time = _epoch_seconds(invoice.expires_at) @@ -416,6 +431,15 @@ def get_invoice( "status": status_name, "additionalStatus": additional_status, "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": "wowcheckout wallet-rpc", } @@ -557,7 +581,7 @@ def create_webhook( ): _require_store(store_id, user) _validate_webhook_events(payload.authorizedEvents.specificEvents) - secret = generate_webhook_secret() + secret = payload.secret or generate_webhook_secret() webhook = BtcpayWebhook( user_id=user.id, url=str(payload.url), @@ -620,6 +644,8 @@ def update_webhook( hook.url = str(payload.url) if payload.authorizedEvents is not None: hook.authorized_events = payload.authorizedEvents.model_dump() + if payload.secret is not None: + hook.secret_encrypted = encrypt_secret(payload.secret) db.add(hook) db.commit() db.refresh(hook) diff --git a/api/app/btcpay_schemas.py b/api/app/btcpay_schemas.py index 24e9333..5c97e8c 100644 --- a/api/app/btcpay_schemas.py +++ b/api/app/btcpay_schemas.py @@ -16,6 +16,7 @@ from pydantic import ( class BtcpayCheckout(BaseModel): redirectURL: AnyUrl | None = None speedPolicy: str | None = None + confirmationsRequired: conint(ge=0, le=10000) | None = None paymentMethods: list[str] | None = None expirationMinutes: conint(ge=1, le=10080) | None = None monitoringMinutes: conint(ge=0, le=10080) | None = None @@ -52,6 +53,7 @@ class BtcpayWebhookCreate(BaseModel): automaticRedelivery: bool = True url: AnyUrl authorizedEvents: BtcpayWebhookEvents + secret: str | None = Field(default=None, min_length=16, max_length=512) class BtcpayWebhookUpdate(BaseModel): @@ -59,6 +61,7 @@ class BtcpayWebhookUpdate(BaseModel): automaticRedelivery: bool | None = None url: AnyUrl | None = None authorizedEvents: BtcpayWebhookEvents | None = None + secret: str | None = Field(default=None, min_length=16, max_length=512) class BtcpayInvoiceStatusUpdate(BaseModel): diff --git a/api/app/btcpay_webhooks.py b/api/app/btcpay_webhooks.py index 1530fee..10084ba 100644 --- a/api/app/btcpay_webhooks.py +++ b/api/app/btcpay_webhooks.py @@ -12,7 +12,7 @@ import requests from requests import RequestException from sqlalchemy.orm import Session -from .models import BtcpayWebhook, Invoice +from .models import BtcpayWebhook, Invoice, InvoiceTransfer from .security import decrypt_secret logger = logging.getLogger(__name__) @@ -39,6 +39,7 @@ def dispatch_btcpay_webhooks( if not hooks: return payload = _build_payload( + db=db, event_type=event_type, user_id=user_id, invoice=invoice, @@ -113,6 +114,7 @@ def _event_allowed(authorized_events: object, event_type: str) -> bool: def _build_payload( *, + db: Session, event_type: str, user_id: str, invoice: Invoice, @@ -122,6 +124,17 @@ def _build_payload( quote = metadata.get("quote") if isinstance(metadata, 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 + 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 { "type": event_type, "timestamp": int(time.time()), @@ -144,6 +157,11 @@ def _build_payload( if getattr(invoice, "amount_xmr", None) is not None else None ), + "confirmationsRequired": invoice.confirmation_target, + "confirmationsObserved": invoice.confirmations or 0, + "transactionIds": [transfer.txid for transfer in transfers], + "observedAt": observed_at, + "observationSource": "wowcheckout wallet-rpc", } diff --git a/api/tests/test_btcpay_contract.py b/api/tests/test_btcpay_contract.py new file mode 100644 index 0000000..c2ee50e --- /dev/null +++ b/api/tests/test_btcpay_contract.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index b7d25a6..e218681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD} MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD} MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets} - MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568} + MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env} GUNICORN_WORKERS: ${GUNICORN_WORKERS:-5} volumes: - ./qr:/qr @@ -55,7 +55,7 @@ services: MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD} MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD} MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets} - MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568} + MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env} depends_on: api: condition: service_started @@ -114,7 +114,7 @@ services: "18083", "--confirm-external-bind", "--daemon-address", - "${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", + "${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env}", "--rpc-login", "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", "--wallet-dir", @@ -143,7 +143,7 @@ services: "18083", "--confirm-external-bind", "--daemon-address", - "${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", + "${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env}", "--rpc-login", "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", "--wallet-dir", @@ -172,7 +172,7 @@ services: "18083", "--confirm-external-bind", "--daemon-address", - "${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", + "${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env}", "--rpc-login", "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", "--wallet-dir",