The private ledger and editing offchainDifference

The screen at /admin/ecosystem/ledger, what offchainDifference means to the withdrawal engine, why editing it changes how much money the platform believes it can source, and when a non-zero value is a bug.

8 min readUpdated 6 August 2026ledger, withdrawals, coverage, custody, admin

Admin → Ecosystem → Wallets → Ledgers looks like a read-only diagnostic table. It is not. It carries an Edit action on a single number, and that number is one of the inputs the withdrawal engine uses to decide how much it can source from an address.

offchainDifference is subtracted before the withdrawal engine will source a payout from a wallet's address, and before the custody overview reports coverage for the asset. Raising it makes funds unspendable. Lowering it makes the platform believe it can spend coins that may already be gone. There is no approval step, no second signature and no undo — the edit takes effect on the next withdrawal. Treat it as an accounting correction of last resort, made from evidence, not as a way to unblock a stuck payout.

The screen

/admin/ecosystem/ledger, one row per unique (walletId, index, currency, chain, network) combination.

Column What it is
currency The asset symbol — BTC, USDT, ETH
chain The chain the entry is scoped to
network mainnet, testnet, or whichever network name that chain's <CHAIN>_NETWORK variable is set to
index The HD derivation index of the address, copied from wallet_data.index
offchainDifference The figure this whole page is about
walletId The ECO wallet the entry belongs to
createdAt When the entry was first created, not when it last changed

Only currency and chain are visible in the table by default; the rest are on the expanded row.

The column's tooltip in the product calls it a sequential ledger entry number. It is not. It is the derivation index of the deposit address the entry covers, which is what makes the entry addressable back to a specific derived address. The core transfer path writes 0 there rather than a real index, so a row at index 0 is normal and does not mean "the first entry".

Opening a row gives the parts a bare table cannot: the signed difference in the asset's own units, the wallet's balance beside it, the wallet address, and the account holder — name and email — pulled from the joined wallet and user. That is the fastest way to answer "whose money is this entry about", which the grid alone cannot tell you.

Lists private ledger entries with their wallet and user

Two things about what this list shows:

It is paranoid. The underlying table soft-deletes, so a row you cannot find may be present in the database with a deletedAt set. "Missing from this screen" is not the same as "gone".

It is filtered by your configured network. The endpoint drops any entry whose network does not match the <CHAIN>_NETWORK value currently set in .env for that chain. Rows written while the install pointed at a testnet stay in the database and vanish from the screen the moment you switch to mainnet. The pagination total is computed before that filter, so on an install with mixed networks the row count above the table can be larger than the rows in it — that discrepancy is the filter, not a bug in the count.

The account holder's email is masked on demo installations.

What the number means

A positive offchainDifference is balance the platform still records at an address but has already paid away from it — the address's recorded balance overstates what is actually spendable there. The engine therefore subtracts it before it will source anything.

You can see the subtraction in three places, and they agree:

  • Choosing a source address. When a payout has to be sourced from a wallet other than the customer's own, candidates are gated and ranked on the greater of wallet_data.balance - offchainDifference and wallet.balance - offchainDifference. A wallet whose difference exceeds its balance is simply never selected.
  • Checking a PERMIT-token withdrawal. The withdrawal handler reads the wallet's ledger row for that (index, currency, chain) inside the same locked transaction as the debit, subtracts a positive difference from the available balance, and refuses the withdrawal if what is left does not cover the total.
  • Reporting coverage. The custody overview at /admin/ecosystem computes spendable = onChain - committed, where committed is the summed offchainDifference for the network each chain is configured for, and every verdict on that page — shortfall, coverage percentage, the covered/partial/unbacked band — is computed on spendable rather than on the raw record.

A net-negative ledger says the platform's own records understate what an address holds. That is a bookkeeping discrepancy, not spendable coins, so it is never counted as extra cover. The overview may therefore under-state coverage. It will not over-state it — which is the correct direction for the one page whose job is "can we settle".

Who writes it

Nothing on this screen is the primary writer. The ledger is maintained by the platform's ledger service, which performs a row-locked read-modify-write — it selects the existing row FOR UPDATE inside the caller's transaction, so two concurrent mutations of the same entry serialise instead of one overwriting the other.

