RoxCustody SCN Transaction Verifier (Python)
Approve or reject every transaction your RoxCustody Self Custody Node (SCN) is about to sign, from your own server.
How it works
Your SCN runs in your infrastructure. Before it signs anything, it sends the transaction to your webhook and asks: is this what you intended? If you reject, the SCN does not sign. Nobody else, including RoxCustody, can make your SCN sign something you did not approve.
SCN ── signed request ──▶ your webhook ── 1. verify the request signature (SDK)
2. decode the transaction bytes (SDK)
3. compare with YOUR records (your code: the "policy")
4. return the signed answer (SDK)
SCN ◀── signed answer ───┘
Decide from your own records. Compare the transaction with the withdrawal your system created and stored: its recipient, amount and token. Reference data that does not change, such as an asset's decimals (to convert amounts to base units) or the address of one of your wallets, can come from the RoxCustody API. Look it up once, for example when you add the asset or create the wallet, and keep your own copy, so every approval is checked against values you already hold.
Installation
Python 3.10 or newer. Install the extras for the chains you use:
pip install "roxcustody-scn-verifier[evm]" # Ethereum and EVM chains
pip install "roxcustody-scn-verifier[solana]"
pip install "roxcustody-scn-verifier[all]" # EVM, Solana, Bitcoin, Tron, Stellar, XRP
Other extras: bitcoin, tron, stellar, xrp. Pin the exact version in your requirements file.
Setup
- Expose an HTTPS endpoint your SCN can reach, for example
https://your-host/scn/webhook. - Generate a random secret of at least 32 bytes and keep it in your secret manager.
- Configure your SCN with
WEBHOOK_URLandWEBHOOK_SECRET. Set both: the SCN does not start with only one of them. - When you create a withdrawal through the RoxCustody API, send your own
idempotencyKey: a unique id you generate, such as your withdrawal's id. RoxCustody forwards it to your webhook, so you can find your record by it. Store the withdrawal (idempotencyKey, sender, recipient, amount, token) in your own database before you submit it, so the record exists when the SCN asks. - Keep the server clock synchronized (NTP). Requests signed more than 5 minutes ago are rejected.
Quick start (Flask)
import os
from flask import Flask, request, Response
from roxcustody_scn_verifier import create_scn_verifier, ExpectedTransaction, ROXCUSTODY_SIGNATURE_HEADER
app = Flask(__name__)
verifier = create_scn_verifier()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
def policy(decoded, payload):
if decoded.tx_type != "Outgoing": # accept only the types you use
return {"approved": False, "errorReason": "Unsupported transaction type"}
withdrawal = db.find_withdrawal(payload.get("idempotencyKey")) # YOUR database, with a query timeout
if withdrawal is None:
return {"approved": False, "errorReason": "Unknown transaction"}
mismatches = verifier.matches(decoded, ExpectedTransaction(
from_address=withdrawal.sender, # always compare the sender, see the note below
to_address=withdrawal.recipient,
amount=withdrawal.amount, # smallest unit (wei, lamports, satoshis, ...)
contract_address=withdrawal.token, # None for the native coin (then any token transfer is a mismatch)
))
if mismatches:
return {"approved": False, "errorReason": "Does not match our record"}
return {"approved": True}
@app.post("/scn/webhook")
def scn_webhook():
result = verifier.handle_webhook(
request.get_data(), # the RAW body
request.headers.get(ROXCUSTODY_SIGNATURE_HEADER),
WEBHOOK_SECRET,
policy,
)
return Response(result["body"], status=result["status"], headers=result["headers"])
FastAPI
from fastapi import FastAPI, Request, Response
@app.post("/scn/webhook")
async def scn_webhook(request: Request):
raw_body = await request.body()
result = verifier.handle_webhook(raw_body, request.headers.get(ROXCUSTODY_SIGNATURE_HEADER), WEBHOOK_SECRET, policy)
return Response(content=result["body"], status_code=result["status"], headers=result["headers"])
handle_webhook is blocking. If your policy queries a database, avoid blocking the event loop (for example, run it in a thread pool).
Pass the raw body and return the result unchanged. The signature covers the exact bytes. Parsing and re-serializing the JSON breaks the request check (
403), and changing the response body or dropping theScn-RoxCustody-Signatureheader breaks the response check.
The policy
policy(decoded, payload) receives the decoded transaction and the original request (payload["idempotencyKey"] is the key you sent when creating the withdrawal, and identifies your record). It returns:
| Return value | Result |
|---|---|
{"approved": True} |
Approved: the SCN signs |
{"approved": False, "errorReason": "..."} |
Rejected: the transaction ends with an error showing your reason |
{"approved": False} |
Rejected, without a reason |
anything else (True, "ok", None, ...) |
Rejected, same as {"approved": False} |
| raises an exception | Not decided: the transaction stays pending and is retried later |
Keep the policy fast and local: a database lookup and a comparison. The SCN waits 15 seconds per attempt and the policy runs in your own process, so give your database and HTTP clients their own timeouts (around 5 seconds). When you cannot decide, raise: a retry is safe, a wrong approval is not.
What decoded contains
The SDK decodes the raw transaction the SCN is about to sign, not a description sent next to it, and returns the digest computed from those same bytes. The SCN signs only if that digest matches, so what you check is exactly what gets signed.
Amounts are integer strings in the chain's base unit (its smallest unit: wei, lamports, satoshis, sun, stroops, drops), not decimal coin amounts. For example, 1 ETH is "1000000000000000000". XRP issued-currency (token) amounts are decimal strings, exactly as on the XRP Ledger.
tx_type |
to_address |
contract_address |
amount |
|---|---|---|---|
Outgoing, Internal (native coin) |
recipient | none | amount |
Outgoing, Internal (token) |
recipient | token | amount |
Allowance |
spender | token | allowance |
MINT_TOKEN |
receiver of the minted tokens | token | amount minted |
BURN_TOKEN |
see note | token | amount burned |
NFT_TRANSFER |
recipient | NFT | 1 on EVM, none on Solana |
TOKEN_DEPLOYMENT |
none | none, or the new token | none (never carries native coin) |
Swap, CONTRACT_CALL |
call target or spender | contract or token | depends on the call |
Also available: from_address, call_data, is_account_delegation, and operations (the individual steps when a transaction holds several, for example on Solana or in an EVM batch call).
Good to know:
- Always compare the sender (
from_address). For some calls the sender is not your wallet. For example, an EVMtransferFromspends tokens from a third party that approved your wallet, and the decoder reports that third party asfrom_address. A policy that checks only the recipient and amount would approve it. - Accept only the transaction types you use and reject the rest.
SwapandCONTRACT_CALL: you get the exact call target, value andcall_data. Comparecall_datawith the call you built before approving.BURN_TOKEN: comparecontract_addressandamount, which identify the token and the quantity burned on every chain.- EIP-7702 delegation (EVM account setup): approved automatically when the target is the RoxCustody delegation contract, rejected for any other target; your policy is not called. Pass
auto_approve_delegation=Falsetohandle_webhookto decide yourself. - Bitcoin: a transaction with several inputs produces one request per input, all with the same
transactionId. - Repeated requests: the same request can arrive more than once. The policy must give the same answer and must not change your data.
matches(decoded, expected)
Optional helper. expected is an ExpectedTransaction with the same field names as decoded (from_address, to_address, amount, contract_address, call_data). Only the fields you set are checked, and it returns the names of the fields that differ; an empty list means a match. EVM addresses are compared case-insensitively, other chains exactly, amounts as integers (XRP token amounts as exact strings). A field you expect but the transaction does not contain counts as a mismatch. An amount without a contract_address means the native coin, so a token transfer of the same amount is a mismatch.
Responses and retries
handle_webhook returns {"status", "headers", "body"}. Only two kinds of response end a transaction:
| Response | Result |
|---|---|
200, signed, {"approved": true, "computedDigestHex": ...} |
Approved |
200, signed, {"approved": false, ...}, or 403 with {"approved": false, ...} |
Rejected |
anything else (503, other statuses, unsigned or malformed body, no answer within 15 s) |
Not an answer: the transaction stays pending and signing is retried periodically |
handle_webhook answers 403 when the request signature is invalid, so a wrong secret rejects transactions with "Invalid webhook signature". It answers 503 when the policy raises. A wrong URL path, a timeout or a proxy that changes the response never approves or rejects anything; transactions stay pending until it is fixed. Make sure proxies in front of your server keep the status, the body and the Scn-RoxCustody-Signature header unchanged.
Testing without an SCN
Run this to check your installation and secret handling (requires the evm extra). It signs a sample request exactly as an SCN would and passes it to handle_webhook:
import json
from roxcustody_scn_verifier import create_scn_verifier, ExpectedTransaction, SCNWebhookAuthenticator
SECRET = "test-secret"
verifier = create_scn_verifier()
body = json.dumps({
"transactionId": "458",
"networkCategory": "EVM",
"txType": "Outgoing",
"transactionContext": json.dumps({
"protocol": "eoa",
"txHex": "02ee83014a341083124f80838583b0825e56941ae810a85329c899efeaef4a68a18bcf0cf680d3860ae9f7bcc00080c0",
}),
"scnContext": {
"scnId": "2",
"signerPubKeyHex": "04dc00de52bfb05f589a70731f7a043417ba03af382776dc91ac666d71fbd1f048485744f5cc08d59cbb34c2d1930ff3be3746c21b2d4484b6d8745904f94bcb60",
},
})
signature = SCNWebhookAuthenticator(secret=SECRET).sign_response(body)
def policy(decoded, payload):
# The sample sends 12000000000000 wei from 0x98ae4ca3c5edce704b59b19be7749ba07ddf47e0 to 0x1ae810a85329c899efeaef4a68a18bcf0cf680d3
expected = ExpectedTransaction(
from_address="0x98ae4ca3c5edce704b59b19be7749ba07ddf47e0",
to_address="0x1ae810a85329c899efeaef4a68a18bcf0cf680d3",
amount="12000000000000",
)
mismatches = verifier.matches(decoded, expected)
return {"approved": not mismatches}
result = verifier.handle_webhook(body, signature, SECRET, policy)
print(result["status"], result["body"])
# 200 {"approved": true, "computedDigestHex": "3bccb2fe913b10b5f7f4cfa27dbf5c019db1ff8082d9c00d1668fdc8a1fa461f"}
To test your running endpoint, POST the same body with the header Scn-RoxCustody-Signature: <signature>. Before going live, check that:
- A matching withdrawal is approved (
200,"approved": true). - A wrong recipient, a wrong amount and an unknown
idempotencyKeyare rejected (200,"approved": false). - A transaction type you do not support is rejected.
- A wrong signature returns
403. - A failing database (simulated) returns
503.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Transactions rejected with "Invalid webhook signature" (your server answers 403) |
Different secrets on the SCN and your server, the body was re-serialized (pass the raw bytes), or the server clock is off by more than 5 minutes |
503 for every EVM transaction, log says "Missing dependencies" |
The evm extra is not installed. Install it and restart |
| Transactions stay pending | The SCN did not get a final answer: timeout, wrong URL path, your server returned an error, or a proxy changed the response |
Lower-level API
verifier.decode(payload) and verifier.compute_digest(payload) are available if you build the exchange yourself. You then also verify the request and sign the response with SCNWebhookAuthenticator(secret=...) (verify_request(body, header) and sign_response(body)), and follow the response rules above.
Support
Metadata
Release files for roxcustody-scn-verifier 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| roxcustody_scn_verifier-1.1.0.tar.gz | 30.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| roxcustody_scn_verifier-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 70.3 kB
Release files / roxcustody_scn_verifier-1.1.0.tar.gz
| Download URL | roxcustody_scn_verifier-1.1.0.tar.gz |
|---|---|
| Size | 30.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
010dcd280d3fa7eaeb7f1a41e1c9090d4fda8cae20407141cee45079dd877498
|
|
BLAKE2b-256 checksum How to use checksums |
2c3545c44e8816a5bed317d22bceeb9caaa2b0b46fdbcb05ec93d3586e49d140
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / roxcustody_scn_verifier-1.1.0-py3-none-any.whl
| Download URL | roxcustody_scn_verifier-1.1.0-py3-none-any.whl |
|---|---|
| Size | 39.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8db18e11cda526e61d8daa719334a8f3ca1b19f4609be3092c1fa7c7b79811f6
|
|
BLAKE2b-256 checksum How to use checksums |
ce8348533bb58798244cc61631774c72b808f26ee74692a8b7421545fcc49211
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Security for roxcustody-scn-verifier 1.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.