diff --git a/.eslintrc.json b/.eslintrc.json index 5892e8a..c7462f6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,7 @@ "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], "parserOptions": { "ecmaVersion": 2020, "sourceType": "module" }, "rules": { - "no-console": ["warn", { "allow": ["warn", "error"] }], + "no-console": ["warn", { "allow": ["warn", "error", "log"] }], "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] }, "env": { "node": true, "es2020": true }, diff --git a/Changelog.md b/Changelog.md index 5c0b471..df414f2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,7 +2,14 @@ ## [Unreleased] +### Fixed +- Implemented `handleDispute()` escrow lifecycle step to move escrow to platform-only signer mode by submitting signer and threshold updates, then verifying the account config via follow-up Horizon fetch (`src/escrow/index.ts`) + ### Added +- `isValidAmount()` validator: validates positive Stellar amount strings with up to 7 decimal places and rejects scientific notation (`src/utils/validation.ts`) +- `EscrowManager` class with dependency-injected escrow lifecycle methods: `createAccount`, `lockFunds`, `releaseFunds`, `handleDispute`, `getBalance`, and `getStatus` (`src/escrow/index.ts`) +- Consistent escrow manager error wrapping for non-SDK errors using `ESCROW_MANAGER_ERROR` (`src/escrow/index.ts`) +- Unit tests for escrow manager instantiation and method delegation (`tests/unit/escrow/escrowManager.test.ts`) - `getMinimumReserve()` utility to calculate the minimum XLM balance required for an account based on signers, offers, and trustlines (`src/accounts/keypair.ts`) - `Percentage` branded type: compile-time guarantee that a number is validated to [0, 100] (`src/types/escrow.ts`) - `asPercentage()` runtime guard: validates and casts a number to `Percentage`, throws `RangeError` on NaN, Infinity, or out-of-range values (`src/types/escrow.ts`) @@ -25,5 +32,17 @@ - Logger class in src/utils/logger.ts with redaction, log levels, and JSON output - Unit tests for logger redaction and log level filtering - GitHub Actions CI workflow: lint, unit tests, build, security audit, npm publish on tag + + +### Added +- `lockCustodyFunds()` implementation for escrow lifecycle: + - Validates custodian, owner, platform public keys, deposit amount, and duration + - Computes deterministic `conditionsHash` using SHA-256 + - Calculates `unlockDate` based on duration + - Builds and submits Stellar transaction to create escrow account + - Sets multi-sig signers (custodian, owner, platform) + - Encodes conditionsHash in transaction memo + - Returns `LockResult` with unlockDate, conditionsHash, escrowPublicKey, and transactionHash +- Unit tests for `lockCustodyFunds()` including happy path, validation errors, deterministic hashing, unlock date, and edge cases diff --git a/README.md b/README.md index 56ad21e..51b021d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,908 @@ -# @petad/stellar-sdk +# PetAd Chain — Stellar SDK ⛓️ -Blockchain infrastructure SDK for PetAd — escrow, custody, and trust anchoring on Stellar. +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/) +[![Stellar](https://img.shields.io/badge/Stellar-SDK-7D00FF.svg)](https://stellar.org/) +[![npm](https://img.shields.io/badge/npm-@petad/stellar--sdk-red.svg)](https://www.npmjs.com/package/@petad/stellar-sdk) + +Blockchain infrastructure SDK for PetAd — escrow, custody, and trust anchoring on Stellar + +Production-grade blockchain infrastructure SDK for PetAd. Provides secure, reusable utilities and abstractions for escrow, custody guarantees, and transaction management on the Stellar network. + +> **⚠️ SECURITY CRITICAL:** This SDK handles blockchain transactions and private keys. It requires rigorous testing and external security audits before production use. + +--- + +## 📋 Table of Contents + +- [Overview](#overview) +- [Purpose](#purpose) +- [Features](#features) +- [Tech Stack](#tech-stack) +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [As a Dependency](#as-a-dependency) + - [From Source](#from-source) +- [Configuration](#configuration) +- [Quick Start](#quick-start) +- [Usage Examples](#usage-examples) + - [Escrow Account Creation](#escrow-account-creation) + - [Multisig Transaction](#multisig-transaction) + - [Custody Lock & Release](#custody-lock--release) + - [Event Verification](#event-verification) +- [Project Structure](#project-structure) +- [API Reference](#api-reference) +- [Testing](#testing) +- [Security Guidelines](#security-guidelines) +- [Scripts](#scripts) +- [Deployment Tools](#deployment-tools) +- [Contributing](#contributing) +- [Audit & Security](#audit--security) +- [License](#license) + +--- + +## 🌟 Overview + +**PetAd Chain** is the blockchain infrastructure layer powering the PetAd platform. It serves as a standalone TypeScript SDK that abstracts the complexity of Stellar blockchain operations into clean, type-safe APIs. + +This repository is designed to be: + +- 📦 **Modular** - Use as a library in your backend or as a standalone service +- 🔒 **Security-First** - Built with security best practices and external audit readiness +- 🧪 **Well-Tested** - Comprehensive test suite covering all escrow scenarios +- 🎯 **Production-Ready** - Battle-tested abstractions for real-world blockchain operations + +### System Context + +``` +┌─────────────────────────────────────────────────────┐ +│ PetAd Backend (NestJS) │ +│ Application Logic Layer │ +└───────────────────┬─────────────────────────────────┘ + │ + │ imports @petad/stellar-sdk + ▼ +┌─────────────────────────────────────────────────────┐ +│ PetAd Chain SDK (This Repository) │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Escrow Utilities │ │ +│ └──────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Transaction Builders │ │ +│ └──────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Account Management │ │ +│ └──────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Horizon Client Wrapper │ │ +│ └──────────────────────────────────────────┘ │ +└───────────────────┬─────────────────────────────────┘ + │ + │ Stellar SDK + ▼ +┌─────────────────────────────────────────────────────┐ +│ Stellar Network (Testnet/Mainnet) │ +│ Blockchain Layer │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 🎯 Purpose + +This SDK abstracts blockchain complexity and exposes **clean, type-safe APIs** for: + +### Core Capabilities + +| Feature | Description | +| --------------------------- | -------------------------------------------------------- | +| **Escrow Account Creation** | Generate 2-of-3 multisig escrow accounts with time locks | +| **Multisig Orchestration** | Build and sign multi-signature transactions | +| **Custody Locking** | Lock funds in escrow for time-bound custody agreements | +| **Automatic Release** | Time-based or condition-based escrow settlement | +| **Event Verification** | Cryptographically verify on-chain event anchoring | +| **Network Abstraction** | Seamless switching between testnet and mainnet | + +--- + +## ✨ Features + +- ✅ **Type-Safe** - Full TypeScript support with strict typing +- ✅ **Idempotent** - Safe to retry all operations +- ✅ **Testnet Support** - Complete testing infrastructure +- ✅ **Error Handling** - Comprehensive error types and recovery +- ✅ **Transaction Monitoring** - Track transaction status and confirmations +- ✅ **Key Management** - Secure key handling with HSM support +- ✅ **Gas Optimization** - Minimal transaction fees +- ✅ **Event Anchoring** - Hash-based event verification on-chain + +--- + +## 🛠️ Tech Stack + +| Technology | Version | Purpose | +| --------------- | ------- | ---------------------- | +| **TypeScript** | 5.0+ | Type-safe development | +| **Stellar SDK** | Latest | Blockchain interaction | +| **Node.js** | 20+ | Runtime environment | +| **Jest** | Latest | Testing framework | +| **ESLint** | Latest | Code quality | +| **Prettier** | Latest | Code formatting | + +--- + +## 📦 Prerequisites + +- **Node.js** `>= 20.0.0` +- **npm** `>= 10.0.0` or **pnpm** `>= 8.0.0` +- **Stellar Account** (testnet or mainnet) +- **TypeScript** knowledge recommended + +**Verify installations:** + +```bash +node --version +npm --version +``` + +--- + +## 🚀 Installation + +### As a Dependency + +Install in your project: + +```bash +npm install @petad/stellar-sdk +``` + +Or with pnpm: + +```bash +pnpm add @petad/stellar-sdk +``` + +Or with yarn: + +```bash +yarn add @petad/stellar-sdk +``` + +--- + +### From Source + +Clone and build locally: + +```bash +# 1. Clone the repository +git clone https://github.com/petad/petad-chain.git +cd petad-chain + +# 2. Install dependencies +npm install + +# 3. Build the SDK +npm run build + +# 4. Link locally (for development) +npm link +``` + +**In your project:** + +```bash +npm link @petad/stellar-sdk +``` + +--- + +## ⚙️ Configuration + +### Environment Setup + +Create a `.env` file in your project root: + +```env +# Network Configuration +STELLAR_NETWORK=testnet # Options: testnet | public +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org + +# Platform Keys (for escrow operations) +MASTER_SECRET_KEY=S... # Platform escrow signing key +MASTER_PUBLIC_KEY=G... # Platform public address + +# Optional: Advanced Configuration +STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +TRANSACTION_TIMEOUT=180 # seconds +MAX_FEE=10000 # stroops (0.001 XLM) +``` + +> **⚠️ CRITICAL SECURITY WARNING:** +> +> - Never commit `.env` files with real secret keys +> - Use environment variables or secrets managers in production +> - Rotate keys regularly +> - Use different keys for testnet vs mainnet + +### Testnet Setup + +1. **Generate keypair:** + + ```bash + npm run generate-keypair + ``` + +2. **Fund account:** + - Visit: https://friendbot.stellar.org + - Paste your public key (G...) + - Get 10,000 test XLM + +3. **Verify account:** + ```bash + npm run verify-account -- YOUR_PUBLIC_KEY + ``` + +--- + +## 🎬 Quick Start + +### Basic Usage + +```typescript +import { StellarSDK } from '@petad/stellar-sdk'; + +// Initialize SDK +const sdk = new StellarSDK({ + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + masterSecretKey: process.env.MASTER_SECRET_KEY, +}); + +// Create an escrow account +const escrow = await sdk.escrow.createAccount({ + adopterPublicKey: 'G...', + ownerPublicKey: 'G...', + depositAmount: '100', // XLM + duration: 30, // days +}); + +console.log('Escrow created:', escrow.accountId); +console.log('Transaction hash:', escrow.transactionHash); +``` + +--- + +## 📚 Usage Examples + +### Escrow Account Creation + +Create a 2-of-3 multisig escrow account for adoption deposits: + +```typescript +import { createEscrowAccount } from '@petad/stellar-sdk'; + +async function setupAdoptionEscrow() { + const escrow = await createEscrowAccount({ + // Signer 1: Adopter + adopterPublicKey: 'GADOPTER...', + + // Signer 2: Pet Owner/Shelter + ownerPublicKey: 'GOWNER...', + + // Signer 3: Platform (automatically added) + // platformPublicKey is read from env + + // Escrow configuration + depositAmount: '500', // 500 XLM deposit + adoptionFee: '50', // 50 XLM platform fee + + // Time lock (optional) + unlockDate: new Date('2026-03-15'), + + // Metadata + petId: 'pet-12345', + adoptionId: 'adoption-67890', + }); + + return { + escrowAccountId: escrow.accountId, + transactionHash: escrow.hash, + signers: escrow.signers, + thresholds: escrow.thresholds, + }; +} +``` + +**Response:** + +```typescript +{ + escrowAccountId: 'GESCROW...', + transactionHash: '0x123abc...', + signers: [ + { publicKey: 'GADOPTER...', weight: 1 }, + { publicKey: 'GOWNER...', weight: 1 }, + { publicKey: 'GPLATFORM...', weight: 1 } + ], + thresholds: { + low: 0, + medium: 2, // 2-of-3 required + high: 2 + } +} +``` + +--- + +### Multisig Transaction + +Build and sign a multisig transaction to release escrow funds: + +```typescript +import { buildMultisigTransaction, signTransaction } from '@petad/stellar-sdk'; + +async function releaseEscrowFunds() { + // 1. Build transaction + const transaction = await buildMultisigTransaction({ + sourceAccount: 'GESCROW...', // Escrow account + operations: [ + { + type: 'payment', + destination: 'GOWNER...', + amount: '450', // Release to owner + asset: 'native', // XLM + }, + { + type: 'payment', + destination: 'GPLATFORM...', + amount: '50', // Platform fee + asset: 'native', + }, + ], + memo: 'Adoption #67890 - Completed', + fee: '1000', // 0.0001 XLM + }); + + // 2. Sign with adopter key + const adopterSigned = await signTransaction(transaction, 'SADOPTERSECRET...'); + + // 3. Sign with platform key + const platformSigned = await signTransaction(adopterSigned, process.env.MASTER_SECRET_KEY); + + // 4. Submit to network (2-of-3 threshold met) + const result = await sdk.horizon.submitTransaction(platformSigned); + + return { + successful: result.successful, + hash: result.hash, + ledger: result.ledger, + }; +} +``` + +--- + +### Custody Lock & Release + +Lock funds for temporary custody with automatic time-based release: + +```typescript +import { lockCustodyFunds, scheduleCustodyRelease } from '@petad/stellar-sdk'; + +async function setupTemporaryCustody() { + // 1. Lock custody deposit + const lock = await lockCustodyFunds({ + custodianPublicKey: 'GCUSTODIAN...', + ownerPublicKey: 'GOWNER...', + depositAmount: '200', + durationDays: 14, + + // Conditions for early release + conditions: { + noViolations: true, + petReturned: true, + }, + }); + + // 2. Schedule automatic release + const releaseSchedule = await scheduleCustodyRelease({ + escrowAccountId: lock.accountId, + releaseDate: lock.unlockDate, + + // Release distribution + distribution: [ + { recipient: 'GOWNER...', percentage: 95 }, + { recipient: 'GPLATFORM...', percentage: 5 }, + ], + }); + + return { + custodyId: lock.accountId, + lockedAmount: lock.amount, + unlockDate: lock.unlockDate, + scheduledReleaseId: releaseSchedule.id, + }; +} +``` + +--- + +### Event Verification + +Verify that an event was properly anchored on the Stellar blockchain: + +```typescript +import { verifyEventHash, anchorEventHash } from '@petad/stellar-sdk'; + +// Anchor an event +async function anchorAdoptionEvent(adoptionData: any) { + const eventHash = crypto.createHash('sha256').update(JSON.stringify(adoptionData)).digest('hex'); + + const anchoring = await anchorEventHash({ + hash: eventHash, + eventType: 'ADOPTION_COMPLETED', + metadata: { + adoptionId: adoptionData.id, + timestamp: new Date().toISOString(), + }, + }); + + return { + eventHash, + transactionHash: anchoring.txHash, + ledger: anchoring.ledger, + }; +} + +// Verify the event later +async function verifyAdoption(eventHash: string, txHash: string) { + const verification = await verifyEventHash({ + expectedHash: eventHash, + transactionHash: txHash, + }); + + return { + verified: verification.isValid, + timestamp: verification.timestamp, + ledger: verification.ledger, + confirmations: verification.confirmations, + }; +} +``` + +--- + +## 📁 Project Structure + +``` +src/ +├── escrow/ # Escrow lifecycle management +│ ├── create.ts # Account creation +│ ├── lock.ts # Fund locking +│ ├── release.ts # Settlement logic +│ ├── dispute.ts # Dispute handling +│ └── types.ts # Type definitions +│ +├── accounts/ # Account utilities +│ ├── create.ts # Account generation +│ ├── fund.ts # Funding operations +│ ├── multisig.ts # Multisig configuration +│ └── keypair.ts # Key management +│ +├── transactions/ # Transaction builders +│ ├── builder.ts # Transaction construction +│ ├── signer.ts # Signing utilities +│ ├── submit.ts # Network submission +│ └── monitor.ts # Status tracking +│ +├── clients/ # Network clients +│ ├── horizon.ts # Horizon API wrapper +│ ├── friendbot.ts # Testnet funding +│ └── network.ts # Network utilities +│ +├── utils/ # Shared utilities +│ ├── crypto.ts # Hashing, signing +│ ├── errors.ts # Error types +│ ├── validation.ts # Input validation +│ └── constants.ts # Network constants +│ +├── index.ts # Main SDK export +└── types/ # Global type definitions + ├── escrow.ts + ├── transaction.ts + └── network.ts +``` + +--- + +## 📖 API Reference + +### Core Classes + +#### `StellarSDK` + +Main SDK entry point. + +```typescript +class StellarSDK { + constructor(config: SDKConfig); + + escrow: EscrowManager; + accounts: AccountManager; + transactions: TransactionManager; + horizon: HorizonClient; +} +``` + +#### `EscrowManager` + +Handles escrow operations. + +```typescript +class EscrowManager { + createAccount(params: CreateEscrowParams): Promise; + lockFunds(params: LockFundsParams): Promise; + releaseFunds(params: ReleaseParams): Promise; + handleDispute(params: DisputeParams): Promise; +} +``` + +#### `TransactionManager` + +Builds and manages transactions. + +```typescript +class TransactionManager { + build(params: BuildParams): Promise; + sign(tx: Transaction, secretKey: string): Promise; + submit(tx: Transaction): Promise; + monitor(hash: string): Promise; +} +``` + +### Type Definitions + +```typescript +interface CreateEscrowParams { + adopterPublicKey: string; + ownerPublicKey: string; + depositAmount: string; + adoptionFee?: string; + unlockDate?: Date; + metadata?: Record; +} + +interface EscrowAccount { + accountId: string; + transactionHash: string; + signers: Signer[]; + thresholds: Thresholds; + unlockDate?: Date; +} + +interface Signer { + publicKey: string; + weight: number; +} + +interface Thresholds { + low: number; + medium: number; + high: number; +} +``` + +--- + +## 🧪 Testing + +### Run Tests + +```bash +# Run all tests +npm test + +# Run with coverage +npm run test:coverage + +# Run specific test file +npm test -- escrow.test.ts + +# Watch mode +npm run test:watch +``` + +### Test Structure + +``` +tests/ +├── unit/ +│ ├── escrow/ +│ │ ├── create.test.ts +│ │ ├── lock.test.ts +│ │ └── release.test.ts +│ ├── accounts/ +│ └── transactions/ +│ +├── integration/ +│ ├── escrow-lifecycle.test.ts +│ └── multisig-flow.test.ts +│ +└── e2e/ + └── full-adoption.test.ts +``` + +### Example Test + +```typescript +// tests/unit/escrow/create.test.ts +import { createEscrowAccount } from '../../../src/escrow'; + +describe('Escrow Creation', () => { + it('should create 2-of-3 multisig escrow', async () => { + const escrow = await createEscrowAccount({ + adopterPublicKey: testKeys.adopter.publicKey(), + ownerPublicKey: testKeys.owner.publicKey(), + depositAmount: '100', + }); + + expect(escrow.accountId).toMatch(/^G[A-Z0-9]{55}$/); + expect(escrow.signers).toHaveLength(3); + expect(escrow.thresholds.medium).toBe(2); + }); + + it('should reject invalid deposit amounts', async () => { + await expect( + createEscrowAccount({ + adopterPublicKey: testKeys.adopter.publicKey(), + ownerPublicKey: testKeys.owner.publicKey(), + depositAmount: '-100', // Invalid + }), + ).rejects.toThrow('Deposit amount must be positive'); + }); +}); +``` + +--- + +## 🔒 Security Guidelines + +### Critical Security Requirements + +✅ **Private Key Management** + +- Never commit private keys to version control +- Use environment variables or secrets managers +- Rotate keys every 90 days minimum +- Use hardware wallets (Ledger/Trezor) for mainnet + +✅ **Transaction Review** + +- Always verify transaction details before signing +- Implement multi-party approval for high-value transactions +- Log all transactions for audit trails + +✅ **Network Validation** + +- Verify network before submitting transactions +- Use testnet for all development and testing +- Double-check Horizon URLs + +✅ **Input Validation** + +- Validate all user inputs +- Sanitize public keys and amounts +- Reject malformed transaction data + +✅ **Error Handling** + +- Never expose secret keys in error messages +- Log errors securely (no sensitive data) +- Implement proper retry logic + +✅ **Audit Trail** + +- Log all escrow operations +- Maintain immutable audit logs +- Monitor for suspicious activity + +### Pre-Production Checklist + +- [ ] External security audit completed +- [ ] Penetration testing performed +- [ ] Key rotation procedures documented +- [ ] Incident response plan in place +- [ ] Insurance/liability coverage secured +- [ ] Legal review of smart contract logic +- [ ] Disaster recovery plan tested + +--- + +## 📜 Scripts + +| Script | Description | +| -------------------------- | -------------------------------- | +| `npm run build` | Compile TypeScript to JavaScript | +| `npm run dev` | Development mode with watch | +| `npm test` | Run test suite | +| `npm run test:coverage` | Generate coverage report | +| `npm run lint` | Run ESLint | +| `npm run format` | Format with Prettier | +| `npm run type-check` | TypeScript type checking | +| `npm run generate-keypair` | Generate new Stellar keypair | +| `npm run verify-account` | Verify account on network | + +--- + +## 🛠️ Deployment Tools + +### CLI Scripts + +The `scripts/` directory contains deployment utilities: + +#### Create Escrow Account + +```bash +node scripts/create-escrow.js --adopter GADOPTER... --owner GOWNER... --amount 500 +``` + +#### Monitor Transaction + +```bash +node scripts/monitor-tx.js --hash 0x123abc... +``` + +#### Broadcast Transaction + +```bash +node scripts/broadcast.js --file transaction.json --network testnet +``` + +#### Verify Event Anchoring + +```bash +node scripts/verify-event.js --hash sha256hash --tx 0x456def... +``` + +--- + +## 🤝 Contributing + +We welcome blockchain-focused contributions! + +### Areas of Interest + +- 🔐 **Escrow Optimizations** - Gas efficiency, feature enhancements +- 🛡️ **Security Hardening** - Vulnerability fixes, best practices +- 🧪 **Testing Improvements** - Edge cases, integration tests +- 📚 **Documentation** - Examples, guides, API docs +- 🔧 **Tooling** - CLI utilities, monitoring dashboards + +### Contribution Workflow + +1. **Fork the repository** +2. **Create feature branch** (`git checkout -b feature/escrow-improvement`) +3. **Write tests** for new functionality +4. **Ensure all tests pass** (`npm test`) +5. **Lint your code** (`npm run lint`) +6. **Commit changes** (`git commit -m 'feat: improve escrow gas efficiency'`) +7. **Push to fork** (`git push origin feature/escrow-improvement`) +8. **Open Pull Request** + +### Code Review Requirements + +Pull requests affecting these areas require **2+ maintainer reviews**: + +- Escrow logic (`src/escrow/`) +- Transaction signing (`src/transactions/signer.ts`) +- Key management (`src/accounts/keypair.ts`) +- Network submission (`src/transactions/submit.ts`) + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. + +--- + +## 🛡️ Audit & Security + +### Security Audits + +This SDK is **security-critical infrastructure** that handles: + +- Private key operations +- Financial transactions +- Escrow fund management + +**Production deployment requires:** + +- [ ] External security audit by reputable firm +- [ ] Penetration testing +- [ ] Code review by blockchain security experts +- [ ] Bug bounty program + +### Responsible Disclosure + +Found a security vulnerability? **Do not open a public issue.** + +Email: security@petad.com + +We follow a responsible disclosure process: + +1. Report received and acknowledged (24h) +2. Vulnerability verified and assessed (72h) +3. Fix developed and tested (varies) +4. Security patch released +5. Public disclosure (30 days after patch) + +### Known Limitations + +- Stellar network downtime affects all operations +- Transaction fees subject to network congestion +- Testnet resets may clear historical data +- Time locks require network time synchronization + +--- + +## 📄 License + +This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. + +--- + +## 🙏 Acknowledgments + +- Built with ❤️ for transparent, trustworthy pet adoption +- Powered by [Stellar](https://stellar.org) blockchain technology +- Inspired by the need for financial guarantees in peer-to-peer custody agreements + +--- + +## 📞 Support + +For questions, issues, or security concerns: + +- **GitHub Issues:** [github.com/petad/petad-chain/issues](https://github.com/petad/petad-chain/issues) +- **Security:** security@petad.com +- **Email:** dev@petad.com +- **Discord:** [Join our developer community](https://discord.gg/petad-dev) +- **Documentation:** [docs.petad.com/chain](https://docs.petad.com/chain) + +--- + +## 🔗 Related Projects + +- **Backend:** [petad-backend](https://github.com/petad/petad-backend) - NestJS API server +- **Frontend:** [petad-frontend](https://github.com/petad/petad-frontend) - React web app +- **Documentation:** [petad-docs](https://github.com/petad/petad-docs) - Technical docs + +--- + +## 📚 Further Reading + +- [Stellar Documentation](https://developers.stellar.org/) +- [Stellar SDK Reference](https://stellar.github.io/js-stellar-sdk/) +- [Multisig Accounts Guide](https://developers.stellar.org/docs/encyclopedia/signatures-multisig) +- [Transaction Lifecycle](https://developers.stellar.org/docs/fundamentals-and-concepts/stellar-data-structures/operations-and-transactions) + +--- + +**Made with ⛓️ by the PetAd Team** + +_Building blockchain trust infrastructure, one escrow at a time._ ## Install + npm install @petad/stellar-sdk ## Setup + cp .env.example .env # Fill in your Stellar testnet keys ## Development + npm install npm run build npm test diff --git a/package-lock.json b/package-lock.json index 0bf8009..2fa5621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -594,9 +594,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -644,9 +644,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2156,9 +2156,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2891,9 +2891,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3372,9 +3372,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3448,9 +3448,9 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5814,9 +5814,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 02113e5..789c525 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "test:cov": "jest --testPathPattern=tests/unit --coverage --passWithNoTests", "test:watch": "jest --testPathPattern=tests/unit --watch", "test:integration": "jest --testPathPattern=tests/integration --runInBand --forceExit --passWithNoTests", + "test-keypair": "node dist/tests/keypair.test.js", + "demo-keypair": "node dist/src/cli/keypair-demo.js", "lint": "eslint src/ tests/ --ext .ts", "lint:fix": "eslint src/ tests/ --ext .ts --fix", "format": "prettier --write src/ tests/", diff --git a/src/accounts/friendbot.ts b/src/accounts/friendbot.ts new file mode 100644 index 0000000..7c17652 --- /dev/null +++ b/src/accounts/friendbot.ts @@ -0,0 +1,99 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; + +export interface FriendbotResult { + funded: boolean; + amount?: string; + reason?: string; +} + +export class FriendbotError extends Error { + constructor(message: string, public statusCode?: number) { + super(message); + this.name = 'FriendbotError'; + } +} + +/** + * Funds a testnet account using the Stellar Friendbot service + * @param publicKey - Stellar public key to fund + * @returns Promise - Funding result + * @throws FriendbotError - When funding fails after retries + * @throws Error - When called on mainnet or with invalid public key + */ +export async function fundTestnetAccount(publicKey: string): Promise { + // Validate public key format + if (!isValidPublicKey(publicKey)) { + throw new Error('Invalid public key format'); + } + + // Guard: only callable on testnet + const isTestnet = (process.env['STELLAR_NETWORK'] ?? 'testnet') === 'testnet'; + if (!isTestnet) { + throw new Error('Friendbot funding is only available on testnet network'); + } + + const friendbotUrl = `https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}`; + + try { + return await attemptFunding(friendbotUrl); + } catch (error) { + if (error instanceof FriendbotError && error.statusCode && error.statusCode >= 500 && error.statusCode < 600) { + // Retry once after 2s for 5xx errors + await new Promise(resolve => setTimeout(resolve, 2000)); + return await attemptFunding(friendbotUrl); + } + throw error; + } +} + +async function attemptFunding(url: string): Promise { + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 200) { + await response.json(); + return { + funded: true, + amount: "10000" + }; + } else if (response.status === 400) { + // Account already funded + return { + funded: false, + reason: "already_funded" + }; + } else if (response.status >= 500 && response.status < 600) { + // Server error - throw for retry logic + throw new FriendbotError(`Friendbot server error: ${response.status}`, response.status); + } else { + // Other errors + const errorText = await response.text(); + throw new FriendbotError(`Friendbot error: ${response.status} - ${errorText}`, response.status); + } + } catch (error) { + if (error instanceof FriendbotError) { + throw error; + } + // Network or other errors + throw new FriendbotError(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Validates if a string is a valid Stellar public key + * @param publicKey - Public key to validate + * @returns boolean - True if valid + */ +function isValidPublicKey(publicKey: string): boolean { + try { + StellarSdk.Keypair.fromPublicKey(publicKey); + return true; + } catch { + return false; + } +} diff --git a/src/accounts/index.ts b/src/accounts/index.ts index 90133d3..3afe6c3 100644 --- a/src/accounts/index.ts +++ b/src/accounts/index.ts @@ -1 +1 @@ -export { getMinimumReserve } from './keypair'; +export { getMinimumReserve, generateKeypair } from './keypair'; diff --git a/src/accounts/keypair.ts b/src/accounts/keypair.ts index 6ad1df4..79d7005 100644 --- a/src/accounts/keypair.ts +++ b/src/accounts/keypair.ts @@ -1,3 +1,4 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; import { BASE_RESERVE_XLM } from '../utils/constants'; function formatXlm(amount: number): string { @@ -23,3 +24,24 @@ export function getMinimumReserve( return formatXlm(reserve); } +export interface KeypairResult { + publicKey: string; + secretKey: string; +} + +/** + * Generates a secure random Stellar keypair + * @returns {KeypairResult} Object containing publicKey and secretKey + * + * SECURITY WARNING: Never log or expose the secretKey in production code. + * The secretKey provides full control over the Stellar account and should be + * treated as highly sensitive information. + */ +export function generateKeypair(): KeypairResult { + const keypair = StellarSdk.Keypair.random(); + + return { + publicKey: keypair.publicKey(), + secretKey: keypair.secret(), + }; +} diff --git a/src/cli/friendbot-demo.ts b/src/cli/friendbot-demo.ts new file mode 100644 index 0000000..40dc9b1 --- /dev/null +++ b/src/cli/friendbot-demo.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { fundTestnetAccount, FriendbotError } from '../accounts/friendbot.js'; +import { generateKeypair } from '../accounts/keypair.js'; + +async function demoFriendbotFunding() { + console.log('🤖 PetAdChain Friendbot Funding Demo\n'); + + try { + // Generate a new keypair for demonstration + const keypair = generateKeypair(); + + console.log('✅ Generated new Stellar keypair:'); + console.log(`🔑 Public Key: ${keypair.publicKey}`); + console.log(`🔐 Secret Key: ${keypair.secretKey.substring(0, 8)}... (truncated for security)`); + console.log(); + + console.log('📝 Attempting to fund account with Friendbot...'); + console.log('⚠️ This will only work on Stellar testnet'); + console.log(); + + // Try to fund the account + const result = await fundTestnetAccount(keypair.publicKey); + + if (result.funded) { + console.log('🎉 Account successfully funded!'); + console.log(`💰 Amount: ${result.amount} XLM`); + console.log('🔗 You can now use this account for testnet transactions'); + } else { + console.log('⚠️ Account was not funded'); + if (result.reason === 'already_funded') { + console.log('ℹ️ This account was already funded on testnet'); + } else { + console.log(`❌ Reason: ${result.reason}`); + } + } + + console.log(); + console.log('📚 Usage Example:'); + console.log('```javascript'); + console.log('import { fundTestnetAccount } from "petad-chain";'); + console.log('import { generateKeypair } from "petad-chain";'); + console.log(); + console.log('const keypair = generateKeypair();'); + console.log('const result = await fundTestnetAccount(keypair.publicKey);'); + console.log('console.log(result.funded); // true or false'); + console.log('```'); + + } catch (error) { + if (error instanceof FriendbotError) { + console.error('❌ Friendbot Error:', error.message); + if (error.statusCode) { + console.error(`🔗 Status Code: ${error.statusCode}`); + } + } else if (error instanceof Error) { + console.error('❌ Error:', error.message); + } else { + console.error('❌ Unknown error occurred'); + } + + console.log(); + console.log('💡 Troubleshooting:'); + console.log(' - Ensure you are on testnet network'); + console.log(' - Check your internet connection'); + console.log(' - Verify the friendbot service is available'); + } +} + +demoFriendbotFunding(); diff --git a/src/cli/keypair-demo.ts b/src/cli/keypair-demo.ts new file mode 100644 index 0000000..730abce --- /dev/null +++ b/src/cli/keypair-demo.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { generateKeypair } from '../accounts/keypair.js'; + +async function demoKeypairGeneration() { + console.log('🔑 PetAdChain Keypair Generation Demo\n'); + + try { + // Generate a new keypair + const keypair = generateKeypair(); + + console.log('✅ Generated new Stellar keypair:'); + console.log(`🔑 Public Key: ${keypair.publicKey}`); + console.log(`🔐 Secret Key: ${keypair.secretKey}`); + console.log(); + + console.log('⚠️ SECURITY WARNING:'); + console.log(' - Never share or log your secret key'); + console.log(' - Store the secret key securely (e.g., in environment variables)'); + console.log(' - Anyone with the secret key has full control of the account'); + console.log(); + + console.log('📚 Usage Example:'); + console.log('```javascript'); + console.log('import { generateKeypair } from "petad-chain";'); + console.log(); + console.log('const keypair = generateKeypair();'); + console.log('console.log(keypair.publicKey); // G...'); + console.log('console.log(keypair.secretKey); // S...'); + console.log('```'); + + } catch (error) { + console.error('❌ Demo failed:', error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} + +demoKeypairGeneration(); diff --git a/src/escrow/index.ts b/src/escrow/index.ts index 3dbf7c1..b06d39f 100644 --- a/src/escrow/index.ts +++ b/src/escrow/index.ts @@ -1,8 +1,587 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function createEscrowAccount(..._args: unknown[]): unknown { return undefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function lockCustodyFunds(..._args: unknown[]): unknown { return undefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function anchorTrustHash(..._args: unknown[]): unknown { return undefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function verifyEventHash(..._args: unknown[]): unknown { return undefined; } +import { + Keypair, + Memo, + TransactionBuilder, + Operation, + Networks, + BASE_FEE, + Account, + Transaction, +} from '@stellar/stellar-sdk'; + +import { + CreateEscrowParams, + DisputeParams, + DisputeResult, + EscrowAccount, + EscrowStatus, + ReleaseParams, + ReleaseResult, + Signer, + Thresholds, +} from '../types/escrow'; +import { getMinimumReserve } from '../accounts'; +import { SdkError, ValidationError } from '../utils/errors'; +import { isValidPublicKey, isValidAmount, isValidSecretKey } from '../utils/validation'; + +import crypto from 'crypto'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface LockCustodyFundsParams { + custodianPublicKey: string; + ownerPublicKey: string; + platformPublicKey: string; + sourceKeypair: Keypair; + depositAmount: string; + durationDays: number; +} + +export interface LockResult { + unlockDate: Date; + conditionsHash: string; + escrowPublicKey: string; + transactionHash: string; +} + +export interface EscrowHorizonClient { + loadAccount: (publicKey: string) => Promise; + submitTransaction: (tx: Transaction) => Promise<{ hash: string }>; +} + +export interface EscrowAccountManager { + create: (args: { + publicKey: string; + startingBalance: string; + }) => Promise<{ accountId: string; transactionHash: string }>; + getBalance: (publicKey: string) => Promise; +} + +export interface EscrowTransactionManager { + releaseFunds: ( + params: ReleaseParams, + context: { + horizonClient: EscrowHorizonClient; + masterSecretKey: string; + }, + ) => Promise; + handleDispute: ( + params: DisputeParams, + context: { + horizonClient: EscrowHorizonClient; + masterSecretKey: string; + }, + ) => Promise; + getStatus: ( + escrowAccountId: string, + context: { + horizonClient: EscrowHorizonClient; + }, + ) => Promise; +} + +export interface EscrowManagerDependencies { + horizonClient: EscrowHorizonClient; + accountManager: EscrowAccountManager; + transactionManager: EscrowTransactionManager; + masterSecretKey: string; +} + +export interface HandleDisputeParams extends DisputeParams { + masterSecretKey: string; +} + +interface HorizonSignerLike { + key?: string; + publicKey?: string; + ed25519PublicKey?: string; + weight: number; +} + +interface HorizonThresholdsLike { + low?: number; + medium?: number; + high?: number; + low_threshold?: number; + med_threshold?: number; + high_threshold?: number; +} + +interface HorizonAccountLike { + sequence?: string; + sequenceNumber?: string; + signers?: HorizonSignerLike[]; + thresholds?: HorizonThresholdsLike; + low_threshold?: number; + med_threshold?: number; + high_threshold?: number; +} + +const MS_PER_DAY = 86_400_000; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function hashData(data: Record): string { + const sorted = JSON.stringify(data, Object.keys(data).sort()); + return crypto.createHash('sha256').update(sorted).digest('hex'); +} + +export function memoFromHash(hash: string): string { + return hash.slice(0, 28); +} + +// ─── calculateStartingBalance ───────────────────────────────────────────────── + +export function calculateStartingBalance(depositAmount: string): string { + if (!isValidAmount(depositAmount)) { + throw new ValidationError( + 'depositAmount', + `Invalid deposit amount: ${depositAmount}`, + ); + } + + const minimumReserve = parseFloat(getMinimumReserve(3, 0, 0)); + const deposit = parseFloat(depositAmount); + const totalBalance = minimumReserve + deposit; + + return totalBalance.toFixed(7).replace(/\.?0+$/, ''); +} + +// ─── createEscrowAccount ────────────────────────────────────────────────────── + +export async function createEscrowAccount( + params: CreateEscrowParams, + accountManager: { + create: (args: { publicKey: string; startingBalance: string }) => Promise<{ accountId: string; transactionHash: string }>; + getBalance: (publicKey: string) => Promise; + }, +): Promise { + if (!isValidPublicKey(params.adopterPublicKey)) { + throw new ValidationError('adopterPublicKey', 'Invalid public key'); + } + + if (!isValidPublicKey(params.ownerPublicKey)) { + throw new ValidationError('ownerPublicKey', 'Invalid public key'); + } + + if (!isValidAmount(params.depositAmount)) { + throw new ValidationError('depositAmount', 'Invalid amount'); + } + + const escrowKeypair = Keypair.random(); + const startingBalance = calculateStartingBalance(params.depositAmount); + + const result = await accountManager.create({ + publicKey: escrowKeypair.publicKey(), + startingBalance, + }); + + const signers: Signer[] = [ + { publicKey: escrowKeypair.publicKey(), weight: 1 }, + { publicKey: params.adopterPublicKey, weight: 1 }, + { publicKey: params.ownerPublicKey, weight: 1 }, + ]; + + const thresholds: Thresholds = { + low: 1, + medium: 2, + high: 2, + }; + + return { + accountId: result.accountId, + transactionHash: result.transactionHash, + signers, + thresholds, + unlockDate: params.unlockDate, + }; +} + +// ─── lockCustodyFunds ───────────────────────────────────────────────────────── + +export async function lockCustodyFunds( + params: LockCustodyFundsParams, + horizonServer: { + loadAccount: (publicKey: string) => Promise; + submitTransaction: (tx: Transaction) => Promise<{ hash: string }>; + }, + networkPassphrase: string = Networks.TESTNET, +): Promise { + const { + custodianPublicKey, + ownerPublicKey, + platformPublicKey, + sourceKeypair, + depositAmount, + durationDays, + } = params; + + // VALIDATION + if (!isValidPublicKey(custodianPublicKey)) { + throw new ValidationError('custodianPublicKey', 'Invalid public key'); + } + if (!isValidPublicKey(ownerPublicKey)) { + throw new ValidationError('ownerPublicKey', 'Invalid public key'); + } + if (!isValidPublicKey(platformPublicKey)) { + throw new ValidationError('platformPublicKey', 'Invalid public key'); + } + if (!isValidAmount(depositAmount)) { + throw new ValidationError('depositAmount', 'Invalid deposit amount'); + } + if (!Number.isInteger(durationDays) || durationDays <= 0) { + throw new ValidationError('durationDays', 'Invalid durationDays'); + } + + const conditionsHash = hashData({ + noViolations: true, + petReturned: true, + }); + + const unlockDate = new Date(Date.now() + durationDays * MS_PER_DAY); + + const escrowKeypair = Keypair.random(); + + // ✅ FIX: ensure Account instance + const loaded = await horizonServer.loadAccount(sourceKeypair.publicKey()); + + const sourceAccount = + loaded instanceof Account + ? loaded + : new Account(sourceKeypair.publicKey(), loaded.sequence); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + Operation.createAccount({ + destination: escrowKeypair.publicKey(), + startingBalance: depositAmount, + }), + ) + .addOperation( + Operation.setOptions({ + source: escrowKeypair.publicKey(), + signer: { ed25519PublicKey: custodianPublicKey, weight: 1 }, + }), + ) + .addOperation( + Operation.setOptions({ + source: escrowKeypair.publicKey(), + signer: { ed25519PublicKey: ownerPublicKey, weight: 1 }, + }), + ) + .addOperation( + Operation.setOptions({ + source: escrowKeypair.publicKey(), + signer: { ed25519PublicKey: platformPublicKey, weight: 1 }, + }), + ) + .addOperation( + Operation.setOptions({ + source: escrowKeypair.publicKey(), + masterWeight: 0, + lowThreshold: 2, + medThreshold: 2, + highThreshold: 2, + }), + ) + .addMemo(Memo.text(memoFromHash(conditionsHash))) + .setTimeout(30) + .build(); + + tx.sign(sourceKeypair, escrowKeypair); + + const result = await horizonServer.submitTransaction(tx); + + return { + unlockDate, + conditionsHash, + escrowPublicKey: escrowKeypair.publicKey(), + transactionHash: result.hash, + }; +} + +function getSequence(account: Account | HorizonAccountLike): string { + const loaded = account as HorizonAccountLike; + + if (typeof loaded.sequence === 'string' && loaded.sequence.length > 0) { + return loaded.sequence; + } + + if (typeof loaded.sequenceNumber === 'string' && loaded.sequenceNumber.length > 0) { + return loaded.sequenceNumber; + } + + throw new Error('Unable to determine account sequence from Horizon response'); +} + +function getSignerPublicKey(signer: HorizonSignerLike): string | undefined { + return signer.publicKey ?? signer.key ?? signer.ed25519PublicKey; +} + +function pickNumber(...values: Array): number { + for (const value of values) { + if (typeof value === 'number') { + return value; + } + } + + return 0; +} + +function getAccountSigners(account: Account | HorizonAccountLike): Signer[] { + const loaded = account as HorizonAccountLike; + if (!Array.isArray(loaded.signers)) return []; + + return loaded.signers + .map((signer): Signer | null => { + const publicKey = getSignerPublicKey(signer); + const weight = Number(signer.weight); + + if (!publicKey || !Number.isFinite(weight)) { + return null; + } + + return { + publicKey, + weight, + }; + }) + .filter((signer): signer is Signer => signer !== null); +} + +function getAccountThresholds(account: Account | HorizonAccountLike): Thresholds { + const loaded = account as HorizonAccountLike; + const fromNested = loaded.thresholds ?? {}; + + return { + low: pickNumber(fromNested.low, fromNested.low_threshold, loaded.low_threshold), + medium: pickNumber(fromNested.medium, fromNested.med_threshold, loaded.med_threshold), + high: pickNumber(fromNested.high, fromNested.high_threshold, loaded.high_threshold), + }; +} + +function isPlatformOnlyConfig(account: Account | HorizonAccountLike, platformPublicKey: string): boolean { + const thresholds = getAccountThresholds(account); + const activeSigners = getAccountSigners(account).filter(signer => signer.weight > 0); + + if (activeSigners.length !== 1) return false; + if (activeSigners[0].publicKey !== platformPublicKey) return false; + if (activeSigners[0].weight !== 3) return false; + + return thresholds.low === 0 && thresholds.medium === 2 && thresholds.high === 2; +} + +export async function handleDispute( + params: HandleDisputeParams, + horizonServer: { + loadAccount: (publicKey: string) => Promise; + submitTransaction: (tx: Transaction) => Promise<{ hash: string }>; + }, + networkPassphrase: string = Networks.TESTNET, +): Promise { + const { escrowAccountId, masterSecretKey } = params; + + if (!isValidPublicKey(escrowAccountId)) { + throw new ValidationError('escrowAccountId', 'Invalid escrow account ID'); + } + + if (!isValidSecretKey(masterSecretKey)) { + throw new ValidationError('masterSecretKey', 'Invalid master secret key'); + } + + let platformKeypair: Keypair; + try { + platformKeypair = Keypair.fromSecret(masterSecretKey); + } catch { + throw new ValidationError('masterSecretKey', 'Invalid master secret key'); + } + + const platformPublicKey = platformKeypair.publicKey(); + const currentConfig = await horizonServer.loadAccount(escrowAccountId); + const currentSigners = getAccountSigners(currentConfig); + const sourceAccount = + currentConfig instanceof Account + ? currentConfig + : new Account(escrowAccountId, getSequence(currentConfig)); + + const txBuilder = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }); + + currentSigners + .filter(signer => signer.publicKey !== platformPublicKey && signer.weight > 0) + .forEach(signer => { + txBuilder.addOperation( + Operation.setOptions({ + source: escrowAccountId, + signer: { + ed25519PublicKey: signer.publicKey, + weight: 0, + }, + }), + ); + }); + + txBuilder + .addOperation( + Operation.setOptions({ + source: escrowAccountId, + signer: { + ed25519PublicKey: platformPublicKey, + weight: 3, + }, + }), + ) + .addOperation( + Operation.setOptions({ + source: escrowAccountId, + masterWeight: 0, + lowThreshold: 0, + medThreshold: 2, + highThreshold: 2, + }), + ); + + const tx = txBuilder.setTimeout(30).build(); + tx.sign(platformKeypair); + + const submitResult = await horizonServer.submitTransaction(tx); + + const updatedConfig = await horizonServer.loadAccount(escrowAccountId); + if (!isPlatformOnlyConfig(updatedConfig, platformPublicKey)) { + throw new Error('Dispute signer update verification failed'); + } + + return { + accountId: escrowAccountId, + pausedAt: new Date(), + platformOnlyMode: true, + txHash: submitResult.hash, + }; +} + +// ─── Placeholders ───────────────────────────────────────────────────────────── + +export function anchorTrustHash(): undefined { + return undefined; +} + +export function verifyEventHash(): undefined { + return undefined; +} + +export class EscrowManager { + private readonly horizonClient: EscrowHorizonClient; + + private readonly accountManager: EscrowAccountManager; + + private readonly transactionManager: EscrowTransactionManager; + + private readonly masterSecretKey: string; + + /** + * Creates an escrow manager with injected dependencies. + */ + constructor(dependencies: EscrowManagerDependencies) { + this.horizonClient = dependencies.horizonClient; + this.accountManager = dependencies.accountManager; + this.transactionManager = dependencies.transactionManager; + this.masterSecretKey = dependencies.masterSecretKey; + } + + /** + * Creates a new escrow account and configures signer thresholds. + */ + async createAccount(params: CreateEscrowParams): Promise { + return this.executeWithErrorWrapping('createAccount', () => + createEscrowAccount(params, this.accountManager), + ); + } + + /** + * Locks custody funds in escrow. + */ + async lockFunds( + params: LockCustodyFundsParams, + networkPassphrase: string = Networks.TESTNET, + ): Promise { + return this.executeWithErrorWrapping('lockFunds', () => + lockCustodyFunds(params, this.horizonClient, networkPassphrase), + ); + } + + /** + * Releases escrow funds using the configured transaction manager. + */ + async releaseFunds(params: ReleaseParams): Promise { + return this.executeWithErrorWrapping('releaseFunds', () => + this.transactionManager.releaseFunds(params, { + horizonClient: this.horizonClient, + masterSecretKey: this.masterSecretKey, + }), + ); + } + + /** + * Applies dispute handling flow for an escrow account. + */ + async handleDispute(params: DisputeParams): Promise { + return this.executeWithErrorWrapping('handleDispute', () => + this.transactionManager.handleDispute(params, { + horizonClient: this.horizonClient, + masterSecretKey: this.masterSecretKey, + }), + ); + } + + /** + * Gets the XLM balance for an account. + */ + async getBalance(publicKey: string): Promise { + return this.executeWithErrorWrapping('getBalance', () => + this.accountManager.getBalance(publicKey), + ); + } + + /** + * Retrieves the current escrow status. + */ + async getStatus(escrowAccountId: string): Promise { + return this.executeWithErrorWrapping('getStatus', () => + this.transactionManager.getStatus(escrowAccountId, { + horizonClient: this.horizonClient, + }), + ); + } + + private async executeWithErrorWrapping( + operation: string, + action: () => Promise, + ): Promise { + try { + return await action(); + } catch (error) { + throw this.wrapError(operation, error); + } + } + + private wrapError(operation: string, error: unknown): SdkError { + if (error instanceof SdkError) { + return error; + } + + if (error instanceof Error) { + return new SdkError( + `EscrowManager.${operation} failed: ${error.message}`, + 'ESCROW_MANAGER_ERROR', + false, + ); + } + + return new SdkError(`EscrowManager.${operation} failed`, 'ESCROW_MANAGER_ERROR', false); + } +} \ No newline at end of file diff --git a/src/escrow/lockCustodyFunds.ts b/src/escrow/lockCustodyFunds.ts new file mode 100644 index 0000000..c6cc20e --- /dev/null +++ b/src/escrow/lockCustodyFunds.ts @@ -0,0 +1,109 @@ +import { + Keypair, + TransactionBuilder, + Operation, + Networks, + BASE_FEE, + Account, + Transaction, +} from '@stellar/stellar-sdk'; + +import { ValidationError } from '../utils/errors'; +import { isValidPublicKey, isValidAmount } from '../utils/validation'; +import { hashData } from './index'; // keep only what you use + +// ─── Types ───────────────────────────────────────────────────────── + +export interface LockCustodyFundsParams { + custodianPublicKey: string; + ownerPublicKey: string; + platformPublicKey: string; + sourceKeypair: Keypair; + depositAmount: string; + durationDays: number; +} + +export interface LockResult { + unlockDate: Date; + conditionsHash: string; + escrowPublicKey: string; + transactionHash: string; +} + +const MS_PER_DAY = 86_400_000; + +// ─── MAIN FUNCTION ───────────────────────────────────────────────── + +export async function lockCustodyFunds( + params: LockCustodyFundsParams, + horizonServer: { + loadAccount: (publicKey: string) => Promise; + submitTransaction: (tx: Transaction) => Promise<{ hash: string }>; + }, + networkPassphrase: string = Networks.TESTNET, +): Promise { + const { + custodianPublicKey, + ownerPublicKey, + platformPublicKey, + sourceKeypair, + depositAmount, + durationDays, + } = params; + + if (!isValidPublicKey(custodianPublicKey)) { + throw new ValidationError('custodianPublicKey', 'Invalid public key'); + } + if (!isValidPublicKey(ownerPublicKey)) { + throw new ValidationError('ownerPublicKey', 'Invalid public key'); + } + if (!isValidPublicKey(platformPublicKey)) { + throw new ValidationError('platformPublicKey', 'Invalid public key'); + } + if (!isValidAmount(depositAmount)) { + throw new ValidationError('depositAmount', 'Invalid deposit amount'); + } + if (!Number.isInteger(durationDays) || durationDays <= 0) { + throw new ValidationError('durationDays', 'Invalid durationDays'); + } + + const conditionsHash = hashData({ + noViolations: true, + petReturned: true, + }); + + const unlockDate = new Date(Date.now() + durationDays * MS_PER_DAY); + + const escrowKeypair = Keypair.random(); + + const loaded = await horizonServer.loadAccount(sourceKeypair.publicKey()); + + const sourceAccount = + loaded instanceof Account + ? loaded + : new Account(sourceKeypair.publicKey(), loaded.sequence); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + Operation.createAccount({ + destination: escrowKeypair.publicKey(), + startingBalance: depositAmount, + }), + ) + .setTimeout(30) + .build(); + + tx.sign(sourceKeypair, escrowKeypair); + + const result = await horizonServer.submitTransaction(tx); + + return { + unlockDate, + conditionsHash, + escrowPublicKey: escrowKeypair.publicKey(), + transactionHash: result.hash, + }; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a2007d7..b800f4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,16 +19,36 @@ export { } from './utils/errors'; // 3. Escrow types (canonical source for Signer + Thresholds) -export type { CreateEscrowParams, Signer, Thresholds, EscrowAccount, Distribution, ReleaseParams, ReleasedPayment, ReleaseResult, Percentage } from './types/escrow'; +export type { + CreateEscrowParams, + Signer, + Thresholds, + EscrowAccount, + Distribution, + ReleaseParams, + ReleasedPayment, + ReleaseResult, + Percentage, + LockFundsParams, + LockResult, +} from './types/escrow'; export { EscrowStatus, asPercentage } from './types/escrow'; // 4. Network types (Signer + Thresholds excluded to avoid conflict) export type { SDKConfig, KeypairResult, AccountInfo, BalanceInfo } from './types/network'; // 5. Transaction types -export type { SubmitResult, TransactionStatus } from './types/transaction'; +export type { SubmitResult, TransactionStatus, BuildParams, Operation } from './types/transaction'; // 6. Standalone functions -export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash } from './escrow'; -export { buildMultisigTransaction } from './transactions'; -export { getMinimumReserve } from './accounts'; +export { + createEscrowAccount, + calculateStartingBalance, + lockCustodyFunds, + EscrowManager, + handleDispute, + anchorTrustHash, + verifyEventHash, +} from './escrow'; +export { buildMultisigTransaction, buildSetOptionsOp } from './transactions'; +export { getMinimumReserve, generateKeypair } from './accounts'; diff --git a/src/transactions/builder.ts b/src/transactions/builder.ts new file mode 100644 index 0000000..98b3505 --- /dev/null +++ b/src/transactions/builder.ts @@ -0,0 +1,77 @@ +import { Operation, xdr } from '@stellar/stellar-sdk'; + +import { ValidationError } from '../utils/errors'; +import { isValidPublicKey } from '../utils/validation'; + +export interface SetOptionsSignerInput { + publicKey: string; + weight: number; +} + +export interface SetOptionsThresholdsInput { + low: number; + medium: number; + high: number; +} + +export interface BuildSetOptionsOpParams { + signers?: SetOptionsSignerInput[]; + thresholds?: SetOptionsThresholdsInput; + masterWeight?: number; +} + +function validateUint8Field(field: string, value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 255) { + throw new ValidationError(field, 'Must be an integer between 0 and 255'); + } +} + +export function buildSetOptionsOp(params: BuildSetOptionsOpParams): xdr.Operation[] { + const operations: xdr.Operation[] = []; + + if (params.signers) { + params.signers.forEach((signer, index) => { + if (!isValidPublicKey(signer.publicKey)) { + throw new ValidationError(`signers[${index}].publicKey`, 'Invalid public key'); + } + + validateUint8Field(`signers[${index}].weight`, signer.weight); + + operations.push( + Operation.setOptions({ + signer: { + ed25519PublicKey: signer.publicKey, + weight: signer.weight, + }, + }), + ); + }); + } + + if (params.masterWeight !== undefined) { + validateUint8Field('masterWeight', params.masterWeight); + } + + if (params.thresholds) { + validateUint8Field('thresholds.low', params.thresholds.low); + validateUint8Field('thresholds.medium', params.thresholds.medium); + validateUint8Field('thresholds.high', params.thresholds.high); + } + + if (params.masterWeight !== undefined || params.thresholds) { + operations.push( + Operation.setOptions({ + ...(params.masterWeight !== undefined ? { masterWeight: params.masterWeight } : {}), + ...(params.thresholds + ? { + lowThreshold: params.thresholds.low, + medThreshold: params.thresholds.medium, + highThreshold: params.thresholds.high, + } + : {}), + }), + ); + } + + return operations; +} \ No newline at end of file diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 04d4874..095d3c8 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1,2 +1,46 @@ + +import { HorizonSubmitError } from '../utils/errors'; +import { TESTNET_HORIZON_URL } from '../utils/constants'; +export { buildSetOptionsOp } from './builder'; + +/** + * Internal: Fetch a single transaction status from Horizon by hash. + * @param hash Transaction hash + * @returns {Promise<{found: true, successful: boolean, ledger: number, createdAt: string} | {found: false}>} + * @throws {HorizonSubmitError} On network error + */ +export async function fetchTransactionOnce(hash: string): Promise< + | { found: true; successful: boolean; ledger: number; createdAt: string } + | { found: false } +> { + const url = `${TESTNET_HORIZON_URL}/transactions/${hash}`; + try { + const res = await fetch(url); + if (res.status === 404) { + return { found: false }; + } + if (!res.ok) { + throw new HorizonSubmitError(`horizon_http_${res.status}`); + } + // Use unknown and type guard for data + const data: unknown = await res.json(); + if ( + typeof data === 'object' && data !== null && + 'successful' in data && 'ledger' in data && 'created_at' in data + ) { + return { + found: true, + successful: Boolean((data as { successful: unknown }).successful), + ledger: Number((data as { ledger: unknown }).ledger), + createdAt: (data as { created_at: string }).created_at, + }; + } + throw new HorizonSubmitError('horizon_invalid_response'); + } catch { + // Only throw for network errors + throw new HorizonSubmitError('network_error'); + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function buildMultisigTransaction(..._args: unknown[]): unknown { return undefined; } diff --git a/src/types/escrow.ts b/src/types/escrow.ts index 74b2e7d..6dcddf4 100644 --- a/src/types/escrow.ts +++ b/src/types/escrow.ts @@ -92,3 +92,28 @@ export interface DisputeResult { platformOnlyMode: true; txHash: string; } + +// --------------------------------------------------------------------------- +// Custody fund locking types (Issue #33) +// --------------------------------------------------------------------------- + +/** Parameters required to lock funds under custodian control. */ +export interface LockFundsParams { + custodianPublicKey: string; + ownerPublicKey: string; + depositAmount: string; + durationDays: number; + conditions?: { + noViolations: boolean; + petReturned: boolean; + }; +} + +/** Result returned after custody funds are successfully locked. */ +export interface LockResult { + accountId: string; + lockedAmount: string; + unlockDate: Date; + conditionsHash: string; + transactionHash: string; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 30bb951..7ac9d21 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,6 +1,7 @@ +import { StrKey } from '@stellar/stellar-sdk'; + export function isValidPublicKey(key: string): boolean { - if (!key || typeof key !== 'string') return false; - return key.startsWith('G') && key.length === 56; + return StrKey.isValidEd25519PublicKey(key); } export function isValidSecretKey(key: string): boolean { @@ -10,15 +11,20 @@ export function isValidSecretKey(key: string): boolean { export function isValidAmount(amount: string): boolean { if (!amount || typeof amount !== 'string') return false; - const num = parseFloat(amount); - return !isNaN(num) && num > 0 && /^\d+(\.\d{1,7})?$/.test(amount); + + // Stellar amounts are plain decimal strings with up to 7 fractional digits. + // This also rejects scientific notation (e.g. "1e5"). + if (!/^\d+(\.\d{1,7})?$/.test(amount)) return false; + + const num = Number(amount); + return Number.isFinite(num) && num > 0; } export function isValidDistribution( distribution: { recipient: string; percentage: number }[], ): boolean { if (!distribution || distribution.length === 0) return false; - if (!distribution.every(d => isValidPublicKey(d.recipient))) return false; + if (!distribution.every((d) => isValidPublicKey(d.recipient))) return false; const total = distribution.reduce((sum, d) => sum + d.percentage, 0); return Math.round(total) === 100; } diff --git a/tests/friendbot.test.ts b/tests/friendbot.test.ts new file mode 100644 index 0000000..363634d --- /dev/null +++ b/tests/friendbot.test.ts @@ -0,0 +1,74 @@ +import { fundTestnetAccount, FriendbotError } from '../src/accounts/friendbot.js'; +import { generateKeypair } from '../src/accounts/keypair.js'; + +async function testFriendbotFunding() { + console.log('🤖 Testing Friendbot Funding...\n'); + + try { + // Test 1: Invalid public key validation + console.log('📝 Test 1: Invalid public key validation...'); + try { + await fundTestnetAccount('invalid-key'); + throw new Error('Should have thrown error for invalid public key'); + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid public key format')) { + console.log('✅ Test 1 passed: Invalid public key rejected'); + } else { + throw error; + } + } + + // Test 2: Mainnet guard + console.log('📝 Test 2: Mainnet guard...'); + const originalNetwork = process.env['STELLAR_NETWORK']; + process.env['STELLAR_NETWORK'] = 'public'; + + try { + const validKey = generateKeypair().publicKey; + await fundTestnetAccount(validKey); + throw new Error('Should have thrown error for mainnet'); + } catch (error) { + if (error instanceof Error && error.message.includes('only available on testnet')) { + console.log('✅ Test 2 passed: Mainnet guard active'); + } else { + throw error; + } + } finally { + process.env['STELLAR_NETWORK'] = originalNetwork ?? 'testnet'; + } + + // Test 3: Valid public key format (without actually calling friendbot) + console.log('📝 Test 3: Valid public key format...'); + const validKey = generateKeypair().publicKey; + + try { + await fundTestnetAccount(validKey); + } catch (error) { + if (error instanceof FriendbotError && error.message.includes('Network error')) { + console.log('✅ Test 3 passed: Valid public key accepted (network error expected)'); + } else if (error instanceof Error && !error.message.includes('Invalid public key') && !error.message.includes('only available on testnet')) { + console.log('✅ Test 3 passed: Valid public key format accepted'); + } else { + throw error; + } + } + + // Test 4: FriendbotError class + console.log('📝 Test 4: FriendbotError class...'); + const error = new FriendbotError('Test error', 500); + + if (error.name === 'FriendbotError' && error.message === 'Test error' && error.statusCode === 500) { + console.log('✅ Test 4 passed: FriendbotError class working correctly'); + } else { + throw new Error('FriendbotError class not working correctly'); + } + + console.log('\n🎉 All friendbot tests passed successfully!'); + + } catch (error) { + console.error('❌ Test failed:', error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} + +testFriendbotFunding(); diff --git a/tests/keypair.test.ts b/tests/keypair.test.ts new file mode 100644 index 0000000..dd42f40 --- /dev/null +++ b/tests/keypair.test.ts @@ -0,0 +1,88 @@ +import { generateKeypair } from '../src/accounts/keypair.js'; +import * as StellarSdk from '@stellar/stellar-sdk'; + +async function testKeypairGeneration() { + console.log('🔑 Testing Keypair Generation...\n'); + + try { + // Test 1: Generate valid Stellar keypair with correct format + console.log('📝 Test 1: Generating keypair and validating format...'); + const keypair = generateKeypair(); + + // Check that publicKey starts with 'G' and has correct length (56 characters) + if (!keypair.publicKey.match(/^G[A-Z0-9]{55}$/)) { + throw new Error(`Invalid public key format: ${keypair.publicKey}`); + } + + // Check that secretKey starts with 'S' and has correct length (56 characters) + if (!keypair.secretKey.match(/^S[A-Z0-9]{55}$/)) { + throw new Error(`Invalid secret key format: ${keypair.secretKey}`); + } + + // Verify the keys are valid Stellar keys + try { + StellarSdk.Keypair.fromPublicKey(keypair.publicKey); + StellarSdk.Keypair.fromSecret(keypair.secretKey); + } catch (error) { + throw new Error(`Keys are not valid Stellar keys: ${error}`); + } + + // Verify the secret key corresponds to the public key + const derivedKeypair = StellarSdk.Keypair.fromSecret(keypair.secretKey); + if (derivedKeypair.publicKey() !== keypair.publicKey) { + throw new Error('Secret key does not correspond to public key'); + } + + console.log(`✅ Public Key: ${keypair.publicKey}`); + console.log(`✅ Secret Key: ${keypair.secretKey.substring(0, 8)}... (truncated for security)`); + console.log('✅ Test 1 passed: Valid keypair format\n'); + + // Test 2: Generate different keypairs on multiple calls + console.log('📝 Test 2: Testing multiple calls produce different keypairs...'); + const keypair1 = generateKeypair(); + const keypair2 = generateKeypair(); + + // Both should be valid + if (!keypair1.publicKey.match(/^G[A-Z0-9]{55}$/) || !keypair1.secretKey.match(/^S[A-Z0-9]{55}$/)) { + throw new Error('First keypair has invalid format'); + } + + if (!keypair2.publicKey.match(/^G[A-Z0-9]{55}$/) || !keypair2.secretKey.match(/^S[A-Z0-9]{55}$/)) { + throw new Error('Second keypair has invalid format'); + } + + // But they should be different + if (keypair1.publicKey === keypair2.publicKey || keypair1.secretKey === keypair2.secretKey) { + throw new Error('Keypairs are not unique - same keys generated twice'); + } + + console.log(`✅ First keypair: ${keypair1.publicKey}`); + console.log(`✅ Second keypair: ${keypair2.publicKey}`); + console.log('✅ Test 2 passed: Different keypairs generated\n'); + + // Test 3: Verify KeypairResult interface structure + console.log('📝 Test 3: Testing KeypairResult interface structure...'); + const keypair3 = generateKeypair(); + + // Verify the object has the expected properties + if (!keypair3.publicKey || !keypair3.secretKey) { + throw new Error('KeypairResult missing required properties'); + } + + // Verify the types + if (typeof keypair3.publicKey !== 'string' || typeof keypair3.secretKey !== 'string') { + throw new Error('KeypairResult properties are not strings'); + } + + console.log('✅ Test 3 passed: Correct interface structure\n'); + + console.log('🎉 All keypair tests passed successfully!'); + console.log('✅ Keypair generation utility is working correctly'); + + } catch (error) { + console.error('❌ Test failed:', error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} + +testKeypairGeneration(); diff --git a/tests/unit/escrow/escrowManager.test.ts b/tests/unit/escrow/escrowManager.test.ts new file mode 100644 index 0000000..cd44568 --- /dev/null +++ b/tests/unit/escrow/escrowManager.test.ts @@ -0,0 +1,163 @@ +import { Account, Keypair } from '@stellar/stellar-sdk'; + +import { EscrowStatus, asPercentage } from '../../../src/types/escrow'; +import { EscrowManager } from '../../../src/escrow'; +import { SdkError, ValidationError } from '../../../src/utils/errors'; + +describe('EscrowManager', () => { + const sourceKeypair = Keypair.random(); + const ownerPublicKey = Keypair.random().publicKey(); + const adopterPublicKey = Keypair.random().publicKey(); + const platformPublicKey = Keypair.random().publicKey(); + + const horizonClient = { + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + + const accountManager = { + create: jest.fn(), + getBalance: jest.fn(), + }; + + const transactionManager = { + releaseFunds: jest.fn(), + handleDispute: jest.fn(), + getStatus: jest.fn(), + }; + + const manager = new EscrowManager({ + horizonClient, + accountManager, + transactionManager, + masterSecretKey: 'S_TEST_MASTER_SECRET', + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('instantiates successfully with injected dependencies', () => { + expect(manager).toBeInstanceOf(EscrowManager); + }); + + it('delegates createAccount using injected accountManager', async () => { + accountManager.create.mockResolvedValue({ + accountId: 'GABC1234', + transactionHash: 'tx-create', + }); + + const result = await manager.createAccount({ + adopterPublicKey, + ownerPublicKey, + depositAmount: '10', + }); + + expect(accountManager.create).toHaveBeenCalledWith({ + publicKey: expect.any(String), + startingBalance: '12.5', + }); + expect(result.accountId).toBe('GABC1234'); + expect(result.transactionHash).toBe('tx-create'); + }); + + it('delegates lockFunds using injected horizon client', async () => { + horizonClient.loadAccount.mockResolvedValue(new Account(sourceKeypair.publicKey(), '1')); + horizonClient.submitTransaction.mockResolvedValue({ hash: 'tx-lock' }); + + const result = await manager.lockFunds({ + custodianPublicKey: adopterPublicKey, + ownerPublicKey, + platformPublicKey, + sourceKeypair, + depositAmount: '20', + durationDays: 2, + }); + + expect(horizonClient.loadAccount).toHaveBeenCalledWith(sourceKeypair.publicKey()); + expect(horizonClient.submitTransaction).toHaveBeenCalledTimes(1); + expect(result.transactionHash).toBe('tx-lock'); + }); + + it('delegates releaseFunds to transactionManager', async () => { + transactionManager.releaseFunds.mockResolvedValue({ + successful: true, + txHash: 'tx-release', + ledger: 10, + payments: [{ recipient: ownerPublicKey, amount: '50' }], + }); + + const params = { + escrowAccountId: 'GESCROW123', + distribution: [{ recipient: ownerPublicKey, percentage: asPercentage(100) }], + }; + + const result = await manager.releaseFunds(params); + + expect(transactionManager.releaseFunds).toHaveBeenCalledWith(params, { + horizonClient, + masterSecretKey: 'S_TEST_MASTER_SECRET', + }); + expect(result.txHash).toBe('tx-release'); + }); + + it('delegates handleDispute to transactionManager', async () => { + const disputeParams = { escrowAccountId: 'GESCROW123' }; + transactionManager.handleDispute.mockResolvedValue({ + accountId: 'GESCROW123', + pausedAt: new Date('2026-03-29T00:00:00.000Z'), + platformOnlyMode: true, + txHash: 'tx-dispute', + }); + + const result = await manager.handleDispute(disputeParams); + + expect(transactionManager.handleDispute).toHaveBeenCalledWith(disputeParams, { + horizonClient, + masterSecretKey: 'S_TEST_MASTER_SECRET', + }); + expect(result.txHash).toBe('tx-dispute'); + }); + + it('delegates getBalance to accountManager', async () => { + accountManager.getBalance.mockResolvedValue('42.5'); + + const result = await manager.getBalance(ownerPublicKey); + + expect(accountManager.getBalance).toHaveBeenCalledWith(ownerPublicKey); + expect(result).toBe('42.5'); + }); + + it('delegates getStatus to transactionManager', async () => { + transactionManager.getStatus.mockResolvedValue(EscrowStatus.FUNDED); + + const result = await manager.getStatus('GESCROW123'); + + expect(transactionManager.getStatus).toHaveBeenCalledWith('GESCROW123', { + horizonClient, + }); + expect(result).toBe(EscrowStatus.FUNDED); + }); + + it('wraps non-SDK errors in a consistent SdkError', async () => { + transactionManager.releaseFunds.mockRejectedValue(new Error('network down')); + + await expect( + manager.releaseFunds({ + escrowAccountId: 'GESCROW123', + distribution: [{ recipient: ownerPublicKey, percentage: asPercentage(100) }], + }), + ).rejects.toMatchObject({ + code: 'ESCROW_MANAGER_ERROR', + message: 'EscrowManager.releaseFunds failed: network down', + }); + }); + + it('rethrows existing SDK errors without re-wrapping', async () => { + const validationError = new ValidationError('publicKey', 'invalid public key'); + accountManager.getBalance.mockRejectedValue(validationError); + + await expect(manager.getBalance('INVALID')).rejects.toBe(validationError); + await expect(manager.getBalance('INVALID')).rejects.toBeInstanceOf(SdkError); + }); +}); diff --git a/tests/unit/escrow/index.test.ts b/tests/unit/escrow/index.test.ts index 1c7d960..aa04a5e 100644 --- a/tests/unit/escrow/index.test.ts +++ b/tests/unit/escrow/index.test.ts @@ -1,16 +1,609 @@ import { createEscrowAccount, - lockCustodyFunds, + calculateStartingBalance, + handleDispute, anchorTrustHash, verifyEventHash, } from '../../../src/escrow'; +import { ValidationError } from '../../../src/utils/errors'; +import { CreateEscrowParams } from '../../../src/types/escrow'; +import { InsufficientBalanceError } from '../../../src/utils/errors'; +import { Account, Keypair, Operation } from '@stellar/stellar-sdk'; -describe('escrow module placeholders', () => { - it('exports callable placeholder functions', () => { - expect(createEscrowAccount()).toBeUndefined(); - expect(lockCustodyFunds()).toBeUndefined(); +describe('calculateStartingBalance', () => { + describe('happy path', () => { + it('calculates starting balance with 10 XLM deposit', () => { + expect(calculateStartingBalance('10')).toBe('12.5'); + }); + + it('calculates starting balance with 100 XLM deposit', () => { + expect(calculateStartingBalance('100')).toBe('102.5'); + }); + + it('calculates starting balance with 0.5 XLM deposit', () => { + expect(calculateStartingBalance('0.5')).toBe('3'); + }); + + it('calculates starting balance with 1 XLM deposit', () => { + expect(calculateStartingBalance('1')).toBe('3.5'); + }); + + it('calculates starting balance with 0.0000001 XLM deposit (smallest unit)', () => { + expect(calculateStartingBalance('0.0000001')).toBe('2.5000001'); + }); + + it('calculates starting balance with 10000 XLM deposit', () => { + expect(calculateStartingBalance('10000')).toBe('10002.5'); + }); + + it('handles decimal amounts with 7 decimal precision', () => { + expect(calculateStartingBalance('1.2345678')).toBe('3.7345678'); + }); + }); + + describe('validation errors', () => { + it('throws ValidationError for invalid amount format', () => { + expect(() => calculateStartingBalance('invalid')).toThrow(ValidationError); + expect(() => calculateStartingBalance('invalid')).toThrow('Invalid deposit amount: invalid'); + }); + + it('throws ValidationError for zero amount', () => { + expect(() => calculateStartingBalance('0')).toThrow(ValidationError); + }); + + it('throws ValidationError for negative amount', () => { + expect(() => calculateStartingBalance('-10')).toThrow(ValidationError); + }); + + it('throws ValidationError for empty string', () => { + expect(() => calculateStartingBalance('')).toThrow(ValidationError); + }); + + it('throws ValidationError for more than 7 decimal places', () => { + expect(() => calculateStartingBalance('10.12345678')).toThrow(ValidationError); + }); + }); +}); + +describe('createEscrowAccount', () => { + const mockAccountManager = { + create: jest.fn(), + getBalance: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const validParams: CreateEscrowParams = { + adopterPublicKey: 'GAGVLQRZZTHIXM7FYEXYA3Q2HNYOZ3FLQORBQIISF6YJQIHE5UIE2JMX', + ownerPublicKey: 'GAPEGAX7B6NBY6NOCLTM7QOQIZWD72KLZRWSYSOT25MFNY5ADK7KR7EE', + depositAmount: '10', + }; + + describe('happy path', () => { + it('creates an escrow account with correct starting balance', async () => { + mockAccountManager.create.mockResolvedValue({ + accountId: 'GXXX123456789', + transactionHash: 'abc123def456', + }); + + const result = await createEscrowAccount(validParams, mockAccountManager); + + expect(mockAccountManager.create).toHaveBeenCalledWith({ + publicKey: expect.any(String), + startingBalance: '12.5', + }); + + expect(result.accountId).toBe('GXXX123456789'); + expect(result.transactionHash).toBe('abc123def456'); + expect(result.signers).toHaveLength(3); + expect(result.thresholds).toEqual({ + low: 1, + medium: 2, + high: 2, + }); + }); + + it('includes unlockDate when provided', async () => { + const unlockDate = new Date('2024-12-31'); + mockAccountManager.create.mockResolvedValue({ + accountId: 'GXXX123456789', + transactionHash: 'abc123def456', + }); + + const result = await createEscrowAccount({ ...validParams, unlockDate }, mockAccountManager); + + expect(result.unlockDate).toEqual(unlockDate); + }); + + it('handles different deposit amounts correctly', async () => { + mockAccountManager.create.mockResolvedValue({ + accountId: 'GXXX123456789', + transactionHash: 'abc123def456', + }); + + await createEscrowAccount({ ...validParams, depositAmount: '50' }, mockAccountManager); + + expect(mockAccountManager.create).toHaveBeenCalledWith({ + publicKey: expect.any(String), + startingBalance: '52.5', + }); + }); + }); + + describe('validation errors', () => { + it('throws ValidationError for invalid adopter public key', async () => { + await expect( + createEscrowAccount({ ...validParams, adopterPublicKey: 'INVALID' }, mockAccountManager), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError for invalid owner public key', async () => { + await expect( + createEscrowAccount({ ...validParams, ownerPublicKey: 'INVALID' }, mockAccountManager), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError for invalid deposit amount', async () => { + await expect( + createEscrowAccount({ ...validParams, depositAmount: '-10' }, mockAccountManager), + ).rejects.toThrow(ValidationError); + }); + }); + + describe('InsufficientBalanceError handling', () => { + it('re-throws InsufficientBalanceError from account manager', async () => { + const error = new InsufficientBalanceError('100', '50'); + mockAccountManager.create.mockRejectedValue(error); + + await expect(createEscrowAccount(validParams, mockAccountManager)).rejects.toThrow( + InsufficientBalanceError, + ); + }); + + it('error message contains required and available amounts', async () => { + const error = new InsufficientBalanceError('100', '50'); + mockAccountManager.create.mockRejectedValue(error); + + await expect(createEscrowAccount(validParams, mockAccountManager)).rejects.toThrow( + 'Insufficient balance. Required: 100, available: 50', + ); + }); + }); + + describe('other errors', () => { + it('re-throws generic errors from account manager', async () => { + const error = new Error('Network error'); + mockAccountManager.create.mockRejectedValue(error); + + await expect(createEscrowAccount(validParams, mockAccountManager)).rejects.toThrow( + 'Network error', + ); + }); + }); +}); + +describe('placeholder functions', () => { + it('anchorTrustHash and verifyEventHash are callable stubs', () => { expect(anchorTrustHash()).toBeUndefined(); expect(verifyEventHash()).toBeUndefined(); }); }); +describe('handleDispute', () => { + const escrowAccountId = Keypair.random().publicKey(); + const platformKeypair = Keypair.random(); + const adopterPublicKey = Keypair.random().publicKey(); + const ownerPublicKey = Keypair.random().publicKey(); + + const mockHorizonServer = { + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('sets adopter and owner signer weights to zero and sets platform to weight 3', async () => { + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + mockHorizonServer.loadAccount + .mockResolvedValueOnce({ + sequence: '101', + signers: [ + { key: adopterPublicKey, weight: 1 }, + { key: ownerPublicKey, weight: 1 }, + { key: platformKeypair.publicKey(), weight: 1 }, + ], + thresholds: { low: 1, medium: 2, high: 2 }, + }) + .mockResolvedValueOnce({ + sequence: '102', + signers: [ + { key: adopterPublicKey, weight: 0 }, + { key: ownerPublicKey, weight: 0 }, + { key: platformKeypair.publicKey(), weight: 3 }, + ], + thresholds: { low: 0, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'dispute-hash' }); + + const result = await handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ); + + expect(result.accountId).toBe(escrowAccountId); + expect(result.platformOnlyMode).toBe(true); + expect(result.txHash).toBe('dispute-hash'); + expect(result.pausedAt).toBeInstanceOf(Date); + + expect(mockHorizonServer.loadAccount).toHaveBeenCalledTimes(2); + expect(mockHorizonServer.loadAccount).toHaveBeenNthCalledWith(1, escrowAccountId); + expect(mockHorizonServer.loadAccount).toHaveBeenNthCalledWith(2, escrowAccountId); + + expect(setOptionsSpy).toHaveBeenCalledWith({ + source: escrowAccountId, + signer: { + ed25519PublicKey: adopterPublicKey, + weight: 0, + }, + }); + + expect(setOptionsSpy).toHaveBeenCalledWith({ + source: escrowAccountId, + signer: { + ed25519PublicKey: ownerPublicKey, + weight: 0, + }, + }); + + expect(setOptionsSpy).toHaveBeenCalledWith({ + source: escrowAccountId, + signer: { + ed25519PublicKey: platformKeypair.publicKey(), + weight: 3, + }, + }); + + expect(setOptionsSpy).toHaveBeenCalledWith({ + source: escrowAccountId, + masterWeight: 0, + lowThreshold: 0, + medThreshold: 2, + highThreshold: 2, + }); + }); + + it('throws ValidationError for invalid escrow account id', async () => { + await expect( + handleDispute( + { + escrowAccountId: 'invalid', + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError for invalid master secret key', async () => { + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: 'invalid', + }, + mockHorizonServer, + ), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError for checksum-invalid master secret key', async () => { + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: `S${'A'.repeat(55)}`, + }, + mockHorizonServer, + ), + ).rejects.toThrow(ValidationError); + }); + + it('is idempotent when account is already in platform-only mode', async () => { + mockHorizonServer.loadAccount + .mockResolvedValueOnce({ + sequence: '201', + signers: [ + { key: adopterPublicKey, weight: 0 }, + { key: ownerPublicKey, weight: 0 }, + { key: platformKeypair.publicKey(), weight: 3 }, + ], + thresholds: { low: 0, medium: 2, high: 2 }, + }) + .mockResolvedValueOnce({ + sequence: '202', + signers: [ + { key: adopterPublicKey, weight: 0 }, + { key: ownerPublicKey, weight: 0 }, + { key: platformKeypair.publicKey(), weight: 3 }, + ], + thresholds: { low: 0, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'idempotent-hash' }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).resolves.toMatchObject({ + platformOnlyMode: true, + txHash: 'idempotent-hash', + }); + }); + + it('supports sequenceNumber-only Horizon responses', async () => { + mockHorizonServer.loadAccount + .mockResolvedValueOnce({ + sequenceNumber: '501', + signers: [ + { key: adopterPublicKey, weight: 1 }, + { key: ownerPublicKey, weight: 1 }, + { key: platformKeypair.publicKey(), weight: 1 }, + ], + thresholds: { low: 1, medium: 2, high: 2 }, + }) + .mockResolvedValueOnce({ + sequenceNumber: '502', + signers: [ + { key: adopterPublicKey, weight: 0 }, + { key: ownerPublicKey, weight: 0 }, + { key: platformKeypair.publicKey(), weight: 3 }, + ], + thresholds: { low: 0, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'sequence-number-hash' }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).resolves.toMatchObject({ + txHash: 'sequence-number-hash', + platformOnlyMode: true, + }); + }); + + it('supports top-level threshold keys from Horizon response', async () => { + mockHorizonServer.loadAccount + .mockResolvedValueOnce({ + sequence: '601', + signers: [ + { key: adopterPublicKey, weight: 1 }, + { key: ownerPublicKey, weight: 1 }, + { key: platformKeypair.publicKey(), weight: 1 }, + ], + thresholds: { low: 1, medium: 2, high: 2 }, + }) + .mockResolvedValueOnce({ + sequence: '602', + signers: [ + { key: adopterPublicKey, weight: 0 }, + { key: ownerPublicKey, weight: 0 }, + { key: platformKeypair.publicKey(), weight: 3 }, + ], + low_threshold: 0, + med_threshold: 2, + high_threshold: 2, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'threshold-hash' }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).resolves.toMatchObject({ + txHash: 'threshold-hash', + platformOnlyMode: true, + }); + }); + + it('supports signer keys from ed25519PublicKey field', async () => { + mockHorizonServer.loadAccount + .mockResolvedValueOnce({ + sequence: '651', + signers: [ + { ed25519PublicKey: adopterPublicKey, weight: 1 }, + { ed25519PublicKey: ownerPublicKey, weight: 1 }, + { ed25519PublicKey: platformKeypair.publicKey(), weight: 1 }, + ], + thresholds: { low: 1, medium: 2, high: 2 }, + }) + .mockResolvedValueOnce({ + sequence: '652', + signers: [ + { ed25519PublicKey: adopterPublicKey, weight: 0 }, + { ed25519PublicKey: ownerPublicKey, weight: 0 }, + { ed25519PublicKey: platformKeypair.publicKey(), weight: 3 }, + ], + thresholds: { low: 0, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'ed25519-fallback-hash' }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).resolves.toMatchObject({ + txHash: 'ed25519-fallback-hash', + platformOnlyMode: true, + }); + }); + + it('handles Account instance from loadAccount', async () => { + mockHorizonServer.loadAccount + .mockResolvedValueOnce(new Account(escrowAccountId, '701')) + .mockResolvedValueOnce({ + sequence: '702', + signers: [{ key: platformKeypair.publicKey(), weight: 3 }], + thresholds: { low: 0, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'account-instance-hash' }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).resolves.toMatchObject({ + txHash: 'account-instance-hash', + platformOnlyMode: true, + }); + }); + + it('ignores invalid signer entries from Horizon and still succeeds', async () => { + mockHorizonServer.loadAccount + .mockResolvedValueOnce({ + sequence: '801', + signers: [ + { key: adopterPublicKey, weight: 1 }, + { key: ownerPublicKey, weight: 1 }, + { key: platformKeypair.publicKey(), weight: 1 }, + { weight: 1 }, + { key: Keypair.random().publicKey(), weight: Number.NaN }, + ], + thresholds: { low: 1, medium: 2, high: 2 }, + }) + .mockResolvedValueOnce({ + sequence: '802', + signers: [ + { key: adopterPublicKey, weight: 0 }, + { key: ownerPublicKey, weight: 0 }, + { key: platformKeypair.publicKey(), weight: 3 }, + ], + thresholds: { low: 0, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'invalid-signer-filter-hash' }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).resolves.toMatchObject({ + txHash: 'invalid-signer-filter-hash', + platformOnlyMode: true, + }); + }); + + it('throws when Horizon account response has no sequence value', async () => { + mockHorizonServer.loadAccount.mockResolvedValueOnce({ + signers: [{ key: platformKeypair.publicKey(), weight: 1 }], + thresholds: { low: 1, medium: 2, high: 2 }, + }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).rejects.toThrow('Unable to determine account sequence from Horizon response'); + }); + + it('throws when post-submit signer verification fails', async () => { + mockHorizonServer.loadAccount + .mockResolvedValueOnce({ + sequence: '301', + signers: [ + { key: adopterPublicKey, weight: 1 }, + { key: ownerPublicKey, weight: 1 }, + { key: platformKeypair.publicKey(), weight: 1 }, + ], + thresholds: { low: 1, medium: 2, high: 2 }, + }) + .mockResolvedValueOnce({ + sequence: '302', + signers: [ + { key: adopterPublicKey, weight: 0 }, + { key: ownerPublicKey, weight: 1 }, + { key: platformKeypair.publicKey(), weight: 3 }, + ], + thresholds: { low: 0, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockResolvedValue({ hash: 'bad-hash' }); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).rejects.toThrow('Dispute signer update verification failed'); + }); + + it('re-throws submitTransaction errors from Horizon', async () => { + mockHorizonServer.loadAccount.mockResolvedValue({ + sequence: '901', + signers: [ + { key: adopterPublicKey, weight: 1 }, + { key: ownerPublicKey, weight: 1 }, + { key: platformKeypair.publicKey(), weight: 1 }, + ], + thresholds: { low: 1, medium: 2, high: 2 }, + }); + + mockHorizonServer.submitTransaction.mockRejectedValue(new Error('tx_bad_auth')); + + await expect( + handleDispute( + { + escrowAccountId, + masterSecretKey: platformKeypair.secret(), + }, + mockHorizonServer, + ), + ).rejects.toThrow('tx_bad_auth'); + }); +}); diff --git a/tests/unit/escrow/lockCustodyFunds.test.ts b/tests/unit/escrow/lockCustodyFunds.test.ts new file mode 100644 index 0000000..497e258 --- /dev/null +++ b/tests/unit/escrow/lockCustodyFunds.test.ts @@ -0,0 +1,152 @@ +import { Keypair, Account } from '@stellar/stellar-sdk'; +import { lockCustodyFunds } from '../../../src/escrow'; +import { ValidationError } from '../../../src/utils/errors'; + +describe('lockCustodyFunds', () => { + const sourceKeypair = Keypair.random(); + + const mockHorizonServer = { + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + + const baseParams = { + custodianPublicKey: Keypair.random().publicKey(), + ownerPublicKey: Keypair.random().publicKey(), + platformPublicKey: Keypair.random().publicKey(), + sourceKeypair, + depositAmount: '100', + durationDays: 7, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // FIX: proper Account instance + mockHorizonServer.loadAccount.mockResolvedValue( + new Account(sourceKeypair.publicKey(), '1') + ); + + mockHorizonServer.submitTransaction.mockResolvedValue({ + hash: 'mock-hash', + }); + }); + + // ─── HAPPY PATH ───────────────────────────────────────────── + + it('should lock funds successfully', async () => { + const result = await lockCustodyFunds(baseParams, mockHorizonServer); + + expect(result).toHaveProperty('unlockDate'); + expect(result).toHaveProperty('conditionsHash'); + expect(result).toHaveProperty('escrowPublicKey'); + expect(result).toHaveProperty('transactionHash'); + }); + + // ─── UNLOCK DATE ──────────────────────────────────────────── + + it('should calculate unlockDate correctly', async () => { + const durationDays = 5; + + const result = await lockCustodyFunds( + { ...baseParams, durationDays }, + mockHorizonServer + ); + + const expected = Date.now() + durationDays * 86400000; + + expect(result.unlockDate.getTime()).toBeGreaterThan(expected - 1000); + }); + + // ─── HASH TESTS ───────────────────────────────────────────── + + it('should generate deterministic conditionsHash', async () => { + const result1 = await lockCustodyFunds(baseParams, mockHorizonServer); + const result2 = await lockCustodyFunds(baseParams, mockHorizonServer); + + expect(result1.conditionsHash).toBe(result2.conditionsHash); + }); + + it('should produce same hash for same inputs (idempotency)', async () => { + const r1 = await lockCustodyFunds(baseParams, mockHorizonServer); + const r2 = await lockCustodyFunds(baseParams, mockHorizonServer); + + expect(r1.conditionsHash).toEqual(r2.conditionsHash); + }); + + // ─── SERVER CALLS ─────────────────────────────────────────── + + it('should call horizon server', async () => { + await lockCustodyFunds(baseParams, mockHorizonServer); + + expect(mockHorizonServer.loadAccount).toHaveBeenCalled(); + expect(mockHorizonServer.submitTransaction).toHaveBeenCalled(); + }); + + // ─── VALIDATION ───────────────────────────────────────────── + + it('should throw error for invalid custodian key', async () => { + await expect( + lockCustodyFunds( + { ...baseParams, custodianPublicKey: 'invalid' }, + mockHorizonServer + ) + ).rejects.toThrow(ValidationError); + }); + + it('should throw error for invalid owner key', async () => { + await expect( + lockCustodyFunds( + { ...baseParams, ownerPublicKey: 'invalid' }, + mockHorizonServer + ) + ).rejects.toThrow(ValidationError); + }); + + it('should throw error for invalid platform key', async () => { + await expect( + lockCustodyFunds( + { ...baseParams, platformPublicKey: 'invalid' }, + mockHorizonServer + ) + ).rejects.toThrow(ValidationError); + }); + + it('should throw error for invalid depositAmount', async () => { + await expect( + lockCustodyFunds( + { ...baseParams, depositAmount: 'invalid' }, + mockHorizonServer + ) + ).rejects.toThrow(ValidationError); + }); + + it('should throw error for invalid durationDays', async () => { + await expect( + lockCustodyFunds( + { ...baseParams, durationDays: 0 }, + mockHorizonServer + ) + ).rejects.toThrow(ValidationError); + }); + + // ─── EDGE CASES ───────────────────────────────────────────── + + it('should handle large durationDays', async () => { + const result = await lockCustodyFunds( + { ...baseParams, durationDays: 3650 }, + mockHorizonServer + ); + + expect(result.unlockDate).toBeInstanceOf(Date); + }); + + it('should handle small valid amount', async () => { + const result = await lockCustodyFunds( + { ...baseParams, depositAmount: '0.0000001' }, + mockHorizonServer + ); + + expect(result.conditionsHash).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/tests/unit/transactions/index.test.ts b/tests/unit/transactions/index.test.ts index e3de91c..faf251a 100644 --- a/tests/unit/transactions/index.test.ts +++ b/tests/unit/transactions/index.test.ts @@ -1,4 +1,8 @@ -import { buildMultisigTransaction } from '../../../src/transactions'; + +import { Keypair, Operation } from '@stellar/stellar-sdk'; + +import { buildMultisigTransaction, buildSetOptionsOp, fetchTransactionOnce } from '../../../src/transactions'; +import { HorizonSubmitError, ValidationError } from '../../../src/utils/errors'; describe('transactions module placeholders', () => { it('exports callable placeholder function', () => { @@ -6,3 +10,131 @@ describe('transactions module placeholders', () => { }); }); +describe('buildSetOptionsOp', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('builds setOptions operation for adding a signer', () => { + const signerPublicKey = Keypair.random().publicKey(); + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + signers: [{ publicKey: signerPublicKey, weight: 1 }], + }); + + expect(operations).toHaveLength(1); + expect(setOptionsSpy).toHaveBeenCalledWith({ + signer: { ed25519PublicKey: signerPublicKey, weight: 1 }, + }); + }); + + it('builds setOptions operation for removing a signer with weight 0', () => { + const signerPublicKey = Keypair.random().publicKey(); + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + signers: [{ publicKey: signerPublicKey, weight: 0 }], + }); + + expect(operations).toHaveLength(1); + expect(setOptionsSpy).toHaveBeenCalledWith({ + signer: { ed25519PublicKey: signerPublicKey, weight: 0 }, + }); + }); + + it('builds setOptions operation for thresholds', () => { + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + thresholds: { low: 1, medium: 2, high: 3 }, + }); + + expect(operations).toHaveLength(1); + expect(setOptionsSpy).toHaveBeenCalledWith({ + lowThreshold: 1, + medThreshold: 2, + highThreshold: 3, + }); + }); + + it('builds mixed setOptions operations for signers and thresholds', () => { + const signerPublicKey = Keypair.random().publicKey(); + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + signers: [{ publicKey: signerPublicKey, weight: 2 }], + thresholds: { low: 1, medium: 2, high: 2 }, + masterWeight: 0, + }); + + expect(operations).toHaveLength(2); + expect(setOptionsSpy).toHaveBeenNthCalledWith(1, { + signer: { ed25519PublicKey: signerPublicKey, weight: 2 }, + }); + expect(setOptionsSpy).toHaveBeenNthCalledWith(2, { + masterWeight: 0, + lowThreshold: 1, + medThreshold: 2, + highThreshold: 2, + }); + }); + + it('throws ValidationError when a signer public key is invalid', () => { + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + expect(() => + buildSetOptionsOp({ + signers: [{ publicKey: 'INVALID_PUBLIC_KEY', weight: 1 }], + }), + ).toThrow(ValidationError); + + expect(setOptionsSpy).not.toHaveBeenCalled(); + }); +}); + +describe('fetchTransactionOnce', () => { + const hash = 'abc123'; + const baseUrl = 'https://horizon-testnet.stellar.org/transactions/'; + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns found: true for successful tx', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + ok: true, + json: async () => ({ successful: true, ledger: 123, created_at: '2024-01-01T00:00:00Z' }), + }); + const result = await fetchTransactionOnce(hash); + expect(result).toEqual({ found: true, successful: true, ledger: 123, createdAt: '2024-01-01T00:00:00Z' }); + expect(global.fetch).toHaveBeenCalledWith(baseUrl + hash); + }); + + it('returns found: true for failed tx', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + ok: true, + json: async () => ({ successful: false, ledger: 456, created_at: '2024-01-02T00:00:00Z' }), + }); + const result = await fetchTransactionOnce(hash); + expect(result).toEqual({ found: true, successful: false, ledger: 456, createdAt: '2024-01-02T00:00:00Z' }); + }); + + it('returns found: false for 404', async () => { + global.fetch = jest.fn().mockResolvedValue({ status: 404, ok: false }); + const result = await fetchTransactionOnce(hash); + expect(result).toEqual({ found: false }); + }); + + it('throws HorizonSubmitError for network error', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('network down')); + await expect(fetchTransactionOnce(hash)).rejects.toThrow(HorizonSubmitError); + }); + + it('throws HorizonSubmitError for non-404 HTTP error', async () => { + global.fetch = jest.fn().mockResolvedValue({ status: 500, ok: false }); + await expect(fetchTransactionOnce(hash)).rejects.toThrow(HorizonSubmitError); + }); +}); + diff --git a/tests/unit/utils/validation.test.ts b/tests/unit/utils/validation.test.ts index 5445fa8..ce6cc85 100644 --- a/tests/unit/utils/validation.test.ts +++ b/tests/unit/utils/validation.test.ts @@ -1,44 +1,57 @@ import { - isValidPublicKey, isValidSecretKey, - isValidAmount, isValidDistribution, + isValidPublicKey, + isValidSecretKey, + isValidAmount, + isValidDistribution, } from '../../../src/utils/validation'; -// const VALID_KEY_G = 'GADOPTER1111111111111111111111111111111111111111111111111111'; -// const VALID_KEY_S = 'SADOPTER1111111111111111111111111111111111111111111111111111'; -const VALID_KEY_G = 'GADOPTER111111111111111111111111111111111111111111111111'; +const VALID_KEY_G = 'GAGVLQRZZTHIXM7FYEXYA3Q2HNYOZ3FLQORBQIISF6YJQIHE5UIE2JMX'; const VALID_KEY_S = 'SADOPTER111111111111111111111111111111111111111111111111'; describe('isValidPublicKey', () => { it('accepts a valid G... key', () => expect(isValidPublicKey(VALID_KEY_G)).toBe(true)); - it('rejects S... key', () => expect(isValidPublicKey(VALID_KEY_S)).toBe(false)); - it('rejects empty string', () => expect(isValidPublicKey('')).toBe(false)); - it('rejects short key', () => expect(isValidPublicKey('GSHORT')).toBe(false)); + it('rejects S... key', () => expect(isValidPublicKey(VALID_KEY_S)).toBe(false)); + it('rejects empty string', () => expect(isValidPublicKey('')).toBe(false)); + it('rejects short key', () => expect(isValidPublicKey('GSHORT')).toBe(false)); }); describe('isValidSecretKey', () => { it('accepts a valid S... key', () => expect(isValidSecretKey(VALID_KEY_S)).toBe(true)); - it('rejects G... key', () => expect(isValidSecretKey(VALID_KEY_G)).toBe(false)); - it('rejects empty string', () => expect(isValidSecretKey('')).toBe(false)); + it('rejects G... key', () => expect(isValidSecretKey(VALID_KEY_G)).toBe(false)); + it('rejects empty string', () => expect(isValidSecretKey('')).toBe(false)); }); describe('isValidAmount', () => { it('accepts positive decimal', () => expect(isValidAmount('100.50')).toBe(true)); it('accepts whole number', () => expect(isValidAmount('500')).toBe(true)); + it('accepts smallest 7dp value', () => expect(isValidAmount('0.0000001')).toBe(true)); it('rejects zero', () => expect(isValidAmount('0')).toBe(false)); it('rejects negative', () => expect(isValidAmount('-50')).toBe(false)); it('rejects non-numeric', () => expect(isValidAmount('abc')).toBe(false)); + it('rejects scientific notation', () => expect(isValidAmount('1e5')).toBe(false)); it('rejects empty string', () => expect(isValidAmount('')).toBe(false)); + it('accepts whole number', () => expect(isValidAmount('500')).toBe(true)); + it('rejects zero', () => expect(isValidAmount('0')).toBe(false)); + it('rejects negative', () => expect(isValidAmount('-50')).toBe(false)); + it('rejects non-numeric', () => expect(isValidAmount('abc')).toBe(false)); + it('rejects empty string', () => expect(isValidAmount('')).toBe(false)); }); describe('isValidDistribution', () => { - it('accepts 60/40 split', () => expect(isValidDistribution([ - { recipient: VALID_KEY_G, percentage: 60 }, - { recipient: VALID_KEY_G, percentage: 40 }, - ])).toBe(true)); - it('rejects sum of 90', () => expect(isValidDistribution([ - { recipient: VALID_KEY_G, percentage: 60 }, - { recipient: VALID_KEY_G, percentage: 30 }, - ])).toBe(false)); + it('accepts 60/40 split', () => + expect( + isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 60 }, + { recipient: VALID_KEY_G, percentage: 40 }, + ]), + ).toBe(true)); + it('rejects sum of 90', () => + expect( + isValidDistribution([ + { recipient: VALID_KEY_G, percentage: 60 }, + { recipient: VALID_KEY_G, percentage: 30 }, + ]), + ).toBe(false)); it('rejects empty array', () => expect(isValidDistribution([])).toBe(false)); }); diff --git a/tsconfig.json b/tsconfig.json index 7b1c703..5c72882 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "baseUrl": "./src" }, "include": ["src"], "exclude": ["tests", "node_modules", "dist", "scripts"]