The paths that call it are all money movements that change what an address owes without changing what is on-chain at that address:

  • an internal ECO-to-ECO transfer between two customers, which writes both sides;
  • the alternative-wallet sourcing step of a PERMIT-token withdrawal, when a payout is funded from an address other than the withdrawing customer's;
  • chain-scoped transfers inside the wallet service.

What protects those paths from applying the same change twice is the row lock above, plus the idempotency key each caller carries into the wallet service. That is the protection actually in force.

transaction_ledger_applied

There is also a bridge table designed for exactly this problem. transaction_ledger_applied carries (id, transactionId, walletId, currency, chain, appliedAt) with a unique key on (transactionId, walletId, currency, chain) and a foreign key to transaction(id). Its intent is to make a ledger decrement apply at most once per transaction and wallet, so a duplicate becomes a constraint violation rather than a silent second subtraction.

It is created by a migration script rather than by Sequelize's sync:

# Report what is missing (dry run — this is the default)
node backend/scripts/migration-ecosys06-broadcast-hash-lock.mjs

# Create it
node backend/scripts/migration-ecosys06-broadcast-hash-lock.mjs --apply

The script is idempotent — every statement is guarded by an existence check — and it also converts ecosystem_utxo.status from a boolean to the UNSPENT/LOCKED/SPENT enum and adds two recovery indexes that ship with the same work. Those parts matter on their own; run it. Restart the backend afterwards.

No code in the backend inserts a row into transaction_ledger_applied or reads one. The unique key exists; nothing populates it. So do not treat the table's presence as proof that a decrement cannot be applied twice — the at-most-once property it describes is not currently enforced by it. If you are investigating a ledger figure that looks doubled, the question to ask is whether the calling operation was retried with a fresh idempotency key, not whether this table is present.

Editing an entry

Updates one private ledger entry

The edit dialog in the product exposes one field: offchainDifference. The endpoint behind it accepts five — index, currency, chain, network and offchainDifference — so a direct API call can also re-point an entry at a different index, asset, chain or network. That moves the deduction to a different address, which is a larger change than it looks and is not something the UI will ever ask you to do.

walletId is not accepted. An entry always belongs to the wallet it was created against.

Before you change the number, know which case you are in.

When a non-zero value is correct

  • A payout was sourced from another address. This is the designed case. On a PERMIT-token withdrawal the platform may fund the payout from a different customer's deposit address that holds the token, and the difference on that wallet is what stops the same coins being counted as available twice. The entry should shrink as the sourcing is reconciled, and it should be on a wallet whose address genuinely paid out.
  • An internal transfer moved a balance without moving coins. A transfer between two customers on this install changes what each is owed while every coin stays exactly where it was; both sides get a ledger entry as a result. See Internal transfers.
  • The custody overview flags an asset as under-covered while the raw record looks healthy. That gap is this screen. It is doing its job.

When it is worth escalating

  • A large or growing difference on a wallet that has never been a payout source and whose customer has only ever deposited and withdrawn normally.
  • A difference that keeps growing on a retried withdrawal — the classic shape of a decrement applied more than once. Nothing in the shipped backend enforces at-most-once on this table (see above), so a retry that minted a fresh idempotency key is a real candidate.
  • A difference larger than the wallet's own balance on that chain, which makes the address permanently unusable as a source.
  • Entries for a network your install is not configured for. Those are invisible on this screen, and the custody overview drops them too — it sums offchainDifference grouped by (chain, currency, network) and then skips any row whose network is not the one that chain is configured for, because folding a testnet row into the mainnet figure would deduct cover that was never committed. So they cannot be behind a coverage shortfall. What makes them worth finding is that they are dormant rather than gone: point that chain's <CHAIN>_NETWORK back at the network they carry and every one of them becomes a live deduction again, on this screen, in the coverage figure and in the withdrawal engine at once.

In every one of those cases the fix is to find the transaction that wrote the entry — transaction rows for that wallet, around the entry's timestamps — and correct the cause. Zeroing the number here hides the symptom and tells the withdrawal engine it may spend coins nobody has confirmed are there.

Create and delete

The screen disables both, deliberately. A ledger entry is created by the code path that needs it, keyed on (walletId, index, currency, chain, network), and hand-creating one produces an entry no money movement backs. Deleting one removes a deduction the engine is relying on.

create.ecosystem.private.ledger and delete.ecosystem.private.ledger are both seeded and grantable in Admin → Roles. Granting them does not add the buttons — there is no create form and no delete action on this table — so they are, in practice, keys with no screen behind them.