Security

How your funds are actually handled

This page describes what the code does, including the parts that are not flattering. A security page that only lists strengths is a marketing document, and you cannot make a custody decision from one.

This deployment is running on testnet

Every address issued here is a testnet address and every coin that moves through it is worthless. Nothing on this page describes a production custody posture yet, because there is no production money in it. It is stated at the top rather than in a footnote, because someone evaluating a payments product deserves to know before they read three screens of controls.

The custody model

We are a custodian. That is the trade.

Funds paid to your invoices are held in wallets we control, and your balance is our record of what we owe you. It is the arrangement that makes instant swaps and one-click payouts possible, and it is also the arrangement in which you are trusting us.

What we hold

  • One master wallet per asset and network

    Created once, with a database constraint that makes a second one impossible. Every deposit address is derived from it.

  • A dedicated address per purpose

    Your standing deposit address, and a separate freshly derived address for every payment request. The derivation index is claimed under a lock, so two payees can never be handed the same address.

  • The seed, encrypted at rest

    The wallet mnemonic is stored encrypted using the application key and is hidden from every model serialisation. It is decrypted in exactly one place: the code path that broadcasts a withdrawal.

What that means, said plainly

  • The key material is not in an HSM today

    It is encrypted with the application key and stored in our database. Whoever holds both holds the funds. Moving this to a managed key service is known, written down in the code as a comment, and not done yet.

  • The signing key reaches our blockchain provider

    Transactions are signed and relayed through Tatum, which means the seed or a key derived from it is transmitted to them at broadcast time. If your threat model does not allow that, this is the fact to weigh.

  • You do not hold your own keys here

    There is no non-custodial mode. If self-custody is a requirement, we are the wrong product, and we would rather tell you now than after onboarding.

The ledger

Double entry, or it is not written

Every movement of money is a group of entries that must sum to exactly zero. The group is refused before anything is persisted if it does not.

  • One writer

    A single service is permitted to write balances or ledger entries. Nothing else in the application may, which is what makes the invariants below enforceable at all.

  • Zero sum, checked first

    The legs are summed with exact decimal arithmetic and compared to zero before the transaction opens. Two legs minimum.

  • Accounts are an allowlist

    A leg naming an account that is not on the list throws. Without that, a typo'd account name would still balance the group while minting a balance out of nowhere.

  • Balances cannot go negative

    The balance row is locked and the result checked; a movement that would take it below zero rolls back the whole group. This is the anti-double-spend control, and it is a database constraint rather than a hope.

  • Amounts are exact decimal strings

    Eighteen places of precision, arbitrary-precision arithmetic, and a hard refusal to construct a balance from a float. A binary float cannot hold 0.1, and a satoshi lost to rounding does not come back.

A withdrawal, in entries

user:available -0.2501
user:held +0.2501
sum0.0000

The hold is taken the moment a payout is requested, not when it is broadcast. Between the two, that money is visibly reserved and cannot be spent again. If the payout is rejected the hold is released — once, tracked by its own timestamp so no later path can release it a second time.

Balances are a cache, and it is audited

The balance row is written in the same transaction as the entries, so it cannot drift by accident. It is checked anyway: an hourly job re-derives every balance from the raw entries and compares. A mismatch is logged at critical severity rather than quietly corrected — a discrepancy is information, and silently fixing it would destroy the evidence.

Money coming in

Nothing is credited on somebody's word

Not on our provider's, and not on the payload of a webhook that anyone can POST at us.

Amounts re-read from the chain

A deposit notification is a hint to go and look, not a statement of fact. The amount is fetched from the chain rather than taken from the body.

A float amount is refused

If the provider hands us an imprecise number where a value was expected, we decline to credit it rather than credit something approximate.

Idempotent on the transaction

Deposits are keyed on the canonicalised chain transaction hash, so a redelivered confirmation credits nothing twice. Re-running the whole reconciliation sweep credits nothing twice either.

Pending until confirmed

A deposit lands in pending and becomes spendable only after the asset's confirmation threshold. Unconfirmed funds are exactly the ones a reorg can take back.

Provider callbacks are signature-checked

The inbound webhook carries an HMAC, verified before it is acted on. Where no signing secret is configured, every transaction is re-verified against the chain instead — the fallback is more work, not less checking.

A sweep catches what was missed

An hourly job looks for addresses that appear under-credited and puts them back through the same ingest path, so a dropped webhook is an inconvenience rather than lost money.

Money going out

The controls that matter most

Every one of these lives in the service that all paths call, not in the screen or the middleware — because a scheduled run passes through neither.

