feat: expose settlement evidence in Greenfield API

This commit is contained in:
jwinterm
2026-07-16 08:13:02 -04:00
parent c9df923a2f
commit ad661ebad9
8 changed files with 94 additions and 14 deletions
+2 -3
View File
@@ -26,9 +26,8 @@ MONERO_WALLET_RPC_WALLET_PASSWORD=
MONERO_WALLET_RPC_WALLET_DIR=/wallets MONERO_WALLET_RPC_WALLET_DIR=/wallets
# Wownero daemon # Wownero daemon
# - Default: uses a local daemon # Required. Use an operator-controlled daemon URL, or run the bundled `wownerod` service and use:
# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568 MONERO_DAEMON_URL=http://wownerod:34568
MONERO_DAEMON_URL=http://192.168.88.235:34568
# Admin panel (generate with: openssl rand -base64 32 | tr -d '=' | head -c 32) # Admin panel (generate with: openssl rand -base64 32 | tr -d '=' | head -c 32)
ADMIN_API_KEY= ADMIN_API_KEY=
+10 -3
View File
@@ -145,9 +145,10 @@ cp .env.example .env
- Wownero view-only wallet settings (`MONERO_WALLET_RPC_*`) - Wownero view-only wallet settings (`MONERO_WALLET_RPC_*`)
- `MONERO_DAEMON_URL` (choose one of the options below) - `MONERO_DAEMON_URL` (choose one of the options below)
3. Choose a Wownero daemon source: 3. Choose a Wownero daemon source. `MONERO_DAEMON_URL` is required and has no tracked deployment
- **Use an existing daemon (default):** default:
- Leave `MONERO_DAEMON_URL` as-is (the default points at a local Wownero daemon). - **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:** - **Run your own daemon via Docker Compose:**
- Set `MONERO_DAEMON_URL=http://wownerod:34568` - Set `MONERO_DAEMON_URL=http://wownerod:34568`
- Start the stack with the `local-daemon` profile (see step 5) - 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. 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`: 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
@@ -325,7 +325,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,
@@ -363,6 +367,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": "wowcheckout wallet-rpc",
} }
@@ -392,6 +401,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)
@@ -416,6 +431,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": "wowcheckout wallet-rpc",
} }
@@ -557,7 +581,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),
@@ -620,6 +644,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": "wowcheckout 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()
+5 -5
View File
@@ -32,7 +32,7 @@ services:
MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD} MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD}
MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD} MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD}
MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets} 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} GUNICORN_WORKERS: ${GUNICORN_WORKERS:-5}
volumes: volumes:
- ./qr:/qr - ./qr:/qr
@@ -55,7 +55,7 @@ services:
MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD} MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD}
MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD} MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD}
MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets} 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: depends_on:
api: api:
condition: service_started condition: service_started
@@ -114,7 +114,7 @@ services:
"18083", "18083",
"--confirm-external-bind", "--confirm-external-bind",
"--daemon-address", "--daemon-address",
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", "${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env}",
"--rpc-login", "--rpc-login",
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
"--wallet-dir", "--wallet-dir",
@@ -143,7 +143,7 @@ services:
"18083", "18083",
"--confirm-external-bind", "--confirm-external-bind",
"--daemon-address", "--daemon-address",
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", "${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env}",
"--rpc-login", "--rpc-login",
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
"--wallet-dir", "--wallet-dir",
@@ -172,7 +172,7 @@ services:
"18083", "18083",
"--confirm-external-bind", "--confirm-external-bind",
"--daemon-address", "--daemon-address",
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", "${MONERO_DAEMON_URL:?set MONERO_DAEMON_URL in .env}",
"--rpc-login", "--rpc-login",
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
"--wallet-dir", "--wallet-dir",