Skip to main content

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

  1. Expose an HTTPS endpoint your SCN can reach, for example https://your-host/scn/webhook.
  2. Generate a random secret of at least 32 bytes and keep it in your secret manager.
  3. Configure your SCN with WEBHOOK_URL and WEBHOOK_SECRET. Set both: the SCN does not start with only one of them.
  4. 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.
  5. 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 the Scn-RoxCustody-Signature header 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 EVM transferFrom spends tokens from a third party that approved your wallet, and the decoder reports that third party as from_address. A policy that checks only the recipient and amount would approve it.
  • Accept only the transaction types you use and reject the rest.
  • Swap and CONTRACT_CALL: you get the exact call target, value and call_data. Compare call_data with the call you built before approving.
  • BURN_TOKEN: compare contract_address and amount, 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=False to handle_webhook to 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:

  1. A matching withdrawal is approved (200, "approved": true).
  2. A wrong recipient, a wrong amount and an unknown idempotencyKey are rejected (200, "approved": false).
  3. A transaction type you do not support is rejected.
  4. A wrong signature returns 403.
  5. 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

integration@roxcustody.com

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)

Source distribution for roxcustody-scn-verifier 1.1.0
File Size Uploaded Provenance
roxcustody_scn_verifier-1.1.0.tar.gz 30.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for roxcustody-scn-verifier 1.1.0
File Interpreter ABI Platform
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.

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page