Skip to main content

Private Links (ZK)

A private link allows you to prove ownership of a wallet on another blockchain — or use it to authorize on-chain actions — without ever writing the address to chain storage.

Only a Poseidon commitment is stored on-chain. The real address exists only in your hands.


Public chain links (see Chain links) publish your external wallet address permanently. In many cases that is desirable, but sometimes it is not:

  • You may want to demonstrate wallet ownership to a specific auditor, DAO, or regulator without making it universally public.
  • You may want to use a wallet from another chain to authorize on-chain actions without revealing which wallet it is.

Private links solve both cases.


The commitment scheme

The on-chain commitment is computed using Poseidon hash (BN254 scalar field):

inner      = Poseidon2( Fr::from(chain_id),  address_as_field_element )
commitment = Poseidon2( inner, blinding_as_field_element )

Field element encoding:

InputEncoding
chain_idFr::from(chain_id as u64) → 4-byte LE, zero-padded to 32 bytes
addressRaw address bytes, zero-padded on the right to 32 bytes, interpreted as LE
blinding32-byte random scalar, interpreted as LE

The blinding is a random value you generate off-chain and must keep secret. Without it, no one can reverse-engineer your address from the commitment alone (Poseidon is a one-way function in this context).

warning

The blinding scalar must be stored securely. If lost, the commitment cannot be revealed and reveal_private_link will fail permanently for that slot.


pallet: AccountMapping (index 14)
call: register_private_link (index 14)
args:
chain_id: u32 — identifier of the external chain
commitment: [u8; 32] — Poseidon commitment computed off-chain
origin: Signed (alias owner)

The runtime stores the PrivateChainLink { chain_id, commitment } in PrivateChainLinks<T> keyed by alias. No signature verification is performed here — the commitment alone carries the binding.

Constraints:

  • The caller must have an alias (NoAlias).
  • No duplicate chain_id or identical commitment (PrivateLinkAlreadyExists).
  • Maximum 16 links (shared limit with public chain links, TooManyPrivateLinks).

Note: chain_id does not need to be registered in SupportedChains for private links. Signature validation only happens at reveal time.

Emits:

PrivateChainLinkAdded { account, chain_id, commitment }

pallet: AccountMapping (index 14)
call: remove_private_link (index 15)
args: commitment: [u8; 32]
origin: Signed (alias owner)

No signature required. Owning the alias is sufficient proof. The address is never exposed during removal.

Emits: PrivateChainLinkRemoved { account, chain_id, commitment }.


Revealing permanently promotes a private link to a public chain link. This operation is irreversible.

pallet: AccountMapping (index 14)
call: reveal_private_link (index 16)
args:
commitment: [u8; 32] — the stored commitment to reveal
address: Vec<u8> — the real external address
blinding: [u8; 32] — the blinding scalar used when computing the commitment
signature: Vec<u8> — external wallet signature over the owner's AccountId32
origin: Signed (alias owner)

Steps executed on-chain:

  1. Locates the PrivateChainLink by commitment in PrivateChainLinks.
  2. Recomputes Poseidon2(Poseidon2(chain_id_fe, address_fe), blinding_fe) and checks it equals commitment (CommitmentMismatch).
  3. Verifies the external wallet's signature over encode(AccountId32) using the SignatureScheme registered for chain_id (InvalidSignature). The chain must be in SupportedChains at reveal time.
  4. Removes the private link.
  5. Inserts the address into the public chain_links of the IdentityRecord.
  6. Adds the reverse index (chain_id, address) → AccountId32.

Emits: PrivateChainLinkRevealed { account, chain_id, address }.

warning

Reveal is irreversible. After this call, the address is permanently public, queryable by anyone on-chain.

Use case: compliance on demand. You can register a private link at any time, then reveal it months later to a specific auditor, DAO, or grant committee — proving you owned the wallet from a certain block height, without having published the address from day one.


Dispatching calls privately (ZK proof)

Private links can authorize on-chain actions without revealing the wallet address at any point — not even during dispatch.

pallet: AccountMapping (index 14)
call: dispatch_as_private_link (index 17)
args:
owner: AccountId32 — the Orbinum account that owns the private link
commitment: [u8; 32] — the commitment of the private link to use
zk_proof: Vec<u8> — Groth16 proof
call: RuntimeCall — the inner call to execute as `owner`
origin: Signed (any relayer)

The Groth16 proof asserts:

  • Knowledge of (address, blinding) such that Poseidon2(Poseidon2(chain_id_fe, address_fe), blinding_fe) == commitment.
  • The external wallet at address signed blake2_256(encode(call)).

Execution flow:

  1. Verifies commitment is registered under owner's alias (PrivateLinkNotFound).
  2. Computes call_hash = blake2_256(encode(call)).
  3. Verifies the ZK proof via T::PrivateLinkVerifier (InvalidProof).
  4. Dispatches call with origin = Signed(owner).
  5. The relayer pays the fee; the address is never exposed.

Emits: PrivateLinkDispatchExecuted { owner, commitment }.

WIP — ZK verifier circuit

The dispatch_as_private_link extrinsic requires the private_link_dispatch Groth16 circuit to be deployed in pallet-zk-verifier. Until that artifact is available, the runtime is configured with type PrivateLinkVerifier = DisabledPrivateLinkVerifier, which rejects all proofs. The extrinsic exists and is callable, but will always return InvalidProof in the current MVP.


                    register_private_link

┌───────────┴──────────────┐
│ │
remove_private_link reveal_private_link
(address never seen) (irreversible, public)
│ │
[deleted] dispatch_as_linked_account
(now a public chain link)

For the ZK dispatch path (address stays private forever):

   register_private_link

dispatch_as_private_link ← ZK proof, address never revealed

(can dispatch many times with same commitment)

remove_private_link ← when no longer needed

Security properties

PropertyGuarantee
Address privacy (registration)The address is never written on-chain. Only the Poseidon commitment is stored.
Address privacy (dispatch)The ZK proof verifies signature knowledge without revealing the address.
BindingThe commitment binds chain_id, address, and blinding. No other address can produce the same commitment (collision resistance of Poseidon).
Proof soundnessThe Groth16 proof is sound under the BN254 discrete log assumption.
Revelation controlOnly the alias owner can reveal. No one else can force disclosure.

Stated limitations (MVP):

  • dispatch_as_private_link is not yet functional (circuit not deployed).
  • The blinding scalar management is entirely off-chain. Orbinum does not provide a custody mechanism.
  • Poseidon parameters follow the BN254 / iden3 convention (light-poseidon library, full rounds 8, partial rounds 57 for 2 inputs).