diff --git a/README.md b/README.md index 3e8d1cc4..8627af3c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ DIP stands for Dash Improvement Proposal. Similar to Bitcoin's [BIPs](https://github.com/bitcoin/bips/), a DIP is a design document providing information to the Dash community, or describing a new feature for Dash or its processes or environment. The DIP should provide a concise technical specification of the feature and a rationale for the feature. -Because Dash is forked from the Bitcoin codebase, many of the BIPs can be applied to Dash as well (a list of the BIPs updated to include Dash-specific details can be found [here](https://github.com/dashevo/bips)). The purpose of the DIPs is not to duplicate those which exist as BIPs, but to introduce protocol upgrades or feature specifications which are unique to Dash. +Because Dash is forked from the Bitcoin codebase, many of the BIPs can be applied to Dash as well (a list of the BIPs updated to include Dash-specific details can be found in the [dashevo/bips repository](https://github.com/dashevo/bips)). The purpose of the DIPs is not to duplicate those which exist as BIPs, but to introduce protocol upgrades or feature specifications which are unique to Dash. ## Contributions @@ -47,6 +47,7 @@ Number | Layer | Title | Owner | Type | Status [29](dip-0029.md) | Consensus | Randomness Beacon For LLMQ Selection | Virgile Bartolo | Standard | Proposed [30](dip-0030.md) | Consensus | Replay Attack Prevention and State Transition Nonces | Samuel Westrich | Standard | Proposed [31](dip-0031.md) | Consensus | Platform Proof of Service | Ivan Shumkov, Pasta | Standard | Proposed +[33](dip-0033.md) | Applications | DashPay Payment Addresses and Payment Notifications | Samuel Westrich | Standard | Proposed ## License diff --git a/dip-0033.md b/dip-0033.md new file mode 100644 index 00000000..0877a722 --- /dev/null +++ b/dip-0033.md @@ -0,0 +1,381 @@ +
+ DIP: 0033 + Title: DashPay Payment Addresses + Author(s): Samuel Westrich + Special-Thanks: Dash Platform Team + Comments-Summary: No comments yet. + Status: Proposed + Type: Standard + Created: 2026-08-03 + License: MIT License + Requires: 13, 15, 17, 18 + Replaces: - + Superseded-By: - ++ +## Table of Contents + +1. [Abstract](#abstract) +1. [Motivation](#motivation) +1. [Prior Work](#prior-work) +1. [Terminology](#terminology) +1. [Specification](#specification) + 1. [Overview](#overview) + 1. [Profile Payment Address Fields](#profile-payment-address-fields) + 1. [Identity Shielded Accounts](#identity-shielded-accounts) + 1. [Profile Shielded Address](#profile-shielded-address) + 1. [Per-Contact Shielded Addresses](#per-contact-shielded-addresses) + 1. [Per-Contact Diversifier Index](#per-contact-diversifier-index) + 1. [Rotation and Existing Contacts](#rotation-and-existing-contacts) + 1. [Shielded Memo Kinds](#shielded-memo-kinds) + 1. [Client Behavior](#client-behavior) +1. [Rationale](#rationale) +1. [Backwards Compatibility](#backwards-compatibility) +1. [Security Considerations](#security-considerations) +1. [Privacy Considerations](#privacy-considerations) +1. [Test Vectors](#test-vectors) +1. [Considered and Deferred: A Notified Stealth-Address Tier](#considered-and-deferred-a-notified-stealth-address-tier) +1. [Future Extensions](#future-extensions) +1. [Copyright](#copyright) + +## Abstract + +This DIP extends DashPay with payment addresses across the payment rails of the Dash network: the Dash Core chain, Dash Platform payment addresses ([DIP-17](dip-0017.md)/[DIP-18](dip-0018.md)), and the Dash Platform shielded pool. It defines two tiers: + +* A **public tier**: optional payment address fields on the DashPay profile document (a Core chain address and a Platform payment address), allowing anyone to pay a profile with zero interaction. Payments to these addresses are publicly linkable to the profile by design. +* A **shielded tier**: a published shielded-pool payment address on the profile for private zero-interaction payments, and per-contact diversified shielded addresses exchanged through [DIP-15](dip-0015.md) contact requests for private payments with built-in attribution. Shielded payments natively hide the payer, the recipient, and the amount; publishing a shielded address reveals only that the profile accepts shielded payments. + +Per-contact addresses are derived deterministically from the contact request that carries them, so a wallet never hands the same address to two contacts, on any device and after any restore, without keeping allocation state. + +A third design, payer-derived stealth addresses on the transparent rails announced by encrypted Platform notifications, was fully specified, reviewed, and deliberately deferred; the [appendix](#considered-and-deferred-a-notified-stealth-address-tier) records the design and the reasons, chiefly that any payment-time Platform artifact is timing-correlatable with its settlement transaction. + +## Motivation + +[DIP-15](dip-0015.md) establishes private payment channels between Dash identities, but only after a contact relationship is established: each side derives a per-contact address chain from encrypted extended public keys exchanged in contact requests. Two gaps remain: + +1. **Zero-interaction payments.** There is no standard way to pay a profile you have no relationship with: no "tips jar", no pay-by-username without a prior contact handshake. +2. **Private payments without a relationship.** Establishing a DashPay contact publishes a public contact edge. There is no way to pay someone privately (unlinkable by third parties) without first creating that public relationship. + +Additionally, [DIP-17](dip-0017.md) introduced Platform payment addresses as identity-independent value holders, and payments to them face the same two gaps. + +This DIP fills both gaps with the minimum machinery: static profile addresses for the public case, and the Platform shielded pool for the private case. The pool already provides recipient unlinkability, hidden amounts, and light-client detection by trial decryption of note ciphertexts; this DIP only standardizes how DashPay publishes and exchanges shielded payment addresses and how payments are attributed. + +## Prior Work + +* [DIP-0011: Identities](dip-0011.md) +* [DIP-0013: Identities in Hierarchical Deterministic Wallets](dip-0013.md) +* [DIP-0015: DashPay](dip-0015.md) +* [DIP-0017: Dash Platform Payment Addresses and HD Derivation](dip-0017.md) +* [DIP-0018: Dash Platform Payment Address Encodings](dip-0018.md) +* [ZIP-32: Shielded Hierarchical Deterministic Wallets](https://zips.z.cash/zip-0032) (Orchard key derivation and diversifier indices) +* [Zcash Protocol Specification](https://zips.z.cash/protocol/protocol.pdf), section 5.6.4 (Orchard raw payment address and raw full viewing key encodings) +* [RFC 2104: HMAC](https://www.rfc-editor.org/rfc/rfc2104) and [RFC 5869: HKDF](https://www.rfc-editor.org/rfc/rfc5869) +* [BIP-0047: Reusable Payment Codes for Hierarchical Deterministic Wallets](https://github.com/bitcoin/bips/blob/master/bip-0047.mediawiki) (rejected approach; see appendix) +* [BIP-0352: Silent Payments](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki) (rejected approach; see appendix) +* Zcash Orchard diversified payment addresses (the unlinkability mechanism the shielded tier builds on) +* Stealth addresses as originally described by Peter Todd and used by CryptoNote-family protocols (deferred approach; see appendix) + +## Terminology + +* **Rail**: a settlement system for value. This DIP touches the **Core rail** (Dash Core chain transactions), the **Platform rail** (Platform payment addresses per DIP-17/18), and the **shielded rail** (the Dash Platform shielded pool). +* **Public tier**: payment targets published in the DashPay profile document whose payments are publicly linkable to the profile. +* **Shielded tier**: payment targets in the shielded pool; payments to them are unlinkable by third parties and hide their amounts. +* **Diversified address**: one of many unlinkable shielded payment addresses derivable from a single full viewing key; addresses at different diversifier indices cannot be linked to each other without the viewing key. +* **Diversifier index**: the 88-bit index `j` from which a full viewing key derives the diversified address at index `j` (ZIP-32). Distinct indices always give distinct addresses. +* **IVK / FVK / OVK**: the pool's incoming viewing key (detects and decrypts incoming notes), full viewing key, and outgoing viewing key (lets a sender recover what they paid). +* **Identity shielded account**: the shielded account a wallet derives for one of its identities ([Identity Shielded Accounts](#identity-shielded-accounts)). All shielded addresses this DIP publishes or exchanges come from it. +* **DIP-15 shared key**: the 32-byte AES key that [DIP-15](dip-0015.md) derives by ECDH between the sender key and recipient key of a contact request, used for its `encryptedPublicKey` field. + +## Specification + +### Overview + +A recipient publishes up to three payment targets: + +1. A **Core chain address** and/or a **Platform payment address** in the profile document (public tier). +2. A **shielded payment address** in the profile document (shielded tier, zero-interaction). +3. **Per-contact diversified shielded addresses**, one per contact request, delivered inside DIP-15 contact requests (shielded tier, attributed). + +Payments to the public tier are ordinary transparent payments, linkable to the profile by design. Payments to the shielded tier are ordinary shielded-pool payments: the recipient detects them by the pool's native note trial decryption, and no additional discovery mechanism, publication, or ordering rule is required, because the encrypted note carried by the payment itself is the notification. + +Identity-to-identity credit transfers are out of scope: they are inherently public, identity-linked operations. Value privacy for identity-held value is obtained by moving it through the shielded pool. + +### Profile Payment Address Fields + +The DashPay data contract's `profile` document type gains two OPTIONAL fields. Field positions continue after the existing `displayName` (position 4); existing fields, indices, and required properties are unchanged. + +```json +"corePaymentAddress": { + "type": "array", + "byteArray": true, + "minItems": 21, + "maxItems": 21, + "position": 5, + "description": "Core chain address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by 20-byte HASH160) for public payments" +}, +"platformPaymentAddress": { + "type": "array", + "byteArray": true, + "minItems": 21, + "maxItems": 21, + "position": 6, + "description": "DIP-18 platform address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by 20-byte HASH160) for public payments" +} +``` + +Normative requirements: + +* `corePaymentAddress` uses the same storage serialization as `platformPaymentAddress`: a variant byte (`0x00` for P2PKH, `0x01` for P2SH) followed by the 20-byte HASH160 (RIPEMD160 of SHA256) of the public key or redeem script. No network byte is stored: the network is determined by the Platform chain the document lives on, which makes a network mismatch unrepresentable. Clients MUST render the address in Base58Check with the version byte of the network they are on. A Base58Check string was rejected as the storage form because consensus could not prove its checksum or network byte anyway, while the byte form is smaller, exact-size-validated, and symmetric with the platform field. +* `platformPaymentAddress` MUST use the [DIP-18](dip-0018.md) storage serialization. Clients MUST display it in the [DIP-18](dip-0018.md) bech32m encoding. +* **Consensus enforces the type byte**: profile creation and replacement MUST reject either field whose leading byte is not `0x00` or `0x01` (implemented as a data trigger, since the schema vocabulary cannot constrain individual bytes of a byte array). Together with the exact-length constraint, an accepted value is always fully decodable as the advertised address type. +* Both fields are single static addresses, intentionally reusable and publicly linkable to the profile. Publishing an extended public key or any other derivation source is NOT part of this specification: since anything published in the profile is world-readable, a published chain is exactly as linkable as a single address while adding gap-limit scanning and index-coordination costs. +* Owners MAY rotate either address at any time by updating the profile document. Clients SHOULD continue monitoring previously published addresses they have handed out. +* Payments to these addresses carry no sender attribution by themselves. + +### Identity Shielded Accounts + +Every shielded address this DIP publishes or exchanges belongs to the **identity shielded account** of the identity that publishes it. For an identity with [DIP-13](dip-0013.md) identity index `identity_index`, the account is derived with ZIP-32 Orchard key derivation: + +```text +m_Orchard / 32' / coin_type' / (0x40000000 + identity_index)' +``` + +* `coin_type'` MUST be `5'` on mainnet and `1'` on testnet, devnets, and regtest, as in [DIP-17](dip-0017.md). +* `identity_index` MUST be less than `0x40000000`. Account indices from `0x40000000` to `0x7FFFFFFF` are reserved for identity shielded accounts; wallets MUST NOT use them for other purposes. +* Addresses are taken from the account's external scope. The diversifier index space of the account is partitioned as follows: + +| Diversifier index `j` | Use | +| --------------------- | --- | +| `0` | The profile `shieldedAddress` | +| `1` to `2^32 - 1` | Profile address rotations and other wallet-local receiving addresses; never given to a contact | +| `2^32` to `2^87 - 1` | Reserved | +| `2^87` to `2^88 - 1` | Per-contact addresses, derived only as specified in [Per-Contact Diversifier Index](#per-contact-diversifier-index) | + +* Wallets MUST NOT publish or hand out, for any purpose defined in this DIP, a shielded address from any other account. In particular, index `0` of a wallet's ordinary accounts receives change and MUST NOT appear in a profile. +* Wallets MUST scan the identity shielded account of every identity they manage, and on restore from seed MUST derive and scan the identity shielded account of every identity they discover. Funds received there are ordinary funds of that identity, and wallets SHOULD present them as such. +* An identity without a DIP-13 identity index (for example, one whose keys were imported rather than derived) has no identity shielded account; wallets MUST NOT set either shielded field defined in this DIP for it. + +### Profile Shielded Address + +The `profile` document type gains one further OPTIONAL field: + +```json +"shieldedAddress": { + "type": "array", + "byteArray": true, + "minItems": 43, + "maxItems": 43, + "position": 7, + "description": "Shielded pool payment address in raw form (11-byte diversifier followed by 32-byte diversified transmission key) for private payments" +} +``` + +Normative requirements: + +* The value is the raw 43-byte shielded payment address (11-byte diversifier `d` followed by the 32-byte diversified transmission key `pk_d`), exactly as used by shielded pool outputs. Clients MUST validate that the bytes decode to a valid shielded payment address before paying and MUST treat an undecodable value as no address rather than as an error in the profile. +* The published address MUST be the address at diversifier index `0` of the publishing identity's identity shielded account. A rotated address MUST come from indices `1` to `2^32 - 1` of the same account. The profile address MUST NOT be handed out as a per-contact address, so that attribution by diversifier ([Client Behavior](#client-behavior)) stays meaningful. +* Owners MAY rotate the published address at any time by updating the profile document; because shielded addresses never appear on chain, rotation is hygiene rather than necessity, and old addresses remain spendable-to and detected by the same viewing key. +* Publishing is OPTIONAL. An identity that does not publish a profile shielded address still receives private payments from its contacts through per-contact addresses. + +### Per-Contact Shielded Addresses + +The `contactRequest` document type gains one OPTIONAL field. Contact requests are immutable; the field is set at creation: + +```json +"encryptedShieldedAddress": { + "type": "array", + "byteArray": true, + "minItems": 64, + "maxItems": 80, + "encryptedFor": { + "recipient": "toUserId", + "recipientKey": "recipientKeyIndex", + "senderKey": "senderKeyIndex", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, + "position": 7, + "description": "Sender's per-contact shielded payment address (43 bytes), encrypted to the contact with the DIP-15 shared key" +} +``` + +The `encryptedFor` declaration is the one `encryptedPublicKey` carries. It makes consensus accept only ciphertext lengths that are a multiple of 16 bytes, so with the size bounds above only 64-byte and 80-byte values are valid. + +Normative requirements: + +* **Plaintext.** The plaintext is the sender's 43-byte raw shielded payment address at the per-contact diversifier index defined in [Per-Contact Diversifier Index](#per-contact-diversifier-index), taken from the sender identity's identity shielded account. +* **Encoding.** The value is a random 16-byte IV followed by the AES-256-CBC encryption, with PKCS#7 padding, of the 43-byte plaintext under the DIP-15 shared key of this contact request (the same key that encrypts its `encryptedPublicKey`), for 16 + 48 = 64 bytes. Each field of a contact request MUST use its own random IV. Writers MUST write 64-byte values. 80-byte values are reserved for a future encoding; readers MUST ignore a value whose length or content they cannot decode. +* **Activation.** Writers MUST include the field only when the DashPay contract on the connected chain defines it. On a chain whose contract predates the field, a contact request carrying it is invalid as a whole. +* **Consistency.** A wallet that supports this DIP SHOULD include the field on every contact request it creates. If it cannot derive the address at that moment, it SHOULD retry or defer the request rather than send it without the field, because the field's presence on some requests and absence on others is observable per contact. +* **Acceptance.** A reader MUST use a decrypted value only if all of the following hold: the same DIP-15 shared key decrypts the request's `encryptedPublicKey` to a well-formed extended public key; the decrypted plaintext is exactly 43 bytes (so the padding is exactly five `0x05` bytes); it decodes as a valid shielded payment address; it is not an address of any account of the reader's own wallet; and it differs from the sender's current profile `shieldedAddress`. CBC provides no authentication, so these checks are what reject a value decrypted under the wrong key. +* **Selection.** A reader uses the accepted address from the newest contact request from that sender that carries one. A later contact request without the field (for example, one sent by an older client) does not remove a previously accepted address. +* **Change warning.** Readers SHOULD remember the first address accepted for each contact and SHOULD warn the user before the first payment after it changes, with the same weight as a change of the contact's DIP-15 extended public key. +* The recipient of the contact request uses this address for shielded payments to the sender, in preference to the sender's public profile `shieldedAddress`. + +Per-contact diversified addresses provide attribution for free: an incoming note to one of them identifies the paying contact without any memo, while remaining unlinkable, on chain and to third parties, to the profile address and to every other contact's address. + +### Per-Contact Diversifier Index + +The per-contact diversifier index is a pseudorandom function of the contact request's unique key, keyed by the sender's full viewing key: + +```text +K = HMAC-SHA256(key = "DashPay/DIP-0033/contact-index-key/v1", + msg = FVK) +t = HMAC-SHA256(key = K, + msg = "DashPay/DIP-0033/contact-index/v1" || ownerId || toUserId || accountReference) +j = t[0..11], with t[10] |= 0x80 +``` + +where: + +* `FVK` is the 96-byte raw encoding of the full viewing key of the sender identity's identity shielded account (`ak || nk || rivk`, Zcash Protocol Specification section 5.6.4.4). `K` is HKDF-Extract with the label as salt and the FVK as input keying material. +* The labels are ASCII strings with no terminator. +* `ownerId` is the 32-byte identifier of the sending identity, `toUserId` the 32-byte identifier of the recipient identity, and `accountReference` the contact request's `accountReference` exactly as published in the document, encoded as an unsigned 64-bit little-endian integer. +* `j` is the first 11 bytes of `t`, read as a little-endian 88-bit diversifier index, with bit 87 set. It therefore always lies in the per-contact range `2^87` to `2^88 - 1`. + +The address is the external-scope address at diversifier index `j` of the identity shielded account. + +Normative requirements: + +* Wallets MUST derive per-contact indices only with this function. They MUST NOT allocate per-contact indices from a counter or from any other per-device or per-installation state. +* Wallets MUST compute `j` from the `accountReference` value published in the contact request, never from a locally recomputed value. +* Before sending a contact request, a wallet MUST check the derived index against the indices it has already issued to other recipients. If it matches one issued to a different `toUserId`, the wallet MUST NOT send the request; it SHOULD increment the DIP-15 version and derive again. (With 87 free bits of HMAC output, the probability of any collision among `n` requests from one identity is about `n^2 / 2^88`, roughly `3 * 10^-19` for `n = 10^4`.) + +Consequences, all of which follow from the function and need no further rule: + +* **Never the same address for two contacts.** `toUserId` is an input, and distinct indices give distinct addresses. +* **One address per contact request.** The inputs are the document's unique index `($ownerId, toUserId, accountReference)`. +* **Multi-device safety.** Every device holding the same seed derives the same address for the same contact request. Two devices creating the same request with the same `accountReference` derive the same address, and the platform accepts only one of the two requests. +* **Stateless restore.** Given the seed, a wallet recomputes every index it has issued from its own sent contact requests, all of whose inputs are public fields, with no signer and no decryption. +* **Delegation boundary.** A holder of only the IVK of the account can recover `j` from an address but cannot compute `K`, so it cannot tell which contact an index belongs to. A holder of the FVK can. + +### Rotation and Existing Contacts + +**Rotation.** A wallet gives a contact a new per-contact address by sending a new contact request with the [DIP-15](dip-0015.md) version incremented and the same extended public key. The new `accountReference` yields a new index and a new address. DIP-15's four version bits allow 15 rotations per DashPay account. + +* A reader receiving a contact request from a known sender whose decrypted extended public key is unchanged SHOULD treat it as an update of the shielded address only: no new acceptance prompt and no rebuild of the transparent payment account for that contact. +* Previous per-contact addresses cannot be revoked and keep receiving. Wallets SHOULD continue to attribute receipts on them to the contact, marked as an earlier address. + +**Contacts established before a wallet supported this DIP.** Their contact requests are immutable and lack the field. + +* Wallets MUST NOT send a new contact request automatically in response to receiving a payment. Doing so would publicly timestamp the payment, and because `PaymentRef` memos are unauthenticated, anyone could trigger it and make the wallet spend fees. +* Wallets MAY offer the user a way to give such a contact a per-contact address by rotation. They SHOULD show the fee, SHOULD do so only when the contact shows support for this DIP (a contact request from them carries the field, or their profile carries `shieldedAddress`), and SHOULD send after a random delay. +* Until then, such a contact pays the profile shielded address and attributes the payment with a `ContactTag` memo ([Shielded Memo Kinds](#shielded-memo-kinds)). + +### Shielded Memo Kinds + +Shielded pool note memos are 36 bytes: a little-endian `u32` kind tag followed by a 32-byte payload. Kinds `0` (empty) and `1` (UTF-8 text) are already established; unknown kinds round-trip untouched. This DIP registers: + +| Kind | Name | Payload | Semantics | +| ---- | ---- | ------- | --------- | +| `2` | `PaymentRef` | Sender identity id (32 bytes) | "This payment is from identity X." **Unauthenticated**: anyone can write any identity id. Clients MUST render the sender as *claimed*. Clients SHOULD use it only when paying an identity that is not a contact, and SHOULD NOT add it by default. | +| `3` | reserved | none | Reserved for a future document-reference kind | +| `4` | `ContactTag` | `nonce (16 bytes) \|\| tag (16 bytes)` | Authenticated attribution between contacts, readable only by the two parties. | + +`ContactTag` is computed as: + +```text +tag = HMAC-SHA256(key = S, msg = "DashPay/DIP-0033/contact-tag/v1" || nonce || payerId)[0..16] +``` + +where `nonce` is 16 fresh random bytes per note, `payerId` is the 32-byte identifier of the paying identity, and `S` is the DIP-15 shared key of the contact request from the payee to the payer if one exists, otherwise of the contact request from the payer to the payee. A recipient verifies a `ContactTag` by recomputing it for each of its contacts with each shared key it holds for that contact; a match attributes the payment to that contact. Clients MUST NOT attribute a `ContactTag` that matches no contact. + +A 36-byte memo cannot carry a signature (65 bytes). `ContactTag` gives attribution that neither third parties nor holders of either side's viewing keys can forge or read, because it is keyed by a secret only the two contacts hold. `PaymentRef` remains for payments to non-contacts, where no shared secret exists. + +### Client Behavior + +* **Detection** is the pool's native mechanism: clients trial-decrypt note ciphertexts with the IVK of each identity shielded account they manage. One IVK covers *all* diversified addresses of its account (the profile address and every per-contact address), so publishing or minting more addresses adds no scanning cost. The cost grows with the number of accounts scanned, not with the number of addresses. +* **Attribution** is by the diversifier index of the address a note arrived on, recovered from the note's recipient address with the account's IVK: + +| Index `j` | Attribution | +| --------- | ----------- | +| `0` | Profile address (or change; the OVK tells the wallet's own change apart). Anonymous unless it carries a verified `ContactTag` or a claimed `PaymentRef`. | +| `1` to `2^32 - 1` | Earlier profile address or wallet-local address | +| `2^87` and above, matching a sent contact request | That contact; shown as received via the address given to that contact, and marked as an earlier address if the request has been superseded | +| `2^87` and above, matching none | Unattributed until the wallet's contact data is synchronized | + +* **Paying a contact.** Clients SHOULD use, in order: the contact's per-contact address; the contact's profile shielded address with a `ContactTag` memo; and only on the user's explicit choice, a transparent rail. Clients MUST NOT silently fall back from a shielded payment to a rail that reveals the payer or the amount; paying from transparent funds directly into the shielded pool to the contact reveals both the payer and the amount, and requires explicit confirmation. Shielding funds to oneself and paying later is preferable to both. +* **Exiting to transparent funds**: a recipient who wants Core-chain or Platform-address funds unshields to themselves at a time and amount of their own choosing. Non-normative hygiene: unshield to fresh addresses, avoid amount-identical shield-then-unshield sequences close in time, and prefer round or denomination-friendly amounts. +* **Watch-only delegation**: the pool's viewing keys provide detection-without-spend delegation; nothing in this DIP adds a delegation mechanism or requires one. An IVK-only delegate sees amounts and memos of incoming notes but cannot map per-contact indices to contacts; an FVK delegate can, and also sees every outgoing payment. +* **Restore from seed**: the wallet derives the identity shielded account of every identity it discovers and rescans the pool. It rebuilds its map from per-contact indices to contacts by recomputing `j` for each contact request it has sent, including superseded ones. An index in the per-contact range that matches none of them was issued by an implementation that does not follow this DIP; the wallet MAY recover it by decrypting its own sent requests' `encryptedShieldedAddress` with the DIP-15 shared keys. Sender-side history comes from the OVK, which recovers each paid address; the wallet matches those against the addresses in every contact request it has received from each contact, including superseded ones. +* **Key disablement.** If an identity key referenced by a contact request's `senderKeyIndex` or `recipientKeyIndex` is disabled, its holder may have leaked it, and anyone holding it can learn the per-contact address. Wallets SHOULD treat attribution through that address as unverified and SHOULD offer to rotate it using different keys. +* **Paying a profile**: given a profile, clients SHOULD offer the shielded address as the private payment option and the transparent addresses as the public ones, and say plainly which is which. + +## Rationale + +* **Static profile addresses, not extended public keys.** A profile is world-readable; a published xpub lets everyone derive and link the full address chain, so it is exactly as linkable as one static address while adding receiver-side gap-limit scanning across light-client filters, payer-side index races, and larger documents. Rotation-by-profile-update captures all the realizable benefit. +* **The byte storage form with a consensus-checked type byte.** The storage form carries no checksum, so unlike a Base58Check string the type-byte-plus-length check is *complete* validation: everything consensus accepts is decodable. (A Base58 pattern was tried and rejected: it could not prove the checksum, and it made every randomly generated test document invalid.) +* **The shielded pool as the private tier, not stealth addresses.** The deferred stealth design ([appendix](#considered-and-deferred-a-notified-stealth-address-tier)) hid the recipient of transparent payments but required a discovery channel. Every payment-time discovery artifact on Platform is timing-correlatable with its settlement transaction, which at minimum marks the settlement as a private payment and at worst links the payer identity to it. The pool needs no discovery artifact at all (the payment carries its own encrypted notification) and additionally hides the amount, which no transparent-rail scheme can. Concentrating private payments in the pool also concentrates the anonymity set in one mechanism instead of splitting it across three. +* **Per-contact diversified addresses rather than memos for attribution.** Diversifier attribution costs nothing at scan time, works with empty memos (leaving the memo free for text), and cannot be spoofed by a third party who has not been given the address. +* **Carried in the contact request itself.** Orchard key derivation is hardened only, so there is no public key from which a contact could derive fresh addresses, as DIP-15 contacts do on the Core rail; the recipient must mint the address and send it. The contact request is the only document the contact can decrypt, it is created atomically with the relationship, and it is immune to profile-key compromise. +* **Deterministic indices, not a counter.** A per-device counter lets two devices on one seed give the same index to two different contacts, and because contact requests are immutable the collision is permanent. Deriving the index from the contact request's own unique key gives the same answer on every device and after every restore, needs no stored state, and makes a version-bumped request a rotation automatically. +* **Keyed by the full viewing key.** The FVK is available wherever the wallet scans, including seedless background sync, so attribution never needs the seed or a signer; an IVK-only delegate still cannot attribute. +* **A partitioned index space.** Placing per-contact indices at `2^87` and above keeps them disjoint from the profile address and from any sequential wallet-local addresses, including those of implementations that allocate from `1` upward. +* **One account per identity.** Separate hardened accounts keep a wallet's identities unlinkable even to someone holding one identity's viewing key, and one IVK per identity covers its profile address and all its contacts. +* **64 to 80 bytes, not exactly 64.** A fixed-size byte array is stored without a length prefix and a ranged one with one, so the choice can never be changed for stored documents. The range allows a future 80-byte encoding (for example an authentication tag, or up to 20 more bytes of plaintext) without any contract change; older readers ignore values they cannot decode. +* **`ContactTag` rather than `PaymentRef` between contacts.** `PaymentRef` is plaintext and unauthenticated, and is readable by viewing-key holders on both sides. A tag keyed by the contacts' shared secret authenticates the payer, reveals nothing to anyone else, and lets contacts established before this DIP attribute payments without sending a new contact request. + +## Backwards Compatibility + +* All fields in this DIP are optional additions at fresh positions; existing profiles and contact requests remain valid. They activate in two DashPay contract versions, each loaded by the Platform protocol version upgrade that introduces it: + * Version 2 (Platform protocol version 14): the profile fields `corePaymentAddress`, `platformPaymentAddress`, and `shieldedAddress`. + * Version 3 (Platform protocol version 15): `contactRequest.encryptedShieldedAddress`, with the `encryptedFor` declaration and the `maximum` of `4294967295` on `senderKeyIndex` and `recipientKeyIndex` that it requires. Neither maximum changes how those properties are stored. +* Documents written under a newer contract version cannot be decoded by clients holding an earlier copy of the DashPay contract: the document format records every optional property, even an absent one, so an older reader finds bytes it does not expect and fails rather than ignoring them. Each document records the contract version it was written under, so such a reader can tell that it must fetch the newer contract. Clients MUST update their copy of the contract when each upgrade activates. +* Until a chain has activated contract version 3, wallets MUST NOT include `encryptedShieldedAddress` ([Per-Contact Shielded Addresses](#per-contact-shielded-addresses)); identities on such a chain still receive shielded payments from contacts through the profile shielded address and `ContactTag`. +* Memo kinds are a client-side registry over the pool's existing extensible memo format; unknown kinds already round-trip, so no protocol change is involved. +* DIP-15 contact-request flows are unchanged, except that a version-bumped request with an unchanged extended public key updates only the shielded address ([Rotation and Existing Contacts](#rotation-and-existing-contacts)). Older DIP-15 clients may ignore such a request or treat it as a new account for the same extended public key; either is harmless. Established contacts continue using per-contact derived chains for transparent payments. The shielded tier is complementary. +* Core chain consensus is unchanged. + +## Security Considerations + +* **Profile address hijacking.** Profile documents update under lower-security keys than identity keys; a compromised profile key can redirect *public-tier* payments and the published shielded address. Clients SHOULD alert users when a contact's payment fields change and pin previously seen values. Per-contact shielded addresses ride the contact request (immutable, created by the contact's identity) and are unaffected by profile compromise. +* **Weak authentication of per-contact addresses.** Anyone who *learns* a per-contact address can pay it: the contact, anyone holding either identity key of the contact request's DIP-15 key pair, and anyone holding the sender's viewing keys once a note has arrived on it. Diversifier attribution therefore means "received on the address given to this contact", not proof of the payer. `ContactTag` is the authenticated alternative. +* **Address changes.** A new per-contact address from a contact looks the same whether the contact rotated it or someone controlling the contact's identity keys replaced it. The change warning in [Per-Contact Shielded Addresses](#per-contact-shielded-addresses) is the defense; clients SHOULD encourage out-of-band confirmation for large payments after a change. +* **Spoofed `PaymentRef` memos.** The memo payload is written by the payer with no authentication; clients MUST NOT render it as a verified sender, and MUST NOT take any automatic action because of it. +* **No consensus enforcement of uniqueness.** Consensus sees only ciphertext and cannot check that an address is unique per contact; the guarantee comes entirely from the index derivation in wallets. +* **Payer-provable payments.** A payer to any published shielded address can later prove they made the payment (they know the note plaintext). This is inherent to any receiving address and usually desirable (receipts). + +## Privacy Considerations + +* **Public tier is public.** Payments to profile addresses are linkable to the profile by anyone, permanently; on the Platform rail the address's balance and history are directly queryable state. Wallets SHOULD say so in UI when a user pays or publishes a tips address. +* **What publishing a shielded address leaks**: only that the profile accepts shielded payments. Payments to it produce note ciphertexts that reveal nothing, and the address itself never appears on chain. Diversified-address unlinkability means the published address cannot be linked to any per-contact address derived from the same viewing key. +* **Public signals in contact requests.** The presence of `encryptedShieldedAddress` on a contact request is public, and so are the DIP-15 version bits of `accountReference` and the request's creation time. A rotation or a later backfill therefore reveals which pair of identities it concerns, when it happened, and how many rotations have occurred. The [Consistency](#per-contact-shielded-addresses) rule and the prohibition on automatic re-sends keep these signals from revealing more. +* **Viewing-key holders.** Whoever holds an account's viewing keys sees the amounts and memos of its incoming notes and, with the FVK, every outgoing payment and the per-contact attribution. Treat an FVK as a full view of the identity's shielded activity. +* **Network-level correlation.** A node serving a wallet sees its queries. Querying a payee's profile or contact requests immediately before broadcasting a shielded payment lets that node link payer and payee. Non-normative: pay from already-synchronized state, and broadcast after a short random delay. +* **Payer-side linkage on entry.** Shielding transparent funds is a visible event. Payers who shield and immediately pay amount-identical values create a correlation between their shielding and the recipient's later unshielding; the hygiene guidance in [Client Behavior](#client-behavior) applies to both sides. +* **The anonymity set is pool activity.** The privacy of the shielded tier is proportional to overall pool usage. This is a deliberate concentration: one well-used private mechanism outperforms several thinly used ones. Platform's minimum pool note count for transfers out of the pool is a bootstrap safeguard, not a measure of anonymity. +* **Quantum adversaries.** The privacy properties in this DIP hold against classical adversaries only. A published shielded address yields its account's IVK to an adversary able to compute discrete logarithms, and per-contact addresses are protected by secp256k1 ECDH in documents that can never be deleted; both can be recorded today and broken later. Owners who do not want a permanent published receiver can omit the profile shielded address. Post-quantum protection is out of scope. + +## Test Vectors + +Full vectors, and the program that generates them against the Orchard implementation Dash Platform uses, are in [dip-0033/test-vectors.md](dip-0033/test-vectors.md). A summary, for the mnemonic `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about` with an empty passphrase, mainnet (`coin_type` `5'`), `identity_index` `0` (account `0x40000000`), `ownerId` = 32 bytes of `0x11`: + +| Contact request | `toUserId` | `accountReference` | `j` (11 bytes, little-endian) | +| --------------- | ---------- | ------------------ | ----------------------------- | +| Bob, version 0 | 32 bytes of `0x22` | `0x01234567` | `8f83dcd11b2e71581e47d4` | +| Carol, version 0 | 32 bytes of `0x33` | `0x01234567` | `aade0fbb97fe88eccfa3ab` | +| Bob, version 1 | 32 bytes of `0x22` | `0x11234567` | `f8fbdcb909400ac429af9a` | + +## Considered and Deferred: A Notified Stealth-Address Tier + +*This section is informational. It records a fully designed alternative and why it was deferred, so the reasoning survives the decision.* + +Earlier drafts of this DIP specified a third tier for the transparent rails: the recipient published two secp256k1 **detection keys** (scan and spend) as new identity key purposes (`PAYMENT_SCAN = 7`, `PAYMENT_SPEND = 8`, derivation under a reserved DIP-9 feature index `33'`); the payer derived a one-time (stealth) address `P = B_spend + H(ECDH(r, B_scan) || …)·G`, paid it on either transparent rail, and published an encrypted, fixed-size **payment notification** document on Platform that only the recipient could detect by trial decryption. The notification carried the settlement locator, the stealth ephemeral key, an authenticated sender identity, and a memo; an out-ciphertext mirroring the pool's outgoing-viewing-key pattern gave the payer seed-only recovery. The design was implemented through review, including consensus validation of the new key purposes. + +It was deferred for three reasons: + +1. **Timing correlation defeats the goal.** The notification had to reach Platform finality before settlement broadcast (it was the recipient's only recovery record for outputs underivable from their seed). A global observer correlating notification-store writes with settlement transactions therefore links the two events. With identity-funded notifications this links the payer identity to the settlement, which is fatal; with pool-funded notifications it still marks the settlement as a stealth payment, collapsing its anonymity set from "all P2PKH transactions" to "stealth payments in this interval". Randomized delays help only at volumes the mechanism would not have at launch. +2. **The correlation-free alternative is real infrastructure.** Detection without any payment-time artifact means BIP-352-style scanning, whose per-transaction input-key data is absent from compact block filters; light clients would need network infrastructure serving per-transaction ECDH tweaks (a "tweak index"). It is composable with existing GCS filters and deliberately kept open (the detection keys were shaped exactly like BIP-352 key pairs), but it is a substantial commitment relative to demand. +3. **The pool already exceeds it.** Shielded payments hide the amount as well as the parties, need no discovery artifact (the payment is its own encrypted notification, immune to timing analysis by construction), recover from seed plus chain alone, and were shipping regardless. A recipient who wants transparent-rail funds unshields to themselves, achieving what the stealth tier offered with hygiene under their own control. + +Deferral is cheap to reverse: the key purposes, derivation paths, stealth math, notification format, and test vectors exist in the project history (implementation reviewed and parked), and nothing shipped by this DIP occupies or constrains them. A future DIP reviving the tier SHOULD pair it with a tweak index rather than payment-time notifications, and SHOULD reuse the BIP-352-shaped key material unchanged. The DIP-9 feature index `33'` reservation is withdrawn until then. + +## Future Extensions + +* **Notification-less stealth tier.** As recorded in the appendix: a revival would combine the parked detection-key and stealth-derivation design with a network-served per-transaction tweak index, making transparent-rail private payments detectable by scanning with no payment-time artifact. This paragraph is a reservation, not a commitment. +* **Document-reference memo kind.** Memo kind `3` is reserved for pointing a shielded payment at a larger encrypted context document, should an application need more than 32 bytes of payment context. +* **A second encoding of `encryptedShieldedAddress`.** The 80-byte length is reserved for a future encoding within the same field, requiring no contract change. +* **Post-quantum address exchange.** Protecting per-contact addresses against quantum adversaries would need a post-quantum key encapsulation, whose ciphertexts (over a kilobyte) do not fit this field; it would be a new field or document type. + +## Copyright + +Copyright (c) 2026 Dash Core Group, Inc. [Licensed under the MIT License](https://opensource.org/licenses/MIT) diff --git a/dip-0033/test-vectors.md b/dip-0033/test-vectors.md new file mode 100644 index 00000000..09964cc3 --- /dev/null +++ b/dip-0033/test-vectors.md @@ -0,0 +1,188 @@ +# DIP-0033 Test Vectors + +These vectors cover the [identity shielded account](../dip-0033.md#identity-shielded-accounts), the [per-contact diversifier index](../dip-0033.md#per-contact-diversifier-index), the 64-byte encoding of [`encryptedShieldedAddress`](../dip-0033.md#per-contact-shielded-addresses), and the [`ContactTag`](../dip-0033.md#shielded-memo-kinds) memo. All byte strings are hexadecimal. + +Inputs common to all vectors: + +* Mnemonic `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`, empty passphrase. +* `ownerId` = 32 bytes of `0x11`; Bob's identity = 32 bytes of `0x22`; Carol's identity = 32 bytes of `0x33`. +* `accountReference` `0x01234567` for version 0 requests and `0x11234567` for the version 1 rotation (DIP-15 version in the top four bits). +* For the encryption and memo vectors: DIP-15 shared key `000102...1f` (bytes `0x00` to `0x1f`), IV `a0a1...af`, nonce `5051...5f`. Real implementations MUST draw the IV and nonce at random. + +In each account block, `fvk` is the 96-byte raw full viewing key, `profile addr` is the address at diversifier index 0, and `K` is the contact index key. Per-contact vectors are given for mainnet, identity index 0. + +## Output + +```text +mnemonic : abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about +seed : 5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4 + +== mainnet (coin_type 5), identity_index 0, account 0x40000000 +fvk : c0120eb660f8dd2f769e199bcc540476fec169b60f3e7508ffd42a21bd274337b2301310b489575eb68a8adacfed66b88fd07b961d1ca366c7e7e6e619b29a2bfce2cf9cb05a6439997f6f7ad4d9eb4b3d5a0672e571036cff9c8a600439063d +profile addr (j=0) : e126993647eb0c9a4b476e2823380ead099e803ea52c980146e6fa42cfeb9d2ef8f7b1d6e764821d706397 +K : 1c868b6adb3123f5ff837d236e5c645dd940f3e6f1f5469e960396c0ffc1e4aa +-- bob v0: ownerId 1111.. toUserId 2222.. accountReference 0x1234567 + t : 8f83dcd11b2e71581e47543c7f9b3a603d665da783c7a9bf3b0024d528cb2482 + j (11 bytes LE) : 8f83dcd11b2e71581e47d4 + j (integer) : 0xd4471e58712e1bd1dc838f + address (43 B) : 89231e35d309bf8979a7662efc2723b796a5c2b97fabdb5901fe14c18b975190586d080d0b10231ec3bf1a +-- carol v0: ownerId 1111.. toUserId 3333.. accountReference 0x1234567 + t : aade0fbb97fe88eccfa32b534c553513592b11d26f9b482cbb80e9425cf82df5 + j (11 bytes LE) : aade0fbb97fe88eccfa3ab + j (integer) : 0xaba3cfec88fe97bb0fdeaa + address (43 B) : 67e737f5ca9a3ef4766915a4be09ff3d0933412984ea46a49e5cf88e4b15f3180a9dde78780e1cf4d44eb4 +-- bob v1: ownerId 1111.. toUserId 2222.. accountReference 0x11234567 + t : f8fbdcb909400ac429af1aab1215642dc89429b269fad9615bf49c81bcc1d960 + j (11 bytes LE) : f8fbdcb909400ac429af9a + j (integer) : 0x9aaf29c40a4009b9dcfbf8 + address (43 B) : adcc2868980cd98d1a813e9e0a4bbc1256ac91a35cd1348fb53cbb7eff7285b9606210225caa6f4c26ba31 + +== mainnet (coin_type 5), identity_index 1, account 0x40000001 +fvk : 5317987e38a2dd272cebe41d317c33980e53d77c14ed166abc097aaaa1612837d315ce0f5dc87a85557ce30362cd330aaf0396b51c4970e2ddd7e7861dc069224581992d96f19f367bff3f7bbfee475fd5f61e8edccc550d98d9e2f98f7fdb0c +profile addr (j=0) : 936389bea5f4472a82d8c933ce9763f88ce0d8955ebc61b0cbe42914350f1c9b38978c673e4a1247b890ba +K : 1a94e000a31d4b4cdd6a7f3389039d5110f31c5c0398944e4db623e3d790c5cd + +== testnet (coin_type 1), identity_index 0, account 0x40000000 +fvk : e82418aaf0c953565e04048cedfecb0fcd0bd1f9a5ab05be2d36a4fbcc5cd4046aecdbc3837ace5abf1b727bded47346872cdee34bdd36521ae81031fd92f00ecfd073865caa1e41baae4f378be748672b77c55e631f2ac2e7ccb2d205095c35 +profile addr (j=0) : 335feafaef3927edd1dffc09b7af7f2d14be582f5ba3a4501c691f044a4af5a881ddd3951496c6a877c32f +K : 2ea2b1ef93609c7f7c9b8faa6897f504da6bff3bf1755bb91b244668f6999ab0 + +== testnet (coin_type 1), identity_index 1, account 0x40000001 +fvk : b5de92085e87846e0d1d28ddb4971451002b703298ef8d47162d4879ce79a206e4fa29154530eab7ce56526dc6dd93206603df9ea42d532b7ccacdf41920023f0083bff0b5af75f1fd9359e8cc20a73fc76f6c64f0411740b859d18f949efd28 +profile addr (j=0) : 8e039daad501d94c15ad9628ecfc1238d75054db98292b52b6307db9b0e707d88a0d8c7ad6fcfdc16ab895 +K : 7fbe5fabd624237901a29c892fc7fc5c9e8ba0ae2e640702ee25e9c84505a044 + +== encryptedShieldedAddress v1 +shared key : 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f +iv : a0a1a2a3a4a5a6a7a8a9aaabacadaeaf +plaintext (43 B) : 89231e35d309bf8979a7662efc2723b796a5c2b97fabdb5901fe14c18b975190586d080d0b10231ec3bf1a +field (64 B) : a0a1a2a3a4a5a6a7a8a9aaabacadaeaf3b53627ca8c440c7612ba9af8d7e95eaa5c080e0b8c5fa82100aeba7d41ada3b3fef6d06d30c6de649e6255282cd864d + +== ContactTag memo (kind 4) +shared key : 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f +nonce : 505152535455565758595a5b5c5d5e5f +payer identity : 2222222222222222222222222222222222222222222222222222222222222222 +memo (36 B) : 04000000505152535455565758595a5b5c5d5e5fba18e0ee86eab80d47d70466850ecad8 +``` + +## Generator + +The vectors were produced by the program below, built against the Orchard implementation Dash Platform uses (`dashpay/orchard`, tag `dashified-0.14.1`), with `zip32 0.2`, `hmac 0.12`, `sha2 0.10`, `bip39 2.2`, `aes 0.8`, `cbc 0.1` (feature `alloc`), and `hex 0.4`. It asserts that every per-contact index lies in the per-contact range and that the index recovered from each address with the incoming viewing key equals the derived one. + +```rust +//! DIP-33 amendment test vectors: identity shielded account, per-contact +//! diversifier index, encryptedShieldedAddress v1, ContactTag memo. +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use hmac::{Hmac, Mac}; +use orchard::keys::{DiversifierIndex, FullViewingKey, Scope, SpendingKey}; +use orchard::Address; +use sha2::Sha256; + +type Enc = cbc::Encryptor