Metadata-Version: 2.4
Name: roxcustody-scn-verifier
Version: 1.1.0
Summary: RoxCustody SCN Transaction Verifier SDK for Python
Project-URL: Homepage, https://roxcustody.com
Project-URL: Documentation, https://documentation.roxcustody.com
Author-email: Ahmed Aref <ahmed.aref@roxcustody.com>, RoxCustody <integration@roxcustody.com>
License: Proprietary
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security :: Cryptography
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pydantic<3.0.0,>=2.4.0
Provides-Extra: all
Requires-Dist: coincurve<22.0.0,>=21.0.0; extra == 'all'
Requires-Dist: embit<0.9.0,>=0.8.0; extra == 'all'
Requires-Dist: eth-abi<6.0.0,>=5.0.1; extra == 'all'
Requires-Dist: eth-account<1.0.0,>=0.13.0; extra == 'all'
Requires-Dist: eth-utils<6.0.0,>=5.0.0; extra == 'all'
Requires-Dist: protobuf<7.0.0,>=6.33.5; extra == 'all'
Requires-Dist: rlp<5.0.0,>=4.0.0; extra == 'all'
Requires-Dist: solders<0.28.0,>=0.27.0; extra == 'all'
Requires-Dist: stellar-sdk<14.0.0,>=13.0.0; extra == 'all'
Requires-Dist: tronpy<0.7.0,>=0.6.0; extra == 'all'
Requires-Dist: xrpl-py<5.0.0,>=4.0.0; extra == 'all'
Provides-Extra: bitcoin
Requires-Dist: embit<0.9.0,>=0.8.0; extra == 'bitcoin'
Provides-Extra: dev
Requires-Dist: build>=1.0.0; extra == 'dev'
Requires-Dist: pip-audit>=2.7.0; extra == 'dev'
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Requires-Dist: twine>=5.0.0; extra == 'dev'
Provides-Extra: evm
Requires-Dist: coincurve<22.0.0,>=21.0.0; extra == 'evm'
Requires-Dist: eth-abi<6.0.0,>=5.0.1; extra == 'evm'
Requires-Dist: eth-account<1.0.0,>=0.13.0; extra == 'evm'
Requires-Dist: eth-utils<6.0.0,>=5.0.0; extra == 'evm'
Requires-Dist: rlp<5.0.0,>=4.0.0; extra == 'evm'
Provides-Extra: solana
Requires-Dist: solders<0.28.0,>=0.27.0; extra == 'solana'
Provides-Extra: stellar
Requires-Dist: stellar-sdk<14.0.0,>=13.0.0; extra == 'stellar'
Provides-Extra: tron
Requires-Dist: protobuf<7.0.0,>=6.33.5; extra == 'tron'
Requires-Dist: tronpy<0.7.0,>=0.6.0; extra == 'tron'
Provides-Extra: xrp
Requires-Dist: xrpl-py<5.0.0,>=4.0.0; extra == 'xrp'
Description-Content-Type: text/markdown

# 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.

```text
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:

```bash
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)

```python
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

```python
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`:

```python
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>
