>_

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:

  1. 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 receive null or have individual entries removed from arrays.

  2. 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

FieldWhat an unauthorized viewer sees
from[PRIVATE]
to[PRIVATE]
value (ETH amount)Hidden (zero / omitted)
inputData (calldata)Hidden (omitted)
nonceHidden (omitted) — prevents transaction-count fingerprinting of the sender
error / revertReasonHidden (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

FieldWhat an unauthorized viewer sees
from[PRIVATE]
to[PRIVATE]
value (token amount)Hidden (zero / omitted)
tokenAddressVisible — 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.

ScenarioWhat 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 ABIEntire 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 registeredLog 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

ScenarioWhat an unauthorized viewer sees
Address is HiddenHolder entry removed from the list entirely
Address is RedactedEntry shown as [PRIVATE]; balance and percentage hidden
Address is visibleFull 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.

FieldVisibility
number, hash, timestampAlways public
gasUsed, gasLimit, baseFeePerGasAlways public
minerAlways public
transactions (hashes only)Always public
transactions (full objects)Per-transaction participant check applied; non-participant transactions removed
logsBloomAlways 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_levelVisibilityLevel (internal)What the auditor sees for the granted address
fullVisibilityFullReal hex address (0xa32c…94cd). For regulatory subpoenas.
pseudonymousVisibilityPseudonymousStable 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).
redactedVisibilityRedacted[PRIVATE] placeholder. Timing / direction / gas / status visible, no counterparty correlation.
missing / unknownVisibilityRedacted (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.

Surfacefull grantpseudonymous grantredacted grantNo grant
GET /tx/:hashfrom / to (granted party)Real addressAddress-XXXX[PRIVATE][PRIVATE] or row dropped
GET /tx/:hash — counterparty in a tx involving the granted party — see noteReal addressAddress-XXXX (counterparty demoted to lens)[PRIVATE]Per counterparty's own visibility
GET /block/:n — embedded txsReal addressAddress-XXXX (all parties under lens)[PRIVATE]Row dropped (participant check)
GET /address/:addr — tx list (rows for granted target)Real addressAddress-XXXX (granted party + all counterparties under lens)[PRIVATE] (timing-only audit)404 / 403
Token transfer rows (/transfers)Real addressAddress-XXXX (granted party + counterparty)[PRIVATE][PRIVATE] or row dropped
Event logs — emitter addressReal addressAddress-XXXX[PRIVATE]; topics + data zeroedLog denied or [PRIVATE]
Event logs — indexed address topicsReal addressTopic slot zeroed (0x000…000) — see noteTopic slot zeroedTopic slot zeroed
Event logs — non-indexed address ABI params in dataReal addressZeroed (the alias is a human-readable string, not an address, so it can't occupy a 32-byte slot)ZeroedZeroed
Holder list (/address/:contract/holders)Real holder addressAddress-XXXX row[PRIVATE] rowRow dropped
Auditor dashboard (PrivacyDashboard → Disclosed Addresses)Real addressAddress-XXXX[PRIVATE]Empty list
Granted-address page (/grant/:grantId/:addressId) — all partiesReal 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 party404
value (ETH / token amount) on a tx involving the granted addressVisibleVisibleVisible (timing-only audit explicitly preserves value)Hidden
inputData (calldata) on a tx involving the granted addressVisibleVisibleVisibleHidden

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-XXXX and a public-contract counterparty (e.g. USDC) as the real 0x… 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. visibleTo shares (explicit hash-share by the sender) are similarly exempt.

The granted-address page (/grant/:grantId/:addressId) uses a richer label set — Address-XXXX for the granted target, External-XXXX for non-granted counterparties (deterministic per (addr, grant_id) so the auditor can spot recurring counterparties without resolving them), and Mine for the viewer's own linked addresses. The regular tx-list surfaces use Address-XXXX uniformly 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-XXXX alias 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 like 0x000…0a32c…94cd would 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 to Address-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-XXXX under 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-XXXX for pseudonymous grants, [PRIVATE] for redacted grants). Field-rendering matrix governs the exact substitution.
SurfaceOwn tx (viewer is participant)RBAC-visible (org member sees own-org contracts)Hidden (no relation)Disclosure grant fullDisclosure grant pseudonymousDisclosure grant redacted
GET /transactions (list)keep + realkeep + realdropkeep + realkeep + lenskeep + [PRIVATE] (timing-only audit)
GET /transactions/:hash (by-hash)keep + realkeep + real404keep + realkeep + lenskeep + [PRIVATE]
GET /transactions/:hash/transferskeep + realkeep + realdropkeep + realkeep + lenskeep + [PRIVATE]
GET /transactions/:hash/internalkeep + realkeep + realdropinherits parent (keep)inherits parent (keep + lens)inherits parent (keep + [PRIVATE])
GET /transactions/:hash/logskeep (participant override)keep (emitting-contract visible)drop (emitting contract Hidden)keep + real (grant on emitting contract)keep + lensdrop (Hidden) / keep + zeroed (Redacted)
GET /block/:n → embedded txsper-tx participant checkper-tx visibility checkdropkeep + realkeep + lenskeep + [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.VisibleTxHashesRedactOpts.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 classStrict (flag off)ORG_ADMIN_VIEW_USER_TXS on (admin viewer)
bothHidden tx (both from and to Hidden/Redacted to admin)dropkeep + [PRIVATE] / [PRIVATE], value preserved, timing visible
bothHidden token transferdropkeep + [PRIVATE] / [PRIVATE], amount preserved
bothHidden internal txdropkeep + [PRIVATE] / [PRIVATE], value preserved
Contract deployment by Hidden deployerdropkeep + [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, …): call s.buildVisibilityFilter(ctx, viewerDID) to get a VisibilityFilter (used by the SQL allowlist), then derive RedactOpts via redactOptsFromFilter(filter). Set ViewerIsAdmin and apply the applyAdminTxView wiring on the returned opts.
  • Single-item handlers (/transactions/:hash, /transactions/:hash/internal, …): call s.buildRedactOptsForViewer(ctx, viewerDID). This helper internally delegates to buildVisibilityFilterredactOptsFromFilter, then applies admin-flag wiring — guaranteeing list-path and by-hash see the identical VisibleTxHashes set, the identical ViewerIsAdmin value, and the identical OrgAdminViewUserTxs flag.

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:

  1. /transactions/transfers. If a token-transfer row with tx_hash=X surfaces to the viewer in /transactions/:hash/transfers or any list-level transfer feed, then /transactions (list) also contains a row for X. Previously the two drop predicates evaluated bothHidden on different address sets — /transactions on tx from/to, /transfers on transfer from/to — and disagreed.
  2. List ↔ by-hash agree on row survival. If /transactions (list) contains a row for X, then GET /transactions/:hash=X returns 200 (not 404). And vice versa: if the list drops X, by-hash returns 404. The single shared opts builder above is the load-bearing contract.
  3. Parent-tx allowlist propagates to derived feeds (this PR). If GET /transactions/:hash=X returns 200, then for every row the underlying tables hold for parent X, GET /transactions/:hash=X/transfers, GET /transactions/:hash=X/internal, and GET /transactions/:hash=X/logs surface 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.
  4. 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.
  5. 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 /internal regardless of the counterparty's own visibility. Field-level rendering follows the grant level: full reveals the counterparty's real address (audited as a regulatory subpoena reveal — RedactStats.GrantFullRevealsrbac_audit_log with resource_type=disclosure_grant); pseudonymous renders both parties under the Address-XXXX lens; redacted renders both as [PRIVATE] with value preserved (proof-of-activity audit lens). Participants and visibleTo recipients 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:

  1. Register an ABI for every contract whose events should be visible. Without an ABI the proxy cannot decode non-indexed address parameters in event data, 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-deny event_rules up-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_type to 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": [...]}
    
  2. 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.

  3. 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.