diff --git a/python/numbersprotocol_capture/client.py b/python/numbersprotocol_capture/client.py index 529391a..19d778b 100644 --- a/python/numbersprotocol_capture/client.py +++ b/python/numbersprotocol_capture/client.py @@ -12,7 +12,7 @@ import httpx -from .crypto import create_integrity_proof, sha256, sign_integrity_proof +from .crypto import create_integrity_proof, sha256, sign_integrity_proof, _serialize_proof from .errors import CaptureError, ValidationError, create_api_error from .types import ( Asset, @@ -158,6 +158,13 @@ def __init__( self._base_url = base_url or DEFAULT_BASE_URL self._client = httpx.Client(timeout=30.0) + def __del__(self) -> None: + """Close the HTTP client when garbage collected.""" + try: + self._client.close() + except Exception: + pass + def __enter__(self) -> Capture: return self @@ -307,7 +314,7 @@ def register( "asset_mime_type": proof.asset_mime_type, "created_at": proof.created_at, } - form_data["signed_metadata"] = json.dumps(proof_dict) + form_data["signed_metadata"] = _serialize_proof(proof_dict) sig_dict = { "proofHash": signature.proof_hash, diff --git a/python/numbersprotocol_capture/crypto.py b/python/numbersprotocol_capture/crypto.py index 597523c..d448cc4 100644 --- a/python/numbersprotocol_capture/crypto.py +++ b/python/numbersprotocol_capture/crypto.py @@ -45,6 +45,11 @@ def create_integrity_proof(proof_hash: str, mime_type: str) -> IntegrityProof: ) +def _serialize_proof(proof_dict: dict) -> str: + """Serializes a proof dict to a canonical JSON string with sorted keys and compact separators.""" + return json.dumps(proof_dict, sort_keys=True, separators=(",", ":")) + + def sign_integrity_proof(proof: IntegrityProof, private_key: str) -> AssetSignature: """ Signs an integrity proof using EIP-191 standard. @@ -68,7 +73,7 @@ def sign_integrity_proof(proof: IntegrityProof, private_key: str) -> AssetSignat "asset_mime_type": proof.asset_mime_type, "created_at": proof.created_at, } - proof_json = json.dumps(proof_dict, separators=(",", ":")) + proof_json = _serialize_proof(proof_dict) integrity_sha = sha256(proof_json.encode("utf-8")) # Sign the integrity hash using EIP-191 diff --git a/ts/src/client.ts b/ts/src/client.ts index c86440e..4e5dc62 100644 --- a/ts/src/client.ts +++ b/ts/src/client.ts @@ -16,10 +16,11 @@ import { } from './types.js' import { ValidationError, + NetworkError, createApiError, CaptureError, } from './errors.js' -import { sha256, createIntegrityProof, signIntegrityProof } from './crypto.js' +import { sha256, createIntegrityProof, signIntegrityProof, serializeProof } from './crypto.js' const DEFAULT_BASE_URL = 'https://api.numbersprotocol.io/api/v3' const HISTORY_API_URL = @@ -173,11 +174,18 @@ export class Capture { requestBody = JSON.stringify(body) } - const response = await fetch(url, { - method, - headers, - body: requestBody, - }) + let response: Response + try { + response = await fetch(url, { + method, + headers, + body: requestBody, + }) + } catch (error) { + throw new NetworkError( + `Network error: ${error instanceof Error ? error.message : String(error)}` + ) + } if (!response.ok) { let message = `API request failed with status ${response.status}` @@ -254,7 +262,7 @@ export class Capture { const proof = createIntegrityProof(proofHash, mimeType) const signature = await signIntegrityProof(proof, options.sign.privateKey) - formData.append('signed_metadata', JSON.stringify(proof)) + formData.append('signed_metadata', serializeProof(proof)) formData.append('signature', JSON.stringify([signature])) } @@ -371,13 +379,20 @@ export class Capture { url.searchParams.set('testnet', 'true') } - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `token ${this.token}`, - }, - }) + let response: Response + try { + response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${this.token}`, + }, + }) + } catch (error) { + throw new NetworkError( + `Network error: ${error instanceof Error ? error.message : String(error)}` + ) + } if (!response.ok) { throw createApiError(response.status, 'Failed to fetch asset history', nid) @@ -427,14 +442,21 @@ export class Capture { timestampCreated: c.timestamp, })) - const response = await fetch(MERGE_TREE_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `token ${this.token}`, - }, - body: JSON.stringify(commitData), - }) + let response: Response + try { + response = await fetch(MERGE_TREE_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${this.token}`, + }, + body: JSON.stringify(commitData), + }) + } catch (error) { + throw new NetworkError( + `Network error: ${error instanceof Error ? error.message : String(error)}` + ) + } if (!response.ok) { throw createApiError(response.status, 'Failed to merge asset trees', nid) @@ -517,13 +539,20 @@ export class Capture { } // Verify Engine API requires token in Authorization header, not form data - const response = await fetch(ASSET_SEARCH_API_URL, { - method: 'POST', - headers: { - Authorization: `token ${this.token}`, - }, - body: formData, - }) + let response: Response + try { + response = await fetch(ASSET_SEARCH_API_URL, { + method: 'POST', + headers: { + Authorization: `token ${this.token}`, + }, + body: formData, + }) + } catch (error) { + throw new NetworkError( + `Network error: ${error instanceof Error ? error.message : String(error)}` + ) + } if (!response.ok) { let message = `Asset search failed with status ${response.status}` @@ -573,14 +602,21 @@ export class Capture { throw new ValidationError('nid is required for NFT search') } - const response = await fetch(NFT_SEARCH_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `token ${this.token}`, - }, - body: JSON.stringify({ nid }), - }) + let response: Response + try { + response = await fetch(NFT_SEARCH_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${this.token}`, + }, + body: JSON.stringify({ nid }), + }) + } catch (error) { + throw new NetworkError( + `Network error: ${error instanceof Error ? error.message : String(error)}` + ) + } if (!response.ok) { let message = `NFT search failed with status ${response.status}` diff --git a/ts/src/crypto.ts b/ts/src/crypto.ts index de214ab..9c05262 100644 --- a/ts/src/crypto.ts +++ b/ts/src/crypto.ts @@ -30,6 +30,14 @@ export function createIntegrityProof( } } +/** + * Serializes an integrity proof to a canonical JSON string with sorted keys. + * Used for both hashing and form submission to ensure consistent output. + */ +export function serializeProof(proof: IntegrityProof): string { + return JSON.stringify(proof, Object.keys(proof).sort()) +} + /** * Signs an integrity proof using EIP-191 standard. * Returns the signature data required for asset registration. @@ -40,8 +48,8 @@ export async function signIntegrityProof( ): Promise { const wallet = new Wallet(privateKey) - // Compute integrity hash of the signed metadata JSON - const proofJson = JSON.stringify(proof) + // Compute integrity hash of the signed metadata JSON (sorted keys for cross-SDK consistency) + const proofJson = serializeProof(proof) const proofBytes = new TextEncoder().encode(proofJson) const integritySha = await sha256(proofBytes)