This document describes how to report a vulnerability in DeroPay and the security model of the shipped code — the trust boundaries, the guarantees the code actually provides, the key material an operator holds, and the known limitations. Claims here track the code in this repository; where a guarantee has a limit, it is stated plainly rather than rounded up.
Report privately through GitHub. Open a private advisory via the repository's Security tab → Report a vulnerability (GitHub private vulnerability reporting / Security Advisories). This keeps the report confidential until a fix is available and lets us collaborate on it in a private fork.
Please do not open a public issue or PR for a security-relevant bug before it has been triaged.
A useful report includes:
- the affected package or app (e.g.
dero-pay/escrow,apps/gateway,apps/facilitator) and version, - the trust boundary crossed (fund safety, access control, key exposure, griefing/DoS),
- reproduction steps or a proof of concept, and
- the impact you believe it has.
Because DeroPay is self-hosted, a fix ships as a released version and a note in the changelog; there is no central service to patch on your behalf. Operators are responsible for updating their own deployments.
DeroPay is self-hosted payment infrastructure. You run the gateway and hold the keys; funds settle peer-to-peer on DERO. There is no custodian and no operator in the middle who can reverse a settled transaction. The model below reflects that.
DERO transactions are irreversible once confirmed. A confirmed invoice payment is final — there is no automatic chargeback and no processor-initiated reversal. A refund is a new, deliberate transaction (an escrow refund path, or a merchant sending funds back), never an unwind of the original.
Build application logic on top of that assumption: fulfill only after the required confirmations, and treat a refund as an explicit action.
Escrow access control lives in the DERO smart contract
(dero-pay/escrow, embedded in contract.ts), not in the server. The server
cannot override it. Key properties of the shipped contract:
- Buyer is captured on-chain at deposit.
Deposit()storesbuyer = SIGNER()at first funding. Settlement paths gate on that stored identity — only the buyer canConfirmDelivery,Dispute, or self-refund; only the seller/owner canRefundBuyer; only the seller canClaimAfterExpiry(and only after the expiry window). - Ring-size-2 signer required for identity capture. Functions that persist an
identity (
Initialize→ owner,Deposit→ buyer,ClaimOwnership→ owner) reject an unidentifiable signer (IS_ADDRESS_VALID(SIGNER()) == 0). A call made at ring size > 2 yields a zeroSIGNER()and is refused before any state is written, so an anonymous caller cannot capture the buyer seat. - Deposits are exact-or-refunded.
Deposit()rejects an underpayment (DEROVALUE() < expectedAmount) — the deposit reverts and the DERO returns to the payer. An overpayment funds the exactexpectedAmountand refunds the surplus to the depositor in the same call. A rejected/underpaid deposit leaves the contract untouched (verified on mainnet). - The fund-holding code is immutable. The escrow contract ships with no
UpdateCode/UPDATE_SC_CODEentrypoint by design. Once a box is deployed, its logic cannot be rewritten — the platform can neither drain nor alter a funded box.
The happy path is Deposit → ConfirmDelivery. When it breaks down:
- The buyer calls
Dispute(), which locks the funds (status 5) and records the dispute block height. - The named arbitrator resolves via
Arbitrate(releaseToSeller): pay the seller (minus fee) or fully refund the buyer. - If the arbitrator never resolves, the buyer has an escape hatch:
RefundAfterDisputeTimeout()returns the buyer's full deposit (no fee) after the on-chain dispute window (14400blocks, roughly 3 days at ~18s per block). Refund-to-buyer is the deliberate safe default for an absent arbitrator; the contract never auto-releases to the seller.
The owner circuit-breaker (Pause()) can freeze Deposit and the discretionary
settlement paths on a box discovered to be misbehaving. It deliberately does
not block RefundAfterDisputeTimeout — a paused box can never permanently trap
the buyer's funds. Pause cannot claw back or drain a funded box; it is a
freeze, not a seizure.
The dispute default favors the buyer — sellers should know this. Because an
absent arbitrator resolves to a full buyer refund, a buyer can Dispute() a box
whose goods were legitimately delivered and, if the arbitrator does not rule
inside the ~3-day window, reclaim the deposit through
RefundAfterDisputeTimeout(). A disputed box (status 5) has no seller-side
self-serve terminal — only the arbitrator's ruling protects a seller against a
bad-faith dispute. On-chain code cannot close this: telling an honest dispute
from a griefing one is a judgment, which is exactly what the arbitrator is for.
The mitigation is operational, not cryptographic — run a responsive
arbitrator (ideally one bound to an availability commitment) for any escrow you
adjudicate. The contract guarantees the funds cannot be stolen; it does not
guarantee that a slow or absent arbitrator can't cost a seller a legitimate sale.
The arbitrator is only as neutral as the address the merchant binds. Bind
refuses an arbitrator equal to the seller (no seller self-dealing), but it does
not refuse an arbitrator equal to the box owner (the platform/merchant
operator). In the default hosted layout owner ≠ seller, so the arbitrator is a
distinct third party; in an owner == seller self-host the same wallet can be
owner, seller, and arbitrator at once. Arbitration is neutral only by virtue of
who was bound — the contract does not enforce it. Disclose the bound
arbitrator address to the buyer before they deposit so the buyer can see who
would rule on a dispute and decline a box that self-adjudicates.
The x402 rail (dero-exact scheme: dero-pay/x402, dero-pay/agent,
apps/facilitator) trades DERO's default sender anonymity for identifiable,
refundable payments — and does so by writing the payer's identity to public
chain state. An x402 payment is a Pay(merchant, order) into x402-pay.bas,
whose DVM state is world-readable via DERO.GetSC. Per (merchant, order) it
records the payer's plaintext address (paid_<mkey>), the amount
(amt_<mkey>), and the block height (h_<mkey>).
The rail is hardcoded to ring size 2 (DERO's default is 16). This is intentional — refund-on-reject and owner-only-withdraw need a real, identifiable signer — but the consequence is that every x402 payment publicly and permanently links payer ↔ merchant ↔ order ↔ amount ↔ height, a regression from DERO's baseline sender anonymity. Account balances stay homomorphically encrypted; payer identity does not. There is no ring-16 mode. If payer privacy matters, do not meter it over x402, or fund each payment from a fresh, unlinked wallet. (Invoice-style integrated-address payments are a different rail and are unaffected.)
For metered APIs and agent payments (dero-pay/x402, dero-pay/agent,
apps/facilitator):
- Resource binding. The facilitator issues an Ed25519-signed receipt whose
signed payload includes the purchased
resource,merchantId, andorderId. A relying party recomputes the canonical form and verifies the signature, then checks thatpayload.resourcematches the resource it is serving — so a receipt minted for one resource fails verification if edited to name another. See X402-RECEIPTS-SPEC.md. - Single-use. Same-
(merchant, order)replay is stopped at three layers: the contractEXISTS-reverts a second deposit to the same order (and refunds the ring-2 payer), the facilitator's/settleis idempotent on a hash of the full payment payload, and the agent payer refuses to pay the same demand twice. Under a guard running withenforceSingleUseReceipts/singleUse: true, each receipt jti is burned in a replay ledger so one payment unlocks exactly one call.
Known gate-level limitation (documented, not fixed): the withX402 gate
authorizes on (scid, merchant, order, amount) and does not take the
resource as a gate input. A payment settled for resource A can therefore unlock a
different resource B when B shares the same merchant + SCID and is priced at or
below the amount paid. The receipt still records the true resource (A), so a
relying party that inspects the receipt catches the mismatch — but the gate
grants access before the receipt is inspected. Until the gate-level fix lands
(proposed in the spec §2.6), treat the receipt's resource field as the
enforcement point and do not rely on the gate alone to separate resources that
share a merchant and SCID.
The autonomous payer (dero-pay/agent) is deny-by-default: an empty
allowOrigins authorizes nothing, and every payment is reserved against the
spend policy (per-request and rolling-window caps) before the wallet is touched.
createWalletRpcPayer refuses a non-loopback wallet URL unless
allowNonLoopback: true is set explicitly — an autonomous payer pointed at a
remote wallet is a key-exfiltration hazard. Attenuable credentials
(CredentialPolicy) can only ever narrow the authority they were minted with;
widening is structurally impossible. See
AGENT-PAYER.md.
An operator holds several secrets. Treat every one as a credential.
The gateway and SDK server sign transactions and smart-contract calls through a
DERO wallet RPC. Whoever can reach that wallet RPC can move funds. Bind the
wallet RPC to loopback or a private network, set DERO_RPC_USERNAME /
DERO_RPC_PASSWORD, and never expose it publicly.
All gateway endpoints except /health, /status, /price, and /convert
require the X-DeroPay-ApiKey header. Generate a key with the gateway's
generate-key script — it produces 32 bytes of CSPRNG output
(crypto.getRandomValues) hex-encoded. Store it in DEROPAY_API_KEY
(comma-separated for multiple keys). The API key is a bearer secret: anyone
holding it can create invoices, act on escrows, and deploy/operate routers.
Webhooks are signed with HMAC-SHA256 using DEROPAY_WEBHOOK_SECRET, and the
signature is delivered in the X-DeroPay-Signature header. Your callback
endpoint must verify this signature before acting on a webhook — otherwise any
party can POST a forged "paid" event to it. The secret is symmetric; keep it out
of logs and source control.
The HMAC-signed DPAY-RECEIPT rail (agent/MCP paid tools) shares a
receiptSecret (e.g. DEROPAY_RECEIPT_SECRET) between the guard that issues
receipts and the code that verifies them. The Ed25519 facilitator receipts
(apps/facilitator) sign with a private key the facilitator holds; relying
parties verify with its public key. Protect the signing key material as you would
any other.
Every one of these secrets travels in an HTTP header or request body. Serve the
gateway, checkout, and facilitator over HTTPS in any deployment that is not
fully loopback. Set a specific DEROPAY_CORS_ORIGIN in production rather than
the permissive * default.
Stated plainly. These are properties of the shipped design, not open bugs.
Escrow checkout links carry a claimToken as a URL parameter. The token is
minted server-side on invoice creation and gates the escrow claim/bind step:
it stops a random stranger from front-running the bind and burning platform
deploy gas. It is not a wallet proof.
Because the token lives in the URL, it is a bearer credential — anyone who
obtains the link (browser history, a proxy, an over-the-shoulder glance) holds
it. The checkout page sends Referrer-Policy: no-referrer to prevent
cross-site referrer leakage, but a leaked link still enables griefing: a
stranger can bind the wrong buyer address into the box. This is a griefing/DoS
risk, not fund theft — the on-chain contract still captures the real buyer from
SIGNER() at deposit, and only the genuine depositor's own funds are ever at
play. Residual griefing is rate-limited (per-IP and per-invoice) on the public
claim endpoint, and a never-funded box can be cancelled and re-quoted. Send
escrow checkout links over private channels and treat them as one-time secrets.
The instant-settlement payment router (dero-pay/router) ships with no
UpdateCode entrypoint. The deploying wallet becomes the merchant, and the
contract splits each payment between merchant and fee recipient on-chain. The
merchant address can be rotated with UpdateMerchant (current-merchant only),
but the contract logic cannot be changed and a router cannot be revoked or
undeployed after deploy. Deploy a router deliberately: choose the fee
configuration up front, and if you need different terms, deploy a new router
rather than expecting to reconfigure an existing one.
- Self-hosting is your responsibility. DeroPay ships the software; operating it securely — RPC isolation, TLS, secret storage, updates — is on the operator.
- Multi-process deployments require the durable store. The escrow claim guard and keeper inventory need cross-process-atomic state; a multi-process server must use the SQLite store, not the in-memory one. See ARCHITECTURE.md and DEPLOYMENT.md.
- The facilitator is trusted to read chain state honestly. A relying party that does not want to trust it can re-read the same public contract keys itself; DERO balances stay encrypted throughout and the facilitator never sees one.
We do not overstate these guarantees. If you find a case where the code does less than this document claims, that is itself a vulnerability — please report it via the Security tab.