diff --git a/public/AI_REFERENCE.md b/public/AI_REFERENCE.md index e9b20bc..9a79237 100644 --- a/public/AI_REFERENCE.md +++ b/public/AI_REFERENCE.md @@ -2248,7 +2248,34 @@ Returns: Example: ```javascript -const result = await sdk.dpns.registerName({ label, identity, identityKey, signer, preorderCallback }); +import { IdentitySigner } from '@dashevo/evo-sdk'; + +const identityId = '5DbLwAxGBzUzo81VewMUwn4b5P4bpv9FNFybi25XB5Bk'; +const identity = await sdk.identities.fetch(identityId); + +const signer = new IdentitySigner(); +signer.addKeyFromWif('L1ExamplePrivateKeyWifGoesHere'); + +// DPNS registration requires a strong authentication key. +const identityKey = identity.publicKeys.find( + k => k.purpose === 'AUTHENTICATION' + && ['CRITICAL', 'HIGH'].includes(k.securityLevel), +); +if (!identityKey) { + throw new Error( + 'DPNS registration requires an AUTHENTICATION key with CRITICAL or HIGH security level', + ); +} + +const result = await sdk.dpns.registerName({ + label: 'alice', + identity, + identityKey, + signer, + preorderCallback: (preorderDocument) => { + console.log('preorder submitted', preorderDocument.id?.toString?.()); + }, +}); ``` #### Token Transitions @@ -2926,7 +2953,34 @@ Returns: Example: ```javascript -const result = await sdk.voting.masternodeVote({ masternodeProTxHash, votePoll, voteChoice, votingKey, signer }); +import { IdentitySigner, ResourceVoteChoice, VotePoll } from '@dashevo/evo-sdk'; + +const masternodeProTxHash = '143dcd6a6b7684fde01e88a10e5d65de9a29244c5ecd586d14a342657025f113'; + +const signer = new IdentitySigner(); +signer.addKeyFromWif('L1ExampleVotingKeyWifGoesHere'); + +// Voting key must match the masternode voting public key on the identity. +const votingIdentity = await sdk.identities.fetch(masternodeProTxHash); +const votingKey = votingIdentity.publicKeys.find(k => k.purpose === 'VOTING'); +if (!votingKey) { + throw new Error('Masternode voting requires a VOTING-purpose identity key'); +} + +const votePoll = new VotePoll({ + contractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'domain', + indexName: 'parentNameAndLabel', + indexValues: ['dash', 'alice'], +}); + +await sdk.voting.masternodeVote({ + masternodeProTxHash, + votePoll, + voteChoice: ResourceVoteChoice.TowardsIdentity('5DbLwAxGBzUzo81VewMUwn4b5P4bpv9FNFybi25XB5Bk'), + votingKey, + signer, +}); ``` **Contested Resource** - `voting.masternodeVote` @@ -2969,7 +3023,33 @@ Returns: Example: ```javascript -const result = await sdk.voting.masternodeVote({ masternodeProTxHash, votePoll, voteChoice, votingKey, signer }); +import { IdentitySigner, ResourceVoteChoice, VotePoll } from '@dashevo/evo-sdk'; + +const masternodeProTxHash = '143dcd6a6b7684fde01e88a10e5d65de9a29244c5ecd586d14a342657025f113'; + +const signer = new IdentitySigner(); +signer.addKeyFromWif('L1ExampleVotingKeyWifGoesHere'); + +const votingIdentity = await sdk.identities.fetch(masternodeProTxHash); +const votingKey = votingIdentity.publicKeys.find(k => k.purpose === 'VOTING'); +if (!votingKey) { + throw new Error('Masternode voting requires a VOTING-purpose identity key'); +} + +const votePoll = new VotePoll({ + contractId: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', + documentTypeName: 'domain', + indexName: 'parentNameAndLabel', + indexValues: ['dash', 'alice'], +}); + +await sdk.voting.masternodeVote({ + masternodeProTxHash, + votePoll, + voteChoice: ResourceVoteChoice.TowardsIdentity('5DbLwAxGBzUzo81VewMUwn4b5P4bpv9FNFybi25XB5Bk'), + votingKey, + signer, +}); ``` #### Platform Address Transitions diff --git a/public/api-definitions.json b/public/api-definitions.json index 409daaf..6217619 100644 --- a/public/api-definitions.json +++ b/public/api-definitions.json @@ -575,7 +575,7 @@ "type": "text", "label": "Label", "required": true, - "placeholder": "ąlice" + "placeholder": "\u0105lice" } ], "sdk_method": "dpns.convertToHomographSafe" diff --git a/scripts/check_documentation.py b/scripts/check_documentation.py index 78c9761..927f81c 100644 --- a/scripts/check_documentation.py +++ b/scripts/check_documentation.py @@ -120,7 +120,42 @@ def main(): if missing_anchors: errors.append(f"ERROR: Missing HTML return type anchors: {', '.join(missing_anchors)}") + # Guard against pre-v4 privateKeyWif-in-call examples (issue #63). + if not errors: + classic_patterns = [ + ( + r'documents\.create\(\{\s*contractId,\s*type:\s*documentType,\s*ownerId,\s*data,\s*entropyHex,\s*privateKeyWif\s*\}', + 'pre-v4 documents.create(... privateKeyWif) example', + ), + ( + r'identities\.topUp\(\{\s*identityId,\s*assetLockProof,\s*assetLockPrivateKeyWif\s*\}', + 'pre-v4 identities.topUp(... assetLockPrivateKeyWif) example', + ), + ( + r'identities\.create\(\{\s*assetLockProof,\s*assetLockPrivateKeyWif,\s*publicKeys\s*\}', + 'pre-v4 identities.create(... assetLockPrivateKeyWif, publicKeys) example', + ), + ( + r'sdk\.\.\(\{\s*\.\.\.params,\s*privateKeyWif\s*\}', + 'pre-v4 state-transition pattern with privateKeyWif in call', + ), + ( + r'\bprivateKeyWif\s*:', + 'privateKeyWif object property; use IdentitySigner.addKeyFromWif / PrivateKey.fromWIF', + ), + ] + for label, file_path in (('docs.html', docs_file), ('AI_REFERENCE.md', ai_file)): + if not file_path.exists(): + continue + content = file_path.read_text(encoding='utf-8') + for pattern, description in classic_patterns: + if re.search(pattern, content): + errors.append(f'ERROR: {label} still contains {description}') + if 'IdentitySigner' not in content: + errors.append(f'ERROR: {label} is missing IdentitySigner usage expected for v4 write examples') + # Compose report + lines = [ '=' * 80, 'Evo SDK Documentation Check', @@ -145,7 +180,17 @@ def main(): report = '\n'.join(lines) print(report) - (PUBLIC_DIR / 'documentation-check-report.txt').write_text(report, encoding='utf-8') + + # Avoid timestamp-only churn in git: keep the previous report when the + # status body is unchanged, rewriting only when warnings/errors change. + report_path = PUBLIC_DIR / 'documentation-check-report.txt' + previous = report_path.read_text(encoding='utf-8') if report_path.exists() else '' + + def body_without_timestamp(text: str) -> str: + return re.sub(r'^Timestamp:.*$', 'Timestamp:', text, count=1, flags=re.M) + + if body_without_timestamp(previous) != body_without_timestamp(report): + report_path.write_text(report, encoding='utf-8') sys.exit(1 if errors else 0) diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py index becc7c8..187cd61 100644 --- a/scripts/generate_docs.py +++ b/scripts/generate_docs.py @@ -75,6 +75,107 @@ def example(text: str) -> str: return textwrap.dedent(text).strip() +EXAMPLE_IDENTITY_SIGNER_WIF = 'L1ExamplePrivateKeyWifGoesHere' +EXAMPLE_VOTING_KEY_WIF = 'L1ExampleVotingKeyWifGoesHere' +EXAMPLE_ASSET_LOCK_WIF = 'cVExampleAssetLockKeyForIdentityFunding' +EXAMPLE_RECIPIENT_ID = 'H72iEt2zG4MEyoh3ZzCEMkYbDWqx1GvK1xHmpM8qH1yL' +AUTH_SECURITY_LEVELS_DOC = "['CRITICAL', 'HIGH', 'MEDIUM']" + + +def identity_signer_setup( + wif: str = EXAMPLE_IDENTITY_SIGNER_WIF, + *, + comment: str | None = None, + comment_before_add_key: bool = False, + signer_name: str = 'signer', +) -> str: + """Common IdentitySigner construction used by most write examples.""" + lines: List[str] = [] + if comment and not comment_before_add_key: + for part in comment.split('\n'): + lines.append(part if part.startswith('//') else f'// {part}') + lines.append(f'const {signer_name} = new IdentitySigner();') + if comment and comment_before_add_key: + for part in comment.split('\n'): + lines.append(part if part.startswith('//') else f'// {part}') + lines.append(f"{signer_name}.addKeyFromWif('{wif}');") + return '\n'.join(lines) + + +def auth_identity_key_setup( + identity_var: str, + *, + security_levels: str = AUTH_SECURITY_LEVELS_DOC, + compact: bool = False, + critical_only: bool = False, +) -> str: + """Fetch identity keys and select an AUTHENTICATION key for write options.""" + if critical_only: + predicate = "k => k.purpose === 'AUTHENTICATION' && k.securityLevel === 'CRITICAL'" + else: + predicate = ( + "k => k.purpose === 'AUTHENTICATION' && " + f"{security_levels}.includes(k.securityLevel)" + ) + + if compact: + if identity_var == 'identityId': + get_keys = "const keys = await sdk.identities.getKeys({ identityId, request: { type: 'all' } });" + else: + get_keys = ( + f'const keys = await sdk.identities.getKeys(' + f"{{ identityId: {identity_var}, request: {{ type: 'all' }} }});" + ) + return f'{get_keys}\nconst identityKey = keys.find(\n {predicate},\n);' + + return ( + 'const keys = await sdk.identities.getKeys({\n' + f' identityId: {identity_var},\n' + " request: { type: 'all' },\n" + '});\n' + 'const identityKey = keys.find(\n' + f' {predicate},\n' + ');' + ) + + +def signer_and_auth_key_setup( + identity_var: str, + *, + compact: bool = False, + critical_only: bool = False, + blank_before_keys: bool = True, + wif: str = EXAMPLE_IDENTITY_SIGNER_WIF, + comment: str | None = None, +) -> str: + parts = [identity_signer_setup(wif, comment=comment)] + if blank_before_keys: + parts.append('') + parts.append( + auth_identity_key_setup( + identity_var, + compact=compact, + critical_only=critical_only, + ) + ) + return '\n'.join(parts) + + +def normalize_example_lines(code: str | None) -> List[str]: + formatted = (code or '').replace('\\n', '\n').strip() + if not formatted: + return [] + return [re.sub(r'\bclient\b', 'sdk', line) for line in formatted.split('\n')] + + +def code_line_indexes(lines: Iterable[str]) -> List[int]: + return [ + index + for index, line in enumerate(lines) + if line.strip() and not line.strip().startswith('//') + ] + + def load_api_definitions(api_definitions_file: Path) -> tuple[dict, dict]: with open(api_definitions_file, 'r', encoding='utf-8') as f: api_data = json.load(f) @@ -514,47 +615,802 @@ def evo_example_for_query(key: str, inputs: List[dict]): return examples.get(key) +def compose_example(*sections: str) -> str: + """Join example fragments that may mix f-strings and shared helper snippets.""" + chunks = [ + textwrap.dedent(section).strip('\n') + for section in sections + if section and section.strip() + ] + return '\n\n'.join(chunks).strip() + + def evo_example_for_transition(key: str): + """Return practical v4 state-transition examples matching shipped Options interfaces. + + Prefer examples rendered by the typed transition registry. Operations that are + not yet executable in the UI retain explicit documentation-only examples here. + """ if key in TRANSITION_OPERATION_EXAMPLES: return TRANSITION_OPERATION_EXAMPLES[key] + + identity = TESTNET_TEST_DATA['identity_id'] + contract = TESTNET_TEST_DATA['data_contract_id'] + token_contract = TESTNET_TEST_DATA['token_contract_id'] + document_id = TESTNET_TEST_DATA['document_id'] + document_type = TESTNET_TEST_DATA['document_type'] + pro_tx = TESTNET_TEST_DATA['pro_tx_hash'] + platform_address = TESTNET_TEST_DATA['platform_address'] + recipient = EXAMPLE_RECIPIENT_ID + + signer_only = identity_signer_setup() + signer_master = identity_signer_setup( + comment='Master key is required to add/disable identity keys.', + comment_before_add_key=True, + ) + # Credit transfer requires a TRANSFER key; withdrawal allows TRANSFER or OWNER. + # Examples omit signingKey so the SDK auto-selects a matching purpose key. + signer_transfer = identity_signer_setup( + comment='Add the private key for a TRANSFER purpose identity key.', + comment_before_add_key=True, + ) + signer_transfer_or_owner = identity_signer_setup( + comment='Add the private key for a TRANSFER or OWNER purpose identity key.', + comment_before_add_key=True, + ) + signer_voting = identity_signer_setup(EXAMPLE_VOTING_KEY_WIF) + signer_and_doc_key = signer_and_auth_key_setup('ownerId') + signer_and_buyer_key = signer_and_auth_key_setup('buyerId') + # Token examples keep signer + getKeys tightly packed (no blank line). + signer_and_identity_key = signer_and_auth_key_setup('identityId', compact=True, blank_before_keys=False) + signer_and_sender_key = signer_and_auth_key_setup('senderId', compact=True, blank_before_keys=False) + signer_and_authority_key = signer_and_auth_key_setup('authorityId', compact=True, blank_before_keys=False) + signer_and_contract_key = signer_and_auth_key_setup('ownerId', critical_only=True) + m = { # Identities - # 'identityCreate' - example in api-definitions.json - # 'identityTopUp' - example in api-definitions.json - 'identityCreditTransfer': "await client.identities.creditTransfer({ identity, recipientId, amount: BigInt(amount), signer, signingKey: identityKey })", - 'identityCreditWithdrawal': "await client.identities.creditWithdrawal({ identity, amount: BigInt(amount), toAddress, coreFeePerByte, signer, signingKey: identityKey })", - 'identityUpdate': "await client.identities.update({ identity, addPublicKeys, disablePublicKeys, signer })", - 'dataContractCreate': "await client.contracts.publish({ dataContract, identityKey, signer })", - 'dataContractUpdate': "await client.contracts.update({ dataContract, identityKey, signer })", + 'identityCreate': compose_example( + """ + import { + AssetLockProof, + Identity, + IdentityPublicKeyInCreation, + IdentitySigner, + KeyType, + PrivateKey, + Purpose, + SecurityLevel, + } from '@dashevo/evo-sdk'; + + // Asset-lock proof and the separate private key controlling its Core output. + const assetLockProof = AssetLockProof.fromHex('a9147d3b...(hex-encoded)'); + """, + f"const assetLockPrivateKey = PrivateKey.fromWIF('{EXAMPLE_ASSET_LOCK_WIF}');", + f""" + // Identity key registered on Platform and held by IdentitySigner for key proofs. + const identityPrivateKey = PrivateKey.fromWIF('{EXAMPLE_IDENTITY_SIGNER_WIF}'); + const identity = new Identity(assetLockProof.createIdentityId()); + const masterKey = new IdentityPublicKeyInCreation({{ + keyId: 0, + purpose: Purpose.AUTHENTICATION, + securityLevel: SecurityLevel.MASTER, + keyType: KeyType.ECDSA_SECP256K1, + data: identityPrivateKey.getPublicKey().toBytes(), + }}).toIdentityPublicKey(); + identity.addPublicKey(masterKey); + + const signer = new IdentitySigner(); + signer.addKey(identityPrivateKey); + + await sdk.identities.create({{ + identity, + assetLockProof, + assetLockPrivateKey, + signer, + }}); + """, + ), + 'identityTopUp': compose_example( + f""" + import {{ AssetLockProof, PrivateKey }} from '@dashevo/evo-sdk'; + + const identity = await sdk.identities.fetch('{identity}'); + const assetLockProof = AssetLockProof.fromHex('a9147d3b...(hex-encoded)'); + // Asset-lock signing only — top-up does not use IdentitySigner. + const assetLockPrivateKey = PrivateKey.fromWIF('{EXAMPLE_ASSET_LOCK_WIF}'); + + const newBalance = await sdk.identities.topUp({{ + identity, + assetLockProof, + assetLockPrivateKey, + }}); + """, + ), + 'identityCreditTransfer': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const identity = await sdk.identities.fetch('{identity}'); + """, + signer_transfer, + f""" + // Omit signingKey so the SDK auto-selects an available TRANSFER key. + // Supplying a non-TRANSFER key (for example AUTHENTICATION) is invalid. + await sdk.identities.creditTransfer({{ + identity, + recipientId: '{recipient}', + amount: 1000000n, + signer, + }}); + """, + ), + 'identityCreditWithdrawal': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const identity = await sdk.identities.fetch('{identity}'); + """, + signer_transfer_or_owner, + """ + // Omit signingKey so the SDK auto-selects a matching TRANSFER or OWNER key. + // Supplying a non-matching key (for example AUTHENTICATION) is invalid. + const remainingBalance = await sdk.identities.creditWithdrawal({ + identity, + amount: 1000000n, + toAddress: 'yT8DDY5NkX4Zt44Fy8QjmCekheJQH4EMkv', + coreFeePerByte: 1, + signer, + }); + """, + ), + 'identityUpdate': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const identity = await sdk.identities.fetch('{identity}'); + """, + signer_master, + """ + await sdk.identities.update({ + identity, + addPublicKeys: undefined, // optional IdentityPublicKeyInCreation[] + disablePublicKeys: [2], // optional key ids to disable + signer, + }); + """, + ), + + # Data contracts + 'dataContractCreate': compose_example( + f""" + import {{ DataContract, IdentitySigner }} from '@dashevo/evo-sdk'; + + const ownerId = '{identity}'; + """, + identity_signer_setup(), + """ + const keys = await sdk.identities.getKeys({ + identityId: ownerId, + request: { type: 'all' }, + }); + // Contract publish requires a CRITICAL authentication key. + const identityKey = keys.find( + k => k.purpose === 'AUTHENTICATION' && k.securityLevel === 'CRITICAL', + ); + """, + """ + const identityNonce = (await sdk.identities.nonce(ownerId)) ?? 0n; + const dataContract = new DataContract({ + ownerId, + identityNonce: identityNonce + 1n, + schemas: { + note: { + type: 'object', + properties: { + message: { type: 'string', maxLength: 200, position: 0 }, + }, + required: ['message'], + additionalProperties: false, + }, + }, + }); + + const published = await sdk.contracts.publish({ + dataContract, + identityKey, + signer, + }); + """, + ), + 'dataContractUpdate': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const ownerId = '{identity}'; + const contractId = '{contract}'; + const dataContract = await sdk.contracts.fetch(contractId); + dataContract.version = (dataContract.version || 1) + 1; + // Optionally merge additional document type schemas before update: + // dataContract.setSchemas(mergedSchemas, undefined, false, sdk.version()); + """, + signer_and_contract_key, + """ + await sdk.contracts.update({ dataContract, identityKey, signer }); + """, + ), + # Documents - 'documentCreate': "await client.documents.create({ document, identityKey, signer })", - 'documentReplace': "await client.documents.replace({ document, identityKey, signer })", - 'documentDelete': "await client.documents.delete({ document, identityKey, signer })", - 'documentTransfer': "await client.documents.transfer({ document, recipientId, identityKey, signer })", - 'documentPurchase': "await client.documents.purchase({ document, buyerId, price: BigInt(price), identityKey, signer })", - 'documentSetPrice': "await client.documents.setPrice({ document, price: BigInt(price), identityKey, signer })", + 'documentCreate': compose_example( + f""" + import {{ Document, IdentitySigner }} from '@dashevo/evo-sdk'; + + const ownerId = '{identity}'; + """, + signer_and_doc_key, + f""" + const document = new Document({{ + dataContractId: '{contract}', + documentTypeName: '{document_type}', + ownerId, + properties: {{ /* fields required by the document type schema */ }}, + }}); + + await sdk.documents.create({{ document, identityKey, signer }}); + """, + ), + 'documentReplace': compose_example( + f""" + import {{ Document, IdentitySigner }} from '@dashevo/evo-sdk'; + + const ownerId = '{identity}'; + """, + signer_and_doc_key, + f""" + // Fetch current document, then rebuild/replace with revision + 1. + const current = await sdk.documents.get('{contract}', '{document_type}', '{document_id}'); + const document = new Document({{ + dataContractId: '{contract}', + documentTypeName: '{document_type}', + ownerId, + id: '{document_id}', + revision: BigInt(current.revision) + 1n, + properties: {{ /* updated properties */ }}, + }}); + + await sdk.documents.replace({{ document, identityKey, signer }}); + """, + ), + 'documentDelete': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const ownerId = '{identity}'; + """, + signer_and_doc_key, + f""" + await sdk.documents.delete({{ + document: {{ + id: '{document_id}', + ownerId, + dataContractId: '{contract}', + documentTypeName: '{document_type}', + }}, + identityKey, + signer, + }}); + """, + ), + 'documentTransfer': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const ownerId = '{identity}'; + """, + signer_and_doc_key, + f""" + const document = await sdk.documents.get('{contract}', '{document_type}', '{document_id}'); + document.revision = BigInt(document.revision) + 1n; + + await sdk.documents.transfer({{ + document, + recipientId: '{recipient}', + identityKey, + signer, + }}); + """, + ), + 'documentPurchase': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const buyerId = '{identity}'; + """, + signer_and_buyer_key, + f""" + const document = await sdk.documents.get('{contract}', '{document_type}', '{document_id}'); + document.revision = BigInt(document.revision) + 1n; + + await sdk.documents.purchase({{ + document, + buyerId, + price: 1000n, + identityKey, + signer, + }}); + """, + ), + 'documentSetPrice': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const ownerId = '{identity}'; + """, + signer_and_doc_key, + f""" + const document = await sdk.documents.get('{contract}', '{document_type}', '{document_id}'); + document.revision = BigInt(document.revision) + 1n; + + await sdk.documents.setPrice({{ + document, + price: 1000n, + identityKey, + signer, + }}); + """, + ), + # Tokens - 'tokenMint': "await client.tokens.mint({ dataContractId, tokenPosition, amount: BigInt(amount), identityId, recipientId, publicNote, identityKey, signer })", - 'tokenBurn': "await client.tokens.burn({ dataContractId, tokenPosition, amount: BigInt(amount), identityId, publicNote, identityKey, signer })", - 'tokenTransfer': "await client.tokens.transfer({ dataContractId, tokenPosition, amount: BigInt(amount), senderId, recipientId, publicNote, identityKey, signer })", - 'tokenFreeze': "await client.tokens.freeze({ dataContractId, tokenPosition, authorityId, frozenIdentityId, publicNote, identityKey, signer })", - 'tokenUnfreeze': "await client.tokens.unfreeze({ dataContractId, tokenPosition, authorityId, frozenIdentityId, publicNote, identityKey, signer })", - 'tokenDestroyFrozen': "await client.tokens.destroyFrozen({ dataContractId, tokenPosition, authorityId, frozenIdentityId, publicNote, identityKey, signer })", - 'tokenSetPriceForDirectPurchase': "await client.tokens.setPrice({ dataContractId, tokenPosition, authorityId, price: BigInt(price), publicNote, identityKey, signer })", - 'tokenDirectPurchase': "await client.tokens.directPurchase({ dataContractId, tokenPosition, buyerId, amount: BigInt(amount), maxTotalCost: BigInt(maxTotalCost), identityKey, signer })", - 'tokenClaim': "await client.tokens.claim({ dataContractId, tokenPosition, distributionType, identityId, publicNote, identityKey, signer })", - 'tokenEmergencyAction': "await client.tokens.emergencyAction({ dataContractId, tokenPosition, authorityId, action, publicNote, identityKey, signer })", - # Voting - 'dpnsUsername': "await client.voting.masternodeVote({ masternodeProTxHash, votePoll, voteChoice, votingKey, signer })", - 'masternodeVote': "await client.voting.masternodeVote({ masternodeProTxHash, votePoll, voteChoice, votingKey, signer })", - 'dpnsRegister': "await client.dpns.registerName({ label, identity, identityKey, signer, preorderCallback })", - # Platform Addresses - 'addressTransfer': "await client.addresses.transfer({ inputs, outputs, signer })", - 'addressTopUpIdentity': "await client.addresses.topUpIdentity({ identity, inputs, signer })", - 'addressWithdraw': "await client.addresses.withdraw({ inputs, coreFeePerByte, pooling, outputScript, signer })", - 'addressTransferFromIdentity': "await client.addresses.transferFromIdentity({ identity, outputs, signer })", - 'addressFundFromAssetLock': "await client.addresses.fundFromAssetLock({ assetLockProof, assetLockPrivateKey, outputs, signer })", - 'addressCreateIdentity': "await client.addresses.createIdentity({ identity, inputs, identitySigner, addressSigner })", + 'tokenMint': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const identityId = '{identity}'; + """, + signer_and_identity_key, + f""" + const result = await sdk.tokens.mint({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + amount: 100n, + identityId, + recipientId: identityId, + publicNote: 'mint', + identityKey, + signer, + }}); + """, + ), + 'tokenBurn': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const identityId = '{identity}'; + """, + signer_and_identity_key, + f""" + const result = await sdk.tokens.burn({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + amount: 10n, + identityId, + publicNote: 'burn', + identityKey, + signer, + }}); + """, + ), + 'tokenTransfer': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const senderId = '{identity}'; + """, + signer_and_sender_key, + f""" + const result = await sdk.tokens.transfer({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + amount: 5n, + senderId, + recipientId: '{recipient}', + publicNote: 'transfer', + identityKey, + signer, + }}); + """, + ), + 'tokenFreeze': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const authorityId = '{identity}'; + """, + signer_and_authority_key, + f""" + await sdk.tokens.freeze({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + authorityId, + frozenIdentityId: '{recipient}', + publicNote: 'freeze', + identityKey, + signer, + }}); + """, + ), + 'tokenUnfreeze': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const authorityId = '{identity}'; + """, + signer_and_authority_key, + f""" + await sdk.tokens.unfreeze({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + authorityId, + frozenIdentityId: '{recipient}', + publicNote: 'unfreeze', + identityKey, + signer, + }}); + """, + ), + 'tokenDestroyFrozen': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const authorityId = '{identity}'; + """, + signer_and_authority_key, + f""" + await sdk.tokens.destroyFrozen({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + authorityId, + frozenIdentityId: '{recipient}', + publicNote: 'destroy', + identityKey, + signer, + }}); + """, + ), + 'tokenSetPriceForDirectPurchase': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const authorityId = '{identity}'; + """, + signer_and_authority_key, + f""" + await sdk.tokens.setPrice({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + authorityId, + price: 1000n, // or null to clear + publicNote: 'set price', + identityKey, + signer, + }}); + """, + ), + 'tokenDirectPurchase': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const buyerId = '{identity}'; + """, + signer_and_auth_key_setup('buyerId', compact=True, blank_before_keys=False), + f""" + const result = await sdk.tokens.directPurchase({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + buyerId, + amount: 10n, + maxTotalCost: 10000n, + identityKey, + signer, + }}); + """, + ), + 'tokenClaim': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const identityId = '{identity}'; + """, + signer_and_identity_key, + f""" + const result = await sdk.tokens.claim({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + identityId, + distributionType: 'perpetual', // or 'preProgrammed' + publicNote: 'claim', + identityKey, + signer, + }}); + """, + ), + 'tokenEmergencyAction': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const authorityId = '{identity}'; + """, + signer_and_authority_key, + f""" + await sdk.tokens.emergencyAction({{ + dataContractId: '{token_contract}', + tokenPosition: 0, + authorityId, + action: 'pause', // or 'resume' + publicNote: 'pause trading', + identityKey, + signer, + }}); + """, + ), + + # DPNS / voting + 'dpnsRegister': compose_example( + f""" + import {{ IdentitySigner }} from '@dashevo/evo-sdk'; + + const identityId = '{identity}'; + const identity = await sdk.identities.fetch(identityId); + """, + signer_only, + """ + // DPNS registration requires a strong authentication key. + const identityKey = identity.publicKeys.find( + k => k.purpose === 'AUTHENTICATION' + && ['CRITICAL', 'HIGH'].includes(k.securityLevel), + ); + if (!identityKey) { + throw new Error( + 'DPNS registration requires an AUTHENTICATION key with CRITICAL or HIGH security level', + ); + } + + const result = await sdk.dpns.registerName({ + label: 'alice', + identity, + identityKey, + signer, + preorderCallback: (preorderDocument) => { + console.log('preorder submitted', preorderDocument.id?.toString?.()); + }, + }); + """, + ), + 'dpnsUsername': compose_example( + f""" + import {{ IdentitySigner, ResourceVoteChoice, VotePoll }} from '@dashevo/evo-sdk'; + + const masternodeProTxHash = '{pro_tx}'; + """, + signer_voting, + f""" + // Voting key must match the masternode voting public key on the identity. + const votingIdentity = await sdk.identities.fetch(masternodeProTxHash); + const votingKey = votingIdentity.publicKeys.find(k => k.purpose === 'VOTING'); + if (!votingKey) {{ + throw new Error('Masternode voting requires a VOTING-purpose identity key'); + }} + + const votePoll = new VotePoll({{ + contractId: '{contract}', + documentTypeName: 'domain', + indexName: 'parentNameAndLabel', + indexValues: ['dash', 'alice'], + }}); + + await sdk.voting.masternodeVote({{ + masternodeProTxHash, + votePoll, + voteChoice: ResourceVoteChoice.TowardsIdentity('{identity}'), + votingKey, + signer, + }}); + """, + ), + 'masternodeVote': compose_example( + f""" + import {{ IdentitySigner, ResourceVoteChoice, VotePoll }} from '@dashevo/evo-sdk'; + + const masternodeProTxHash = '{pro_tx}'; + """, + signer_voting, + f""" + const votingIdentity = await sdk.identities.fetch(masternodeProTxHash); + const votingKey = votingIdentity.publicKeys.find(k => k.purpose === 'VOTING'); + if (!votingKey) {{ + throw new Error('Masternode voting requires a VOTING-purpose identity key'); + }} + + const votePoll = new VotePoll({{ + contractId: '{contract}', + documentTypeName: 'domain', + indexName: 'parentNameAndLabel', + indexValues: ['dash', 'alice'], + }}); + + await sdk.voting.masternodeVote({{ + masternodeProTxHash, + votePoll, + voteChoice: ResourceVoteChoice.TowardsIdentity('{identity}'), + votingKey, + signer, + }}); + """, + ), + + # Platform Addresses — keep address-signer vs identity-signer distinction + 'addressTransfer': compose_example( + f""" + import {{ + PlatformAddressInput, + PlatformAddressOutput, + PlatformAddressSigner, + PrivateKey, + }} from '@dashevo/evo-sdk'; + + const privateKey = PrivateKey.fromWIF('cPrivateKeyWif...'); + const signer = new PlatformAddressSigner(); + const senderAddr = signer.addKey(privateKey); // derives P2PKH platform address + + const input = new PlatformAddressInput(senderAddr, 0, 100000n); + const output = new PlatformAddressOutput( + /* recipient PlatformAddress or bech32m */ '{platform_address}', + 90000n, + ); + + const result = await sdk.addresses.transfer({{ + inputs: [input], + outputs: [output], + signer, + }}); + """, + ), + 'addressTopUpIdentity': compose_example( + f""" + import {{ PlatformAddressInput, PlatformAddressSigner, PrivateKey }} from '@dashevo/evo-sdk'; + + const identity = await sdk.identities.fetch('{identity}'); + const privateKey = PrivateKey.fromWIF('cPrivateKeyWif...'); + const signer = new PlatformAddressSigner(); + const sourceAddr = signer.addKey(privateKey); + const input = new PlatformAddressInput(sourceAddr, 0, 50000n); + + const result = await sdk.addresses.topUpIdentity({{ + identity, + inputs: [input], + signer, + }}); + """, + ), + 'addressWithdraw': compose_example( + """ + import { + CoreScript, + PlatformAddressInput, + PlatformAddressSigner, + PoolingWasm, + PrivateKey, + } from '@dashevo/evo-sdk'; + + const privateKey = PrivateKey.fromWIF('cPrivateKeyWif...'); + const signer = new PlatformAddressSigner(); + const platformAddr = signer.addKey(privateKey); + const input = new PlatformAddressInput(platformAddr, 0, 100000n); + + // Caller-supplied 20-byte public-key hash for the spendable Core L1 destination. + const corePubkeyHashHex = 'replace-with-40-hex-character-core-pubkey-hash'; + if (!/^[0-9a-fA-F]{40}$/.test(corePubkeyHashHex)) { + throw new Error('Set corePubkeyHashHex to the 20-byte hash from your Core P2PKH address'); + } + const corePubkeyHash = Uint8Array.from( + corePubkeyHashHex.match(/../g), + byte => Number.parseInt(byte, 16), + ); + const outputScript = CoreScript.fromP2PKH(corePubkeyHash); + + const result = await sdk.addresses.withdraw({ + inputs: [input], + coreFeePerByte: 1, + pooling: PoolingWasm.Standard, + outputScript, + signer, + }); + """, + ), + 'addressTransferFromIdentity': compose_example( + f""" + import {{ IdentitySigner, PlatformAddressOutput }} from '@dashevo/evo-sdk'; + + // Uses IdentitySigner (identity transfer key), not PlatformAddressSigner. + const identity = await sdk.identities.fetch('{identity}'); + """, + signer_only, + f""" + const output = new PlatformAddressOutput('{platform_address}', 100000n); + const result = await sdk.addresses.transferFromIdentity({{ + identity, + outputs: [output], + signer, + }}); + """, + ), + 'addressFundFromAssetLock': compose_example( + """ + import { + AssetLockProof, + PlatformAddressOutput, + PlatformAddressSigner, + PrivateKey, + } from '@dashevo/evo-sdk'; + + // Asset-lock key signs the L1 funding proof; address signer controls outputs. + const assetLockPrivateKey = PrivateKey.fromWIF('cAssetLockPrivateKeyWif...'); + const addressPrivateKey = PrivateKey.fromWIF('cAddressPrivateKeyWif...'); + const assetLockProof = AssetLockProof.fromHex('a9147d3b...(hex-encoded)'); + + const signer = new PlatformAddressSigner(); + const platformAddr = signer.addKey(addressPrivateKey); + const output = new PlatformAddressOutput(platformAddr, 100000n); + + const result = await sdk.addresses.fundFromAssetLock({ + assetLockProof, + assetLockPrivateKey, + outputs: [output], + signer, + }); + """, + ), + 'addressCreateIdentity': compose_example( + """ + import { + Identity, + IdentityPublicKeyInCreation, + IdentitySigner, + KeyType, + PlatformAddressInput, + PlatformAddressSigner, + PrivateKey, + Purpose, + SecurityLevel, + } from '@dashevo/evo-sdk'; + + const addressPrivateKey = PrivateKey.fromWIF('cAddressPrivateKeyWif...'); + const identityPrivateKey = PrivateKey.fromWIF('cIdentityKeyWif...'); + + const identity = new Identity(/* 32-byte id */ new Uint8Array(32)); + identity.addPublicKey( + new IdentityPublicKeyInCreation({ + keyId: 0, + purpose: Purpose.AUTHENTICATION, + securityLevel: SecurityLevel.MASTER, + keyType: KeyType.ECDSA_SECP256K1, + data: identityPrivateKey.getPublicKey().toBytes(), + }).toIdentityPublicKey(), + ); + + const addressSigner = new PlatformAddressSigner(); + const sourceAddr = addressSigner.addKey(addressPrivateKey); + const identitySigner = new IdentitySigner(); + identitySigner.addKey(identityPrivateKey); + const input = new PlatformAddressInput(sourceAddr, 0, 50_000_000_000n); + + const result = await sdk.addresses.createIdentity({ + identity, + inputs: [input], + identitySigner, + addressSigner, + }); + """, + ), } return m.get(key) @@ -640,23 +1496,20 @@ def render_method_signature_html(signature: str, params: List[dict], return_type def format_example(code: str, header: str, add_return: bool) -> str: - formatted = (code or '').replace('\\n', '\n').strip() - if not formatted: + body_lines = normalize_example_lines(code) + if not body_lines: return header - body_lines = formatted.split('\n') - if add_return and body_lines: - first_line = body_lines[0].lstrip() - if first_line and not first_line.startswith('return'): - body_lines[0] = f'return {first_line}' - - def replace_client(line: str) -> str: - return line.replace('client', 'sdk') + if add_return: + indexes = code_line_indexes(body_lines) + if len(indexes) == 1: + index = indexes[0] + stripped = body_lines[index].lstrip() + indent = body_lines[index][: len(body_lines[index]) - len(stripped)] + if not stripped.startswith('return '): + body_lines[index] = f'{indent}return {stripped}' - processed = [replace_client(line) for line in body_lines] - lines = [header] - lines.extend(processed) - return '\n'.join(lines).rstrip() + return '\n'.join([header, *body_lines]).rstrip() def render_operation( @@ -1350,45 +2203,57 @@ def generate_docs_html(query_defs: dict, transition_defs: dict, type_metadata: d def format_ai_example_block(code: str | None, item_key: str) -> str: - snippet = (code or '').replace('\\n', '\n').strip() - if not snippet: + """Format a snippet for AI_REFERENCE.md code fences. + + Query examples are often a single expression that spans multiple lines and + begin with `return await ...`. Those must become valid top-level JS + (`const result = await ...`). Multi-statement transition examples already + include imports/setup and a final call — leave them intact. + """ + processed = normalize_example_lines(code) + if not processed: return f"// Example currently unavailable for `{item_key}`" - raw_lines = snippet.split('\n') - processed = [line.replace('client', 'sdk') for line in raw_lines] - - first_index = 0 - while first_index < len(processed) and processed[first_index].strip().startswith('//'): - first_index += 1 - - if first_index >= len(processed): - result_only = '\n'.join(processed).rstrip() - if not result_only.endswith(';'): - result_only += ';' - return result_only - - first_line = processed[first_index] - stripped = first_line.lstrip() - indent = first_line[: len(first_line) - len(stripped)] - - # Multi-statement operation examples begin with imports/declarations and already - # contain their final SDK call. Only expression-style examples need a result binding. - already_has_declaration = ( - stripped.startswith('import ') or - stripped.startswith('export ') or - stripped.startswith('const ') or - stripped.startswith('let ') or - stripped.startswith('var ') or - 'const result' in snippet - ) + indexes = code_line_indexes(processed) + if not indexes: + return '\n'.join(processed).rstrip() + + def is_setup_line(line: str) -> bool: + stripped = line.lstrip() + return ( + stripped.startswith('import ') + or stripped.startswith('const ') + or stripped.startswith('let ') + or stripped.startswith('var ') + or stripped.startswith('function ') + or stripped.startswith('class ') + or stripped.startswith('if ') + or stripped.startswith('throw ') + ) + + # Pure multi-line expressions (no imports/bindings) still need wrapping. + has_setup = any(is_setup_line(processed[i]) for i in indexes) - if not already_has_declaration: + if not has_setup: + first_index = indexes[0] + first_line = processed[first_index] + stripped = first_line.lstrip() + indent = first_line[: len(first_line) - len(stripped)] if stripped.startswith('return '): stripped = stripped[len('return ') :].lstrip() - processed[first_index] = f"{indent}const result = {stripped}" + if not ( + stripped.startswith('const ') + or stripped.startswith('let ') + or stripped.startswith('var ') + or stripped.startswith('await ') + ): + processed[first_index] = f'{indent}const result = {stripped}' + elif stripped.startswith('await '): + # Bare top-level await expression without prior setup. + processed[first_index] = f'{indent}const result = {stripped}' joined = '\n'.join(processed).rstrip() - if not joined.endswith(';'): + if joined and not joined.endswith(';'): joined += ';' return joined diff --git a/tests/unit/transition-examples.test.js b/tests/unit/transition-examples.test.js index a54b0e7..dd34cb9 100644 --- a/tests/unit/transition-examples.test.js +++ b/tests/unit/transition-examples.test.js @@ -1,29 +1,604 @@ -import fs from 'node:fs'; import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const docs = fs.readFileSync(path.join(ROOT, 'public/docs.html'), 'utf8'); +const aiReference = fs.readFileSync(path.join(ROOT, 'public/AI_REFERENCE.md'), 'utf8'); +const apiDefinitions = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/api-definitions.json'), 'utf8')); +const sdkOperationCatalog = JSON.parse( + fs.readFileSync(path.join(ROOT, 'public/sdk-operation-catalog.json'), 'utf8'), +); +const wasmSdkDts = fs.readFileSync(path.join(ROOT, 'node_modules/@dashevo/wasm-sdk/dist/sdk.d.ts'), 'utf8'); + +const TRANSITION_ENTRIES = Object.values(apiDefinitions.transitions).flatMap((category) => + Object.entries(category.transitions || {}), +); +const TRANSITION_KEYS = TRANSITION_ENTRIES.map(([key]) => key); +const TRANSITION_BY_KEY = Object.fromEntries(TRANSITION_ENTRIES); +const OPERATION_BY_KEY = Object.fromEntries( + sdkOperationCatalog.operations.map((operation) => [operation.key, operation]), +); +const TOKEN_TRANSITION_KEYS = [ + 'tokenMint', + 'tokenBurn', + 'tokenTransfer', + 'tokenFreeze', + 'tokenUnfreeze', + 'tokenDestroyFrozen', + 'tokenSetPriceForDirectPurchase', + 'tokenDirectPurchase', + 'tokenClaim', + 'tokenEmergencyAction', +]; +const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const TOKEN_CONTRACT_ID = 'ALybvzfcCwMs7sinDwmtumw17NneuW7RgFtFHgjKmF3A'; + +const METHOD_TO_OPTIONS = { + 'identities.create': 'IdentityCreateOptions', + 'identities.topUp': 'IdentityTopUpOptions', + 'identities.creditTransfer': 'IdentityCreditTransferOptions', + 'identities.creditWithdrawal': 'IdentityCreditWithdrawalOptions', + 'identities.update': 'IdentityUpdateOptions', + 'contracts.publish': 'ContractPublishOptions', + 'contracts.update': 'ContractUpdateOptions', + 'documents.create': 'DocumentCreateOptions', + 'documents.replace': 'DocumentReplaceOptions', + 'documents.delete': 'DocumentDeleteOptions', + 'documents.transfer': 'DocumentTransferOptions', + 'documents.purchase': 'DocumentPurchaseOptions', + 'documents.setPrice': 'DocumentSetPriceOptions', + 'tokens.mint': 'TokenMintOptions', + 'tokens.burn': 'TokenBurnOptions', + 'tokens.transfer': 'TokenTransferOptions', + 'tokens.freeze': 'TokenFreezeOptions', + 'tokens.unfreeze': 'TokenUnfreezeOptions', + 'tokens.destroyFrozen': 'TokenDestroyFrozenOptions', + 'tokens.emergencyAction': 'TokenEmergencyActionOptions', + 'tokens.setPrice': 'TokenSetPriceOptions', + 'tokens.directPurchase': 'TokenDirectPurchaseOptions', + 'tokens.claim': 'TokenClaimOptions', + 'dpns.registerName': 'DpnsRegisterNameOptions', + 'voting.masternodeVote': 'MasternodeVoteOptions', + 'addresses.transfer': 'AddressFundsTransferOptions', + 'addresses.topUpIdentity': 'IdentityTopUpFromAddressesOptions', + 'addresses.withdraw': 'AddressFundsWithdrawOptions', + 'addresses.transferFromIdentity': 'IdentityTransferToAddressesOptions', + 'addresses.fundFromAssetLock': 'AddressFundingFromAssetLockOptions', + 'addresses.createIdentity': 'IdentityCreateFromAddressesOptions', +}; + +/** Pre-v4 call-shape markers that must not appear as options on SDK write calls. */ +const FORBIDDEN_CALL_OPTION_NAMES = new Set([ + 'privateKeyWif', + 'assetLockPrivateKeyWif', + 'votingKeyWif', + 'publicKeyId', + 'onPreorder', + 'entropyHex', + 'priceType', + 'priceData', + 'totalAgreedPrice', + 'actionType', + 'identityToFreeze', + 'identityToUnfreeze', + 'freezerId', + 'unfreezerId', + 'destroyerId', + 'definition', + 'updates', +]); + +/** Classic one-liners from issue #63 that must not reappear in generated docs. */ +const CLASSIC_PRE_V4_PATTERNS = [ + /documents\.create\(\{\s*contractId,\s*type:\s*documentType,\s*ownerId,\s*data,\s*entropyHex,\s*privateKeyWif\s*\}\)/, + /identities\.topUp\(\{\s*identityId,\s*assetLockProof,\s*assetLockPrivateKeyWif\s*\}\)/, + /identities\.create\(\{\s*assetLockProof,\s*assetLockPrivateKeyWif,\s*publicKeys\s*\}\)/, + /sdk\.\.\(\{\s*\.\.\.params,\s*privateKeyWif\s*\}\)/, +]; + +function loadTransitionExamplesFromGenerator() { + const script = ` +import importlib.util +from pathlib import Path +spec = importlib.util.spec_from_file_location('generate_docs', Path(${JSON.stringify(path.join(ROOT, 'scripts/generate_docs.py'))})) +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) +import json +keys = ${JSON.stringify(TRANSITION_KEYS)} +out = {key: mod.evo_example_for_transition(key) for key in keys} +print(json.dumps(out)) +`; + const result = spawnSync('python3', ['-c', script], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error(`Failed to load transition examples: ${result.stderr || result.stdout}`); + } + return JSON.parse(result.stdout); +} + +const interfaceAllPropsCache = new Map(); +const interfaceRequiredPropsCache = new Map(); +const wasmSdkSource = ts.createSourceFile('sdk.d.ts', wasmSdkDts, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + +function collectInterfaceProps(interfaceName) { + if (interfaceAllPropsCache.has(interfaceName)) { + return { + all: interfaceAllPropsCache.get(interfaceName), + required: interfaceRequiredPropsCache.get(interfaceName), + }; + } + + const all = new Set(); + let required = null; + function visit(node) { + if (ts.isInterfaceDeclaration(node) && node.name.text === interfaceName && node.members) { + const requiredHere = new Set(); + for (const member of node.members) { + if (ts.isPropertySignature(member) && member.name && ts.isIdentifier(member.name)) { + all.add(member.name.text); + if (!member.questionToken) requiredHere.add(member.name.text); + } + } + // Prefer the first declaration for requiredness (write-options shapes). + if (required === null) required = requiredHere; + } + ts.forEachChild(node, visit); + } + visit(wasmSdkSource); + if (required === null) required = new Set(); + interfaceAllPropsCache.set(interfaceName, all); + interfaceRequiredPropsCache.set(interfaceName, required); + return { all, required }; +} + +function interfaceProperties(_sourceText, interfaceName) { + return collectInterfaceProps(interfaceName).all; +} + +/** Required property names from the first matching interface declaration. */ +function interfaceRequiredProperties(_sourceText, interfaceName) { + return collectInterfaceProps(interfaceName).required; +} + +function sdkParamByName(transitionKey, paramName) { + const parameters = OPERATION_BY_KEY[transitionKey]?.parameters || []; + const properties = parameters.flatMap((parameter) => parameter.properties || []); + const property = properties.find((candidate) => candidate.name === paramName); + return property ? { ...property, required: !property.optional } : undefined; +} + +function extractSdkCallSites(example) { + const calls = []; + const callRe = /await\s+sdk\.([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*\(/g; + let match; + while ((match = callRe.exec(example)) !== null) { + const namespace = match[1]; + const method = match[2]; + const openIdx = match.index + match[0].length - 1; + let depth = 0; + let end = -1; + for (let i = openIdx; i < example.length; i += 1) { + const ch = example[i]; + if (ch === '(') depth += 1; + else if (ch === ')') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) continue; + calls.push({ + method: `${namespace}.${method}`, + argsText: example.slice(openIdx + 1, end).trim(), + }); + } + return calls; +} + +function topLevelObjectKeys(argsText) { + const trimmed = argsText.trim(); + if (!trimmed.startsWith('{')) return []; + const source = ts.createSourceFile( + 'example.ts', + `const __opts = ${trimmed};`, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const keys = []; + function visit(node) { + if (ts.isObjectLiteralExpression(node) && node.parent && ts.isVariableDeclaration(node.parent)) { + for (const prop of node.properties) { + if ((ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && ts.isIdentifier(prop.name)) { + keys.push(prop.name.text); + } + } + } + ts.forEachChild(node, visit); + } + visit(source); + return keys; +} + +function exampleMentionsForbiddenCallOption(example) { + const hits = []; + for (const call of extractSdkCallSites(example)) { + for (const key of topLevelObjectKeys(call.argsText)) { + if (FORBIDDEN_CALL_OPTION_NAMES.has(key)) { + hits.push(`${call.method}({ ${key} })`); + } + } + } + // Current examples must not document privateKeyWif as an object property at all. + if (/\bprivateKeyWif\s*:/.test(example)) { + hits.push('object property privateKeyWif'); + } + return hits; +} + +function collectClassicPreV4Hits(text, label) { + return CLASSIC_PRE_V4_PATTERNS + .filter((re) => re.test(text)) + .map((re) => `${label} still contains ${re}`); +} + +const generatedExamples = loadTransitionExamplesFromGenerator(); + +describe('v4 state transition documentation examples', () => { + it('covers every transition operation with a generator example', () => { + for (const key of TRANSITION_KEYS) { + expect(generatedExamples[key], key).toBeTruthy(); + } + }); + + it('does not use pre-v4 privateKeyWif-in-call option shapes', () => { + const failures = []; + for (const [key, example] of Object.entries(generatedExamples)) { + const hits = exampleMentionsForbiddenCallOption(example); + if (hits.length) failures.push(`${key}: ${hits.join(', ')}`); + } + failures.push(...collectClassicPreV4Hits(docs, 'docs.html')); + failures.push(...collectClassicPreV4Hits(aiReference, 'AI_REFERENCE.md')); + expect(failures).toEqual([]); + }); + + it('uses IdentitySigner / asset-lock PrivateKey / payload constructors where appropriate', () => { + expect(generatedExamples.documentCreate).toMatch(/new Document\s*\(/); + expect(generatedExamples.documentCreate).toMatch(/IdentitySigner/); + expect(generatedExamples.documentCreate).toMatch(/identityKey/); + expect(generatedExamples.dataContractCreate).toMatch(/new DataContract\s*\(/); + expect(generatedExamples.identityCreate).toMatch(/AssetLockProof/); + expect(generatedExamples.identityCreate).toMatch(/assetLockPrivateKey/); + expect(generatedExamples.identityCreate).toMatch(/new Identity\(assetLockProof\.createIdentityId\(\)\)/); + expect(generatedExamples.identityCreate).toMatch( + /data:\s*identityPrivateKey\.getPublicKey\(\)\.toBytes\(\)/, + ); + expect(generatedExamples.identityCreate).toMatch(/signer\.addKey\(identityPrivateKey\)/); + expect(generatedExamples.identityCreate).not.toMatch(/random-or-derived-identity-id|atob\(/); + expect(generatedExamples.identityTopUp).toMatch(/assetLockPrivateKey/); + // Top-up is asset-lock only — no IdentitySigner construction/import. + expect(generatedExamples.identityTopUp).not.toMatch(/new IdentitySigner|import \{[^}]*IdentitySigner/); + expect(generatedExamples.addressFundFromAssetLock).toMatch(/assetLockPrivateKey/); + expect(generatedExamples.addressFundFromAssetLock).toMatch(/PlatformAddressSigner/); + expect(generatedExamples.addressTransferFromIdentity).toMatch(/IdentitySigner/); + expect(generatedExamples.dpnsRegister).toMatch(/identityKey/); + expect(generatedExamples.dpnsRegister).toMatch(/IdentitySigner/); + }); + + it('selects credit transfer/withdrawal keys by purpose, never AUTHENTICATION fallback', () => { + const transfer = generatedExamples.identityCreditTransfer; + const withdrawal = generatedExamples.identityCreditWithdrawal; + + // Credit transfer requires TRANSFER; withdrawal allows TRANSFER or OWNER. + // Prefer omitting signingKey so the SDK auto-selects a matching purpose key. + for (const [key, example] of [ + ['identityCreditTransfer', transfer], + ['identityCreditWithdrawal', withdrawal], + ]) { + expect(example, key).not.toMatch(/getPublicKeyById\s*\(\s*3\s*\)/); + expect(example, key).not.toMatch(/purpose\s*===\s*['"]AUTHENTICATION['"]/); + expect(example, key).not.toMatch(/transfer\/auth/i); + + const writeMethod = key === 'identityCreditTransfer' + ? 'identities.creditTransfer' + : 'identities.creditWithdrawal'; + const writeCall = extractSdkCallSites(example).find((call) => call.method === writeMethod); + expect(writeCall, key).toBeTruthy(); + const optionKeys = topLevelObjectKeys(writeCall.argsText); + expect(optionKeys, `${key} must not pass signingKey`).not.toContain('signingKey'); + expect(optionKeys, key).toContain('signer'); + } + + expect(transfer).toMatch(/TRANSFER/); + expect(transfer).not.toMatch(/OWNER/); + expect(transfer).toMatch(/auto-selects an available TRANSFER key/i); -const docs = fs.readFileSync(new URL('../../public/docs.html', import.meta.url), 'utf8'); -const aiReference = fs.readFileSync(new URL('../../public/AI_REFERENCE.md', import.meta.url), 'utf8'); + expect(withdrawal).toMatch(/TRANSFER/); + expect(withdrawal).toMatch(/OWNER/); + expect(withdrawal).toMatch(/auto-selects a matching TRANSFER or OWNER key/i); -describe('generated state transition examples', () => { - it('uses the Evo SDK v4 payload and signer call shape', () => { - expect(aiReference).toContain('sdk.documents.create({ document, identityKey, signer })'); - expect(aiReference).toContain('sdk.contracts.publish({ dataContract, identityKey, signer })'); - expect(aiReference).toContain('sdk.identities.topUp({ identity, assetLockProof, assetLockPrivateKey })'); - expect(aiReference).toContain('await sdk.tokens.burn({'); - expect(aiReference).toContain('dataContractId: Identifier.fromBase58(contractId)'); - expect(aiReference).toContain('tokenPosition: Number(tokenPosition)'); + // Generated docs must not reintroduce the invalid authentication fallback. + expect(docs).not.toMatch( + /identity\.getPublicKeyById\(3\)[\s\S]{0,120}purpose === ['"]AUTHENTICATION['"]/, + ); + expect(aiReference).not.toMatch( + /identity\.getPublicKeyById\(3\)[\s\S]{0,120}purpose === ['"]AUTHENTICATION['"]/, + ); }); - it('does not pass WIF strings directly to transition methods', () => { - const legacyTransitionCall = /await sdk\.(?:identities|contracts|documents|tokens|dpns|voting|addresses)\.[^(]+\(\{[^{}]*\b(?:privateKeyWif|assetLockPrivateKeyWif|votingKeyWif)\b/; + it('selects DPNS keys only by authentication purpose and accepted security level', () => { + const example = generatedExamples.dpnsRegister; - expect(aiReference).not.toMatch(legacyTransitionCall); - expect(docs).not.toMatch(legacyTransitionCall); + expect(example).toMatch(/purpose\s*===\s*['"]AUTHENTICATION['"]/); + expect(example).toContain("['CRITICAL', 'HIGH'].includes(k.securityLevel)"); + expect(example).not.toMatch(/getPublicKeyById\s*\(/); + expect(example).not.toMatch(/securityLevel\s*===\s*['"]MEDIUM['"]/); + expect(example).toMatch(/if \(!identityKey\)/); + expect(example).toContain( + 'DPNS registration requires an AUTHENTICATION key with CRITICAL or HIGH security level', + ); + }); + + it('requires a VOTING-purpose key for every masternode voting example', () => { + for (const key of ['dpnsUsername', 'masternodeVote']) { + const example = generatedExamples[key]; + + expect(example, key).toMatch(/find\(k => k\.purpose === ['"]VOTING['"]\)/); + expect(example, key).not.toMatch(/getPublicKeyById\s*\(/); + expect(example, key).not.toMatch(/\|\|\s*votingIdentity\./); + expect(example, key).toMatch(/if \(!votingKey\)/); + expect(example, key).toContain('Masternode voting requires a VOTING-purpose identity key'); + } + }); + + it('passes only declared option properties on the final sdk write call', () => { + const failures = []; + for (const [key, item] of Object.entries(TRANSITION_BY_KEY)) { + const example = generatedExamples[key]; + const sdkMethod = item.sdk_method; + const optionsName = METHOD_TO_OPTIONS[sdkMethod]; + if (!optionsName) { + failures.push(`${key}: no Options mapping for ${sdkMethod}`); + continue; + } + + const allowed = interfaceProperties(wasmSdkDts, optionsName); + // TypeScript may declare the same interface name more than once in the giant d.ts + // (transition object variants). Prefer the richest write-options set when ambiguous. + if (!allowed.size) { + failures.push(`${key}: Options interface ${optionsName} not found`); + continue; + } + + const writeCalls = extractSdkCallSites(example).filter((call) => call.method === sdkMethod); + if (!writeCalls.length) { + failures.push(`${key}: no await sdk.${sdkMethod}(...) call in example`); + continue; + } + + const keys = topLevelObjectKeys(writeCalls[writeCalls.length - 1].argsText); + if (!keys.length) { + failures.push(`${key}: could not parse options object for ${sdkMethod}`); + continue; + } + + const unknown = keys.filter((k) => !allowed.has(k)); + if (unknown.length) { + failures.push( + `${key}: unknown option(s) on ${sdkMethod}: ${unknown.join(', ')} ` + + `(allowed: ${[...allowed].sort().join(', ')})`, + ); + } + } + expect(failures).toEqual([]); + }); + + it('uses the token contract only for token transition examples', () => { + for (const key of TOKEN_TRANSITION_KEYS) { + expect(generatedExamples[key], key).toContain(`dataContractId: '${TOKEN_CONTRACT_ID}'`); + expect(generatedExamples[key], key).not.toContain(`dataContractId: '${DPNS_CONTRACT_ID}'`); + } + expect(generatedExamples.documentCreate).toContain(`dataContractId: '${DPNS_CONTRACT_ID}'`); + }); + + it('embeds v4 document create / identity top-up examples in generated docs', () => { + expect(docs).toContain('new Document({'); + expect(docs).toContain('identityKey'); + expect(docs).toContain('assetLockPrivateKey'); + expect(aiReference).toContain('new Document({'); + expect(aiReference).toContain('new Identity(assetLockProof.createIdentityId())'); expect(aiReference).not.toContain('{ ...params, privateKeyWif }'); }); - it('renders multiline transition examples as valid snippets', () => { + it('uses concrete CoreScript / PoolingWasm values for address withdraw', () => { + const example = generatedExamples.addressWithdraw; + expect(example).toMatch(/CoreScript\.fromP2PKH/); + expect(example).toMatch(/PoolingWasm\.Standard/); + expect(example).not.toMatch(/CoreScript\.newP2PKH/); + expect(example).not.toMatch(/new Uint8Array\(20\)/); + expect(example).toMatch(/const corePubkeyHashHex = 'replace-with-40-hex-character-core-pubkey-hash'/); + expect(example).toMatch(/\^\[0-9a-fA-F\]\{40\}\$/); + expect(example).toMatch(/Uint8Array\.from\(/); + expect(example).toMatch(/CoreScript\.fromP2PKH\(corePubkeyHash\)/); + expect(example).not.toMatch(/pooling:\s*undefined/); + expect(example).not.toMatch(/outputScript:\s*(?:\/\*[^*]*\*\/\s*)?undefined/); + + const writeCall = extractSdkCallSites(example).find((call) => call.method === 'addresses.withdraw'); + expect(writeCall).toBeTruthy(); + const keys = topLevelObjectKeys(writeCall.argsText); + const required = interfaceRequiredProperties(wasmSdkDts, 'AddressFundsWithdrawOptions'); + for (const name of required) { + expect(keys, `missing required option ${name}`).toContain(name); + } + }); + + it('documents Platform Address option metadata with v4 types and requiredness', () => { + // Transfer: typed input/output arrays, not plain objects. + expect(sdkParamByName('addressTransfer', 'inputs')).toMatchObject({ + type: 'PlatformAddressInput[]', + required: true, + }); + expect(sdkParamByName('addressTransfer', 'outputs')).toMatchObject({ + type: 'PlatformAddressOutput[]', + required: true, + }); + expect(sdkParamByName('addressTransfer', 'signer')).toMatchObject({ + type: 'PlatformAddressSigner', + required: true, + }); + expect(sdkParamByName('addressTransfer', 'inputs').description).toMatch(/PlatformAddressInput/); + expect(sdkParamByName('addressTransfer', 'outputs').description).toMatch(/PlatformAddressOutput/); + expect(sdkParamByName('addressTransfer', 'inputs').description).not.toMatch(/\{address, nonce, amount/); + expect(sdkParamByName('addressTransfer', 'outputs').description).not.toMatch(/\{address, amount/); + + expect(sdkParamByName('addressTopUpIdentity', 'identity')).toMatchObject({ + type: 'Identity', + required: true, + }); + expect(sdkParamByName('addressTopUpIdentity', 'identityId')).toBeUndefined(); + expect(sdkParamByName('addressTopUpIdentity', 'inputs')).toMatchObject({ + type: 'PlatformAddressInput[]', + required: true, + }); + expect(sdkParamByName('addressTopUpIdentity', 'inputs').description).not.toMatch(/or \{address/); + + expect(sdkParamByName('addressTransferFromIdentity', 'identity')).toMatchObject({ + type: 'Identity', + required: true, + }); + expect(sdkParamByName('addressTransferFromIdentity', 'identityId')).toBeUndefined(); + expect(sdkParamByName('addressTransferFromIdentity', 'outputs')).toMatchObject({ + type: 'PlatformAddressOutput[]', + required: true, + }); + expect(sdkParamByName('addressTransferFromIdentity', 'outputs').description).not.toMatch(/or \{address/); + + expect(sdkParamByName('addressWithdraw', 'inputs')).toMatchObject({ + type: 'PlatformAddressInput[]', + required: true, + }); + expect(sdkParamByName('addressWithdraw', 'coreFeePerByte')).toMatchObject({ + type: 'number', + required: true, + }); + expect(sdkParamByName('addressWithdraw', 'pooling')).toMatchObject({ + type: 'Pooling', + required: true, + }); + expect(sdkParamByName('addressWithdraw', 'outputScript')).toMatchObject({ + type: 'CoreScript', + required: true, + }); + expect(sdkParamByName('addressWithdraw', 'signer')).toMatchObject({ + type: 'PlatformAddressSigner', + required: true, + }); + expect(sdkParamByName('addressWithdraw', 'inputs').description).not.toMatch(/or \{address/); + + expect(sdkParamByName('addressFundFromAssetLock', 'assetLockProof')).toMatchObject({ + type: 'AssetLockProof', + required: true, + }); + expect(sdkParamByName('addressFundFromAssetLock', 'assetLockPrivateKey')).toMatchObject({ + type: 'PrivateKey', + required: true, + }); + expect(sdkParamByName('addressFundFromAssetLock', 'outputs')).toMatchObject({ + type: 'PlatformAddressOutput[]', + required: true, + }); + expect(sdkParamByName('addressFundFromAssetLock', 'signer')).toMatchObject({ + type: 'PlatformAddressSigner', + required: true, + }); + expect(sdkParamByName('addressFundFromAssetLock', 'outputs').description).not.toMatch(/or \{address/); + + expect(sdkParamByName('addressCreateIdentity', 'identity')).toMatchObject({ + type: 'Identity', + required: true, + }); + expect(sdkParamByName('addressCreateIdentity', 'inputs')).toMatchObject({ + type: 'PlatformAddressInput[]', + required: true, + }); + expect(sdkParamByName('addressCreateIdentity', 'identitySigner')).toMatchObject({ + type: 'IdentitySigner', + required: true, + }); + expect(sdkParamByName('addressCreateIdentity', 'addressSigner')).toMatchObject({ + type: 'PlatformAddressSigner', + required: true, + }); + expect(sdkParamByName('addressCreateIdentity', 'inputs').description).not.toMatch(/\{address, nonce, amount/); + + // Example option objects must include every required declaration member. + const requiredChecks = [ + ['addressTransfer', 'addresses.transfer', 'AddressFundsTransferOptions'], + ['addressTopUpIdentity', 'addresses.topUpIdentity', 'IdentityTopUpFromAddressesOptions'], + ['addressTransferFromIdentity', 'addresses.transferFromIdentity', 'IdentityTransferToAddressesOptions'], + ['addressFundFromAssetLock', 'addresses.fundFromAssetLock', 'AddressFundingFromAssetLockOptions'], + ['addressCreateIdentity', 'addresses.createIdentity', 'IdentityCreateFromAddressesOptions'], + ]; + for (const [key, method, optionsName] of requiredChecks) { + const example = generatedExamples[key]; + const writeCall = extractSdkCallSites(example).find((call) => call.method === method); + expect(writeCall, key).toBeTruthy(); + const keys = topLevelObjectKeys(writeCall.argsText); + const required = interfaceRequiredProperties(wasmSdkDts, optionsName); + for (const name of required) { + expect(keys, `${key} missing required option ${name}`).toContain(name); + } + } + }); + + it('formats multiline query examples as valid top-level const result assignments', () => { expect(docs).not.toMatch(/\breturn\s+(?:const|let|var|\/\/)/); - expect(aiReference).not.toMatch(/const result =\s*import\b/); + + const querySection = aiReference.split('## State Transition Operations')[0] || ''; + const queryBlocks = [...querySection.matchAll(/```javascript\n([\s\S]*?)```/g)].map((m) => m[1]); + + // Pattern intro block uses a placeholder, not a real call — skip blocks without sdk. + const queryCallBlocks = queryBlocks.filter((b) => /\bsdk\./.test(b)); + expect(queryCallBlocks.length).toBeGreaterThan(10); + + const invalidTopLevelReturn = queryCallBlocks.filter((b) => /^\s*return\s+await\b/m.test(b)); + expect(invalidTopLevelReturn, invalidTopLevelReturn.map((b) => b.slice(0, 80)).join('\n---\n')).toEqual([]); + + const multilineExpressionBlocks = queryCallBlocks.filter((b) => { + const codeLines = b.split('\n').filter((l) => l.trim() && !l.trim().startsWith('//')); + return codeLines.length > 1 && !codeLines.some((l) => /^\s*(import|const|let|var|function|class)\b/.test(l)); + }); + // After the formatter fix there should be no bare multiline expressions left; + // every former return-await multiline query is wrapped as const result = ... + expect(multilineExpressionBlocks).toEqual([]); + + // Representative multiline queries must be const result = await ... + for (const needle of [ + "sdk.identities.getKeys({", + "sdk.documents.query({", + "sdk.contracts.getMany([", + "sdk.tokens.statuses([", + ]) { + const block = queryCallBlocks.find((b) => b.includes(needle)); + expect(block, needle).toBeTruthy(); + expect(block).toMatch(/^\s*const result = await /m); + expect(block.trimEnd().endsWith(';')).toBe(true); + } + + // Transition examples stay multi-statement and must not be force-wrapped into one assignment. + const transitionSection = aiReference.split('## State Transition Operations')[1] || ''; + expect(transitionSection).toMatch(/import \{[\s\S]*IdentitySigner[\s\S]*\} from '@dashevo\/evo-sdk';/); + expect(transitionSection).toMatch(/await sdk\.tokens\.mint\(\{/); + // Multi-statement identity create still has setup before the call. + expect(transitionSection).toMatch(/new Identity\(assetLockProof\.createIdentityId\(\)\)/); }); });