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.
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.
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 - offchainDifferenceandwallet.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/ecosystemcomputesspendable = onChain - committed, wherecommittedis the summedoffchainDifferencefor the network each chain is configured for, and every verdict on that page — shortfall, coverage percentage, the covered/partial/unbacked band — is computed onspendablerather 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 --applyThe 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
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
networkyour install is not configured for. Those are invisible on this screen, and the custody overview drops them too — it sumsoffchainDifferencegrouped by(chain, currency, network)and then skips any row whosenetworkis 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>_NETWORKback 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.
Related
- The admin console — every other screen under Admin → Ecosystem
- Internal transfers — one of the paths that writes this table
- Where ecosystem data lives — the tables and how they join
- Deposit wallets and custody — why a payout can be sourced from another address