An omnibus wallet and a set of segregated wallets can hold exactly the same assets while supporting different reconciliation claims. The difference appears when a customer balance changes without a corresponding movement between customer wallets. A pool can still match total liabilities. Dedicated wallets can match that total while backing the wrong customers.
Choose the model by the invariant your ledger must prove. For pooled assets, prove aggregate backing and separately validate customer allocations. For strict segregation, also prove that each customer's assigned wallets match that customer's entitlement. A wallet label cannot establish either result.
The required evidence differs.
The example below uses Alice, Bob and 100 synthetic asset units. A complete Python snapshot checker and twelve tests expose an aggregate check that incorrectly approves a segregated allocation. The fixture makes no calls to a blockchain or custody provider.
Separate the wallet, the ledger and the authority
A wallet describes an operational arrangement for holding and moving assets. A customer ledger describes entitlements, reservations and postings. An authority model describes who can approve or sign a movement. These are separate design decisions, even when a provider presents them together in one interface. In a pooled arrangement, several customers' entitlements are backed by assets held in shared wallets. The application needs a private ledger to assign those entitlements. In a segregated arrangement, identified wallets or vault accounts are assigned to individual customers. The ledger still needs to describe spendable balances, pending operations and corrections. The address does not explain those states by itself.
Fireblocks documents these operational models in Create Direct Custody Wallets. Its description distinguishes pooled vault arrangements from individual customer vaults and treats hot or cold storage as another dimension. That distinction matters: segregation does not automatically mean offline signing, and a hot wallet does not automatically mean pooled customer accounting.
The same documentation explains the application-side mapping for a deposit address:
This deposit address is assigned to Alice and mapped accordingly within the customer’s private ledger.
Fireblocks, Create Direct Custody Wallets, official developer documentation, accessed October 1, 2026. The page does not state an exact publication date.
The quoted sentence identifies the missing join: an observed address must be connected to a customer in the ledger. It does not claim that a deposit address proves legal ownership or that every asset uses a separate address for each customer.
This separation also guides the blockchain engineering work at Pharos Production: selecting wallet infrastructure is only one part of specifying how the surrounding application records financial state. The engineering decision needs an entitlement model and a recovery boundary before an address diagram can serve as an acceptance artifact.
Product terminology deserves a second check. Coinbase describes Prime Custody as an omnibus arrangement using its LSOC terminology, while Prime Vault uses dedicated addresses. Those are statements about named products, not evidence that any architecture called segregated has the same contractual treatment. Obtain the applicable custody agreement separately. Neither this article nor its test fixture determines legal title, insolvency protection or the adequacy of a provider's controls.
Compare the evidence each model needs
The useful comparison is between operating requirements. A diagram with one box per customer may be easier to explain, but its proof obligations can be more demanding when the product permits transfers between customers.
| Decision dimension | Omnibus arrangement | Segregated arrangement |
|---|---|---|
| Customer entitlement | Requires a ledger allocation inside the shared asset pool | Requires a ledger allocation and an explicit customer-to-wallet assignment |
| Backing check | Compare included pool assets with included customer liabilities | Compare the aggregate and every customer's assigned assets with their liabilities |
| Internal customer transfer | Can change ledger allocations while the pool remains unchanged | Needs an explicit policy for pending allocation or corresponding wallet movement |
| Deposit handling | Attribution must survive any sweep into a shared wallet | Attribution must survive wallet creation, reassignment and recovery |
| Operating constraint | Shared movements obscure customer allocation on-chain | More customer wallet movements can increase transaction and monitoring work |
| Disqualifier | Cannot satisfy a requirement for exclusive customer wallet backing | Cannot claim strict backing when allocations move ahead of assigned assets |
These rows are design consequences of the definitions used here, not a provider performance benchmark. There is no universal winner. Pooling may fit a product whose customer transfers are internal ledger operations. Dedicated wallets may fit a product that requires independently inspectable wallet assignments. Both remain dependent on complete asset inventory and correct postings.
Define the acceptance language before comparing suppliers. If the requirement says customer assets remain identifiable, ask whether that means ledger identification, dedicated wallet assignment, a contractual arrangement, or all three. Each interpretation needs different evidence. A vendor showing balances by customer in a dashboard has demonstrated a view. It has not demonstrated the provenance of those balances.
Also specify the accounting perimeter. A customer asset pool, the operator's own funds, a fee wallet and assets in transit must not be silently added together. A mixed dashboard total can hide an excluded liability or compensate for a shortfall with funds outside the agreed perimeter. Keep the comparison per asset, per network and per cutoff before considering any converted reporting total.
A balanced total can hide the wrong allocation
Start with one synthetic asset. Alice has an entitlement of 60 units; Bob has 40. Total customer liabilities are 100. In the omnibus case, the pool contains 100. In the segregated case, Alice's assigned wallet contains 60 and Bob's contains 40.
Now record a transfer of 15 units from Alice to Bob in the customer ledger. The new entitlements are 45 and 55. Total liabilities are still 100. The following table isolates the wallet movement from the ledger posting:
| Snapshot | Alice's ledger | Bob's ledger | Omnibus pool | Segregated Alice/Bob wallets |
|---|---|---|---|---|
| Opening | 60 | 40 | 100 | 60 / 40 |
| Internal entitlement transfer recorded | 45 | 55 | 100 | 60 / 40 |
| Corresponding segregated movement completed | 45 | 55 | 100 | 45 / 55 |
An aggregate checker reports zero difference in every row. For the middle row, the segregated checker must report Alice's assigned assets minus entitlement as +15, and Bob's as −15. The aggregate result is correct. The conclusion that segregation is reconciled is incorrect.
There are two coherent ways to handle that middle state. A strict settled-balance policy can keep the customer transfer pending until the required wallet movement completes. A more complex policy can account explicitly for an allocation bridge or receivable. The second option needs its own permitted states, aging rules and reconciliation equations. It cannot borrow the strict model's passing label while omitting the bridge from the report.
The simple checker below deliberately implements the strict snapshot interpretation. It has no bridge account and no transaction state machine. Its rejection means the dedicated wallet amounts do not match the supplied settled entitlements. It does not mean that every temporary mismatch is fraud, loss or an invalid business operation.
For the omnibus row, aggregate equality establishes only that included pool assets equal included customer liabilities. Alice could still be credited incorrectly, or Bob could receive a duplicated posting, while another posting offsets the error. Customer allocation correctness therefore needs independent journal and authorization checks. A pooled report should state that owner backing was not checked, rather than display an empty exceptions list that resembles a successful customer-level check.
Run the snapshot witness and the regression
The aggregate equation is included wallet assets minus recognized customer liabilities. The strict segregated equation repeats that subtraction for each assigned customer. Both must equal zero for this fixture. An excess is a discrepancy too: it may represent an unattributed deposit, an omitted customer liability or funds outside the declared perimeter. The checker does not classify the cause from the sign alone.
Notice that matching every owner implies the aggregate matches only when the inventory and owner mapping cover the same population. Retaining both outputs makes the declared population inspectable and exposes adapter mistakes. The function rejects an assigned owner absent from the position map instead of quietly excluding that wallet from the owner calculation.
Save the next block as reconciliation.py. Amounts are integer base units. Position.total includes reserved units. Reservations reduce availability, not the liability already represented by the total. A wallet row is one included balance observation, identified by a unique name within this materialized list.
from dataclasses import dataclass
@dataclass(frozen=True)
class Position:
total: int
reserved: int
@dataclass(frozen=True)
class Wallet:
name: str
amount: int
asset: str
block: str
owner: str | None = None
def reconcile(model, positions, wallets, *, asset, block):
"""Pure snapshot witness. Inputs are trusted observations, not fetched here."""
if model not in {"omnibus", "segregated"}:
raise ValueError("unknown model")
for owner, p in positions.items():
if (not owner or type(p.total) is not int or type(p.reserved) is not int
or not 0 <= p.reserved <= p.total):
raise ValueError("invalid position")
seen = set()
owner_assets = {owner: 0 for owner in positions}
for w in wallets:
if w.name in seen:
raise ValueError("duplicate wallet")
seen.add(w.name)
if w.asset != asset:
raise ValueError("asset mismatch")
if w.block != block:
raise ValueError("cutoff mismatch")
if not w.name or type(w.amount) is not int or w.amount < 0:
raise ValueError("invalid wallet")
if model == "segregated":
if w.owner not in positions:
raise ValueError("owner mismatch")
owner_assets[w.owner] += w.amount
elif w.owner is not None:
raise ValueError("owner mismatch")
assets = sum(w.amount for w in wallets)
liabilities = sum(p.total for p in positions.values())
owner_deltas = ({owner: owner_assets[owner] - p.total
for owner, p in positions.items()}
if model == "segregated" else {})
delta = assets - liabilities
return {"asset_total": assets, "liability_total": liabilities,
"available_total": sum(p.total - p.reserved for p in positions.values()),
"aggregate_delta": delta, "owner_deltas": owner_deltas,
"owner_checked": model == "segregated",
"reconciled": delta == 0 and all(v == 0 for v in owner_deltas.values())}
Save the following as test_reconciliation.py in the same directory. The asset identifier and block label are synthetic constants. The tests exercise distinct outcomes and refusal classes rather than every possible combination of inputs.
import unittest
from reconciliation import Position, Wallet, reconcile
ASSET = "chain-a:token-x" # Synthetic asset identity, not a live token.
BLOCK = "block-hash-100" # Synthetic named cutoff, not a live block.
class ReconciliationTests(unittest.TestCase):
def positions(self):
return {"alice": Position(60, 0), "bob": Position(40, 0)}
def wallet(self, name, amount, owner=None, asset=ASSET, block=BLOCK):
return Wallet(name, amount, asset, block, owner)
def check(self, model, positions, wallets):
return reconcile(model, positions, wallets, asset=ASSET, block=BLOCK)
def test_omnibus_total_matches(self):
report = self.check("omnibus", self.positions(), [self.wallet("pool", 100)])
self.assertTrue(report["reconciled"])
self.assertFalse(report["owner_checked"])
self.assertEqual(report["owner_deltas"], {})
def test_segregated_allocation_matches(self):
report = self.check("segregated", self.positions(),
[self.wallet("a", 60, "alice"), self.wallet("b", 40, "bob")])
self.assertTrue(report["reconciled"])
self.assertEqual(report["owner_deltas"], {"alice": 0, "bob": 0})
def test_balanced_total_wrong_owner_fails(self):
positions = {"alice": Position(45, 0), "bob": Position(55, 0)}
report = self.check("segregated", positions,
[self.wallet("a", 60, "alice"), self.wallet("b", 40, "bob")])
self.assertEqual(report["aggregate_delta"], 0)
self.assertFalse(report["reconciled"])
self.assertEqual(report["owner_deltas"], {"alice": 15, "bob": -15})
def test_reserved_is_already_in_total(self):
positions = {"alice": Position(45, 0), "bob": Position(55, 20)}
report = self.check("omnibus", positions, [self.wallet("pool", 100)])
self.assertEqual(report["liability_total"], 100)
self.assertEqual(report["available_total"], 80)
self.assertTrue(report["reconciled"])
def test_asset_shortfall_is_visible(self):
report = self.check("omnibus", self.positions(), [self.wallet("pool", 90)])
self.assertEqual(report["aggregate_delta"], -10)
self.assertFalse(report["reconciled"])
def test_duplicate_wallet_rejected(self):
with self.assertRaisesRegex(ValueError, "duplicate wallet"):
self.check("omnibus", self.positions(), [self.wallet("pool", 50)] * 2)
def test_mixed_asset_rejected(self):
with self.assertRaisesRegex(ValueError, "asset mismatch"):
self.check("omnibus", self.positions(),
[self.wallet("pool", 100, asset="chain-b:token-x")])
def test_mixed_cutoff_rejected(self):
with self.assertRaisesRegex(ValueError, "cutoff mismatch"):
self.check("omnibus", self.positions(),
[self.wallet("pool", 100, block="block-hash-101")])
def test_invalid_reservation_rejected(self):
with self.assertRaisesRegex(ValueError, "invalid position"):
self.check("omnibus", {"alice": Position(10, 11)}, [self.wallet("pool", 10)])
def test_unknown_owner_rejected(self):
with self.assertRaisesRegex(ValueError, "owner mismatch"):
self.check("segregated", self.positions(), [self.wallet("pool", 100, "carol")])
def test_unknown_model_rejected(self):
with self.assertRaisesRegex(ValueError, "unknown model"):
self.check("unreviewed", self.positions(), [self.wallet("pool", 100)])
def test_invalid_wallet_units_rejected(self):
with self.assertRaisesRegex(ValueError, "invalid wallet"):
self.check("omnibus", self.positions(), [self.wallet("pool", -1)])
if __name__ == "__main__":
unittest.main()
Run both files with Python 3.10 or later, which supports the union annotation used above. The recorded local run used Python 3.14.7. The Python unittest documentation describes the command and assertions used here.
python3 -m unittest -v
The regression was first executed against an incomplete aggregate-only version. That version returned reconciled=True for the middle table row, causing test_balanced_total_wrong_owner_fails to fail with AssertionError: True is not false. The correction accumulated assets by assigned owner and required every owner difference to be zero. The same regression then passed, followed by all twelve tests.
Replay of recorded local test output and the recorded correction. This is a synthetic fixture, not a terminal screen recording or a live custody-system test.
The report preserves the distinction between a numerical discrepancy and invalid input. A shortfall produces a report with reconciled=False. Mixed assets, mixed cutoffs, duplicate wallet rows and unknown assigned owners raise errors. A production adapter needs similarly explicit handling, but this function does not establish that its input inventory is complete or authentic.
Reservations and withdrawals need a posting policy
Reserve 20 units for Bob after the transfer, when Alice has 45 and Bob has 55. Bob's available balance becomes 35. Total customer liabilities remain 100, and aggregate available balances become 80. Adding the reservation to the liability total again would report 120 against assets of 100, manufacturing a shortfall.
The reservation test checks this classification only. It does not execute a withdrawal. In a zero-fee illustrative withdrawal that completes for 20 units, assets would fall to 80 and customer liabilities would fall to 80: Alice 45, Bob 35. While the withdrawal is merely reserved, the supplied snapshot still contains the original 100 of liabilities. The accounting transition needs a defined settlement event.
Pharos Production addresses the upstream design choice in its crypto wallet development process, which includes custody-model decisions, threat modeling and recovery design. Those decisions should establish which component may reserve funds, approve a withdrawal and recognize completion. The service description is evidence of that stated process. It is not evidence that this illustrative checker is deployed in a client system.
Specify failure behavior for each boundary. A failed signing request can release a reservation if no asset movement occurred. A submitted transaction with an uncertain outcome needs investigation or a pending state. Releasing the same reservation immediately could make those units available for another withdrawal while the first remains executable. A timeout is an observation about response time, not proof of cancellation.
Fees need an equally explicit rule. If a withdrawal consumes a fee in the same asset, identify whose liability bears it and when that debit becomes final. If the fee uses a different network asset, reconcile that asset separately. Do not conceal it by rounding a converted portfolio value. The example uses no fees so its per-owner mismatch is easy to inspect. A fee policy would extend the fixture and its tests.
Keep availability checks separate from backing checks. The first answers whether a customer may initiate another operation. The second compares recognized obligations with included assets. A customer can have a correctly backed total and no available balance because every unit is reserved. Conversely, an available balance can be overstated even when the aggregate backing report is numerically correct.
Preserve identities through deposits and sweeps
A balance observation needs more identity than an amount and a token symbol. Record the network, the asset identity, the wallet or vault assignment, the source observation and the cutoff. Two assets with the same display symbol must not enter one reconciliation equation merely because the interface renders the same label.
For an ERC-20 token, the standard's balanceOf method returns the balance of an address. It does not return the application's customer identifier. The application must provide that relationship, including the effective period of an assignment. A reused identifier or an incorrect assignment can make accurate chain data answer the wrong customer question.
Fireblocks' deposit-at-scale guidance describes validating deposit observations and maintaining a private ledger after assets are swept. A sweep changes where assets are held. It must not create another customer deposit credit simply because a second wallet received the funds. The credit and the internal asset movement have different accounting meanings.
Build source identity around those meanings. For a token deposit, a transaction can contain multiple relevant events, so a transaction hash alone may be insufficient to distinguish credits. Include the event identity and the adapter's declared network scope. For a provider callback, preserve the provider's operation identity and status history rather than treating every delivery as a new financial operation.
Idempotency then becomes a posting contract: receiving the same recognized deposit twice produces one customer credit. A later status update changes the operation's state under a permitted transition. It does not append another credit by default. A replay check should compare the resulting ledger state and asset inventory, not just count callbacks successfully acknowledged by the server.
The snapshot function rejects duplicated wallet names, but that is a much smaller guarantee. It does not detect duplicated journal entries, verify deposit events, fetch a block, handle a reorganization or prove settlement finality. An adapter must supply observations at the declared cutoff and explain its finality policy. A matching string in every row establishes consistency of the supplied labels, not truth of the underlying chain history.
Signing authority does not replace reconciliation
Separate the ability to move assets from the ability to alter reported obligations. A signing policy can stop an unauthorized withdrawal while a faulty ledger posting still changes customer balances. A perfect journal can coexist with a compromised signer. Review both boundaries because their evidence and failure modes differ.
For each model, identify who may create a customer-to-wallet assignment, who may change it and who may approve exceptions. The reconciliation service should consume a versioned mapping. If an operator silently reassigns Bob's wallet to Alice, a report can change without any asset movement. Retain the previous mapping and the reason for the change so a reviewer can reconstruct the earlier result.
The same principle applies to model selection. Switching a mismatching snapshot from segregated to omnibus removes owner checks and can turn failure into success. That is a change in the promise being evaluated. Require an approved model configuration and bind its version to every report. Do not let a reconciliation operator select the interpretation that produces the fewest exceptions.
Emergency controls need a defined effect on accounting. A pause may prevent new withdrawals while deposits and status observations continue. State whether incoming events are still recorded, whether customer credits remain pending and how reservations are handled. A pause button without these rules can produce a technically stopped service whose ledger keeps drifting.
Recovery should preserve investigation evidence. Before accepting a manual correction, retain the original report, the supporting observations and the proposed posting. Record the correction's author and approval separately from the program that recalculates balances. Then rerun the same invariant under the same perimeter. A balancing entry is not explanatory evidence merely because it reduces the difference to zero.
For segregation, also rehearse a wallet reassignment or recovery operation. Verify that old and new assignments cannot both count the same asset observation, and that the customer's history remains accessible. For pooling, rehearse restoration of the allocation journal from a known point. In either case, a usable backup must support the particular accounting promise, not just restore a wallet service login.
Make the acceptance receipt reconstructable
A supplier demo should hand over a report that another engineer can reproduce. Bind the model version, asset identity, customer perimeter, cutoff, wallet assignments and journal position to the result. Include totals and exceptions together. An empty exception file without the evaluated population cannot establish what passed.
For this fixture, acceptance means recreating the opening balances, applying the internal entitlement change and observing the strict segregated failure. It also means seeing the omnibus report declare owner_checked=False. That boolean is evidence of limited scope. It is not a warning that the pooled model is inherently defective.
Ask for the negative cases before signing off. The wrong-owner case is useful because it preserves the aggregate and challenges the claim that a zero total difference is enough. The shortfall case proves that a real numerical discrepancy remains visible. Invalid-input cases prove that incompatible observations cannot be silently combined into a plausible-looking report.
Keep the reported coverage accurate. The twelve local tests cover snapshot results, reservations and selected validation failures. They do not cover concurrent withdrawals, durable journal posting, signer compromise, provider outages, stale RPC data or inventory completeness. Those requirements belong to the surrounding system and need separate evidence. Passing this suite cannot certify custody safety or accounting compliance.
To extend the example, add one real requirement at a time. If the product allows pending internal transfers between dedicated wallets, specify the bridge account and the permitted duration before implementing it. Write a failure case showing how an expired or unmatched bridge affects acceptance. If the product mixes customer assets with operating funds, first separate the accounting perimeter rather than widening the sum until it balances.
Operational ownership is part of acceptance. Name who investigates a mismatch, which operations may continue and which report releases the restriction. Preserve unresolved cases across restarts. A status page can say a service is healthy while customer backing exceptions remain open. Health and reconciliation are different measurements.
An exception receipt should identify the affected asset and customer, the observed difference, the original cutoff and the next authorized action. If new observations resolve it, retain both results rather than replacing the failed snapshot. If a mapping correction resolves it, preserve the mapping change alongside the recalculation. These records distinguish a late observation from a corrected interpretation. Both may produce a zero difference while requiring different operational responses.
Choose the promise you can keep
Use omnibus custody when pooled backing and an independently controlled customer allocation ledger satisfy the product's actual requirements. Use segregation when the product requires dedicated assignments and can reconcile each customer's assigned assets against recognized entitlements. Evaluate the contractual and signing arrangements alongside that choice, with their own evidence.
The deciding question is what must remain true after a transfer, a reservation, a sweep and a recovery. If the answer requires per-customer backing, require per-customer deltas. If the answer permits shared backing, preserve the allocation journal and state the limits of the aggregate result. A single balance cannot carry both promises without additional records.
Before approving a wallet design, request one trace that leaves the aggregate unchanged while changing customer allocations. Require the team to explain exactly when that trace becomes settled and why the report accepts or rejects it. The 60/40 to 45/55 example supplies a reproducible starting point for that conversation.
For engineers operating dedicated customer wallets: does your system keep an internal customer transfer pending until the assigned assets move, or account for an explicit bridge? What evidence allows that pending state to close?
More insights to read
- How to Specify Stablecoin Reconciliation Acceptance Evidence
- How to Specify a Blockchain Ledger Integration Contract
- How to Specify Audit Evidence Exports in a FinTech Delivery Contract
- What Did the Ledger Know at Cutoff? Build a Reconciliation Snapshot
- How to Choose a Blockchain Integrator for an Existing FinTech Ledger
About the author
Dmytro Nasyrov. Photo supplied by the author.
Written by Dmytro Nasyrov PhD, software architect with 24 years of production experience. Dmytro is the founder and CTO of Pharos Production. He works on production software architecture for FinTech, AI, Web3 and blockchain systems.


Top comments (0)