Response-Level Privacy Filtering
The Open Privacy Suite enforces privacy at two distinct layers. Request filtering (covered in the Security page) determines which methods and contracts a user can access. Response filtering, described here, controls what data a user sees within allowed responses.
Request filtering alone is insufficient for financial privacy. Consider a contract with ten participants who all have access: without response filtering, every participant can read every other participant's transaction calldata, nonces, and event log parameters. Response filtering closes this gap by restricting response data to information that involves the requesting user's own addresses.
How It Works
Response filters run after the proxy forwards a request to the Ethereum node and before the response is returned to the client. Each filtered method has its own logic, but they all share a common concept: the user's linked ETH addresses.
Linked ETH Addresses
A user's linked ETH addresses include two types:
- User-initiated links — addresses explicitly verified through
POST /eth/link/challenge+POST /eth/link/verify, which require an EIP-191 signature proving key ownership. - System-inferred links — addresses automatically linked when a user sends a signed transaction via
eth_sendRawTransaction. The proxy cryptographically recovers the sender address from the transaction signature; no separate linking step is needed.
Both link types are included in participant checks. User-initiated links take precedence when the same address exists under both types.
Response filtering uses these linked addresses to determine what the user is allowed to see. A user is considered a participant in a transaction if their linked address matches the transaction's from or to field.
No linked addresses = no data
If a user has not linked any ETH addresses and has not yet sent any transactions through the proxy, all response-filtered methods return null or empty results. Addresses are linked automatically as users interact with the network via eth_sendRawTransaction.
eth_getStorageAt -- Tiered Access
eth_getStorageAt uses claim-based tiered access instead of a blanket block. Raw storage slot reads bypass contract-level privacy controls (ABI restrictions, parameter rules, method allowlists), so access is restricted based on the user's claims on the target contract.
| Claim level | Access |
|---|---|
| Admin | Unrestricted access to all storage slots |
| Non-admin (any user without admin claim) | Allowed only for well-known proxy infrastructure slots (see below) |
| No claim | Blocked |
Well-known proxy infrastructure slots
Non-admin users can query only the following four slots. These are standard EIP-1967 and EIP-2535 proxy infrastructure slots that expose implementation/admin addresses, not user data:
| Slot | Standard | Purpose |
|---|---|---|
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | EIP-1967 | Implementation address |
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | EIP-1967 | Admin address |
0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | EIP-1967 | Beacon address |
0xc8fcad8db84d3cc18b4c41d551ea0ee66dd599cde068d998e57d5e09332c131c | EIP-2535 | Diamond storage |
Any other slot returns an error for non-admin users. This allows standard proxy introspection tools (e.g., OpenZeppelin Upgrades) to function while preventing raw state extraction of private contract data.
Transaction Data
Transaction data can be fetched by hash or by block position. All three methods apply the same participant check and return the same filtered result.
| Method | Participant check | Non-participant response |
|---|---|---|
eth_getTransactionByHash | from or to matches linked address | null (full tx if the viewer's DID is in the tx's visibleTo list) |
eth_getTransactionByBlockHashAndIndex | same | null (full tx if the viewer's DID is in the tx's visibleTo list) |
eth_getTransactionByBlockNumberAndIndex | same | null (full tx if the viewer's DID is in the tx's visibleTo list) |
What this protects:
- Calldata (
inputfield) -- contains the function selector and encoded arguments. Without filtering, any reader could decode another user's function call parameters. - Nonce -- reveals the sender's transaction count, which can be used to infer activity patterns.
- Value -- the ETH amount transferred.
- Gas parameters -- can reveal information about transaction complexity.
Non-participants receive null, which is indistinguishable from querying a transaction hash that does not exist. This prevents information leakage even through the presence/absence of a response.
Transaction Receipts (eth_getTransactionReceipt)
Transaction receipts contain execution results including status, gas used, and -- critically -- event logs.
Participant check: The receipt's from and to fields are compared against linked addresses.
| User is a participant? | Response |
|---|---|
| Yes | Receipt (but logs are filtered to only show those involving the user, and logsBloom is zeroed) |
| No, but entitled to ≥1 of the tx's logs under their event rules | Receipt, with the logs array filtered to only those entitled logs (logsBloom zeroed) |
| No, and entitled to none of the tx's logs | null |
Even when a user is a participant, the receipt's logs array is filtered using the same rules as eth_getLogs (see below), and the logsBloom is zeroed out to prevent leaking the total number or presence of redacted logs. Note that "the same rules as eth_getLogs" includes the own-transaction rule below: a participant sees every log of their own transaction that was emitted by a contract they have access to — including events that do not carry their address (e.g. an event keyed by a business identifier).
A viewer who is not a participant (and not in the tx's visibleTo, and not an admin) but who is entitled to at least one of the transaction's logs under their group's event rules — for example a payment's payee admitted to a PaymentCreated event by a must_be:self rule on an indexed party parameter — also receives the receipt, with its logs filtered to exactly those entitled logs. The logs themselves reveal nothing new — the viewer already receives them from eth_getLogs (which returns each log's transactionHash). Admitting the receipt envelope does additionally expose the envelope fields from, to, status, and gasUsed, which eth_getLogs does not return; this is the same disclosure posture as a visibleTo recipient, who receives the full receipt for a transaction they are named on. It is justified by the viewer's proven log-entitlement (they are a party to at least one of the transaction's events). Contract-deployment receipts are excluded from this admission — they are returned only to participants/visibleTo/admins, so the deploy receipt's top-level contractAddress is never exposed this way.
Event Logs (eth_getLogs)
eth_getLogs returns an array of log entries matching a filter. The response filter iterates through each log individually and applies per-entry address matching on indexed topics.
Topic structure
Each log entry has up to four topics:
| Topic | Content | Used for filtering? |
|---|---|---|
topics[0] | Event signature hash (keccak256 of the event name and parameter types) for non-anonymous events; may be an address for anonymous events | Yes -- checked for the zero-padded address pattern |
topics[1] | First indexed parameter (zero-padded to 32 bytes) | Yes, if it contains an address |
topics[2] | Second indexed parameter | Yes, if it contains an address |
topics[3] | Third indexed parameter | Yes, if it contains an address |
Filtering logic
For each log entry in the response:
- Check all four topics including
topics[0]-- for standard (non-anonymous) events,topics[0]is the event signature hash (e.g.,keccak256("Transfer(address,address,uint256)")). Because this is not a zero-padded address, the address-pattern check naturally skips it. For anonymous events (declared with theanonymouskeyword),topics[0]may contain an indexed address parameter and is therefore scanned. - Check
topics[1]throughtopics[3]-- for each topic, extract the lower 20 bytes as an Ethereum address (topics are 32 bytes; addresses are the last 20 bytes, zero-padded on the left). - Compare each extracted address against the user's linked ETH addresses (case-insensitive).
- Keep the log entry if at least one topic address matches a linked address. Otherwise, apply the own-transaction rule below; if that also does not apply, remove it.
Own-transaction logs
An event does not always name its stakeholders in an indexed address topic — some contracts emit events keyed only by a business identifier (for example PaymentCompleted(bytes32 paymentKey, string paymentId)). Address matching alone would hide such an event from the very people it concerns, including the account that submitted the transaction.
To close this, a user also sees the logs of transactions they participated in (their linked address is the transaction's from or to), for logs emitted by contracts they have access to — even when the event carries no address of theirs. This reveals nothing new: the user submitted (or received) the transaction and already knows its contents.
This admission is bounded:
- It applies only to contracts the user has a grant on. A log emitted by a contract the user cannot otherwise see stays hidden, even if it appeared in one of their transactions (for example, an internal call into another organization's private contract).
- It does not relax the ABI and dynamic-payload requirements. An event whose contract has no registered ABI, or that carries a dynamic non-indexed parameter without the contract's
events_allow_dynamic_payloadsetting enabled, remains hidden — those protections guard against leaking addresses embedded in the payload, and they apply to participants too.
Example: ERC-20 Transfer event
event Transfer(address indexed from, address indexed to, uint256 value);
For this event:
topics[0]=keccak256("Transfer(address,address,uint256)")-- skippedtopics[1]= sender address (zero-padded to 32 bytes)topics[2]= recipient address (zero-padded to 32 bytes)
A user with linked address 0xAlice would see Transfer logs where 0xAlice is either the sender or recipient. Transfers between two other parties are filtered out.
Embedded address redaction within a kept log
Keeping a log entry does not mean returning it verbatim. A single event can carry several addresses — a sender, a recipient, and other parties — and a user entitled to see the event because one of those addresses is theirs is not necessarily entitled to see the others. Once a log is kept, every embedded address the user is not entitled to see — in the indexed topics and in the ABI-decoded event data — is zeroed out (0x000…0). Only the addresses the user can see are returned in the clear: their own linked addresses, addresses in contracts they hold a grant on, public addresses, and addresses shared with them by a disclosure grant.
This makes eth_getLogs, transaction receipts, and the block explorer reveal the same set of addresses to a given user — a log fetched over JSON-RPC exposes nothing the explorer would have hidden, and vice versa.
A zeroed address looks like the zero address
A redacted address slot is zeroed, which is indistinguishable from a genuine zero address (for example, a token mint or burn whose from/to is 0x000…0). Clients must not treat a zero address inside a returned log as authoritative — it may be a real zero address or a redacted one.
Known Limitations
Events without indexed address parameters
Events that do not include any indexed address parameter in topics[1]--topics[3] are filtered out for all users, because no topic can match any linked address. For example, event Initialized(uint8 version) has only topics[0] (the signature hash) and no indexed address. No user will ever see this event through eth_getLogs.
Multi-party events with non-address identifiers
Events that use a business identifier instead of an address for their indexed parameters (e.g., event PaymentInitiated(string indexed paymentIdentifier)) cannot be matched to any user's linked ETH address. These events are filtered out for all users; a separate stakeholder whitelist mechanism would be needed to surface them selectively.
Block Methods (eth_getBlockByHash, eth_getBlockByNumber)
These methods return an entire block, optionally including full transaction objects. The filtering behavior depends on the fullTxObjects parameter.
| Method | fullTxObjects | Filtering |
|---|---|---|
eth_getBlockByHash | true | Per-tx from/to check; non-participant txs removed from array |
eth_getBlockByHash | false | Per-tx check (proxy requests full objects, filters, and maps to hashes) |
eth_getBlockByNumber | true | Per-tx from/to check; non-participant txs removed from array |
eth_getBlockByNumber | false | Per-tx check (proxy requests full objects, filters, and maps to hashes) |
Whether fullTxObjects is true or false, the transactions array is strictly filtered. For false queries, the proxy transparently fetches full transaction objects behind the scenes, applies the filters, and returns only the hashes of transactions the user participated in. This prevents leaking the exact total number of transactions in a block.
Block Transaction Counts (eth_getBlockTransactionCountByHash/Number)
These methods return the hex-encoded integer count of transactions in a block. The proxy intercepts these calls, fetches the full block, counts only the transactions the user is a participant in, and returns that personalized count.
When fullTxObjects=true, the transactions array contains full transaction objects (the same shape as eth_getTransactionByHash responses). The filter iterates over each transaction and removes entries where neither from nor to matches a linked address. Block-level fields (number, hash, timestamp, gasLimit, etc.) are preserved, but logsBloom, gasUsed, blobGasUsed, and size are forcefully zeroed on every block-shaped response, for every viewer — these aggregate fields (bloom, total gas, and the block's serialized byte-length) would otherwise leak the existence of redacted logs and the volume of other-org activity across the block. The block explorer API omits size entirely.
Block Receipts (eth_getBlockReceipts)
eth_getBlockReceipts returns all transaction receipts in a block. The filter applies the same per-receipt participant check as eth_getTransactionReceipt: for each receipt, it checks whether from or to matches a linked address. Non-participant receipts are removed from the array entirely, consistent with how eth_getBlockByNumber removes non-participant transactions. The returned array contains only receipts for transactions the user was a party to, and just like single receipt queries, their inner logs arrays are strictly filtered and logsBloom is zeroed.
Stateful Filter API -- Blocked
eth_newFilter, eth_newBlockFilter, eth_newPendingTransactionFilter, eth_getFilterLogs, eth_getFilterChanges, and eth_uninstallFilter are globally blocked. These create server-side filter state that cannot be safely scoped per-user, posing the same bypass risk as eth_subscribe.
Use eth_getLogs with explicit block ranges instead -- it is properly response-filtered.
Compliance Requirements
These response-filtering features implement core privacy requirements for regulated financial networks:
| Requirement | Implementation |
|---|---|
| Intra-org transaction confidentiality | eth_getTransactionByHash returns null for non-participants, preventing co-members from reading each other's calldata and transfer details |
| Event log visibility restrictions | eth_getLogs filters entries by indexed address topics, ensuring users see only events involving their own addresses |
| Raw state access prevention | eth_getStorageAt uses tiered access based on claims: admin users get unrestricted slot access, non-admin users can query only EIP-1967 proxy infrastructure slots, and users with no claim are blocked entirely |
| Receipt log redaction | eth_getTransactionReceipt strictly filters inner logs and zeros bloom filters, ensuring even participants cannot see unrelated logs |
These controls ensure that even within a shared private network where all participants have read access to the same contracts, individual transaction privacy is maintained at the response level.
Per-Transaction Visibility (visibleTo)
The visibleTo feature (renamed from logVisibleTo) allows a transaction sender to specify additional DIDs (decentralized identifiers) that can see the transaction and its event logs. This is useful for multi-party workflows where a third party needs to observe events they are not a direct participant of (e.g., an auditor, a settlement bank, or a compliance officer).
Two distinct mechanisms
visibleTo drives two different things with different gating — keep them separate:
| Mechanism | What a listed DID sees | What it requires |
|---|---|---|
| Transaction & receipt visibility (always on) | The full transaction object (eth_getTransactionByHash and the block-index variants) and the full receipt (eth_getTransactionReceipt) for that one tx | Only that the viewer's DID is in the tx's visibleTo list. No group access or contract grant is required. Logs inside a returned receipt are still event-rule filtered. |
| Event-log unlock (per-contract opt-in) | All event logs of that tx — bypassing the event-rules allowlist, param_rules, and field-level redaction | The allow_visibleto_unlock flag on the contract and the viewer holds an in-org contract grant (see eligibility below). Cross-org and anonymous viewers are denied. |
So "I listed Bob in visibleTo" does not by itself mean "Bob sees every event payload." Bob always gets the transaction and receipt; he only gets the unredacted event logs when the contract has opted in and Bob is grant-eligible.
How it works
Recommended: a top-level visibleTo field on the JSON-RPC request (a sibling of params), on either send method. privateFor is accepted as an alias for Quorum/Tessera/Besu compatibility. Recipients may be DIDs and/or Ethereum addresses — an address is resolved to the DID it is linked to (fail-closed: an address with no linked identity is ignored, never widening visibility):
{
"jsonrpc": "2.0", "id": 1,
"method": "eth_sendRawTransaction",
"params": ["0xf86c..."],
"visibleTo": ["did:privado:auditor1", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"]
}
privateFor is an alias with identical behaviour (useful when migrating from Quorum/Tessera):
{
"jsonrpc": "2.0", "id": 1,
"method": "eth_sendRawTransaction",
"params": ["0xf86c..."],
"privateFor": ["0x70997970C51812dc3A010C7d01b50e0d17dc79C8"]
}
The top-level form works with standard Ethereum client libraries. For back-compat the field is also accepted inside the params — in the transaction object for eth_sendTransaction (params[0]), or as a second parameter for eth_sendRawTransaction:
{"method":"eth_sendTransaction","params":[{"from":"0xabc...","to":"0xdef...","data":"0x...","visibleTo":["did:privado:auditor1"]}]}
{"method":"eth_sendRawTransaction","params":["0xf86c...",{"visibleTo":["did:privado:auditor1"]}]}
All forms present on one request are combined (deduped, capped at 32 recipients). The proxy strips every visibleTo/privateFor field before forwarding to the node, resolves addresses to DIDs, stores the rule in the database, and applies it during response filtering and explorer visibility.
Interaction with static event rules
By default visibleTo is additive -- it never restricts existing access:
- If a viewer already has access to an event through normal event rules or address-based filtering,
visibleTohas no effect. - If a viewer is denied an event because
must_be=selfparam rules fail,visibleToserves as a fallback: the log is visible if the viewer's DID appears in the list. This fallback applies only on contracts the viewer already holds a grant to. visibleTodoes not grant access to a contract the viewer has no grant on. Logs from an ungranted contract stay hidden even when the viewer is listed in the tx'svisibleTo— grant eligibility is required. (The viewer still receives the transaction/receipt envelope, but its logs are filtered.) StandalonevisibleTo-based event access requires the per-contract unlock opt-in below.visibleTodoes not bypass the topic0 allowlist. If an event's signature is not in the allowed event rules, it remains hidden regardless ofvisibleTo.- Admin users always see all events --
visibleTodoes not change admin behavior.
Per-contract operators can opt out of additive-only behaviour and into the unlock semantic described below.
Per-contract opt-in: visibleTo as event-visibility unlock
Some workflows need many participants with shifting event-by-event visibility (e.g. settlement banks, multi-party trade settlement). Pre-declaring an event_rules allowlist per group does not scale for those. The contract owner can opt the contract in to the unlock semantic: per-tx visibleTo lists become per-event opt-in unlocks for listed users.
Enable per-contract:
PUT /api/orgs/:org_id/contracts/:address/visibleto-unlock
{"allow_visibleto_unlock": true}
Admin-only on the contract's owning org. Default is false -- existing additive behaviour stays unless explicitly opted in.
When the flag is true and a transaction's visibleTo lists a viewer who is in an eligible group on this contract, the viewer sees ALL event logs of that one transaction -- including events whose topic0 is not in their event-rules allowlist, including events that fail their param_rules, and including embedded private addresses in topics/data. The unlock is per-tx, all-events.
Eligibility requires both:
- The viewer is registered (has a DID-backed user account -- anonymous viewers are denied).
- The viewer is a member of at least one non-system group whose org matches the contract's owning org, AND that group has a
contract_granton this contract. The grant'sevent_rulesmay be deny-all -- the unlock works because of the grant link, not its rule set.
Cross-org isolation: a viewer who has access only in another org cannot be unlocked, even if they are listed in visibleTo. Anonymous and system groups are excluded.
Per-tx blast-radius cap: visibleTo lists at eth_sendTransaction time are capped at 32 entries. Larger lists are rejected with HTTP 400. Use a dedicated group + grant for larger recipient sets.
Operator security checklist before flipping the flag:
- Contracts with the flag on let the tx sender authorise per-event visibility for any DID they list. Set this only on contracts where that's the intended workflow.
- The set of users who can see a contract's events grows beyond what your
groups + grantsenumeration shows when the flag is on. Plan for that in access reviews. - Revoking a viewer's group membership cuts their unlock access immediately on the next request -- the eligibility check runs per-request.
RPC method access still required
visibleTo and the unlock relax redaction, never method access -- every RPC retrieval is gated by the viewer's group method allowlist first. Every RPC retrieval surface is gated by the viewer's group AllowedMethods, before any visibility or redaction logic runs:
- To receive the transaction object, the group must allow
eth_getTransactionByHash(or the block-index variants). - To receive the receipt and its logs, the group must allow
eth_getTransactionReceipt. - To receive filtered logs, the group must allow
eth_getLogs(plus contract access).
A DID listed in visibleTo whose group does not allow the relevant method is denied at the allowlist check -- the feature never adds a method to a viewer's allowlist. The Explorer API is gated by the redaction engine and the viewer's identity rather than the RPC method allowlist, which is why a viewer granted visibility on a transaction can see it in the explorer even if the corresponding raw RPC method is not in their group's allowlist.
Scope
visibleTo grants visibility at two layers:
-
Explorer views: Transactions with
visibleTogrants appear in the regular Transactions and Token Transfers pages for listed DIDs, alongside their own activity. -
JSON-RPC filtering: Listed DIDs can see event logs from these transactions via
eth_getLogsand can bypass the from/to participant check for:eth_getTransactionReceipt-- listed DID receives the full receipt (with logs filtered by the same event rules)eth_getTransactionByHash-- listed DID receives the full transaction objecteth_getTransactionByBlockHashAndIndex/eth_getTransactionByBlockNumberAndIndex-- listed DID receives the full transaction object
This makes
visibleToa consistent cross-method override: whichever RPC the listed DID uses to fetch data for an allowed tx, they get the response they would get as a participant. Unlisted DIDs remain gated by the normal participant check.
Summary Table
| Method | Participant behavior | Non-participant behavior |
|---|---|---|
eth_getStorageAt | Admin: all slots; Non-admin: EIP-1967 proxy slots only | Blocked (no claim) |
eth_getTransactionByHash | Full transaction | null (or full tx if viewer is in visibleTo) |
eth_getTransactionByBlockHashAndIndex | Full transaction | null (or full tx if viewer is in visibleTo) |
eth_getTransactionByBlockNumberAndIndex | Full transaction | null (or full tx if viewer is in visibleTo) |
eth_getTransactionReceipt | Receipt with filtered logs | null (or receipt with filtered logs if viewer is in visibleTo) |
eth_getBlockByHash (fullTxObjects=true) | Full block with all txs | Non-participant txs removed from array |
eth_getBlockByHash (fullTxObjects=false) | Hashes of user's txs | Non-participant hashes removed from array |
eth_getBlockByNumber (fullTxObjects=true) | Full block with all txs | Non-participant txs removed from array |
eth_getBlockByNumber (fullTxObjects=false) | Hashes of user's txs | Non-participant hashes removed from array |
eth_getBlockTransactionCountByHash/Number | Count of user's txs | Count of user's txs only |
eth_getBlockReceipts | Receipts with filtered logs | Non-participant receipts removed from array |
eth_getLogs | Logs where a topic address matches | Those logs removed from response |
eth_newFilter / eth_newBlockFilter / etc. | Blocked (globally) | Blocked (globally) |
Not covered by response filtering
-
Multi-party event stakeholder whitelists -- Events that use a business identifier (e.g.,
event PaymentInitiated(string indexed paymentIdentifier)) require a per-event-ID whitelist of stakeholders (debtor bank, settlement bank, creditor bank). Without it, events with non-address indexed parameters are filtered out for all users. Requires a new data model, admin API, and on-chain lookup to resolve stakeholders dynamically. -
eth_callreturn data -- responses are raw ABI-encoded bytes passed through unmodified; address-typed return values are not field-level redacted. Avoid returning sensitive addresses from view functions exposed to unauthorized callers. -
Value inference for mixed public/private transactions -- When a transaction involves one private address and one public address, the
valuefield is stripped and the private side is replaced with[PRIVATE]in the block explorer. However, the transferred amount may still be inferable from the public address's balance deltas across adjacent blocks. Stripping the value removes the direct leakage path but is not a complete financial privacy guarantee. See Privacy Requirements for details. -
Logs from contracts without a registered ABI -- denied to non-admin viewers (we cannot decode non-indexed
addressparameters indata, so we fail closed). Register the contract's ABI to enable event-level access.