Privacy Requirements & Guarantees
This page describes what the Open Privacy Suite system guarantees about data visibility, what its current limitations are, and what operators must configure to achieve full protection.
What Is Protected
Private addresses and their transaction activity are hidden from unauthorized viewers. The system enforces this at two layers:
-
JSON-RPC proxy layer — when a client queries the Ethereum node directly through the proxy (e.g.
eth_getTransactionByHash), responses are filtered based on whether the caller is a participant in the transaction. Non-participants receivenullor have individual entries removed from arrays. -
Explorer API layer — when the block explorer fetches structured data through the Open Privacy Suite's Explorer API, each entity (transaction, log, token transfer, etc.) is individually processed through the redaction engine. Addresses are replaced with
[PRIVATE], values zeroed, and entries dropped according to the viewer's permission level and the disclosure level assigned to each address.
Both layers work in concert. A viewer who bypasses the block explorer and queries the JSON-RPC endpoint directly will receive the same privacy enforcement — the proxy is the sole gateway.
Protection by Data Type
Transactions
| Field | What an unauthorized viewer sees |
|---|---|
from | [PRIVATE] |
to | [PRIVATE] |
value (ETH amount) | Hidden (zero / omitted) |
inputData (calldata) | Hidden (omitted) |
nonce | Hidden (omitted) — prevents transaction-count fingerprinting of the sender |
error / revertReason | Hidden (omitted) |
contractAddress (deploy txs) | [PRIVATE] (revealed only to the deployer or a viewer with access to the contract) |
Gas parameters (gasUsed, gasPrice, etc.) | Visible — see known limitations below |
When both the sender and receiver of a transaction are private, the transaction is dropped from the response entirely. A viewer cannot determine that the transaction exists.
When one side is private and the other is public, the transaction remains visible but the private party is shown as [PRIVATE] and all sensitive financial fields are hidden.
Coherence with token transfers. A tx that calls a token contract has its EVM-level to set to the token contract address — separate from the actual recipient on the ERC-20 transfer log. When the transfer surfaces to the viewer (because one of its participants is visible to them), the parent tx row also surfaces, so /transactions is always a superset of /transfers. Field-level redaction still applies — the EVM-level from and to follow the same [PRIVATE] / real-address rules described above; only the row-survival decision is unified.
Token Transfers
| Field | What an unauthorized viewer sees |
|---|---|
from | [PRIVATE] |
to | [PRIVATE] |
value (token amount) | Hidden (zero / omitted) |
tokenAddress | Visible — the token contract itself is public infrastructure |
The same drop rule applies: if both sides are private, the transfer record is removed entirely.
Event Logs
Event log visibility depends on the privacy level of the emitting contract, not the transaction parties.
| Scenario | What an unauthorized viewer sees |
|---|---|
| Emitting contract is private (Hidden) | Entire log entry removed — viewer cannot see that the log exists |
| Emitting contract is private (Redacted) | Log kept; emitter address shown as [PRIVATE]; all topic slots and data field zeroed |
| Emitting contract has no registered ABI | Entire log denied at the RPC layer regardless of event_rules (admin viewers excepted). Operators must register an ABI before any event becomes visible. |
| Emitting contract is public + ABI registered | Log kept; indexed address parameters (topics[1]–topics[3]) scanned for private addresses; non-indexed address parameters in data decoded and zeroed |
For anonymous Solidity events (declared with the anonymous keyword), topics[0] may contain an indexed address and is scanned as well.
For events with a dynamic-typed payload (e.g. bytes, string, or dynamic arrays in data), the proxy cannot statically prove every embedded address has been scrubbed. The default behaviour is fail-closed — such logs are denied. Operators who explicitly accept the trade-off can opt-in per contract via the events_allow_dynamic_payload flag on the contract record (M15 follow-up). The flag is per-contract by design: it never globalises, and admin viewers still go through the contract-grant check before bypass applies.
ABI registration is mandatory for any contract whose events should be
visible: without one the proxy cannot decode non-indexed address parameters
and falls closed (denies all logs from that contract). Either upload a custom
ABI via the admin API, or set the contract's metadata.token_type to a
built-in registry value ("ERC20" or "ERC721"). The grant create/update
endpoints reject non-deny event_rules up-front when no ABI is resolvable,
so this constraint is surfaced at configuration time rather than at runtime.
Token Holders
| Scenario | What an unauthorized viewer sees |
|---|---|
| Address is Hidden | Holder entry removed from the list entirely |
| Address is Redacted | Entry shown as [PRIVATE]; balance and percentage hidden |
| Address is visible | Full holder entry shown |
Blocks
Block-level data is always public. Blocks are consensus-layer infrastructure, not user activity, and there is no grant or visibility mechanism for block producers.
| Field | Visibility |
|---|---|
number, hash, timestamp | Always public |
gasUsed, gasLimit, baseFeePerGas | Always public |
miner | Always public |
transactions (hashes only) | Always public |
transactions (full objects) | Per-transaction participant check applied; non-participant transactions removed |
logsBloom | Always replaced with all-zero (256 bytes) on every block-returning response, regardless of viewer |
Disclosure Levels
A viewer holding an approved disclosure grant sees the granted target's
addresses subject to the grant's scope.disclosure_level. The level controls
the substitution applied to every address field on every response surface — not
just the auditor dashboard. The mapping:
disclosure_level | VisibilityLevel (internal) | What the auditor sees for the granted address |
|---|---|---|
full | VisibilityFull | Real hex address (0xa32c…94cd). For regulatory subpoenas. |
pseudonymous | VisibilityPseudonymous | Stable alias Address-XXXX (4-letter human-readable, consistent per address). The alias is non-reversible — it reveals nothing about the underlying address and cannot be turned back into it — so counterparty patterns are inferable but the raw address never reaches the client. Set EXPLORER_PSEUDONYM_KEY in production to also make aliases non-enumerable (an attacker cannot recompute the alias from a candidate address). |
redacted | VisibilityRedacted | [PRIVATE] placeholder. Timing / direction / gas / status visible, no counterparty correlation. |
| missing / unknown | VisibilityRedacted (fail-safe) | Legacy or malformed grants do not silently upgrade to Full. |
Where the level is applied — cross-surface matrix
Every surface that renders a from / to / log / holder address routes the
real address through applyRedaction(addr, level). So the field value the
client receives is the substituted string (Address-XXXX / [PRIVATE]), not
the raw address.
| Surface | full grant | pseudonymous grant | redacted grant | No grant |
|---|---|---|---|---|
GET /tx/:hash — from / to (granted party) | Real address | Address-XXXX | [PRIVATE] | [PRIVATE] or row dropped |
GET /tx/:hash — counterparty in a tx involving the granted party — see note | Real address | Address-XXXX (counterparty demoted to lens) | [PRIVATE] | Per counterparty's own visibility |
GET /block/:n — embedded txs | Real address | Address-XXXX (all parties under lens) | [PRIVATE] | Row dropped (participant check) |
GET /address/:addr — tx list (rows for granted target) | Real address | Address-XXXX (granted party + all counterparties under lens) | [PRIVATE] (timing-only audit) | 404 / 403 |
Token transfer rows (/transfers) | Real address | Address-XXXX (granted party + counterparty) | [PRIVATE] | [PRIVATE] or row dropped |
| Event logs — emitter address | Real address | Address-XXXX | [PRIVATE]; topics + data zeroed | Log denied or [PRIVATE] |
| Event logs — indexed address topics | Real address | Topic slot zeroed (0x000…000) — see note | Topic slot zeroed | Topic slot zeroed |
Event logs — non-indexed address ABI params in data | Real address | Zeroed (the alias is a human-readable string, not an address, so it can't occupy a 32-byte slot) | Zeroed | Zeroed |
Holder list (/address/:contract/holders) | Real holder address | Address-XXXX row | [PRIVATE] row | Row dropped |
Auditor dashboard (PrivacyDashboard → Disclosed Addresses) | Real address | Address-XXXX | [PRIVATE] | Empty list |
Granted-address page (/grant/:grantId/:addressId) — all parties | Real address (granted target shown, counterparties as real) | Address-XXXX granted target + External-XXXX counterparty (deterministic per (addr, grant_id)) + Mine for viewer-linked addresses | [PRIVATE] for every party | 404 |
value (ETH / token amount) on a tx involving the granted address | Visible | Visible | Visible (timing-only audit explicitly preserves value) | Hidden |
inputData (calldata) on a tx involving the granted address | Visible | Visible | Visible | Hidden |
Note on the counterparty-pseudonymisation rule (lens). When a tx is visible to the viewer only because of a pseudonymous-level disclosure grant on one of its parties, every other party in that tx is also rendered with a pseudonym — not their real address. This is the "limited audit lens": the auditor sees the granted party's activity, including who they transacted with as a pseudonym, but cannot tie those counterparties back to real addresses. Without this rule the same tx would show the granted party as
Address-XXXXand a public-contract counterparty (e.g. USDC) as the real0x…hex, leaking through the lens.The lens is bypassed when the viewer is a direct participant in the tx (the from / to is their own linked address, or they're a participant via log / calldata signals): they already know the counterparty via their own wallet, so the lens adds no privacy.
visibleToshares (explicit hash-share by the sender) are similarly exempt.The granted-address page (
/grant/:grantId/:addressId) uses a richer label set —Address-XXXXfor the granted target,External-XXXXfor non-granted counterparties (deterministic per(addr, grant_id)so the auditor can spot recurring counterparties without resolving them), andMinefor the viewer's own linked addresses. The regular tx-list surfaces useAddress-XXXXuniformly because the per-grant deterministic salt isn't available in that context.
Note on event topics with pseudonymous grants. ABI-indexed address parameters live in 32-byte topic slots and must be valid hex. The
Address-XXXXalias is a human-readable string, not an address, so it cannot be encoded into a topic slot; and the real address must never be placed there, since a topic like0x000…0a32c…94cdwould leak it directly. So for pseudonymous grants the redactor zeros the slot — the auditor sees that the event mentioned a granted-target address but not the address itself inside the topics array. The emitter address (rendered separately) is still substituted toAddress-XXXX.
Precedence
When multiple sources claim a stake on the same address (own-address +
disclosure grant, RBAC group + disclosure grant, two grants with different
levels), the most permissive wins. Rank order: Hidden < Redacted < Pseudonymous < Full. A redacted grant cannot downgrade visibility the
viewer already has via ownership or RBAC; a full grant overrides a
co-existing pseudonymous grant on the same address.
For the full grant workflow (request → approval → expiration → revocation) see Selective Disclosure.
Row-survival rules per surface
The matrix above describes how individual fields render once a row is on its way to the client. This section pins the orthogonal axis: which rows survive at all vs which rows are dropped before they ever reach field-level redaction. Confusing the two has been the root cause of every cross-surface coherence bug to date, so they are kept as separate tables.
Outcomes:
- drop — the row is removed from the response entirely; the viewer cannot determine it existed.
- keep +
[PRIVATE]— row stays in the response with private addresses rendered as[PRIVATE]; financial fields (value, calldata, error) are cleared. Timing / block / gas remain visible. - keep + real — row stays with the granted-side address shown as its
real hex (or as
Address-XXXXunder a pseudonymous grant — refer to the field-rendering matrix above for the substitution). - keep + lens — kept, but every counterparty is demoted to the
granting lens (
Address-XXXXfor pseudonymous grants,[PRIVATE]for redacted grants). Field-rendering matrix governs the exact substitution.
| Surface | Own tx (viewer is participant) | RBAC-visible (org member sees own-org contracts) | Hidden (no relation) | Disclosure grant full | Disclosure grant pseudonymous | Disclosure grant redacted |
|---|---|---|---|---|---|---|
GET /transactions (list) | keep + real | keep + real | drop | keep + real | keep + lens | keep + [PRIVATE] (timing-only audit) |
GET /transactions/:hash (by-hash) | keep + real | keep + real | 404 | keep + real | keep + lens | keep + [PRIVATE] |
GET /transactions/:hash/transfers | keep + real | keep + real | drop | keep + real | keep + lens | keep + [PRIVATE] |
GET /transactions/:hash/internal | keep + real | keep + real | drop | inherits parent (keep) | inherits parent (keep + lens) | inherits parent (keep + [PRIVATE]) |
GET /transactions/:hash/logs | keep (participant override) | keep (emitting-contract visible) | drop (emitting contract Hidden) | keep + real (grant on emitting contract) | keep + lens | drop (Hidden) / keep + zeroed (Redacted) |
GET /block/:n → embedded txs | per-tx participant check | per-tx visibility check | drop | keep + real | keep + lens | keep + [PRIVATE] |
Where the "inherits parent" rows come from. The internal-tx / transfer
/ log feeds for a given tx_hash route their drop decision through the
parent tx's allowlist (VisibilityFilter.VisibleTxHashes →
RedactOpts.VisibleTxHashes). When the parent tx survives, every derived
row for that hash survives too — see "Coherence invariants" below for
the unified-path contract.
ORG_ADMIN_VIEW_USER_TXS admin flag
When the deployment flag ORG_ADMIN_VIEW_USER_TXS is set AND the viewer
is an org-admin, rows that would drop under the strict-privacy table
above instead become keep + [PRIVATE] with value preserved. The
goal of the flag is a volume / timing audit lens — counterparty addresses
are NEVER revealed by the flag, only the existence and amount of the row.
| Row class | Strict (flag off) | ORG_ADMIN_VIEW_USER_TXS on (admin viewer) |
|---|---|---|
bothHidden tx (both from and to Hidden/Redacted to admin) | drop | keep + [PRIVATE] / [PRIVATE], value preserved, timing visible |
bothHidden token transfer | drop | keep + [PRIVATE] / [PRIVATE], amount preserved |
bothHidden internal tx | drop | keep + [PRIVATE] / [PRIVATE], value preserved |
| Contract deployment by Hidden deployer | drop | keep + [PRIVATE] deployer, contract address [PRIVATE] (unless the admin has org access to it), value preserved |
Field-level redaction from the earlier matrix still applies on top of the
admin flag: addresses stay [PRIVATE], calldata stays stripped, nonces
stay zeroed. Every row kept ONLY because the flag was on increments
RedactStats.AdminUserTxsRevealed, and the server emits one
rbac_audit_log entry per request when the counter is non-zero so
operators have a record of elevated views.
The flag does not affect the field-rendering matrix above (full /
pseudonymous / redacted substitutions are independent of the flag) —
it only changes the row-survival decision, exactly like VisibleTxHashes.
Unified opts builder — list path and by-hash share the same visibility filter
Every explorer handler that needs to drop or render rows for a viewer
goes through one of two helpers, and both compose the same primitives.
This is a contract for handler authors: do not roll your own
RedactOpts. The current call surface, and the only shape we support
going forward:
- List handlers (
/transactions,/transfers, …): calls.buildVisibilityFilter(ctx, viewerDID)to get aVisibilityFilter(used by the SQL allowlist), then deriveRedactOptsviaredactOptsFromFilter(filter). SetViewerIsAdminand apply theapplyAdminTxViewwiring on the returned opts. - Single-item handlers (
/transactions/:hash,/transactions/:hash/internal, …): calls.buildRedactOptsForViewer(ctx, viewerDID). This helper internally delegates tobuildVisibilityFilter→redactOptsFromFilter, then applies admin-flag wiring — guaranteeing list-path and by-hash see the identicalVisibleTxHashesset, the identicalViewerIsAdminvalue, and the identicalOrgAdminViewUserTxsflag.
A handler that constructs explorer.RedactOpts{} by hand will silently
skip the transfer-participant union, the visibleTo shares,
and the admin-flag wiring — every cross-surface coherence invariant
breaks. PR review must reject hand-rolled opts.
Coherence invariants
The following invariants are pinned by integration tests. Failing any one of them is a privacy or UX regression:
/transactions⊇/transfers. If a token-transfer row withtx_hash=Xsurfaces to the viewer in/transactions/:hash/transfersor any list-level transfer feed, then/transactions(list) also contains a row forX. Previously the two drop predicates evaluatedbothHiddenon different address sets —/transactionson txfrom/to,/transferson transferfrom/to— and disagreed.- List ↔ by-hash agree on row survival.
If
/transactions(list) contains a row forX, thenGET /transactions/:hash=Xreturns 200 (not 404). And vice versa: if the list dropsX, by-hash returns 404. The single shared opts builder above is the load-bearing contract. - Parent-tx allowlist propagates to derived feeds (this PR).
If
GET /transactions/:hash=Xreturns 200, then for every row the underlying tables hold for parentX,GET /transactions/:hash=X/transfers,GET /transactions/:hash=X/internal, andGET /transactions/:hash=X/logssurface their rows (subject to the per-row field-level rules from the field-rendering matrix and event-rule / ABI gates on logs). No derived feed silently contradicts the parent's row-survival decision. - Field-rendering rules are independent of row-survival. When a row survives (under any of the above invariants), the field-rendering matrix governs every field substitution. The row-survival axis decides whether the row reaches the client; the field-rendering axis decides what shape it takes when it does. Mixing the two axes in one drop predicate is a known bug class.
- Disclosure-grant rows survive regardless of counterparty privacy.
When the viewer holds an active disclosure grant on either tx party
(any level:
full,pseudonymous,redacted), the row survives in/transactions,/transactions/:hash,/transfers, and/internalregardless of the counterparty's own visibility. Field-level rendering follows the grant level:fullreveals the counterparty's real address (audited as a regulatory subpoena reveal —RedactStats.GrantFullReveals→rbac_audit_logwithresource_type=disclosure_grant);pseudonymousrenders both parties under theAddress-XXXXlens;redactedrenders both as[PRIVATE]withvaluepreserved (proof-of-activity audit lens). Participants andvisibleTorecipients bypass the grant lens — they already know the counterparty via their own wallet. Pinned by an integration test.
Known Limitations
The following limitations are known and accepted in the current release. They are documented here so operators can make informed decisions about their deployment.
1. Contract deployment address
For a top-level CREATE deployment, the receipt's contractAddress is field-level redacted: an unauthorized viewer sees [PRIVATE], while the deployer (and viewers with access to the contract) see the real address. Contracts deployed through a factory or CREATE2 do not carry a contractAddress on the transaction receipt — their address can instead appear in event logs or internal transactions, which apply their own field-level redaction.
Operator note: Register deployed contracts to the deployer's organization so the contract resolves as private to outsiders and its subsequent activity is access-controlled.
2. Gas parameters are not hidden
Gas fields (gasUsed, gasPrice, maxFeePerGas, maxPriorityFeePerGas, gasLimit) are visible to all viewers, including those who cannot see the transaction parties. Gas parameters do not directly identify an address and are available to all participants in the network via the RPC layer regardless of this proxy.
3. Value inference from public address balance history
When a transaction has one private and one public party, stripping value removes the direct leak, but the amount may still be inferable by correlating the public address's balance across adjacent blocks. This is a known residual limitation for mixed-party transactions, not a complete financial-privacy guarantee.
What Operators Must Do
To achieve the maximum redaction coverage described in this document:
-
Register an ABI for every contract whose events should be visible. Without an ABI the proxy cannot decode non-indexed
addressparameters in eventdata, so it falls closed: every log from that contract is denied at the RPC layer (admin viewers excepted). The grant create/update API also rejects non-denyevent_rulesup-front when no ABI is resolvable. Two ways to satisfy the requirement:- Upload a custom ABI via the admin API (preferred for in-house contracts).
- Set the contract's
metadata.token_typeto a known value — currently"ERC20"or"ERC721"— to use the built-in ABI registry.
To register an ABI, use the admin API:
PUT /api/v1/admin/orgs/{org_id}/contracts/{address}/abi Content-Type: application/json {"abi": [...]} -
Assign all deployed contracts to the correct organization. The redaction engine determines visibility by checking whether a contract address is registered to an organization. Contracts that are not registered to any organization are treated as public. Register contracts with the same organization as their deployer.
-
Assign disclosure levels to sensitive addresses. Addresses that are not explicitly registered receive default public visibility. Ensure that all addresses that should be private are registered to an organization and assigned the appropriate disclosure level. See Selective Disclosure.
Addresses that are not registered to any organization are treated as public and will not be redacted, even if they appear as counterparties in transactions involving private addresses.