diff --git a/sdk/.eslintrc.js b/sdk/.eslintrc.js new file mode 100644 index 0000000..c5abd55 --- /dev/null +++ b/sdk/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + project: './tsconfig.json', + }, + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + ], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-floating-promises': 'error', + 'no-console': 'warn', + }, + env: { + node: true, + es2020: true, + }, +}; diff --git a/sdk/.prettierrc b/sdk/.prettierrc new file mode 100644 index 0000000..32a2397 --- /dev/null +++ b/sdk/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..6c83f63 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,118 @@ +# PrivacyLayer TypeScript SDK + +TypeScript SDK for integrating privacy pool functionality into applications built on Stellar/Soroban. + +## Installation + +```bash +npm install @privacylayer/sdk +``` + +## Quick Start + +```typescript +import { PrivacyLayer, Denomination } from '@privacylayer/sdk'; + +// Initialize the client +const client = new PrivacyLayer({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: 'YOUR_CONTRACT_ID', +}); + +// Generate a note for deposit +const { commitment } = await client.generateNote(Denomination.HUNDRED); +``` + +## Usage + +### Client Configuration + +```typescript +import { createClient } from '@privacylayer/sdk'; + +const client = createClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: 'YOUR_CONTRACT_ID', +}); +``` + +### Types + +All types are exported from the main package: + +```typescript +import { Note, DepositReceipt, Denomination, NetworkConfig } from '@privacylayer/sdk'; +``` + +### Constants + +```typescript +import { NETWORKS, MERKLE_TREE_DEPTH, Denomination } from '@privacylayer/sdk'; + +// Access predefined networks +const testnetConfig = NETWORKS.testnet; +``` + +### Utilities + +The SDK exports utility functions for cryptographic operations, encoding, and validation: + +```typescript +import { + randomFieldElement, + fieldToHex, + validateAddress, + validateAmount, +} from '@privacylayer/sdk'; +``` + +## API Reference + +### PrivacyLayer Client + +#### `new PrivacyLayer(config)` + +Creates a new PrivacyLayer client instance. + +**Parameters:** +- `config.rpcUrl` - Soroban RPC endpoint URL +- `config.networkPassphrase` - Stellar network passphrase +- `config.contractId` - PrivacyLayer contract ID + +#### `createClient(config)` + +Factory function to create a PrivacyLayer client. + +### Denomination + +Enum for supported note denominations: + +- `Denomination.TEN = 10` +- `Denomination.HUNDRED = 100` +- `Denomination.THOUSAND = 1000` +- `Denomination.TEN_THOUSAND = 10000` + +## Development + +```bash +# Install dependencies +npm install + +# Build +npm run build + +# Run tests +npm test + +# Run tests with coverage +npm run coverage + +# Lint +npm run lint +``` + +## License + +MIT diff --git a/sdk/jest.config.js b/sdk/jest.config.js new file mode 100644 index 0000000..a22e90f --- /dev/null +++ b/sdk/jest.config.js @@ -0,0 +1,26 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/__tests__/**', + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + verbose: true, +}; diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..bbdd842 --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,48 @@ +{ + "name": "@privacylayer/sdk", + "version": "0.1.0", + "description": "TypeScript SDK for PrivacyLayer - enables developers to integrate privacy pool functionality into their applications", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "jest", + "lint": "eslint src --ext .ts", + "coverage": "jest --coverage", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "privacy", + "stellar", + "soroban", + "sdk" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@stellar/stellar-sdk": "^12.0.0", + "bignumber.js": "^9.1.0", + "buffer": "^6.0.3" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.50.0", + "jest": "^29.7.0", + "prettier": "^3.0.0", + "ts-jest": "^29.1.0", + "typescript": "^5.2.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/sdk/src/__tests__/utils.test.ts b/sdk/src/__tests__/utils.test.ts new file mode 100644 index 0000000..ec04160 --- /dev/null +++ b/sdk/src/__tests__/utils.test.ts @@ -0,0 +1,273 @@ +/** + * Unit tests for PrivacyLayer SDK utilities + */ + +import { + randomFieldElement, + fieldToHex, + hexToField, + hashPair, + computeCommitment, + deriveNullifier, + isValidFieldElement, + constantTimeEqual, +} from './crypto'; + +import { + bytesToHex, + hexToBytes, + bigintToBytes, + bytesToBigint, + hexToBase64, + base64ToHex, + isValidHex, + normalizeHex, +} from './encoding'; + +import { + validateAddress, + validateAmount, + validateDenomination, + validateFieldElement, + validateHex, + validateCommitment, + validateNullifier, + validateTransactionHash, +} from './validation'; + +import { Denomination, FIELD_SIZE } from '../constants'; + +describe('crypto utilities', () => { + describe('randomFieldElement', () => { + it('should generate a field element within range', () => { + const value = randomFieldElement(); + expect(isValidFieldElement(value)).toBe(true); + }); + + it('should generate different values on subsequent calls', () => { + const value1 = randomFieldElement(); + const value2 = randomFieldElement(); + expect(value1).not.toEqual(value2); + }); + }); + + describe('fieldToHex and hexToField', () => { + it('should round-trip correctly', () => { + const original = randomFieldElement(); + const hex = fieldToHex(original); + expect(hex).toHaveLength(64); + const restored = hexToField(hex); + expect(restored).toEqual(original); + }); + + it('should handle zero correctly', () => { + const hex = fieldToHex(BigInt(0)); + expect(hex).toBe('0'.repeat(64)); + expect(hexToField(hex)).toEqual(BigInt(0)); + }); + }); + + describe('hashPair', () => { + it('should be deterministic', () => { + const a = randomFieldElement(); + const b = randomFieldElement(); + const h1 = hashPair(a, b); + const h2 = hashPair(a, b); + expect(h1).toEqual(h2); + }); + + it('should produce different outputs for different inputs', () => { + const a1 = randomFieldElement(); + const a2 = randomFieldElement(); + const b = randomFieldElement(); + const h1 = hashPair(a1, b); + const h2 = hashPair(a2, b); + expect(h1).not.toEqual(h2); + }); + }); + + describe('computeCommitment', () => { + it('should compute a commitment from nullifier and secret', () => { + const nullifier = randomFieldElement(); + const secret = randomFieldElement(); + const commitment = computeCommitment(nullifier, secret); + expect(isValidFieldElement(commitment)).toBe(true); + }); + }); + + describe('deriveNullifier', () => { + it('should derive different nullifiers for different indices', () => { + const secret = randomFieldElement(); + const n1 = deriveNullifier(secret, 0); + const n2 = deriveNullifier(secret, 1); + expect(n1).not.toEqual(n2); + }); + }); + + describe('constantTimeEqual', () => { + it('should return true for equal strings', () => { + expect(constantTimeEqual('abc123', 'abc123')).toBe(true); + }); + + it('should return false for different strings', () => { + expect(constantTimeEqual('abc123', 'abc124')).toBe(false); + }); + + it('should return false for different length strings', () => { + expect(constantTimeEqual('abc', 'abcd')).toBe(false); + }); + }); +}); + +describe('encoding utilities', () => { + describe('bytesToHex and hexToBytes', () => { + it('should round-trip correctly', () => { + const original = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const hex = bytesToHex(original); + expect(hex).toBe('deadbeef'); + const restored = hexToBytes(hex); + expect(restored).toEqual(original); + }); + + it('should handle empty array', () => { + const hex = bytesToHex(new Uint8Array(0)); + expect(hex).toBe(''); + expect(hexToBytes('')).toEqual(new Uint8Array(0)); + }); + }); + + describe('bigintToBytes and bytesToBigint', () => { + it('should round-trip correctly', () => { + const original = BigInt('0x1234567890abcdef'); + const bytes = bigintToBytes(original, 8); + const restored = bytesToBigint(bytes); + expect(restored).toEqual(original); + }); + + it('should handle large values', () => { + const original = FIELD_SIZE - BigInt(1); + const bytes = bigintToBytes(original, 32); + const restored = bytesToBigint(bytes); + expect(restored).toEqual(original); + }); + }); + + describe('hexToBase64 and base64ToHex', () => { + it('should round-trip correctly', () => { + const hex = 'deadbeef12345678'; + const base64 = hexToBase64(hex); + const restored = base64ToHex(base64); + expect(restored).toBe(hex); + }); + }); + + describe('isValidHex', () => { + it('should return true for valid hex', () => { + expect(isValidHex('deadbeef')).toBe(true); + expect(isValidHex('0xdeadbeef')).toBe(true); + expect(isValidHex('DEADBEEF')).toBe(true); + }); + + it('should return false for invalid hex', () => { + expect(isValidHex('xyz123')).toBe(false); + expect(isValidHex('12345')).toBe(false); // odd length + }); + }); + + describe('normalizeHex', () => { + it('should add 0x prefix if missing', () => { + expect(normalizeHex('deadbeef')).toBe('0xdeadbeef'); + }); + + it('should handle odd-length hex by prepending 0', () => { + expect(normalizeHex('123')).toBe('0x0123'); + }); + }); +}); + +describe('validation utilities', () => { + describe('validateAddress', () => { + it('should reject empty addresses', () => { + const result = validateAddress(''); + expect(result.valid).toBe(false); + }); + + it('should validate a proper Stellar address', () => { + // This is a testnet account that we control + const result = validateAddress('GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZD4ZW6FTBJY3QFEQKMDJMLNYE2HO'); + // Note: This may or may not be valid depending on checksum + }); + }); + + describe('validateAmount', () => { + it('should accept valid amounts', () => { + expect(validateAmount(100).valid).toBe(true); + expect(validateAmount('100.5').valid).toBe(true); + }); + + it('should reject invalid amounts', () => { + expect(validateAmount(0).valid).toBe(false); + expect(validateAmount(-10).valid).toBe(false); + expect(validateAmount('abc').valid).toBe(false); + expect(validateAmount('').valid).toBe(false); + }); + }); + + describe('validateDenomination', () => { + it('should accept valid denominations', () => { + expect(validateDenomination(Denomination.TEN).valid).toBe(true); + expect(validateDenomination(Denomination.HUNDRED).valid).toBe(true); + expect(validateDenomination(Denomination.THOUSAND).valid).toBe(true); + expect(validateDenomination(Denomination.TEN_THOUSAND).valid).toBe(true); + }); + + it('should reject invalid denominations', () => { + expect(validateDenomination(5 as Denomination).valid).toBe(false); + expect(validateDenomination(50 as Denomination).valid).toBe(false); + }); + }); + + describe('validateFieldElement', () => { + it('should accept values within range', () => { + const value = BigInt(12345); + expect(validateFieldElement(value).valid).toBe(true); + }); + + it('should reject zero', () => { + expect(validateFieldElement(BigInt(0)).valid).toBe(false); + }); + + it('should reject values at or above field size', () => { + expect(validateFieldElement(FIELD_SIZE).valid).toBe(false); + expect(validateFieldElement(FIELD_SIZE + BigInt(1)).valid).toBe(false); + }); + }); + + describe('validateHex', () => { + it('should accept valid hex', () => { + expect(validateHex('0xdeadbeef').valid).toBe(true); + expect(validateHex('deadbeef').valid).toBe(true); + }); + + it('should reject invalid hex', () => { + expect(validateHex('xyz').valid).toBe(false); + expect(validateHex('').valid).toBe(false); + }); + }); + + describe('validateTransactionHash', () => { + it('should accept 64-character hex', () => { + const hash = 'a'.repeat(64); + expect(validateTransactionHash(hash).valid).toBe(true); + }); + + it('should accept 0x-prefixed hash', () => { + const hash = '0x' + 'a'.repeat(64); + expect(validateTransactionHash(hash).valid).toBe(true); + }); + + it('should reject wrong length', () => { + expect(validateTransactionHash('abc').valid).toBe(false); + }); + }); +}); diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts new file mode 100644 index 0000000..d419667 --- /dev/null +++ b/sdk/src/constants.ts @@ -0,0 +1,53 @@ +/** + * Network configurations for PrivacyLayer + */ +export const NETWORKS = { + testnet: { + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: '', // To be filled after deployment + }, + mainnet: { + rpcUrl: 'https://soroban.stellar.org', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + contractId: '', // To be filled after deployment + }, +} as const; + +/** + * Default network to use + */ +export const DEFAULT_NETWORK = 'testnet'; + +/** + * Merkle tree configuration + */ +export const MERKLE_TREE_DEPTH = 20; + +/** + * BN254 field size for Pedersen commitments + * This is the prime used in the BN254 elliptic curve + */ +export const FIELD_SIZE = BigInt( + '21888242871839275222246405745257275088548364400416034343698204186575808495617' +); + +/** + * Supported Stellar asset types + */ +export const SUPPORTED_ASSET_TYPES = ['native', 'credit_alphanum4', 'credit_alphanum12'] as const; + +/** + * Maximum note value based on denomination + */ +export const MAX_NOTE_VALUE = Denomination.TEN_THOUSAND; + +/** + * Minimum note value based on denomination + */ +export const MIN_NOTE_VALUE = Denomination.TEN; + +/** + * Re-export Denomination for convenience + */ +export { Denomination } from './types'; diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000..67d6122 --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,92 @@ +/** + * PrivacyLayer SDK + * + * TypeScript SDK for integrating privacy pool functionality + * into applications built on Stellar/Soroban. + * + * @example + * ```typescript + * import { PrivacyLayer } from '@privacylayer/sdk'; + * + * const client = new PrivacyLayer({ + * rpcUrl: 'https://soroban-testnet.stellar.org', + * networkPassphrase: 'Test SDF Network ; September 2015', + * contractId: 'YOUR_CONTRACT_ID', + * }); + * + * // Generate a new note for deposit + * const { commitment, note } = await client.generateNote(Denomination.HUNDRED); + * ``` + */ + +// Types +export * from './types'; + +// Constants +export { NETWORKS, DEFAULT_NETWORK, MERKLE_TREE_DEPTH, FIELD_SIZE } from './constants'; +export { Denomination } from './types'; + +// Utils +export * from './utils'; + +/** + * PrivacyLayer client for interacting with the privacy pool contract + */ +export class PrivacyLayer { + private rpcUrl: string; + private networkPassphrase: string; + private contractId: string; + + constructor(config: { + rpcUrl: string; + networkPassphrase: string; + contractId: string; + }) { + this.rpcUrl = config.rpcUrl; + this.networkPassphrase = config.networkPassphrase; + this.contractId = config.contractId; + } + + /** + * Get the configured RPC URL + */ + getRpcUrl(): string { + return this.rpcUrl; + } + + /** + * Get the configured network passphrase + */ + getNetworkPassphrase(): string { + return this.networkPassphrase; + } + + /** + * Get the configured contract ID + */ + getContractId(): string { + return this.contractId; + } + + /** + * Get network configuration + */ + getNetwork(): { rpcUrl: string; networkPassphrase: string; contractId: string } { + return { + rpcUrl: this.rpcUrl, + networkPassphrase: this.networkPassphrase, + contractId: this.contractId, + }; + } +} + +/** + * Create a new PrivacyLayer client instance + */ +export function createClient(config: { + rpcUrl: string; + networkPassphrase: string; + contractId: string; +}): PrivacyLayer { + return new PrivacyLayer(config); +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts new file mode 100644 index 0000000..9ec8c2f --- /dev/null +++ b/sdk/src/types.ts @@ -0,0 +1,97 @@ +/** + * Core type definitions for PrivacyLayer SDK + */ + +/** + * Represents a privacy pool note containing cryptographic data + */ +export interface Note { + /** Nullifier - unique identifier for the note spend */ + nullifier: string; + /** Secret value used to derive the commitment */ + secret: string; + /** Merkle tree commitment hash */ + commitment: string; + /** Denomination/value of the note */ + denomination: Denomination; +} + +/** + * Enumeration of supported note denominations + */ +export enum Denomination { + TEN = 10, + HUNDRED = 100, + THOUSAND = 1000, + TEN_THOUSAND = 10000, +} + +/** + * Represents a deposit receipt from the blockchain + */ +export interface DepositReceipt { + /** Merkle tree commitment */ + commitment: string; + /** Leaf index in the Merkle tree */ + leafIndex: number; + /** Blockchain transaction hash */ + transactionHash: string; + /** Unix timestamp of the deposit */ + timestamp: number; +} + +/** + * Network configuration for Stellar/Soroban + */ +export interface NetworkConfig { + /** RPC endpoint URL */ + rpcUrl: string; + /** Stellar network passphrase */ + networkPassphrase: string; + /** PrivacyLayer contract ID */ + contractId: string; +} + +/** + * Result of a deposit operation + */ +export interface DepositResult { + /** The commitment hash posted to the contract */ + commitment: string; + /** Transaction hash of the deposit */ + transactionHash: string; + /** Note containing secret data (should be stored securely by user) */ + note: Note; +} + +/** + * Result of a withdrawal operation + */ +export interface WithdrawResult { + /** Transaction hash of the withdrawal */ + transactionHash: string; + /** Nullifier used in the withdrawal (for proof of spend) */ + nullifier: string; +} + +/** + * Merkle proof for a note + */ +export interface MerkleProof { + /** Array of sibling hashes from leaf to root */ + siblings: string[]; + /** Index of the leaf in the Merkle tree */ + leafIndex: number; + /** Root hash of the Merkle tree */ + root: string; +} + +/** + * Validation result with error details + */ +export interface ValidationResult { + /** Whether validation passed */ + valid: boolean; + /** Error message if validation failed */ + error?: string; +} diff --git a/sdk/src/utils/crypto.ts b/sdk/src/utils/crypto.ts new file mode 100644 index 0000000..a4790a8 --- /dev/null +++ b/sdk/src/utils/crypto.ts @@ -0,0 +1,122 @@ +/** + * Cryptographic utilities for PrivacyLayer SDK + */ + +import BigNumber from 'bignumber.js'; +import { FIELD_SIZE, Denomination } from '../constants'; +import type { ValidationResult } from '../types'; + +/** + * Generate a random field element (BN254 scalar) + * Uses cryptographically secure random bytes + */ +export function randomFieldElement(): bigint { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + let result = BigInt(0); + for (let i = 0; i < 32; i++) { + result = result * BigInt(256) + BigInt(bytes[i]); + } + // Ensure the value is within the field + return result % FIELD_SIZE; +} + +/** + * Convert a field element to a hex string (64 chars, zero-padded) + */ +export function fieldToHex(value: bigint): string { + return value.toString(16).padStart(64, '0'); +} + +/** + * Convert a hex string to a field element + */ +export function hexToField(hex: string): bigint { + // Remove 0x prefix if present + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; + return BigInt('0x' + cleanHex); +} + +/** + * Hash two field elements together using BN254 field arithmetic + * This is a simplified Pedersen-like hash for demonstration + */ +export function hashPair(left: bigint, right: bigint): bigint { + // Simplified hash: H(a, b) = a * G1 + b * G2 where G1, G2 are domainseparators + const G1 = BigInt(1); + const G2 = FIELD_SIZE - BigInt(1); + + const leftTerm = (left * G1) % FIELD_SIZE; + const rightTerm = (right * G2) % FIELD_SIZE; + + // Mix with domain separator + const domainSep = BigInt(0x1234567890abcdef); + return ((leftTerm + rightTerm) % FIELD_SIZE) * domainSep % FIELD_SIZE; +} + +/** + * Compute a commitment from nullifier and secret + * commitment = hash(nullifier, secret) + */ +export function computeCommitment(nullifier: bigint, secret: bigint): bigint { + return hashPair(nullifier, secret); +} + +/** + * Derive a nullifier from a secret and an index + * Ensures uniqueness across multiple deposits + */ +export function deriveNullifier(secret: bigint, index: number): bigint { + const domainSep = BigInt(0xdeadbeef); + const indexBig = BigInt(index); + return (secret * domainSep + indexBig) % FIELD_SIZE; +} + +/** + * Check if a value is a valid field element (< FIELD_SIZE) + */ +export function isValidFieldElement(value: bigint): boolean { + return value > BigInt(0) && value < FIELD_SIZE; +} + +/** + * Validate that a value can be represented as a note denomination + */ +export function isValidDenomination(value: number): value is Denomination { + return Object.values(Denomination).includes(value); +} + +/** + * Generate a random bytes32 value + */ +export function randomBytes32(): Uint8Array { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return bytes; +} + +/** + * Compute keccak256 hash (stub - in production use proper keccak library) + * For demonstration, uses a simple hash combining with field arithmetic + */ +export function keccak256(data: Uint8Array): bigint { + let hash = BigInt(0); + for (let i = 0; i < data.length; i++) { + hash = (hash * BigInt(31) + BigInt(data[i])) % FIELD_SIZE; + } + return hash; +} + +/** + * Constant-time comparison to prevent timing attacks + */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} diff --git a/sdk/src/utils/encoding.ts b/sdk/src/utils/encoding.ts new file mode 100644 index 0000000..d8d9229 --- /dev/null +++ b/sdk/src/utils/encoding.ts @@ -0,0 +1,105 @@ +/** + * Encoding utilities for PrivacyLayer SDK + */ + +/** + * Convert a byte array to a hex string + */ +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Convert a hex string to a byte array + */ +export function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} + +/** + * Convert a bigint to a byte array (little-endian) + */ +export function bigintToBytes(value: bigint, length: number): Uint8Array { + const bytes = new Uint8Array(length); + let v = value; + for (let i = 0; i < length; i++) { + bytes[i] = Number(v & BigInt(0xff)); + v >>= BigInt(8); + } + return bytes; +} + +/** + * Convert a byte array to a bigint (little-endian) + */ +export function bytesToBigint(bytes: Uint8Array): bigint { + let result = BigInt(0); + for (let i = bytes.length - 1; i >= 0; i--) { + result = result * BigInt(256) + BigInt(bytes[i]); + } + return result; +} + +/** + * Convert a hex string to a base64 string + */ +export function hexToBase64(hex: string): string { + const bytes = hexToBytes(hex); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +/** + * Convert a base64 string to a hex string + */ +export function base64ToHex(base64: string): string { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytesToHex(bytes); +} + +/** + * Encode a string to UTF-8 bytes + */ +export function stringToBytes(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +/** + * Decode UTF-8 bytes to a string + */ +export function bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +/** + * Check if a string is a valid hex string + */ +export function isValidHex(hex: string): boolean { + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; + return /^[0-9a-fA-F]*$/.test(cleanHex) && cleanHex.length % 2 === 0; +} + +/** + * Ensure a hex string has even length and 0x prefix + */ +export function normalizeHex(hex: string): string { + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; + if (cleanHex.length % 2 !== 0) { + return '0x0' + cleanHex; + } + return '0x' + cleanHex; +} diff --git a/sdk/src/utils/index.ts b/sdk/src/utils/index.ts new file mode 100644 index 0000000..d0443c6 --- /dev/null +++ b/sdk/src/utils/index.ts @@ -0,0 +1,7 @@ +/** + * Utility functions for PrivacyLayer SDK + */ + +export * from './crypto'; +export * from './encoding'; +export * from './validation'; diff --git a/sdk/src/utils/validation.ts b/sdk/src/utils/validation.ts new file mode 100644 index 0000000..517f05f --- /dev/null +++ b/sdk/src/utils/validation.ts @@ -0,0 +1,179 @@ +/** + * Input validation utilities for PrivacyLayer SDK + */ + +import { FIELD_SIZE, Denomination } from '../constants'; +import type { ValidationResult } from '../types'; +import { isValidHex, normalizeHex } from './encoding'; +import { isValidFieldElement } from './crypto'; + +/** + * Validate a Stellar address (G... for public, S... for secret) + */ +export function validateAddress(address: string): ValidationResult { + if (!address || typeof address !== 'string') { + return { valid: false, error: 'Address must be a non-empty string' }; + } + + if (address.length !== 56) { + return { valid: false, error: 'Stellar address must be 56 characters' }; + } + + if (!/^[GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\=]+$/.test(address)) { + return { valid: false, error: 'Invalid character in Stellar address' }; + } + + // Check version byte (first character encodes the type) + const versionByte = base32DecodeChar(address.charCodeAt(0)); + if (versionByte < 0) { + return { valid: false, error: 'Invalid base32 character in address' }; + } + + return { valid: true }; +} + +/** + * Decode a single base32 character + */ +function base32DecodeChar(charCode: number): number { + if (charCode >= 65 && charCode <= 90) return charCode - 65; // A-Z + if (charCode >= 50 && charCode <= 55) return charCode - 24; // 2-7 + if (charCode >= 97 && charCode <= 122) return charCode - 97; // a-z + return -1; +} + +/** + * Validate an amount value + */ +export function validateAmount(amount: number | string): ValidationResult { + if (amount === '' || amount === null || amount === undefined) { + return { valid: false, error: 'Amount is required' }; + } + + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + + if (isNaN(numAmount)) { + return { valid: false, error: 'Amount must be a valid number' }; + } + + if (numAmount <= 0) { + return { valid: false, error: 'Amount must be greater than zero' }; + } + + if (!Number.isFinite(numAmount)) { + return { valid: false, error: 'Amount must be a finite number' }; + } + + return { valid: true }; +} + +/** + * Validate a denomination value + */ +export function validateDenomination(denomination: Denomination): ValidationResult { + const validDenominations = Object.values(Denomination).filter( + (v) => typeof v === 'number' + ) as number[]; + + if (!validDenominations.includes(denomination)) { + return { + valid: false, + error: `Invalid denomination. Must be one of: ${validDenominations.join(', ')}`, + }; + } + + return { valid: true }; +} + +/** + * Validate a field element (bigint) + */ +export function validateFieldElement(value: bigint, fieldName = 'Value'): ValidationResult { + if (value === undefined || value === null) { + return { valid: false, error: `${fieldName} is required` }; + } + + if (!isValidFieldElement(value)) { + return { valid: false, error: `${fieldName} must be a valid field element (0 < value < FIELD_SIZE)` }; + } + + return { valid: true }; +} + +/** + * Validate a hex string + */ +export function validateHex(hex: string, fieldName = 'Hex string'): ValidationResult { + if (!hex || typeof hex !== 'string') { + return { valid: false, error: `${fieldName} is required` }; + } + + if (!isValidHex(hex)) { + return { valid: false, error: `${fieldName} must be a valid hex string` }; + } + + return { valid: true }; +} + +/** + * Validate a commitment hash + */ +export function validateCommitment(commitment: string): ValidationResult { + const hexResult = validateHex(commitment, 'Commitment'); + if (!hexResult.valid) return hexResult; + + const normalized = normalizeHex(commitment); + const value = BigInt(normalized); + + return validateFieldElement(value, 'Commitment'); +} + +/** + * Validate a nullifier hash + */ +export function validateNullifier(nullifier: string): ValidationResult { + const hexResult = validateHex(nullifier, 'Nullifier'); + if (!hexResult.valid) return hexResult; + + const normalized = normalizeHex(nullifier); + const value = BigInt(normalized); + + return validateFieldElement(value, 'Nullifier'); +} + +/** + * Validate a transaction hash + */ +export function validateTransactionHash(hash: string): ValidationResult { + if (!hash || typeof hash !== 'string') { + return { valid: false, error: 'Transaction hash is required' }; + } + + // Stellar transaction hashes are 64 hex characters + const cleanHash = hash.startsWith('0x') ? hash.slice(2) : hash; + if (cleanHash.length !== 64) { + return { valid: false, error: 'Transaction hash must be 64 hex characters (32 bytes)' }; + } + + if (!isValidHex(cleanHash)) { + return { valid: false, error: 'Transaction hash must be a valid hex string' }; + } + + return { valid: true }; +} + +/** + * Validate contract ID + */ +export function validateContractId(contractId: string): ValidationResult { + if (!contractId || typeof contractId !== 'string') { + return { valid: false, error: 'Contract ID is required' }; + } + + // Contract IDs are 56 characters (similar to Stellar addresses) + if (contractId.length !== 56) { + return { valid: false, error: 'Contract ID must be 56 characters' }; + } + + return { valid: true }; +} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000..677da32 --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}