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..beaf9e0953e --- /dev/null +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -0,0 +1,185 @@ +# 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 = { + $createdAt: number; + $createdAtCoreBlockHeight: number; + toUserId: Uint8Array; + encryptedPublicKey: Uint8Array; + senderKeyIndex: number; + recipientKeyIndex: number; + accountReference: number; +}; +``` + +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 +- 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 decryption public key. + +## Derive the contact payment xpub + +```typescript +import { wallet } from '@dashevo/evo-sdk'; + +const senderKeyIndex = 0; +const recipientKeyIndex = 0; +const addressIndex = 0; + +const contactKey = await wallet.deriveDashpayContactKey({ + mnemonic: senderMnemonic, + network: 'testnet', + senderIdentityId, + receiverIdentityId: recipientIdentityId, + account: 0, + 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 +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 serializedXpubPayload(xpub: string): Buffer { + const payload = dashcore.encoding.Base58Check.decode(xpub); + + if (payload.length !== 78) { + throw new Error(`Invalid DashPay contact xpub length: ${payload.length}`); + } + + return payload; +} + +function encryptContactXpub({ + contactXpub, + senderEncryptionPrivateKeyWif, + recipientDecryptionPublicKeyBytes, +}: { + contactXpub: string; + senderEncryptionPrivateKeyWif: string; + recipientDecryptionPublicKeyBytes: Uint8Array; +}): Uint8Array { + const aesKey = deriveSharedKey({ + privateKeyWif: senderEncryptionPrivateKeyWif, + publicKeyBytes: recipientDecryptionPublicKeyBytes, + }); + const payload = serializedXpubPayload(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, + recipientDecryptionPublicKeyBytes, +}); + +const accountReference = 0; + +const document = { + $createdAt: Date.now(), + $createdAtCoreBlockHeight: platformCoreHeight, + toUserId: recipientIdentityIdBytes, + encryptedPublicKey, + senderKeyIndex, + recipientKeyIndex, + accountReference, +}; +``` + +`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 +`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. + +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. 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.