Repository navigation
Phala Pay 0.3.0
Added
-
Versioned releases. A
v<version>tag of amaincommit runs CI, then publishes the images to
GHCR, built on GitHub-hosted runners with pinned Buildx and BuildKit, each with a GitHub build
provenance attestation (phala-payand the reference product are reproducible;postgres-walg
has provenance only), and a GitHub release whose assets areimages.json, the deploy kit
phala-pay-deploy-v<version>.tar.gz(git archiveofLICENSE,deploy/, anddocs/), the
Phala Cloud template's composephala-cloud-template.yml, andSHA256SUMS, each attested.
deploy/verify-release.shverifies a release: its commit inmain's history, the checksums,
and every asset's and image's provenance for that commit. -
Self-hosting without a fork: an operator's repository holds only its environment directory and
a workflow that calls Deploy at a release (docs/self-hosting.md); Deploy runs the release's
verify-release.sh and requires its own commit to be the release's. The kit'sdeploy/phala
runs the Phala Cloud CLI with its dependencies locked (deploy/tools). -
The environment's
compose.yamlmay set only its documented settings (WAL-G's location,
dstack-ingress'sDOMAIN, the RPC key names):render.shrefuses any other change, and every
rendered image must be the release's or one the kit pins. -
Deploy sends the kit's reviewed pre-launch script (
deploy/phala-cloud-pre-launch.sh, Phala
Cloud's v0.0.20) on provision and upgrade, andverify-attestation.shrequires exactly it. -
The Phala Cloud template variant (
deploy/render.sh --template): Phala's staging routes on a
one-click testnet instance served at the app's gateway domain. Exactly the deploy form's values
come from the CVM's env and are unattested: the admin public key and the origin's host, which
topup runreads with--admin-public-key-envand--public-origin-host-envwhen the
configuration leaves them out, and the backup location. A template instance has no restore-check
path. -
postgres-walg starts PostgreSQL or a backup only with WAL-G's file backend or all three S3
settings well formed:WALG_S3_PREFIXa valid bucket,AWS_ENDPOINTanhttpsorigin with a
valid host and port, andAWS_REGION. -
Deposits carry
receipt_log_indexandrevision, the position and revision theiridis
derived from, andblock_hashandblock_time(Unix seconds), in the object and every
deposit.*snapshot. -
Admin:
POST /v1/admin/restore/treasuries/applyapplies again, while frozen after a restore, a
treasury change that applied after the restore point, from the merchant's delivery of its
treasury.updated: only a delivery the service signed, of the restored pending change becoming
active, whose time-lock ended, and screened again when screening answers (a treasury a
sanctions list names now is refused). It applies at the event'screated, audited in the same
transaction, and its events are not sent again.POST /v1/admin/restore/treasuries/verify
reports such a change asapplication_lost, and the treasury it replaced, receivedreplaced,
asreplacement_lostwhen that change is sent in the same request asactive(both were
differs); both arematchesonce the change is restored. A change on a chain without a current
route stays pending, as the time-lock leaves it. -
Admin: each result of
POST /v1/admin/restore/eventscarriesreversed_depositfor a
deposit.reversed:restored,recorded,address_unknown, orrescanned(the rescan recorded
its position first; also a finding status ofGET /v1/admin/restore). -
Deposits carry
replacesandreplaced_by(dep_…ornull), in the object and every
deposit.*snapshot: a deposit recorded for the transfer that took a reversed deposit's receipt
position after a reorganization names that deposit, and the reversed one names it (see Fixed).
Both arenullwhen the other deposit is in another account or mode. -
Webhook delivery honors a receiver's
Retry-Afteron429and503, in seconds or as an HTTP
date: the retry waits at least that long, at most an hour. -
Admin:
POST /v1/admin/deposits/{id}/nudgeanswers400 deposit_unexpected_statefor a deposit
the pump does not process (anything butdetectedorconfirmed); it was a silent no-op. -
Admin:
POST /v1/admin/restore/quotesre-issues a quote given out after the restore point from
the merchant's record of it (the address must be the one itsqt_id derives over the current
treasury), backfilled from the restored cursor so a payment made to it is found. Its terms are
the merchant's record, kept but never applied: a payment to it is credited at spot unless an
imported, signeddeposit.creditedcarries its credit, and itsexpires_atis the restore's
detection at the latest. Aclient_secretthe service issued for the quote is kept, so the
payer's page reads it again;POST /v1/admin/restore/deposit_addressestakes one too. -
Integration guide §2.3, obligation 6: keep each webhook delivery as your receiver got it, once
perwebhook-id, in the transaction that applies it: the raw body bytes and thewebhook-id,
webhook-timestamp, andwebhook-signatureheaders. After a service restore only a delivery
the service signed is imported (§5.12), so a parsed or re-serialized event cannot prove a credit;
keep every quote and deposit address response whole too, itsclient_secretstored like a
credential. -
The reference product (
deploy/product) keeps a webhook inbox (each verified delivery as
received, with its processing state, committed with its ledger effect; a redelivery with another
body keeps the first and is logged) and each quote and deposit address response it gets, client
secret included, in its ledger, now mode 0600.
python -m reference_product export-restore-records(from the ledger, read-only) or
fetch-restore-records(from its account API,GET /accounts/restore-records, signed with the
driver key) prints them as the bodies ofPOST /v1/admin/restore/treasuries/verify,
/treasuries/apply,/deposit_addresses,/quotes, and/events;--sincekeeps what was
created or recorded from five minutes before the restore point on. The controlled restore drill runs the product's receiver and uses
only what it exports, not events the drill made up; it now also restores a treasury change lost
with the restore, an unvalued reversed deposit, and a reorganized deposit's revisions. -
Admin:
GET /v1/admin/attestation?account=&livemode=&nonce=returnsGET /v1/attestationof any
account and mode, so the operator verifies a restored instance, where merchant keys are refused. -
Admin:
POST /v1/admin/restore/unfreezerequiresquotes_reissued. -
Admin:
POST /v1/admin/restore/delivered_credits/discardreleases a deposit held because its
transfer contradicts the delivered event imported for it (acontradictedfinding of
GET /v1/admin/restore); the deposit is then valued from the chain. -
Deposits carry
final_at(Unix seconds;nulluntilfinal), when the finality watch found
the deposit's block final, in the object and everydeposit.*snapshot. -
A path or method the API does not serve answers
404 resource_missingwith the error object
(it had an empty body). The error object'sdoc_urlis optional in the OpenAPI schema, as in
Stripe's; every error of the service still carries it. -
Staging's route
phala-cloud-sepolia-pha-usdis version 3 on the deterministic factory
0x45466D37587E6E46DC35eB96b74ba3D3b1E5b747(implementation
0x49F2F1F1a25269Ea0C6FF2AB1C7B09dCBE9c5bA9), withconfirmations: 2. -
Staging adds a second test-mode route,
phala-cloud-sepolia-usdc-usd: Circle's testnet USDC on
Sepolia (0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238, 6 decimals; https://faucet.circle.com),
valued at one dollar (pricing modestablecoin) with no quote spread, so a $10 quote asks 10 USDC.
GET /v1/configlists it beside PHA, and a deposit address takes both tokens. -
Staging adds Base Sepolia (84532), with the same two test-mode routes:
phala-cloud-base-sepolia-pha-usd(test PHA0x1a6F260377e42ead1418C7C1afDFD5DE371A9284, 18
decimals, publicmint) andphala-cloud-base-sepolia-usdc-usd(Circle's testnet USDC
0x036CbD53842c5426634e7929541eC2318f3dCF7e, 6 decimals, at one dollar with no quote spread), on
the same factory and implementation. Deposits there are credited at the chain'ssafehead
(GET /v1/configreportsconfirmations: "safe"), typically about 5 minutes after inclusion. -
Launch hardening (docs/design/multi-tenant.md, "launch hardening" amendment):
- Restricted keys:
POST /v1/api_keys {"type": "restricted", "permissions": [...]}issues a
ppay_rk_{test,live}_key holding only those permissions (awriteincludes itsread);
api_keyobjects gainpermissions. Restricted keys can no longer be grantedaccount.write
orendpoints.write: keys, treasuries, webhook endpoints, webhook keys, and account settings
need a secret key. POST /v1/account/webhook_keys/roll: in live modeexpires_inis 172800 (48 hours) to 604800;
0is refused there. The default is 172800 (was0). The roll'saccount.updatedis signed
by the retiring key too, whenever it is delivered.- Treasuries gain
crediting_pausedandcrediting_paused_by;POST /v1/treasuries/{id}/pause
and/resume(secret key), and the adminPOST /v1/admin/accounts/{account}/treasuries/{treasury}/pause|resume {reason}, hold deposits to
every forwarder over a treasurypendingwithoutdeposit.crediteduntil resumed; each change
istreasury.updated.
- Restricted keys:
-
Ledger correctness (docs/design/multi-tenant.md, "ledger correctness" amendment):
- Deposits carry
amount_refunded(the cents ofamountsucceeded refunds take back, pro rata
to the refunded tokens, rounded down, cumulative) andamount_reversed(amountonce
reversed), in everydeposit.*snapshot. Balance rule: a deposit nets to
amount - amount_refunded - amount_reversedwhilecreditedorreversed, 0 otherwise;
merge snapshots per deposit (the later status and the larger amounts win) whatever the order. - A refund with a transaction attached (
mark_paid) can no longer be canceled
(400 refund_unexpected_state); it isfailedwith the newfailure_reasons
transaction_dropped(its nonce consumed by another transaction at finality) or
transaction_not_found(never seen within 24 hours), after which a new refund can be
requested. A deposit's reversal cancels only its refunds without a transaction. mark_paidtakesreceipt_log_index, the paying log's position in the transaction's receipt,
and the refund reports it, replacing the block-widelog_index.- Per account and mode, the credit of deposits credited but not final is capped
(max_unfinalized_credit, 100 000 cents by default, set by the operator with
POST /v1/admin/accounts/{account}); a deposit past it stayspendingand is credited once
final.
- Deposits carry
-
API conformance with Stripe (docs/design/multi-tenant.md, "API conformance" amendment):
- Business-state failures are
400(deposit_not_final,deposit_not_refundable,
quote_unexpected_state,quote_payment_received,quote_window_closed,paused,
chain_frozen,treasury_not_set,treasury_change_pending,treasury_unchanged,
treasury_unexpected_state,exposure_cap_exceeded,deposit_address_cap_exceeded,
webhook_endpoint_cap_exceeded,webhook_endpoint_disabled,deposit_address_retired,
refund_unexpected_state,transfer_already_used,api_key_inactive,last_api_key);409
is onlyidempotency_key_in_use. The unused genericconflictcode is gone. The admin API's
signature_replayedis401. - A customer's quote-creation and deposit address rotation limits are
429 customer_rate_limit
(wasrate_limit); every429carriesRetry-After. - Every response carries
Request-Id: req_…, replacingx-request-id. - An
Idempotency-Keysaves the result of every request that started executing,500s
included, and replays it; a request that failed validation (parameter_*), was rate limited,
or met503 unavailableis not saved. Validation failures were saved before,500s were not. - Event
data.objectis rendered in the transaction of the change, not at the first delivery or
read, and never changes. - Treasury events are
treasury.created(every proven treasury, pending or at once active),
treasury.updated(a pending treasury took effect, or was replaced), andtreasury.canceled,
replacingaccount.treasury.pending|updated|canceled; stored events and endpoints'
enabled_eventsare renamed. - A quote's address salt is
keccak256(abi.encode(account, client_reference_id, "quote", quote_id))(was tagged"lock"). - Admin paths:
POST /v1/admin/reconciliation_blocks/{block_key}/liftand
GET /v1/admin/reports/daily(werereconciliation-blocksandreport/daily); a route pause
names its path parameter{route}. - The OpenAPI document
openapi.jsonis the merchant API only; the admin API is
openapi.admin.json(also served at/openapi.admin.json). Each object'sobjectis a
single-value enum.
- Business-state failures are
-
API vocabulary (docs/design/multi-tenant.md §16 PR 10, and the Stripe-conventions audit): the
merchant's customer isclient_reference_ideverywhere (POST /v1/quotes, quotes, deposits,
GET /v1/deposits?client_reference_id=, and the admin path
/v1/admin/accounts/{acct}/customers/{client_reference_id}/pause|resume). -
Deposit
statusispending(recorded at the route's confirmation, being valued and
screened, or held by asettlementpause),credited,rejected, orreversed; the new
booleansfinal(its block is final) andswept(a finalizedFlushedevent after it moved its
forwarder's balance) replace thedetected,confirmed, andsweptstatuses. The status filter
takes the new values. -
A quote's
paymentis the sharedPaymentobject:statusseenorrecorded(wasfinal),
withchain_idandasset;matches_quoteisnullon a deposit address. -
GET /v1/attestation'squoteistdx_quote(and so istopup attest's output). -
GET /v1/admin/deposits/{id}returns theDepositwith anadminobject (state, route,
transitions,events); the separate admin deposit shapes are gone. -
livemodeandmetadataare required on every object in the OpenAPI document. -
ForwarderFactorybounds the gas of every call a target makes (BALANCE_OF_GAS30 000 for a
token'sbalanceOf,FLUSH_GAS200 000 for a forwarder'sflush), so a token whose
balanceOfreverts, burns gas, or returns short data, or a transfer or treasury hook that burns
gas, emitsFlushFailedfor its own target only; aflushwhose gas cannot cover a call's
whole bound reverts withInsufficientGas. The factory and implementation addresses change
(deploy/CONTRACTS.md). -
Events carry
request: {id, idempotency_key}(the request that caused them;nullfor the
service's workers), and every*.updatedeventdata.previous_attributes. New events
refund.created,refund.updated(marked paid, canceled, succeeded, failed, metadata), and
quote.canceled. -
Delivery health: webhook endpoints report
pending_deliveries,oldest_pending_at, and
last_attempt {at, status_code};GET /v1/eventstakesdelivery_successandtypes[]; the
admin daily report listsfailing_webhook_endpointsolder thanfailing_for_hours(24). -
Error objects carry
doc_url, the code's section of the API reference
(https://phala-network.github.io/phala-pay/), built with Redoc fromopenapi.jsonand
published frommain. -
GET /v1/api_keysandGET /v1/treasuriestakelimit,starting_after, andending_before;
GET /v1/depositstakescreated[gt]andcreated[lt]besidecreated[gte]and
created[lte], all compared at whole seconds. -
The OpenAPI documents have
servers,tags, and an example of every object and body. -
Restore mode (docs/design/multi-tenant.md §13, architecture §14): after a restore from backup
the service is frozen until the operator has reconciled it. Every request with an API key
answers503 service_restoringwithRetry-After: 300, reads included, and no event is
delivered and no deposit credited meanwhile. Merchants give the operator their records since the restore point
(integration guide §5.12). Operators:GET /v1/admin/restoreand
POST /v1/admin/restore/{api_keys/revoke, treasuries/verify, webhook_endpoints/delete, deposit_addresses, quotes, events, delivered_credits/discard, unfreeze}
(deploy/runbooks/restore.md);treasuries/verify
re-applies lost treasury cancellations and the merchant's crediting pauses and resumes. On a restore-check
instance, writes other than these answer503 service_restoring(was503 unavailable). -
POST /v1/account {confirmation_policies}requires, per chain, a confirmation stricter than the
route's floor (a depth,safe, orfinalized), applied to every deposit not credited yet;
GET /v1/configreports the effectiveconfirmationsandtypical_credit_seconds, and the
account lists itsconfirmation_policies.POST /v1/account/pause|resume {scopes: ["quotes"]}
pauses the merchant's own quotes and deposit addresses; an operator's pause stays until the
operator lifts it. Both announceaccount.updated. -
GET /v1/balance(per chain and token, unswept and final unswept amounts),GET /v1/sweeps
(finalizedFlushedevents assw_…objects), andGET /v1/forwarders(fwd_…, every issued
address with itsfactory,salt,treasury,quoteordeposit_address, and
superseded_at;sweepable=<token>lists only forwarders safe to sweep, never one holding a
sanctioned deposit or paying a sanctioned treasury). -
GET /v1/quotesandGET /v1/refundslists. -
Deposit addresses carry
payments(the last 24 hours,seenwithin about a block, then
recorded) and each create or rotation returns aclient_secret; with it and no API key,
GET /v1/deposit_addresses/{id}?client_secret=returns the customer'sClientDepositAddress. -
ClientQuote.livemode, andpayment_status: "reversed". -
Treasuries through the API (docs/design/multi-tenant.md D10, §16 PR 7).
POST /v1/treasuries/challenge {chain_id, address}returns an EIP-4361treasury_challenge
(message,nonce,expires_at; single-use, 10 minutes, or 24 hours for an address that holds
code);POST /v1/treasuries {chain_id, message, signature}sets the chain's treasury in the key's mode when the signature is the
address's EIP-191 signature, or a contract deployed at the address returns0x1626ba7efrom
EIP-1271isValidSignatureatfinalizedon both providers (ERC-6492 and undeployed contracts
are refused; the address is screened for sanctions).GET /v1/treasuries,
GET /v1/treasuries/{id}, andPOST /v1/treasuries/{id}/cancel. Atreasury(trs_…) has
chain_id,address,kind(eoaorcontract),status(pending,active,replaced,
canceled),effective_at,replaced_at,canceled_at, andcancellation_reason
(requested, orsanctionedwhen a sanctions list named it at its effective time). Current
treasuries are screened again daily; a listed one pauses the account'squotesand
settlement. Safe owners sign the challenge as a Safe message (EIP-712SafeMessage, the
Safe{Core} SDK'ssignMessage), or approve it withSignMessageLib. A chain's first treasury and
test-mode changes apply at once; a later live change applies after 48 hours unless canceled.
New eventsaccount.treasury.pending,account.treasury.updated, and
account.treasury.canceledgo to every enabled endpoint of the mode whatever its
enabled_events. When a change applies, the chain's network of every deposit address moves to
a forwarder over the new treasury; the old address stays credited and pays the old treasury.
New errors:treasury_proof_invalid,treasury_challenge_expired,treasury_challenge_used,
treasury_not_deployed,treasury_sanctioned,treasury_change_pending,
treasury_unchanged,treasury_unexpected_state, andtreasury_not_set. -
Quotes carry
treasury, the treasury their address pays. -
Admin
POST /v1/admin/accounts/{account}/pauseand/resume{scopes, reason}: pause or resume
scopes of a whole account in both modes, audited and announced asaccount.updated. -
Webhook endpoints managed by the merchant (docs/design/multi-tenant.md D11, §11, §16 PR 8):
POST /v1/webhook_endpoints {url, enabled_events, description?, metadata?},
GET /v1/webhook_endpoints(cursor pagination),GET|POST|DELETE /v1/webhook_endpoints/{id}
(disabled: true|falsedisables or re-enables), andPOST /v1/webhook_endpoints/{id}/test
(awebhook_endpoint.testevent to that endpoint only). At most 16 per account and mode
(409 webhook_endpoint_cap_exceeded);urlishttpson port 443, or in test mode alsohttp
on port 80. The object carrieslivemode,url,enabled_events(types or["*"]),status
(enabled,disabled),disabled_reason(gone),description, andmetadata. -
Events API:
GET /v1/events?type&created[gt|gte|lt|lte](a type, or a group such as
deposit.*; cursor pagination),GET /v1/events/{id}, and
POST /v1/events/{id}/resend {webhook_endpoint}(the same event to one enabled endpoint;
409 webhook_endpoint_disabledotherwise). Events carryactor(key_…,admin, or
system) in the API and in webhook bodies, andpending_webhooksin the API: the audit log. -
Account events
webhook_endpoint.created,webhook_endpoint.updated(with
data.previous_attributes), andwebhook_endpoint.deleted. Account events (account.*,
api_key.*,webhook_endpoint.*) reach every enabled endpoint of the mode whatever its
enabled_events, and a changed or deleted endpoint receives the event about itself first, at
its previous URL.
Permissionsendpoints.read,endpoints.write, andevents.readare enforced. -
Deposit addresses (docs/design/multi-tenant.md "Deposit addresses"), restored per the owner's
2026-09-21 requirement, with one address per customer for every supported token on every chain
(the owner's 2026-09-28 decision, exchange practice).POST /v1/deposit_addresses {client_reference_id}returns the customer's activedeposit_address(da_…), issuing it the
first time and adding a network supported since;GET /v1/deposit_addresses/{id},
GET /v1/deposit_addresses?client_reference_id&status(cursor pagination), and
POST /v1/deposit_addresses/{id}/rotate, which retires it and returns the next version, a new
address on every chain. The object carrieslivemode,address(the address shared by every
network, ornullwhen a network's treasury, and so its address, differs),version,salt
(keccak256(abi.encode(account, livemode, client_reference_id, "deposit_address", version)), no
chain or asset),status(activeorretired),created,retired_at,metadata(set on
create by merging, updated byPOST /v1/deposit_addresses/{id}, carried by rotation, and copied
to each deposit to the address), andnetworks: per chain of the mode,chain_id,address,
treasury, andassets(asset,contract,decimals, and an EIP-681payment_uriwithout an
amount). A transfer of any supported token to an active or retired deposit address is credited
at spot through the quote pipeline, in about 30 seconds; an unsupported token is rejected as at
a quote's address. Its deposit hasquote: nulland the new fielddeposit_address, and
GET /v1/depositsfilters bydeposit_address. A treasury change on one chain changes only that
chain's address; the old one stays credited and its refunds are paid from the old treasury. New
errors:409 deposit_address_cap_exceeded(active addresses per account and mode, default
100 000 live and 1 000 test),409 deposit_address_retired, and429 rate_limitpast 10
rotations per customer per hour. New addresses are refused with409 pausedwhilequotesis
paused, and with409 chain_frozenwhen every chain is frozen; a frozen chain gets no new
network.
Permissionsdeposit_addresses.readand.writejoin the authorization table. -
POST /v1/account/webhook_keys/roll {expires_in}(docs/design/multi-tenant.md D11, §16 PR 6):
the next version of the mode's webhook key signs every delivery, and the current one keeps
signing beside it for up to 7 days (0stops it at once), so each delivery carries onev1a
entry per key.GET /v1/accountlists the versions aswebhook_keys(version,expires_at);
the roll is announced asaccount.updated. Needsaccount.write. -
livemodeon quotes, deposits, refunds, and/v1/config; events carryaccount(acct_…) and
livemode. -
GET /v1/account;GET|POST /v1/api_keys,GET|DELETE /v1/api_keys/{id}, and
POST /v1/api_keys/{id}/roll {expires_in}(the old key works for up to 7 days;0revokes it). -
Events
api_key.created,api_key.updated,api_key.revoked, andaccount.updated; every
event records itsactor(an API key id,admin, orsystem). -
Stripe-style
metadataon quotes, deposits, and refunds (docs/design/multi-tenant.md D15,
docs.stripe.com/api/metadata): up to 50 string
key/value pairs, keys of up to 40 characters without square brackets, values of up to 500
characters. Set it withmetadataonPOST /v1/quotesandPOST /v1/refunds; update it with
the newPOST /v1/quotes/{id},POST /v1/deposits/{id}, andPOST /v1/refunds/{id}, which
merge (""unsets a key,metadata: ""unsets all). Invalid metadata is
400 parameter_invalidwithparammetadata[key]ormetadata. A deposit starts with a
copy of its quote's metadata, so it arrives indeposit.credited'sdata.object. Objects and
webhook payloads always carrymetadata({}when empty); theclient_secretview does not.
Secret keys gaindeposits.write. Do not store sensitive information in metadata. -
Accounts and tenancy (docs/design/multi-tenant.md §14, D13, PR 3). The tenant is an account,
acct_…. Its request signing key id is{acct_…}/v1, and the key is live or test: it quotes on
the routes of its mode and reads only its mode's objects. Another account's object, or the same
account's object in the other mode, answers404like a missing one. Webhook events go to every
enabled endpoint of the event's account and mode. -
POST /v1/admin/accounts {name, livemode, public_key, webhook_url}issues an account and
PUT /v1/admin/accounts/{account}replaces its key and webhook URL;
POST /v1/admin/accounts/{account}/customers/{customer}/pause | resumepauses one customer.
The admin deposit view carries the deposit'saccountandlivemode. -
Fast credit and reversal (docs/design/multi-tenant.md §4, D1). A route's
chain.confirmations(a depth,safe, orfinalized, per chain family; default 2 on
Ethereum L1,safeon OP-stack,finalizedelsewhere) sets when a deposit is credited: at the
default,deposit.creditedis sent about 30 seconds after paying instead of about 15 minutes.
Deposits are watched to finality. A transaction re-included in another block keeps its deposit
and is followed; one proven dropped (its nonce consumed by another transaction), or whose
transfer is missing from its final receipt, makes the depositreversedand sends
deposit.reversed(event iduuid_v5(NS, "deposit.reversed:" + deposit UUID)) when the
deposit was reported credited or rejected. Claw the credit back as fordeposit.refunded. A
quote the deposit completed opens again while its window lasts, otherwise expires with
quote.expired.confirmations: finalizedkeeps the earlier behaviour. -
GET /v1/configassets carryconfirmations("2","safe", or"finalized") and
typical_credit_seconds; the admin deposit view carriesreceipt_log_indexandfinal_at. -
POST /v1/refundsanswers409 deposit_not_finalfor a deposit that is not final yet, so
nothing is paid back for a payment that could still be reversed.
Changed
-
Breaking: the SDKs of this release are
@phala/pay0.3.0 andphala-pay0.3.0
(sdk/js/CHANGELOG.md, sdk/python/CHANGELOG.md):
webhook keys only aswhpk_…, and the event envelope'sactorandrequest, a deposit's
receipt position, revision, and block, and a client view'sconfirmationsrequired. -
Breaking:
GET /v1/attestationlists each webhook key'spublic_keyin Standard Webhooks'
form,whpk_and the standard base64 of its 32 raw bytes, whichreport_databinds; it was
lowercase hex, andstandard_webhooks_public_keyis gone. Pin the keys in this form: both SDKs'
verifiers take no other.topup attestprints them the same way. -
Breaking: a deposit's
receipt_log_index,revision,block_hash, andblock_timeare
required in the OpenAPI schema, andPOST /v1/admin/restore/eventsrefuses a deposit event whose
snapshot lacks them;reversed_depositis neveridentity_missing. -
Breaking: Admin:
POST /v1/admin/restore/eventsand/treasuries/applytake event ids only
asevt_…, as every delivery'swebhook-idis. -
Breaking: Admin: the deposit view and
nudgetake a deposit only by itsdep_…id; a bare
UUID is404. The service's logs, and a nudge's audit subject, name deposits, events, refunds,
and an outbox delivery's object by their prefixed ids (dep_…,evt_…,re_…), the form the
API takes. -
Breaking for operators: the database enforces what the service reads as constraints
(20261023000000_current_invariants): a deposit carries its transaction'stx_fromand
tx_nonceunless it is a reversed deposit restored from a delivered event, every event'sdata
holds itsobject, and a deposit event's snapshot itsreceipt_log_index,revision,
block_hash, andblock_time. A row that breaks one failstopup migrate. -
Breaking: a client quote's
confirmations, and a client deposit address payment's, are
required in the OpenAPI schema, an integer ornull, as the service always sends them. -
The reference product applies a
deposit.*event only with a valid deposit snapshot: a
deposit.creditedwithout one is logged and credits nothing. -
Breaking for operators: Deploy is a reusable workflow that deploys a release (
version),
not a Release images run (release_run_id), from the caller'senvironment_dir; Phala's
instance deploys through "Deploy Phala's instance". Images are published only by the Release
workflow, from a tag, tagged with the version. -
deploy/contracts/verify-deployment.shcompares each chain with the committed reference
deploymentdeploy/contracts/reference.json, which CI checks against a fresh build
(reference-manifest.sh --check), so it needs no Solidity build and runs from the kit. -
The example environment's header no longer names a value preflight refuses, so a filled-in copy
that keeps its comments passes. -
While the service is frozen after a restore, a merchant request without a well-formed API key
answers401 api_key_missingor401 api_key_invalid, as when not frozen (it answered
503 service_restoring): the key's form and checksum are checked first, without a database
read. A well-formed key still answers503 service_restoring, reads included, and nothing is
saved for itsIdempotency-Key. -
An
Idempotency-Keyolder than 24 hours is pruned in the background, not while aPOSTclaims
its own key, so no request pays for pruning every account's keys; such a key is still free for
any request. -
Breaking: A key rolling itself (
POST /v1/api_keys/{id}/rollwith the
requesting key's own id) must keep working for at least an hour:expires_inunder3600,
including the default0, is400 parameter_invalid. A replay never returns the new key's
secret, so an immediate self-roll whose response was lost locked the account out; now the old
key rolls the new one (its id is in the replay) to recover. To stop the old key sooner, revoke
it with the new key. Another key may still roll a key withexpires_in: 0. -
Breaking: A
client_secretis{id}_secret_{nonce}{tag}, 64 lowercase hex
digits after_secret_(was 48), wheretagis the service's HMAC of everything before it. A
forged or malformed secret is refused in memory (404) without touching the database or any
budget, so forgeries can no longer throttle checkout polling; a genuine secret is limited to 120
reads per minute of its quote or deposit address, withRetry-After. -
Breaking: Route files no longer take
unit_decimals: credit is always USD
cents, the API'samount. A route file that sets it is refused. -
Breaking: Open-quote caps are per account and mode only (design §12), set by the operator per account
and mode (defaults: 1 000 open quotes, $50 000 of open quotes per account, $5 000 per customer
in live mode; 100, $10 000, and $5 000 in test mode). There is no global cap, and test-mode
quotes never count against live mode.GET /v1/configreports the effective caps:
max_open_quotesandmax_open_amount_per_customerare new, andmax_open_amount_per_account
is now the account's cap in the mode (it was the per-customer cap).400 exposure_cap_exceeded
also answers a quote pastmax_open_quotes. Route files no longer take
limits.max_open_minor. -
Breaking: Admin:
POST /v1/admin/accounts/{account}takeslimits {livemode, max_open_quotes, max_open_amount_per_account, max_open_amount_per_customer, max_active_deposit_addresses}, and
the admin account response carries the effectivelimitsof both modes. -
Breaking: Deploy runs one production deployment for both modes: every route's
livemodemust match its
chain (live on a mainnet, test on a test network), and staging takes no live route
(deploy/check-route-modes.sh); production no longer requires every route to be on chain 1. -
Breaking: while the service is frozen after a restore from backup, no API
key authenticates, reads included: every request with a key answers503 service_restoring
withRetry-After(only writes did). The restored database can hold a key you revoked after the
restore point as valid; keys work again once the operator has revoked such keys again and
unfrozen the service. A quote's or deposit address'sclient_secretread, the admin API, and
/healthzare unaffected. A restore-check instance refuses every merchant key, whether or not
the freeze is recorded yet. -
Breaking: Admin:
POST /v1/admin/restore/eventstakesdeliveries, each
delivery as the merchant's receiver got it (webhook_id,webhook_timestamp,
webhook_signature, and the rawbody), instead of bare event objects, and imports only
deliveries whosev1asignature verifies with the account's webhook keys. -
Breaking: quotes and deposit address networks pay the account's treasury of the chain, set
through the API, instead of the route's:POST /v1/quotesis409 treasury_not_seton a chain
without one, andPOST /v1/deposit_addressesissues networks only on chains with one (409 treasury_not_setwhen none has). Route files no longer havechain.treasury(a route file that
still names it is refused), and the admin daily report dropstreasury_balance_atomicand
treasury_balance_note. -
Breaking: webhook delivery (§16 PR 8). Retries still continue until delivered (backoff
capped at 1 h) and a failing endpoint is never disabled (owner decision);410 Gonefrom the
receiver disables its endpoint at once (disabled_reason: gone), announced to the account's
other endpoints aswebhook_endpoint.updated. A disabled or deleted endpoint's pending
deliveries stop; resend them withPOST /v1/events/{id}/resend. Each endpoint has at most 4
deliveries in flight, slots go round-robin across endpoints, a failing endpoint is probed one
delivery at a time after a backoff, and events are not ordered. Deliveries leave through an egress proxy (smokescreen) that refuses
addresses that are not publicly routable. -
Breaking: the operator no longer manages merchants' webhooks:
webhook_urlis removed from
POST /v1/admin/accountsandPOST /v1/admin/accounts/{account}(an unknown field is400),
andPOST /v1/admin/outbox/{event_id}/replayand thetopup outbox replaycommand are removed.
Existing endpoints are kept and are now managed through/v1/webhook_endpoints. -
Breaking: webhooks are signed with a key per account and mode (docs/design/multi-tenant.md
D11, §16 PR 6), derived in the attested CVM atsettlement/{acct}/{live|test}/v{n}, instead of
the one sharedsettlement/v1key: an event signed for one account never verifies at another.
GET /v1/attestation?nonce=now needs an API key (401without) and returns{object: "attestation", account, livemode, webhook_keys: [{version, public_key, expires_at}], report_data, quote}, wherereport_data = sha256(len(nonce) ‖ nonce ‖ len(account) ‖ account ‖ livemode ‖ (version ‖ public_key)*);keyidandsettlement_pubkeyare removed. Pin your
account's key per mode and check the event'saccountandlivemode. Test and live events
are delivered by separate workers. -
Breaking: API keys replace RFC 9421 request signing for merchants
(docs/design/multi-tenant.md D7, D8, D12, §16 PR 5). SendAuthorization: Bearer ppay_sk_test_…orppay_sk_live_…; the key selects the account and the mode. A missing,
invalid, or revoked key is401 api_key_missingor401 api_key_invalid, a rolled key past its
expiry401 api_key_expired, and a live key of an account not enabled for live mode
403 testmode_charges_only. Requests are limited per account and mode (100/s live, 25/s test,
with a test-mode platform ceiling):429 rate_limit. -
Breaking: every
POSTis idempotent byIdempotency-Keyfor 24 hours per account and mode:
a repeat of the same request replays the first response (Idempotent-Replayed: true), another
request with the same key is400 idempotency_key_reused(was409), and a repeat while the
first runs is409 idempotency_key_in_use. A repeated quote creation now returns the same
client_secretinstead of a new one. -
Breaking: accounts are created only by the operator:
POST /v1/admin/accounts {name, contact, due_diligence, charges_enabled, reason, webhook_url?}returns the first secret keys;
POST /v1/admin/accounts/{account}(wasPUT) updates live mode, the restricted flag, the
contact, or the webhook URL, and enabling live mode returns the first live key;
POST /v1/admin/accounts/{account}/api_keys {livemode, revoke_existing, reason}issues a
recovery key. Customer pauses takelivemode. -
Every issued address is scanned at every block, not only open quotes' addresses: a late,
repeated, or wrong-amount payment, or one to a persistent address, is credited at the route's
confirmation (about 15 seconds after inclusion on Ethereum at depth 2) instead of at finality,
and shows asseenin the quote'spaymentmeanwhile. RPC usage no longer grows with polling
(docs/architecture.md §8): oneeth_blockNumberper block time and oneeth_getLogsper new
block per chain, whatever the number of addresses; the admin-signedGET /v1/admin/metrics
reports the calls per provider, chain, and method (deploy/README.md, "Measuring RPC usage"). -
Breaking: refunds are paid by the merchant (docs/design/multi-tenant.md D5, PR 9), in
BTCPay's two-step payout flow.POST /v1/refundscreates apendingrefund of a final deposit
and reserves its amount; its destination is screened for sanctions
(400 destination_sanctioned,503 unavailablewhen screening cannot answer). Pay it from the
refund's newtreasuryfield (the treasury of the deposit's own address, not the account's
current one), then attach the transaction withPOST /v1/refunds/{id}/mark_paid {transaction_hash, log_index?}. At finality on both providers, aTransferof the deposit's
token from that treasury to the destination for exactly the amount, in a log no other refund
holds, makes the refundsucceededand sendsdeposit.refunded; anything else makes itfailed
with afailure_reason, releases the reservation, and sendsrefund.failed(Stripe's
event; event iduuid_v5(NS, "refund.failed:" + refund UUID),data.objectthe refund).POST /v1/refunds/{id}/cancelcancels
a pending refund; a reversed deposit cancels its pending refunds. The Refund object gains
treasury,failure_reason, andlog_index, renamestx_hashtotransaction_hash, and its
statusis Stripe'spending,succeeded,failed, orcanceled. New409codes:
refund_unexpected_state,transfer_already_used. The operator's
POST /v1/admin/refunds/{id}/approveand/recordare removed, and the daily report's
refunds_by_statuscounts the new statuses. -
Breaking: the service sends no transactions (docs/design/multi-tenant.md §5, §13, PR 4).
Anyone, usually the merchant with its own wallet or Safe, sweeps forwarders with the
permissionless factory'sflush(treasury, salts, token)and pays the gas. The finalized scanner
indexes the factory'sForwarderCreated,Flushed, andFlushFailedevents for the account's
own(address, treasury)pairs, whoever sent them, and a final credited deposit becomesswept
once a finalizedFlushedevent follows it. AFlushFailedtarget keeps its balance and its
deposits staycredited. Reconciliation compares every active forwarder's finalized balance
with its deposits minus its finalized sweeps; a mismatch freezes crediting on the chain
(409 chain_frozen) until the operator lifts it. -
Breaking: attestation binds only the settlement key:
GET /v1/attestationdrops
operators,report_dataissha256(nonce ‖ settlement_pubkey), andtopup attestdrops
--routeand--operator-key-versionand itsoperators,operator_keyid, and
operator_addressfields. -
Breaking: route files drop
chain.operator_key_version,chain.flush,
limits.min_flush_atomic, andalerts.stuck_after_s.credited(a credited deposit waits for
its merchant's sweep); the pause scopeflushis gone. The daily report dropsflush_planning,
and itsunflushed_balance_atomicis deposits not reversed minus finalizedFlushedamounts. -
Breaking: route files drop
productand requirelivemode, checked against the chain. -
Breaking: products are gone.
POST /v1/admin/products,PUT /v1/admin/products/{slug},
andPOST /v1/admin/products/{slug}/accounts/{account_id}/pause | resumeare replaced by the
account endpoints above; the quote address salt's first input is theacct_…id instead of the
product slug. -
A quote's
account_idholds 1 to 200 characters (was 255 bytes). -
Breaking: a deposit id is
uuid_v5(NS, "{chain_id}:{tx_hash}:{receipt_log_index}"), the transfer's position among its transaction's
receipt logs (0 for a plain token transfer), instead of the block-widelog_index, so a
re-included transaction keeps its id.log_indexandblock_numberstay on the deposit as
evidence and change when the transaction is re-included. -
Deposit
statusgainsreversed; the quote'spayment.statusfinalnow means recorded at
the route's confirmation, and the payer'spayment_statusconfirminglikewise. -
POST /v1/products/{p}/accounts/{ext}/rate-locksandPOST …/deposit-addresscreate the
account when it does not exist, so a quote or an address is one call;POST …/accountsis no
longer required. Reads (GET, rotate, cancel, pause) of an unknown account still answer404. -
DepositResponse(deposits, deposit, and support lookup) carriesexternal_id, the account
of the receiving address, andprice_source(lockorspot). -
PUT /v1/admin/products/{slug} {public_key, webhook_url, reason}(administrative API) replaces
an issued product's verification key and webhook URL, whichPOST /v1/admin/productsrefuses
with409. The key id stays the route'sdestination.product_kid. The cut is immediate: the
old key stops verifying when the change commits, with no overlap. Theauditrow
(product.update) carries the reason and the replaced values; a repeat with the stored values
changes nothing. An unknown slug is404, an unrouted slug or invalid value400. -
POST /v1/admin/reconciliation-blocks/{block_key}/lift {reason}lifts a reconciliation block
(chain:{chain_id}oraddress:{address_id}), which production could not do without a database
owner session. Lifting is manual: the reconciler blocks again if the finding still reproduces.
Theauditrow (reconciliation_block.lift) carries the reason and the removed block; a repeat
returns the first lift, and a key that never blocked is404.GET /v1/admin/report/daily
lists the activereconciliation_blocks. -
POST /v1/admin/outbox/{event_id}/replay {reason}queues an existing webhook event for
delivery again with the same id and payload (audited asoutbox.replay); a repeat while the
event is due changes nothing. The product-signed support lookup
(GET /v1/products/{p}/deposits?tx_hash=|address=|lock_ref=) lists each deposit's webhook
events(id,event_type,created_at,delivered_at). -
GET /v1/admin/report/dailyreturnsexposure_minor, the global open rate-lock credit in
destination minor units (#94). -
Breaking: webhooks are Stripe's Event object,
{"id": "evt_…", "object": "event", "type", "created", "data": {"object": …}}, wheredata.objectis the deposit (deposit.credited,
deposit.rejected,deposit.refunded) or the quote (quote.expired, which replaces
rate_lock.expired) as the API returns it, rendered at the first delivery attempt. The
webhook-idis theevt_id, derived for every type from the event type and its object, so
every re-emission deduplicates.deposit.pendinganddeposit.confirmedare no longer sent:
the quote'spaymentshows a transfer before finality. The admin outbox replay and deposit view take and show
evt_ids. -
Breaking: quotes replace rate locks (docs/architecture.md §9, §12).
POST /v1/quotes {account_id, amount, currency, chain_id, asset},GET /v1/quotes/{id}, and
POST /v1/quotes/{id}/cancelreplace…/accounts/{ext}/rate-locks[/{ref}]; the quote id
(qt_…) replacesproduct_lock_ref,Idempotency-Keymakes creation safe to retry, amounts
are integer cents withcurrency: "usd", timestamps are Unix seconds, and statuses areopen,
complete,expired, andcanceled. A new quote's address salt uses its id as the reference.
Quoting by token amount is removed.GET /v1/configlists the payable assets, limits, and
quote terms;POST …/accountsandGET …/accounts/{ext}/limitsare removed (the first quote or
address creates the account). -
Breaking: quotes are the only flow.
GET|POST …/accounts/{ext}/deposit-address,
…/deposit-address/rotate,GET …/accounts/{ext}/pending-deposits, and theaddressespause
scope are removed. -
Breaking: deposits and refunds are top-level resources.
GET /v1/deposits(a Stripe list
object withstarting_after/ending_before/limitand filtersaccount_id,quote,status,
tx_hash,created[gte|lte]) andGET /v1/deposits/{id}returnDepositobjects (dep_ids,
status,amountin cents,exchange_rate,price_sourcequoteorspot,quote,
amount_refunded_atomic,refunded, Unix timestamps);POST /v1/refunds {deposit, destination_address, amount_atomic?}withIdempotency-KeyandGET /v1/refunds/{id}return
Refundobjects (re_ids, statuspendingorsucceeded).expand[]expands a deposit's
quote, a quote'sdeposit, and a refund'sdeposit. The old deposit list, deposit, support
lookup, and refund-request paths are removed; the operator'sGET /v1/admin/deposits/{id}shows
a deposit's transitions and webhook events, and account pause and resume move to
POST /v1/admin/products/{slug}/accounts/{account_id}/pause|resume. -
POST /v1/quotesreturns aclient_secret, like Stripe's PaymentIntent. The payer's browser
reads the quote's public view,ClientQuote, fromGET /v1/quotes/{id}?client_secret=…without
a signature (any origin; rate-limited). Only the secret's hash is stored:GETreturnsnull,
and a repeat with the sameIdempotency-Keyreturns a new secret. -
Breaking: the product is identified by the request signature's key id,
{product}/v1,
not by the path. -
Breaking: errors are Stripe's error object,
{"error": {"type", "code", "message", "param"}}, with Stripe-style codes (parameter_invalid,resource_missing,
signature_invalid,rate_limit,idempotency_key_reused, …).pausedandchain_frozen
answer409instead of423. -
Route files name only what differs per route or environment (
route,version,product,
chain.{chain_id, forwarder_factory, treasury},asset.{symbol, contract, decimals},
pricing.{primary, check}, andlimits); every other value is a code default, overridable
under its key (architecture §14).topup route show FILEprints the resolved route. The
implementation defaults to the factory's firstCREATE, the sanctions oracle to Chainalysis's
address on chains that have one, and a product's key id is{product}/v1.finality,
destination.product_kid, andrate_lock.enabledare removed; thequotespause scope stops
quote creation. Staging's route moves to version 2. -
A new quote's
amount_atomic(and itspayment_uri) is rounded up to the route's
quote.amount_decimalstoken decimals, default 4, so the payer is asked for273.9185PHA
rather than 18 decimals. The rounding overpays by less than one unit of the last decimal; the
quote'samountcredit is unchanged. -
The admin deposit
nudgeand refundapprove/recordpaths take thedep_andre_ids the
product API returns, as the admin deposit view and outbox replay already did. Their responses (AdminRefundResponse.id,NudgeResponse.deposit_id, and the
deposit view'sid) show the prefixed id instead of the UUID. -
A malformed path parameter, query string, or JSON body on any route, product or admin, answers
the400error object (parameter_invalid,parameter_missing, orparameter_unknown, with
param) instead of plain text; the admin routes' JSON bodies were plain text before. -
Breaking: webhook fulfillment replaces the settlement protocol
(architecture §7, §11; integration guide §5). A deposit that passes screening iscredited
directly (confirmed → credited; theclearedstate is gone), anddeposit.creditedis the
fulfillment event: the product creditsamount_minortoexternal_idonce per deposit id and
answers2xx. Its payload is nowproduct_id,external_id,deposit_id,state,unit,
amount_minor,price_source,price_scaled,price_scale,valuation_at,
product_lock_ref(the receiving address's lock, also for spot-priced payments),address,
route,route_version,chain_id,asset_contract,tx_hash,log_index, and
amount_atomic(nodestination_tx_id), and itswebhook-idis
uuid_v5(DEPOSIT_NAMESPACE, "deposit.credited:<deposit_id>"), the same on every delivery and
after a restore. Deliveries retry until2xx. The service no longer sends
POST {settlement_url}orGET {settlement_url}/{key}, and no deposit becomes
rejected(product_refused)any more: a product refuses a credit by holding it and requesting a
refund. Allowed inside/v1without a deprecation window because no product consumed the
settlement protocol in production (owner decision on #143). -
The attested route's
destination.settlement_urlis removed (routes that set it no longer
load). A product'swebhook_urlmay behttponly when the service's own public origin is. -
GET /v1/admin/report/dailyroute entries replacesettlements_by_statuswith
credited_undeliveredandcredited_undelivered_max_age_seconds:deposit.creditedevents the
product has not acknowledged yet (administrative API). -
POST /v1/products/{p}/deposits/{id}/refund-requestsacceptscreditedandsweptdeposits
too (still notsanctioned, still at least the route'smin_refund_atomic): a product asks to
refund a credit it did not apply or has reversed, for example for a closed workspace; finance
approves every request (integration guide §5.4). -
Settlement conformance: the
unknown_getcase now requires404forGETof an unknown
settlement key and fails200 {"status":"unknown"}, which it used to accept. The service
resends a settlement only after a404by key; the other answer made it poll without ever
resending.topup-conformance-reference --broken unknown-statusanswers the old form and fails
exactly that case. -
Breaking (base URL): the service is served on a custom domain,
https://crypto-topup-api.phala.com
(staginghttps://crypto-topup-api-staging.phala.com), with TLS terminated inside the CVM and
its certificate evidence at/evidences/. Sign@target-urifor that origin; the gateway URL
https://<app_id>-8080.<gateway domain>no longer answers. -
Removed the unreachable
501response from/v1/attestationand thework_packageerror field
fromopenapi.json; both belonged only to the pre-C11 placeholder, and production never
returned them (#90). -
Rate locks carry an optional
paymentobject, chosen by the lock consumption rule: the
deposit that consumed the lock, otherwise the first payment that would consume it, otherwise
the first payment.statusis"seen"while it is abovefinalized(withconfirmations
andestimated_final_at, block time plus 15 minutes) and"finalized"once it is a deposit
(deposit_idlocates it);supported,in_time, andamount_within_tolerancedescribe it
against the lock and are false on a cancelled lock. -
New
GET /v1/products/{p}/accounts/{ext}/pending-depositslists transfers to the account's
persistent addresses seen abovefinalized. They are not deposits and are not credited; once
final they leave this list and appear underdeposits, and a reorg can remove them.
See docs/architecture.md §8 and §12. Both are display only: crediting is unchanged and still
happens only from two-provider finalized data.
-
New event
deposit.pending, sent at most once per chain event when a non-zero transfer of a
routed token to a watched address is first seen abovefinalized. Its payload is marked
provisional: true; it never changes a balance, and the transfer may still disappear in a
reorg. It may arrive afterdeposit.creditedfor the same deposit, so act on fetched state,
not event order. Receivers that do not handle it must ignore it, as with any unknown event
type. -
deposit.creditedanddeposit.rejectedpayloads now includechain_id,state
(creditedorrejected), androute(null when no route was selected);chain_idand
routematch the fields already ondeposit.confirmed. This covers every producer, including
the scanner'sunsupported_assetrejection. The change is additive; existing fields are
unchanged. Seedocs/architecture.md§12. -
A lock now expires by chain time:
rate_lock.expiredis emitted only once the finalized chain
has passedexpires_atand no payment mined inside the window awaits confirmation, so a payment
made in the last minutes of the window is consumed at the lock price and never reported as
expired. Until thenGET …/rate-locks/{ref}returnsstatus: openwith
remaining_seconds: 0; expiry events arrive about 15 minutes afterexpires_at. See
docs/architecture.md§9. -
DELETE …/rate-locks/{ref}on a lock whose payment window has closed but which has not yet
expired now answers409with the new error codewindow_closed("payment window has
closed") instead ofconflict.conflictremains for consumed or expired locks. -
GET …/limitsreset_atis the earliest payment-window close among open reserved locks. It can
be in the past: exposure is released only at chain finality, about 15 minutes later.
Removed
-
The Release images workflow and the fork requirement: nobody needs a fork or their own image
build to deploy (building from source stays possible and yields the samephala-paydigest). -
The reference product's
team_addressestable, written with each quote and deposit address but
never read: the quote and deposit address records hold them. -
The reference product's ledger migrations: it creates its one current schema in a new ledger
and reads no older one, so an existing ledger is reset (Phala's staging's is). -
Webhook events written before Stripe-style events (outbox format 1) and their old envelope;
every event is{id: "evt_…", object: "event", type, created, data: {object}}. -
The retired
rejected(product_refused)reason andclearedstate, and addresses issued before
quotes (persistent addresses). -
Settlement conformance suite (
topup-conformance,topup-conformance-reference,
docs/conformance.md,make product-conformance) and the reference product's conformance mode
(test accounts and the_conformance/ledgerhook). The settlement endpoint it tested is being
replaced by webhook fulfillment (integration guide §5); webhook receivers are
tested withtopup-sdk send-test-event. -
Breaking (administrative API):
GET /v1/admin/report/dailyroute entries no longer carry
exposure_minor,exposure_minor_reason,pnl_minor, orpnl_minor_reason(#94). They were
always null placeholders; route exposure now comes from the report-levelexposure_minor, and
PnL is not defined precisely enough in the design to compute.
Fixed
-
A
client_secret's 120 reads per minute of its quote or deposit address refill at two a
second instead of all at once when a one-minute window rolls over, so a page can no longer read
120 times just before a rollover and 120 more just after it. A read over the budget is retryable
afterRetry-After: 1rather than at the end of the window. -
Admin:
POST /v1/admin/restore/deposit_addressesrefuses,400, aversionmore than 32 past
the customer's latest one, before anything is issued: it issued every version between in one
transaction, however many, so a hugeversionheld its connection and locks until it exhausted
them. A customer further behind is re-issued in steps (version32, 64, …); anaddresswas
already looked for only that far. -
Admin: a restore across a treasury change no longer deadlocks the reconciliation. Deposit
addresses and quotes are re-issued over a treasury in force when they were issued, within 5
minutes (any since the restore point for a deposit address, around itscreatedfor a quote),
not only the current one, once the lost change is applied again withPOST /v1/admin/restore/treasuries/apply; before, they could be re-issued only over the restored
current treasury, while the change would apply only after the unfreeze those re-issues must
precede. Each re-issued deposit address version, by address or by version, keeps a superseded
network over every treasury in force since the restore point, still credited. A restore without
a restore point re-issues nothing, and a quote the restored database does not hold, created well
before the restore point, is refused (one it holds is returned,reissued: false). -
A quote's
createdis taken once its chain's treasury is read under the treasury lock, and a
time-locked treasury change recordsapplied_atas it applies under that lock rather than when
the time-lock's pass started, so each falls while the other's treasury is in force. -
Admin: a deposit reversed after the restore point because a re-included transaction put
another transfer at its receipt position keeps its identity through the restore. Its imported
deposit.reversedrebuilds it, reversed, at its revision, so the rescan records the final
transfer as its successor again, with the same id,replaces, and delivered credit; before, the
rescan recorded it under the reversed deposit's id, held ascontradicted, and the successor
could never be rebuilt. -
Admin:
POST /v1/admin/restore/eventsimports thedeposit.reversedof a deposit that was never
valued (rejected, such as a token without a route); it refused the whole request with400. A
deposit.creditedstill needs its valuation. -
Idempotent requests are atomic (architecture §12; Brandur Leach's
Stripe-like idempotency keys in Postgres): every
merchantPOSTsaves its response in the transaction of its changes, so a retry after a crash,
a dropped connection, or a request slower than a minute replays the result and never runs the
request twice (a quote, a partial refund, an API key, a webhook endpoint, or a webhook key roll
was created twice before). A key whose request never saved a response is still taken over by the
same request after a minute, and the request it replaced can no longer commit: it answers
409 idempotency_key_in_use. A request already making its changes commits, and the repeat
waits for it (at most 5 seconds, then409 idempotency_key_in_use) and replays its response. A
failure while rendering a response now creates nothing and is replayed as it failed (a quote was
created before). A request the database cannot begin, or rolls back on a deadlock or
serialization failure, is an unsaved503withRetry-After, as is one that finds no database
connection to claim its key (it was a500). -
Admin:
POST /v1/admin/restore/quotesand/deposit_addressesaccept aclient_secretonly
when the service issued it for that id to that account. The secret's nonce now carries an owner
tag of the account (its length and format are unchanged, and it is still opaque): before, any
account's record of a lost id with the owner's secret, which the payer's page holds, re-issued
it under that account and address, and the owner's payer page showed that address. -
Authorization runs before the idempotency lookup, as Stripe's: a restricted key no longer
replays a response to a request its permissions refuse, and a401or403is no longer saved,
so the same request by a key that holds the permission then runs. -
A payment made through a contract (a router, a swap output) whose transaction is re-included
before finality against other state, so that the transfer at the same receipt position pays
another amount or another issued address, is now recorded and credited. The first deposit is
reversed(deposit.reversedif you were told of it), and the transfer in the final chain is a
new deposit with a new id and its owndeposit.credited(ordeposit.rejected), already final,
whosereplacesnames the first one; a quote the first one completed goes to it without
quote.expired. The two deposits' events arrive in no set order: the balance rule nets them
whatever the order. The new transfer was never recorded, and custody reconciliation froze the
chain. Existing deposit ids are unchanged, and a transaction re-included unchanged keeps its
deposit. -
After a restore from backup, a deposit the merchant was told was credited keeps that credit: the
amount, exchange rate, price source, and valuation time of the importeddeposit.creditedor
deposit.reversedare the deposit's valuation when the rescan re-derives it, instead of a
re-valuation at spot, soamount,amount_refunded, andamount_reversedmatch what was
delivered. A deposit whose transfer on chain contradicts the delivered event is held, not
credited, until the operator discards the delivered credit. -
A token without a route sent to an issued address is again recorded as
rejected(unsupported_asset), with itsdeposit.rejectedevent, once final. Since the per-block
scanning change, routes in token mode (the default) never saw such transfers: the missing-deposit
check now reads every issued address's transfers of any token in both modes. -
A webhook endpoint's
pending_deliveriesandoldest_pending_at, and the admin daily report's
failing_webhook_endpoints, no longer count the notice of a URL change still pending at the
endpoint's former URL: it is not a delivery to the endpoint as it is now, so a former URL that
was taken down no longer makes the endpoint look unhealthy. The notice is still retried until
delivered. -
Webhook deliveries no longer stall behind a URL change's notice. The notice goes to the
endpoint's former URL, and its failures there counted as the endpoint's: once that URL was
taken down, the endpoint cooled down and every probe picked the failing notice first, so new
events were held for up to an hour at a time. A notice's outcome now neither cools nor clears
the endpoint.
Verify
deploy/verify-release.sh
checks this release as Deploy does (self-hosting guide).