From b62e492974fbbdb377d4071cf2847d7ccff4e353 Mon Sep 17 00:00:00 2001 From: Denis Smirnov Date: Thu, 4 Jun 2026 00:12:26 +0700 Subject: [PATCH 1/4] docs: add DashPay contact request encryption guide --- book/src/SUMMARY.md | 1 + book/src/evo-sdk/dashpay-contact-requests.md | 171 +++++++++++++++++++ book/src/evo-sdk/wallet-utilities.md | 9 +- 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 book/src/evo-sdk/dashpay-contact-requests.md diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 623064242fa..0298cdaf3f6 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -80,6 +80,7 @@ - [Trusted Mode and Proofs](evo-sdk/trusted-mode.md) - [State Transitions](evo-sdk/state-transitions.md) - [Wallet Utilities](evo-sdk/wallet-utilities.md) +- [DashPay Contact Requests](evo-sdk/dashpay-contact-requests.md) - [Networks and Environments](evo-sdk/networks-and-environments.md) - [Tutorials]() - [Car Sales Management](evo-sdk/tutorials/car-sales.md) diff --git a/book/src/evo-sdk/dashpay-contact-requests.md b/book/src/evo-sdk/dashpay-contact-requests.md new file mode 100644 index 00000000000..1b3c055eaa2 --- /dev/null +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -0,0 +1,171 @@ +# DashPay Contact Requests + +DashPay contact requests are `contactRequest` documents in the DashPay data +contract. The document links the sender identity to `toUserId` and carries the +sender's DIP-15 contact payment public key encrypted for the recipient. + +The Evo SDK exposes the DIP-15 derivation primitive through +`wallet.deriveDashpayContactKey`. Applications still need to encrypt the +derived contact xpub before submitting the document. + +## Document fields + +The `contactRequest` document requires these fields: + +```typescript +type DashpayContactRequestDocument = { + toUserId: Uint8Array; + encryptedPublicKey: Uint8Array; + senderKeyIndex: number; + recipientKeyIndex: number; + accountReference: number; +}; +``` + +`encryptedPublicKey` is exactly 96 bytes: + +- 16 bytes: AES-CBC initialization vector +- 80 bytes: AES-CBC ciphertext for the sender's 64-byte abbreviated contact + xpub payload + +The sender derives the contact xpub from the sender identity, recipient +identity, account, and address index. The sender then encrypts that xpub with an +ECDH shared secret from the sender's identity encryption private key and the +recipient's identity encryption public key. The encrypted payload is the +DashPay abbreviated xpub form: + +- 4 bytes: parent fingerprint +- 32 bytes: chain code +- 33 bytes: compressed public key + +## Derive the contact payment xpub + +```typescript +import { wallet } from '@dashevo/evo-sdk'; + +const senderKeyIndex = 0; +const recipientKeyIndex = 0; + +const contactKey = await wallet.deriveDashpayContactKey({ + mnemonic: senderMnemonic, + network: 'testnet', + senderIdentityId, + receiverIdentityId: recipientIdentityId, + account: 0, + addressIndex: senderKeyIndex, +}); + +// contactKey.xpub is encrypted into contactRequest.encryptedPublicKey. +``` + +## Build the encrypted public key + +The following helper shows the byte-level encryption shape. It uses the same +secp256k1 primitives exposed by `@dashevo/dashcore-lib` and Node.js `crypto` +for AES-CBC. + +```typescript +import crypto from 'node:crypto'; +import dashcore from '@dashevo/dashcore-lib'; + +function fixed32(value): Buffer { + return Buffer.from(value.toArray('be', 32)); +} + +function deriveSharedKey({ + privateKeyWif, + publicKeyBytes, +}: { + privateKeyWif: string; + publicKeyBytes: Uint8Array; +}): Buffer { + const privateKey = dashcore.PrivateKey.fromWIF(privateKeyWif); + const publicKey = dashcore.PublicKey.fromBuffer(Buffer.from(publicKeyBytes)); + const sharedPoint = publicKey.point.mul(privateKey.toBigNumber()); + + if (sharedPoint.isInfinity()) { + throw new Error('ECDH shared point is invalid'); + } + + const x = fixed32(sharedPoint.getX()); + const y = fixed32(sharedPoint.getY()); + const compressedPrefix = Buffer.from([2 | (y[31] & 1)]); + + return crypto.createHash('sha256').update(Buffer.concat([compressedPrefix, x])).digest(); +} + +function abbreviatedXpubPayload(xpub: string): Buffer { + const hdPublicKey = new dashcore.HDPublicKey(xpub); + const parentFingerprint = hdPublicKey._buffers?.parentFingerPrint; + const chainCode = hdPublicKey._buffers?.chainCode; + const publicKey = hdPublicKey.publicKey?.toBuffer(); + + if (parentFingerprint?.length !== 4 || chainCode?.length !== 32 || publicKey?.length !== 33) { + throw new Error('Invalid DashPay contact xpub'); + } + + return Buffer.concat([parentFingerprint, chainCode, publicKey]); +} + +function encryptContactXpub({ + contactXpub, + senderEncryptionPrivateKeyWif, + recipientEncryptionPublicKeyBytes, +}: { + contactXpub: string; + senderEncryptionPrivateKeyWif: string; + recipientEncryptionPublicKeyBytes: Uint8Array; +}): Uint8Array { + const aesKey = deriveSharedKey({ + privateKeyWif: senderEncryptionPrivateKeyWif, + publicKeyBytes: recipientEncryptionPublicKeyBytes, + }); + const payload = abbreviatedXpubPayload(contactXpub); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); + const encrypted = Buffer.concat([ + cipher.update(payload), + cipher.final(), + ]); + + const encryptedPublicKey = Buffer.concat([iv, encrypted]); + + if (encryptedPublicKey.length !== 96) { + throw new Error(`DashPay encryptedPublicKey must be 96 bytes, got ${encryptedPublicKey.length}`); + } + + return encryptedPublicKey; +} +``` + +## Submit the document + +```typescript +const encryptedPublicKey = encryptContactXpub({ + contactXpub: contactKey.xpub, + senderEncryptionPrivateKeyWif, + recipientEncryptionPublicKeyBytes, +}); + +const document = { + toUserId: recipientIdentityIdBytes, + encryptedPublicKey, + senderKeyIndex, + recipientKeyIndex, + accountReference: 0, +}; +``` + +When querying received requests through the JavaScript SDK, pass identity IDs in +the representation expected by the SDK call being used. The contract stores +`toUserId` as a 32-byte identifier, while some high-level JavaScript query +helpers accept the base58 identity string and perform the conversion +internally. + +## Current SDK boundary + +`wallet.deriveDashpayContactKey` handles DIP-15 path derivation. It does not +currently submit DashPay documents or encrypt/decrypt `encryptedPublicKey`. +Applications need to combine the wallet helper with identity encryption keys +until a higher-level DashPay contact request helper is added to the JavaScript +SDK. diff --git a/book/src/evo-sdk/wallet-utilities.md b/book/src/evo-sdk/wallet-utilities.md index b30ef759563..dbc27086d62 100644 --- a/book/src/evo-sdk/wallet-utilities.md +++ b/book/src/evo-sdk/wallet-utilities.md @@ -117,8 +117,13 @@ const contactKey = await wallet.deriveDashpayContactKey({ mnemonic, network: 'testnet', senderIdentityId: '...', - recipientIdentityId: '...', + receiverIdentityId: '...', account: 0, - index: 0, + addressIndex: 0, }); ``` + +The returned `xpub` is the contact payment public key that can be encrypted into +a DashPay `contactRequest.encryptedPublicKey` field. See +[DashPay Contact Requests](dashpay-contact-requests.md) for the end-to-end +document payload shape. From 26f478a840aacd72d5a74f37231a61e9c27f053d Mon Sep 17 00:00:00 2001 From: Denis Smirnov Date: Thu, 4 Jun 2026 00:18:56 +0700 Subject: [PATCH 2/4] docs: correct DashPay encrypted xpub payload --- book/src/evo-sdk/dashpay-contact-requests.md | 25 +++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/book/src/evo-sdk/dashpay-contact-requests.md b/book/src/evo-sdk/dashpay-contact-requests.md index 1b3c055eaa2..107329b27c9 100644 --- a/book/src/evo-sdk/dashpay-contact-requests.md +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -25,18 +25,12 @@ type DashpayContactRequestDocument = { `encryptedPublicKey` is exactly 96 bytes: - 16 bytes: AES-CBC initialization vector -- 80 bytes: AES-CBC ciphertext for the sender's 64-byte abbreviated contact - xpub payload +- 80 bytes: AES-CBC ciphertext for the sender's 78-byte serialized contact xpub The sender derives the contact xpub from the sender identity, recipient identity, account, and address index. The sender then encrypts that xpub with an ECDH shared secret from the sender's identity encryption private key and the -recipient's identity encryption public key. The encrypted payload is the -DashPay abbreviated xpub form: - -- 4 bytes: parent fingerprint -- 32 bytes: chain code -- 33 bytes: compressed public key +recipient's identity encryption public key. ## Derive the contact payment xpub @@ -94,17 +88,14 @@ function deriveSharedKey({ return crypto.createHash('sha256').update(Buffer.concat([compressedPrefix, x])).digest(); } -function abbreviatedXpubPayload(xpub: string): Buffer { - const hdPublicKey = new dashcore.HDPublicKey(xpub); - const parentFingerprint = hdPublicKey._buffers?.parentFingerPrint; - const chainCode = hdPublicKey._buffers?.chainCode; - const publicKey = hdPublicKey.publicKey?.toBuffer(); +function serializedXpubPayload(xpub: string): Buffer { + const payload = dashcore.encoding.Base58Check.decode(xpub); - if (parentFingerprint?.length !== 4 || chainCode?.length !== 32 || publicKey?.length !== 33) { - throw new Error('Invalid DashPay contact xpub'); + if (payload.length !== 78) { + throw new Error(`Invalid DashPay contact xpub length: ${payload.length}`); } - return Buffer.concat([parentFingerprint, chainCode, publicKey]); + return payload; } function encryptContactXpub({ @@ -120,7 +111,7 @@ function encryptContactXpub({ privateKeyWif: senderEncryptionPrivateKeyWif, publicKeyBytes: recipientEncryptionPublicKeyBytes, }); - const payload = abbreviatedXpubPayload(contactXpub); + const payload = serializedXpubPayload(contactXpub); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); const encrypted = Buffer.concat([ From 711f5a54e08afe372a1c5447fe12e85f4b569829 Mon Sep 17 00:00:00 2001 From: Denis Smirnov Date: Thu, 4 Jun 2026 00:36:55 +0700 Subject: [PATCH 3/4] docs: clarify DashPay contact request schema boundary --- book/src/evo-sdk/dashpay-contact-requests.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/book/src/evo-sdk/dashpay-contact-requests.md b/book/src/evo-sdk/dashpay-contact-requests.md index 107329b27c9..919ac1b65f4 100644 --- a/book/src/evo-sdk/dashpay-contact-requests.md +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -14,6 +14,8 @@ The `contactRequest` document requires these fields: ```typescript type DashpayContactRequestDocument = { + $createdAt: number; + $createdAtCoreBlockHeight: number; toUserId: Uint8Array; encryptedPublicKey: Uint8Array; senderKeyIndex: number; @@ -22,6 +24,10 @@ type DashpayContactRequestDocument = { }; ``` +The current DashPay contract schema requires the system field +`$createdAtCoreBlockHeight`. Older external references may use +`coreHeightCreatedAt`; do not submit that name to the current contract. + `encryptedPublicKey` is exactly 96 bytes: - 16 bytes: AES-CBC initialization vector @@ -139,6 +145,8 @@ const encryptedPublicKey = encryptContactXpub({ }); const document = { + $createdAt: Date.now(), + $createdAtCoreBlockHeight: platformCoreHeight, toUserId: recipientIdentityIdBytes, encryptedPublicKey, senderKeyIndex, @@ -147,6 +155,10 @@ const document = { }; ``` +`accountReference` above is the current Platform field accepted by the +`contactRequest` schema. It is not a complete implementation of any +ASK/HMAC-based account-reference obfuscation described in older DIP text. + When querying received requests through the JavaScript SDK, pass identity IDs in the representation expected by the SDK call being used. The contract stores `toUserId` as a 32-byte identifier, while some high-level JavaScript query @@ -160,3 +172,7 @@ currently submit DashPay documents or encrypt/decrypt `encryptedPublicKey`. Applications need to combine the wallet helper with identity encryption keys until a higher-level DashPay contact request helper is added to the JavaScript SDK. + +Treat the example as a byte-level reference. A production application should add +contract validation, decrypt round-trip tests, and checks that the selected +identity keys are active secp256k1 keys bounded for DashPay contact requests. From a598be91e97a98266b23c73b79497d247380cc6e Mon Sep 17 00:00:00 2001 From: Denis Smirnov Date: Thu, 4 Jun 2026 00:42:32 +0700 Subject: [PATCH 4/4] docs: address DashPay contact request review notes --- book/src/evo-sdk/dashpay-contact-requests.md | 27 ++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/book/src/evo-sdk/dashpay-contact-requests.md b/book/src/evo-sdk/dashpay-contact-requests.md index 919ac1b65f4..beaf9e0953e 100644 --- a/book/src/evo-sdk/dashpay-contact-requests.md +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -36,7 +36,7 @@ The current DashPay contract schema requires the system field The sender derives the contact xpub from the sender identity, recipient identity, account, and address index. The sender then encrypts that xpub with an ECDH shared secret from the sender's identity encryption private key and the -recipient's identity encryption public key. +recipient's identity decryption public key. ## Derive the contact payment xpub @@ -45,6 +45,7 @@ import { wallet } from '@dashevo/evo-sdk'; const senderKeyIndex = 0; const recipientKeyIndex = 0; +const addressIndex = 0; const contactKey = await wallet.deriveDashpayContactKey({ mnemonic: senderMnemonic, @@ -52,12 +53,16 @@ const contactKey = await wallet.deriveDashpayContactKey({ senderIdentityId, receiverIdentityId: recipientIdentityId, account: 0, - addressIndex: senderKeyIndex, + addressIndex, }); // contactKey.xpub is encrypted into contactRequest.encryptedPublicKey. ``` +`senderKeyIndex` and `recipientKeyIndex` identify the identity public keys used +for ECDH. `addressIndex` is the DIP-15 child index used for contact payment key +derivation and is independent from those identity key indexes. + ## Build the encrypted public key The following helper shows the byte-level encryption shape. It uses the same @@ -107,15 +112,15 @@ function serializedXpubPayload(xpub: string): Buffer { function encryptContactXpub({ contactXpub, senderEncryptionPrivateKeyWif, - recipientEncryptionPublicKeyBytes, + recipientDecryptionPublicKeyBytes, }: { contactXpub: string; senderEncryptionPrivateKeyWif: string; - recipientEncryptionPublicKeyBytes: Uint8Array; + recipientDecryptionPublicKeyBytes: Uint8Array; }): Uint8Array { const aesKey = deriveSharedKey({ privateKeyWif: senderEncryptionPrivateKeyWif, - publicKeyBytes: recipientEncryptionPublicKeyBytes, + publicKeyBytes: recipientDecryptionPublicKeyBytes, }); const payload = serializedXpubPayload(contactXpub); const iv = crypto.randomBytes(16); @@ -141,9 +146,11 @@ function encryptContactXpub({ const encryptedPublicKey = encryptContactXpub({ contactXpub: contactKey.xpub, senderEncryptionPrivateKeyWif, - recipientEncryptionPublicKeyBytes, + recipientDecryptionPublicKeyBytes, }); +const accountReference = 0; + const document = { $createdAt: Date.now(), $createdAtCoreBlockHeight: platformCoreHeight, @@ -151,13 +158,13 @@ const document = { encryptedPublicKey, senderKeyIndex, recipientKeyIndex, - accountReference: 0, + accountReference, }; ``` -`accountReference` above is the current Platform field accepted by the -`contactRequest` schema. It is not a complete implementation of any -ASK/HMAC-based account-reference obfuscation described in older DIP text. +`accountReference` above is a placeholder for the current Platform field +accepted by the `contactRequest` schema. It is not a complete implementation of +any ASK/HMAC-based account-reference obfuscation described in older DIP text. When querying received requests through the JavaScript SDK, pass identity IDs in the representation expected by the SDK call being used. The contract stores