mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
feat: expose settlement evidence in Greenfield API
This commit is contained in:
+2
-3
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user