Before anything is reserved

  • Two factors

    An authenticator code is required to create a withdrawal, a payout batch or a scheduled payout, and to resume a paused one. Pausing is deliberately exempt: stopping money is never the dangerous direction.

  • Approval is opt-in, not opt-out

    The auto-approval threshold defaults to zero, and zero means nothing is auto-approved. An asset nobody has configured requires a human for every single withdrawal.

  • A rolling daily ceiling

    An optional 24-hour cap per asset that counts internal transfers out as well as withdrawals — capping only withdrawals is defeated by moving funds to a second account you also own. Fees count toward it, and it is re-checked inside the locked transaction, not before it.

  • Addresses are structurally validated

    Per chain, including the mixed-case checksum on Ethereum and Polygon. A payout aimed at one of our own deposit addresses is turned into an internal transfer instead of being sent to the chain.

Maker, checker, and at-most-once

  • Approving and broadcasting are different permissions

    Held separately, and the person who approved a given withdrawal is refused when they try to broadcast it. A super-admin is not exempt, so the control cannot be escaped by escalating a role.

  • A batch is judged as a whole

    Rows are never re-assessed individually after approval. That loophole allowed a large total to be split into many small rows that each slipped under a threshold.

  • The send is claimed before it is attempted

    A terminal flag is set under a row lock before the provider call, and never cleared. A crash between the two cannot produce a second broadcast.

  • Writes are never retried

    Our provider offers no idempotency key, so a transparently resent write would be a second real transaction. Reads retry; sends do not.

  • Ambiguity keeps the money reserved

    A timeout or a 5xx leaves the hold in place and escalates to a person. Releasing it would risk paying twice, and a double send cannot be recalled.

Account access

Getting in is the other half

Most funds are not stolen by breaking cryptography. They are stolen by signing in.

Device-aware sign-in

A correct password from a device we have not seen earns a six-digit emailed code before any session exists. The code is hashed at rest, expires in ten minutes, is single-use, and allows five wrong attempts — counted before the comparison, so a crash cannot buy a free guess.

Two-factor, proven at enrolment

Turning it on requires your password, and it does not become active until you have entered a working code. You cannot lock yourself out of your own account by enabling it.

Sign-in history you can read

Successes and failures both, with device, platform and approximate location. A stranger trying your password is visible to you, not only to us. Recognised devices are trusted for 90 days and can be forgotten individually.

How each secret is stored

Secret At rest Why
API key secret SHA-256 hash only Shown once at creation. We cannot read it back to you, and neither can anyone who takes the table.
One-time sign-in codes Bcrypt hash A stolen table does not yield a working code.
Trusted device identifiers SHA-256 hash Otherwise the device table would be a ready-made second-factor bypass.
Two-factor secret and recovery codes Encrypted, hidden from serialisation Must be readable to verify a code, so encryption rather than hashing.
Webhook signing secret Encrypted Must be readable to sign your callbacks and to show it to you.
Wallet mnemonic Encrypted with the application key Must be readable to sign a transaction. This is the one we most want in a key management service, and it is not there yet.

Checked, not assumed

What runs on a clock

Reconciliation that only happens when someone remembers is not reconciliation. These are the jobs the scheduler runs, and none of them broadcasts a transaction.

Every minute

Confirm matured deposits

Counts confirmations and moves pending funds to available once the asset threshold is met.

Every minute

Confirm sent withdrawals

Tracks broadcast transactions to settlement.

Every minute

Deliver webhooks

Attempts due merchant callbacks with backoff, out of band from the payment itself.

Every minute

Process approved batches

Turns approved rows into withdrawals. It creates them; it does not send them.

Every 5 minutes

Refresh prices

Rates older than an hour are refused rather than used.

Every 5 minutes

Expire stale invoices

Closes untouched invoices past their deadline and fires payment.expired.

Hourly

Reconcile deposits against chains

Finds addresses that look under-credited and re-ingests them.

Hourly

Re-derive every balance

Compares the cache against the raw ledger and logs any drift as critical.

Hourly

Run due scheduled payouts

Creates the withdrawal for each occurrence, under the same controls as any other.

Not claimed

The things this page does not say

Absence is usually the most useful part of a security page, so here it is explicitly rather than by omission.

No certification claims

We are not claiming SOC 2, ISO 27001, PCI DSS or any other certification. If we hold one in future it will be named here with its scope and date.

No third-party audit claim

We are not claiming an external security audit or penetration test of this system. The controls above are described from the code, not from a report.

No uptime or volume figures

We are not publishing an availability percentage or a processed-volume number. Neither would mean anything without a measurement window we can show you.

No insurance or guarantee

There is no stated insurance over custodied funds. Crypto assets are volatile and largely unregulated, and value can fall as well as rise.

Found something wrong?

Report it and we will treat it seriously. Please give us a chance to fix an issue before it is published, and please do not test against balances that are not yours.

[email protected]