diff --git a/packages/custodian-sdk/.env.example b/packages/custodian-sdk/.env.example new file mode 100644 index 0000000..fed4dab --- /dev/null +++ b/packages/custodian-sdk/.env.example @@ -0,0 +1,4 @@ +# Custodian private key (64-char hex string) +# Derived with keyPairFromSeed("custodian").privateKey from @denotecapital/token-sdk +CUSTODIAN_PRIVATE_KEY=Y3VzdG9kaWFuMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDD7eWwX55oMQAt1WT0dLYtosRG9jl/SMO3BlA7R+qvYQw== +CUSTODIAN_PARTY_HINT=custodian diff --git a/packages/custodian-sdk/.gitignore b/packages/custodian-sdk/.gitignore new file mode 100644 index 0000000..67ed5f2 --- /dev/null +++ b/packages/custodian-sdk/.gitignore @@ -0,0 +1,4 @@ +.env +node_modules/ +dist/ +.turbo/ diff --git a/packages/custodian-sdk/CLAUDE.md b/packages/custodian-sdk/CLAUDE.md new file mode 100644 index 0000000..499e523 --- /dev/null +++ b/packages/custodian-sdk/CLAUDE.md @@ -0,0 +1,628 @@ +# CLAUDE.md - Custodian SDK + +This file provides guidance to Claude Code (claude.ai/code) when working with the custodian service implementation. + +## Project Overview + +The custodian-sdk is an automated service that polls the Canton ledger for pending request contracts and automatically approves them after mocking external API calls. It is designed to work alongside the `@packages/token-app/` web application, providing automated custodian approval for token and bond operations. + +## Architecture + +### Core Components + +**Main Service (`src/index.ts`)** +- Single-file functional approach (no classes) +- Polls Canton ledger every 60 seconds using `activeContracts()` +- Tracks processed contracts in-memory using a `Set` +- Handles 6 different request types via handler map pattern +- Uses async/await polling loop instead of setInterval + +**Custodian API Mock (`src/custodianApi.ts`)** +- Provides 6 mock external API approval methods +- Each method logs request-specific details and simulates 1-second delay +- In production, these would be replaced with real external API calls +- Methods: `approveMint`, `approveTransfer`, `approveBurn`, `approveBondMint`, `approveBondTransfer`, `approveBondLifecycleClaim` + +**Type Definitions (`src/types/Request.ts`)** +- Generic `Request` interface +- Typed contract parameters from `@denotecapital/token-sdk` +- Handler map pattern with typed request handlers for each template ID + +### Request Types Handled + +The service automatically approves 6 request types: + +1. **IssuerMintRequest** - Token minting (receiver proposes, issuer accepts) +2. **TransferRequest** - Token transfers (sender proposes, admin accepts) +3. **IssuerBurnRequest** - Token burning (owner proposes, issuer accepts) +4. **BondIssuerMintRequest** - Bond minting (receiver proposes, issuer accepts) +5. **BondTransferRequest** - Bond transfers (sender proposes, admin accepts) +6. **BondLifecycleClaimRequest** - Bond lifecycle claims (holder proposes, issuer accepts) + +### Service Flow + +1. **Initialization** (`initializeCustodian`) + - Load `CUSTODIAN_PRIVATE_KEY` from environment + - Derive public key using `nacl.box.keyPair.fromSecretKey()` + - Connect to Canton ledger via `getDefaultSdkAndConnect()` + - Allocate custodian party on ledger + - Create wrapped SDK with key pair + +2. **Polling Loop** (`pollForRequests`) + - Query `activeContracts()` with all 6 template IDs in a single call + - Filter contracts already in `processedContracts` Set + - Return new requests for processing + - **Important**: Includes comment about future Canton Participant Query Store (PQS) integration + +3. **Request Handling** (`handleRequest`) + - Lookup handler from `REQUEST_HANDLER` map using `templateId` + - Each handler is properly typed for its specific request type + - Handler calls corresponding `custodianApi.approve*()` method (mock external API) + - Handler calls `wrappedSdk.*.accept()` to accept on ledger + - Mark contract as processed in `processedContracts` Set + +4. **Main Loop** (`startCustodianService`) + - Initialize custodian + - Start infinite polling loop with 60-second intervals + - Handle errors gracefully (log and retry) + - Support graceful shutdown (SIGINT) + +## Configuration + +### Environment Variables + +**Required:** +- `CUSTODIAN_PRIVATE_KEY` - Custodian's Ed25519 private key (base64-encoded string) + +**Loading:** +```typescript +import dotenv from 'dotenv'; +dotenv.config(); +``` + +### Hard-Coded Constants + +Defined at top of `src/index.ts`: + +```typescript +const POLLING_FREQUENCY_MS = 60000; // 1 minute +export const API_MOCK_DELAY_MS = 1000; // 1 second +``` + +**Note**: `API_MOCK_DELAY_MS` is exported so `custodianApi.ts` can import it. + +### Watched Template IDs + +```typescript +const WATCHED_TEMPLATE_IDS = [ + issuerMintRequestTemplateId, + transferRequestTemplateId, + issuerBurnRequestTemplateId, + bondIssuerMintRequestTemplateId, + bondTransferRequestTemplateId, + bondLifecycleClaimRequestTemplateId, +]; +``` + +These are imported from `@denotecapital/token-sdk` and represent the 6 contract types the service monitors. + +## Code Organization + +### Import Order + +The `src/index.ts` file follows a consistent import organization pattern for better readability and tree-shaking: + +1. **Canton SDK types** - Import type definitions first + ```typescript + import type { WalletSDK } from "@canton-network/wallet-sdk"; + ``` + +2. **Canton SDK functions** - Import runtime functions + ```typescript + import { signTransactionHash } from "@canton-network/wallet-sdk"; + ``` + +3. **Token SDK imports** - Organized alphabetically by name + ```typescript + import { + ActiveContractResponse, + BondIssuerMintRequestParams, + bondIssuerMintRequestTemplateId, + // ... (alphabetically sorted) + } from "@denotecapital/token-sdk"; + ``` + +4. **External libraries** - Third-party dependencies + ```typescript + import dotenv from "dotenv"; + import nacl from "tweetnacl"; + import { decodeBase64, encodeBase64 } from "tweetnacl-util"; + ``` + +5. **Local imports** - Project-specific modules + ```typescript + import { custodianApi } from "./custodianApi.js"; + import { Request } from "./types/Request.js"; + ``` + +### Function Structure + +Functions use clean patterns for better readability: + +- **Early destructuring** - Extract values at function start + ```typescript + async function handleRequest(request: Request, wrappedSdk: WrappedSdkWithKeyPair) { + const { contractId, templateId } = request; // Destructure first + // ... use contractId and templateId directly + } + ``` + +- **Direct imports** - No dynamic imports for better performance + ```typescript + // Good: Direct import at top + import { signTransactionHash } from "@canton-network/wallet-sdk"; + + // Avoid: Dynamic import inside function + // const { signTransactionHash } = await import("@canton-network/wallet-sdk"); + ``` + +## Key Design Decisions + +### 1. Functional Approach (No Classes) + +The service uses plain functions instead of classes for simplicity: +- Easy to understand and modify +- No complex OOP patterns +- Single file with clear function boundaries +- Extracted `custodianApi` for separation of concerns + +### 2. Single activeContracts Call + +Instead of querying each template ID separately, the service queries all 6 template IDs in a single `activeContracts()` call for efficiency: + +```typescript +const response = await sdk.userLedger!.activeContracts({ + templateIds: WATCHED_TEMPLATE_IDS, + filterByParty: true, + parties: [custodianPartyId], + offset, +}); +``` + +### 3. Async/Await Polling Loop + +Uses `while (true)` with `await` instead of `setInterval` for better control: + +```typescript +async function startPollingLoop() { + while (true) { + try { + // Poll and process requests + await new Promise(resolve => setTimeout(resolve, POLLING_FREQUENCY_MS)); + } catch (error) { + // Handle errors and continue + } + } +} +``` + +**Benefits:** +- Sequential processing of requests +- Better error handling +- Easier to add delays between operations +- Natural async/await flow + +### 4. Handler Map Pattern for Type-Safe Request Handling + +Uses a handler map pattern where each `templateId` maps to a typed handler function: + +```typescript +// Type for a handler function +type RequestHandler> = ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair +) => Promise; + +// Handler map with proper types for each request type +const REQUEST_HANDLER = { + [issuerMintRequestTemplateId]: async ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair + ) => { + await custodianApi.approveMint(request); + await wrappedSdk.issuerMintRequest.accept(request.contractId); + console.log(`[CUSTODIAN] ✓ Accepted IssuerMintRequest`); + }, + // ... 5 more handlers +} satisfies Record>; + +// Simplified handleRequest - just lookup and invoke +async function handleRequest(request: Request, wrappedSdk: WrappedSdkWithKeyPair) { + const { contractId, templateId } = request; + + if (!(templateId in REQUEST_HANDLER)) { + console.warn(`[CUSTODIAN] No handler for template ID: ${templateId}`); + return; + } + + const handler = REQUEST_HANDLER[templateId as keyof typeof REQUEST_HANDLER]; + await handler(request as Request, wrappedSdk); + processedContracts.add(contractId); +} +``` + +**Benefits:** +- Each handler has proper types built-in (no `as unknown as` double cast needed) +- Handler map is more concise than switch statement +- Single `as Request` cast at invocation point (contained in one place) +- Easy to add new request types (just add to map) +- Uses `satisfies` for type checking the handler map structure + +### 5. In-Memory State Tracking + +Processed contracts are tracked in a `Set`: + +```typescript +const processedContracts = new Set(); +``` + +**Pros:** +- Simple and fast +- No external dependencies +- Low complexity for MVP + +**Cons:** +- State lost on restart +- No shared state across multiple instances + +**Future Enhancement:** File-based persistence for service restarts. + +### 6. Mock External API SDK + +The `custodianApi` object provides a clean interface for external API calls: + +```typescript +await custodianApi.approveMint(request); +``` + +**Production Replacement:** +- Replace mock methods with real API client +- Keep same interface for minimal code changes +- Add authentication, retry logic, error handling +- Implement actual approval workflows + +## Integration with Token-App + +The custodian service is designed to work alongside the token-app web application: + +1. **User action** (token-app web UI): Create mint/transfer/burn request +2. **Contract creation** (Canton ledger): Request contract created on-chain +3. **Custodian detection** (custodian-sdk): Service detects new request on next poll (≤60s) +4. **Mock API call** (custodian-sdk): Simulates external approval (1s delay) +5. **Ledger acceptance** (custodian-sdk): Accepts request using token-sdk +6. **User feedback** (token-app web UI): Balance updates, operation completes + +## Development Commands + +```bash +# Start with auto-reload +pnpm dev + +# Start without auto-reload +pnpm start + +# Build TypeScript +pnpm build + +# Lint code +pnpm lint + +# Fix lint errors +pnpm lint:fix + +# Check circular dependencies +pnpm madge + +# Clean build artifacts +pnpm clean +``` + +## Testing Strategy + +### Manual Testing with Token-App + +1. Start localnet: `pnpm start:localnet` +2. Start token-app: `pnpm -C packages/token-app dev` +3. Start custodian: `pnpm -C packages/custodian-sdk dev` +4. Create requests via token-app web UI +5. Observe custodian logs for detection and approval +6. Verify operations complete in token-app UI + +### Expected Log Output + +**Startup:** +``` +[CUSTODIAN] Service started +[CUSTODIAN] Party ID: party::custodian::12345... +[CUSTODIAN] Polling every 60000ms +``` + +**Polling (no requests):** +``` +[CUSTODIAN] Polling for new requests... +[CUSTODIAN] No new requests +``` + +**Request detected and processed:** +``` +[CUSTODIAN] Polling for new requests... +[CUSTODIAN] Found 1 new request(s) +[CUSTODIAN] Processing #minimal-token:MyToken.IssuerMintRequest:IssuerMintRequest +[CUSTODIAN] Contract ID: 00abc123... +[CUSTODIAN_API] Calling external API to approve mint... +[CUSTODIAN_API] Request ID: 00abc123... +[CUSTODIAN_API] Receiver: party::alice::67890... +[CUSTODIAN_API] Amount: 100 +[CUSTODIAN_API] ✓ Mint approved +[CUSTODIAN] ✓ Accepted IssuerMintRequest +``` + +## Canton Integration Notes + +### Key Pair Derivation + +The service uses `tweetnacl` for key derivation: + +```typescript +// CUSTODIAN_PRIVATE_KEY should be a base64-encoded string +const secretKeyBase64 = CUSTODIAN_PRIVATE_KEY!; +const secretKey = decodeBase64(secretKeyBase64); +const keyPairDerived = nacl.box.keyPair.fromSecretKey(secretKey); + +const keyPair: UserKeyPair = { + privateKey: secretKeyBase64, + publicKey: encodeBase64(keyPairDerived.publicKey), +}; +``` + +**Important:** +- `CUSTODIAN_PRIVATE_KEY` must be base64-encoded (not hex) +- Private key is used directly as base64 string (no re-encoding needed) +- Public key is derived and base64-encoded for Canton SDK compatibility + +### Party Allocation + +Custodian party is allocated on-chain: + +```typescript +import { signTransactionHash } from "@canton-network/wallet-sdk"; + +// Generate party from public key +const custodianParty = await sdk.userLedger!.generateExternalParty(keyPair.publicKey); + +// Sign multi-hash +const signedHash = signTransactionHash(custodianParty.multiHash, keyPair.privateKey); + +// Allocate party on ledger +const allocatedParty = await sdk.userLedger!.allocateExternalParty(signedHash, custodianParty); +``` + +**Note:** `signTransactionHash` is now imported directly at the top of the file instead of using dynamic import, which improves performance and enables better tree-shaking. + +### Active Contracts Query + +The service uses the ledger end offset for efficient querying: + +```typescript +const { offset } = await sdk.userLedger!.ledgerEnd(); + +const response = await sdk.userLedger!.activeContracts({ + templateIds: WATCHED_TEMPLATE_IDS, + filterByParty: true, + parties: [custodianPartyId], + offset, +}); +``` + +### Response Processing + +Active contract responses have nested structure: + +```typescript +response.forEach(({ contractEntry }) => { + if (!contractEntry.JsActiveContract) return; + + const { contractId, templateId, createArgument } = + contractEntry.JsActiveContract.createdEvent; + + // Process contract... +}); +``` + +## Future Enhancements + +### 1. Canton Participant Query Store (PQS) + +Currently using polling (`activeContracts` every 60s). **Production should use PQS for real-time event subscriptions:** + +```typescript +// Future: Replace polling with PQS event stream +pqsClient.subscribeToCreateEvents({ + templateIds: WATCHED_TEMPLATE_IDS, + onEvent: (event) => handleRequest(event, wrappedSdk), +}); +``` + +**Benefits:** +- Real-time event detection (no 60s delay) +- Lower resource usage (no constant polling) +- More scalable for high-volume systems + +**Implementation Note:** The comment in `pollForRequests()` marks this as a future improvement. + +### 2. File-Based State Persistence + +Track processed contracts in a file for service restarts: + +```typescript +// Load state on startup +const processedContracts = await loadProcessedContracts(); + +// Persist after each approval +await persistProcessedContracts(processedContracts); +``` + +### 3. Advanced Approval Logic + +Replace always-approve mock with configurable rules: + +```typescript +interface ApprovalRule { + type: 'amount' | 'party' | 'time'; + condition: any; +} + +async function shouldApprove(request: Request): Promise { + // Check amount limits + // Verify party whitelist + // Validate time windows + // Call external KYC/AML APIs + return true/false; +} +``` + +### 4. Metrics and Monitoring + +Add Prometheus-compatible metrics: + +```typescript +const metrics = { + totalProcessed: 0, + totalApproved: 0, + totalFailed: 0, + byType: Map, + averageProcessingTime: number, +}; +``` + +### 5. Multi-Custodian Coordination + +For distributed deployments: +- Shared state (Redis, database) +- Leader election (only one custodian processes each request) +- Load balancing across multiple custodians + +## Common Issues and Troubleshooting + +### "CUSTODIAN_PRIVATE_KEY environment variable is required" + +**Cause:** `.env` file missing or empty + +**Solution:** +```bash +cd packages/custodian-sdk +cp .env.example .env +# Edit .env and add your private key +``` + +### "Failed to generate custodian party" + +**Causes:** +- Localnet not running +- Canton ledger not accessible +- Network connectivity issue + +**Solution:** +```bash +# Ensure localnet is running +pnpm start:localnet + +# Check ledger is accessible +curl http://localhost:7575/health +``` + +### No requests being detected + +**Causes:** +- Token-app not creating requests +- Custodian not configured as issuer/admin +- Polling interval hasn't elapsed + +**Debugging:** +1. Check token-app is running and creating requests +2. Verify custodian party ID matches issuer/admin in requests +3. Wait up to 60 seconds for next poll cycle +4. Check custodian logs for errors + +### Type errors in handler map + +**Cause:** Missing or incorrect handler function type annotations + +**Solution:** Ensure each handler in `REQUEST_HANDLER` has proper type annotations: +```typescript +const REQUEST_HANDLER = { + [issuerMintRequestTemplateId]: async ( + request: Request, // Type the request parameter + wrappedSdk: WrappedSdkWithKeyPair + ) => { + await custodianApi.approveMint(request); + await wrappedSdk.issuerMintRequest.accept(request.contractId); + }, +} satisfies Record>; +``` + +## Dependencies + +### Runtime Dependencies +- `@canton-network/wallet-sdk` (^0.16.0) - Canton ledger interaction +- `@denotecapital/token-sdk` (workspace:*) - Token/bond operations +- `dotenv` (^16.4.7) - Environment variable management +- `tweetnacl` (^1.0.3) - Cryptographic key operations +- `tweetnacl-util` (^0.15.1) - Base64 encoding utilities + +### Dev Dependencies +- `typescript` (5.8.2) - TypeScript compiler +- `tsx` (^4.19.2) - Fast TypeScript execution with watch mode +- `@veraswap/tsconfig` - Shared TypeScript configuration +- `@veraswap/eslint-config` - Shared ESLint configuration +- `@veraswap/esbuild-config` - Shared esbuild configuration + +## Related Documentation + +- **Token SDK**: `packages/token-sdk/CLAUDE.md` - Wrapped SDK functions and patterns +- **Token App**: `packages/token-app/README.md` - Web UI integration +- **Root Workspace**: `CLAUDE.md` - Monorepo overview and architecture + +## Key Files + +- `src/index.ts` - Main service implementation (286 lines) + - Well-organized imports (Canton SDK → Token SDK → External libs → Local) + - Handler map pattern for type-safe request processing + - Clean function decomposition with early destructuring + - Direct imports for better performance +- `src/custodianApi.ts` - Mock external API SDK (119 lines) +- `src/types/Request.ts` - Request type definitions (8 lines) +- `.env.example` - Environment variable template +- `package.json` - Dependencies and scripts +- `README.md` - User-facing documentation + +## Contributing Guidelines + +When modifying the custodian service: + +1. **Maintain functional style** - Avoid adding classes +2. **Keep it simple** - Single file main implementation +3. **Import organization** - Follow the established pattern: + - Canton SDK types first + - Canton SDK functions + - Token SDK imports (alphabetically) + - External libraries + - Local imports +4. **Handler map pattern** - Add new request types to `REQUEST_HANDLER` map with proper type annotations +5. **Type safety** - Use `RequestHandler` type for new handlers, include `satisfies` clause +6. **Code style** - Use early destructuring for cleaner code +7. **Avoid dynamic imports** - Import directly at top for better tree-shaking +8. **Mock API** - Keep external API calls in `custodianApi.ts` +9. **Logging** - Use `[CUSTODIAN]` and `[CUSTODIAN_API]` prefixes +10. **Error handling** - Log errors, don't crash, retry on next poll +11. **Documentation** - Update README.md and CLAUDE.md for significant changes diff --git a/packages/custodian-sdk/README.md b/packages/custodian-sdk/README.md new file mode 100644 index 0000000..a965131 --- /dev/null +++ b/packages/custodian-sdk/README.md @@ -0,0 +1,246 @@ +# Custodian SDK + +Automated custodian service for Canton ledger request contracts. This service polls the Canton ledger every 60 seconds to detect new request contracts (mint, transfer, burn, bond lifecycle) and automatically approves them after mocking an external API call. + +## Overview + +The custodian service is designed to work alongside the `@packages/token-app/` demo: + +1. **User creates request** via token-app web UI (e.g., mint request) +2. **Custodian detects** request on next poll cycle (every 60s) +3. **Mock API call** simulates external approval (1s delay) +4. **Custodian accepts** request using token-sdk +5. **User sees result** in token-app (e.g., tokens appear in balance) + +## Request Types Handled + +- **Token mint requests** (IssuerMintRequest) - Receiver proposes, issuer accepts +- **Token transfer requests** (TransferRequest) - Sender proposes, admin accepts +- **Token burn requests** (IssuerBurnRequest) - Owner proposes, issuer accepts +- **Bond mint requests** (BondIssuerMintRequest) - Receiver proposes, issuer accepts +- **Bond transfer requests** (BondTransferRequest) - Sender proposes, admin accepts +- **Bond lifecycle claims** (BondLifecycleClaimRequest) - Holder proposes, issuer accepts + +## Prerequisites + +Before running the custodian service, ensure: + +1. **Localnet is running**: `pnpm start:localnet` from monorepo root +2. **Custodian private key**: You need a custodian private key (64-char hex string) + +## Setup + +### 1. Environment Configuration + +Copy the example environment file and add your custodian private key: + +```bash +cd packages/custodian-sdk +cp .env.example .env +``` + +Edit `.env` and set your custodian private key: + +``` +CUSTODIAN_PRIVATE_KEY=your_64_char_hex_private_key_here +``` + +### 2. Install Dependencies + +From the monorepo root: + +```bash +pnpm install +``` + +## Usage + +### Start the Custodian Service + +From the monorepo root: + +```bash +pnpm -C packages/custodian-sdk dev +``` + +Or from within the package directory: + +```bash +cd packages/custodian-sdk +pnpm dev +``` + +**Expected Output:** + +``` +[CUSTODIAN] Service started +[CUSTODIAN] Party ID: party::custodian::12345... +[CUSTODIAN] Polling every 60000ms +[CUSTODIAN] Polling for new requests... +[CUSTODIAN] No new requests +``` + +### Integration with Token-App + +1. Start the localnet (if not already running): + ```bash + pnpm start:localnet + ``` + +2. Start the token-app in a separate terminal: + ```bash + pnpm -C packages/token-app dev + ``` + +3. Start the custodian service in another terminal: + ```bash + pnpm -C packages/custodian-sdk dev + ``` + +4. Open the token-app in your browser (usually http://localhost:3000) + +5. Create a mint request via the web UI + +6. Watch the custodian service logs - within 60 seconds, you'll see: + ``` + [CUSTODIAN] Found 1 new request(s) + [CUSTODIAN] Processing #minimal-token:MyToken.IssuerMintRequest:IssuerMintRequest + [CUSTODIAN] Contract ID: 00abc123... + [CUSTODIAN_API] Calling external API to approve mint... + [CUSTODIAN_API] Request ID: 00abc123... + [CUSTODIAN_API] Receiver: party::alice::67890... + [CUSTODIAN_API] Amount: 100 + [CUSTODIAN_API] ✓ Mint approved + [CUSTODIAN] ✓ Accepted IssuerMintRequest + ``` + +7. Check the token-app - tokens should appear in the user's balance + +## Configuration + +### Polling Frequency + +The service polls the ledger every 60 seconds (60000ms). This is hard-coded in `src/index.ts`: + +```typescript +const POLLING_FREQUENCY_MS = 60000; // 1 minute +``` + +To change the polling frequency, edit this constant and restart the service. + +### API Mock Delay + +The mock external API call simulates a 1-second delay. This is hard-coded in `src/index.ts`: + +```typescript +const API_MOCK_DELAY_MS = 1000; // 1 second +``` + +### Watched Template IDs + +The service watches 6 contract template IDs. These are imported from `@denotecapital/token-sdk`: + +- `issuerMintRequestTemplateId` +- `transferRequestTemplateId` +- `issuerBurnRequestTemplateId` +- `bondIssuerMintRequestTemplateId` +- `bondTransferRequestTemplateId` +- `bondLifecycleClaimRequestTemplateId` + +## Architecture + +The service uses a simple functional approach: + +- **Single file**: All logic is in `src/index.ts` +- **No classes**: Uses plain functions for simplicity +- **Switch statement**: Handles different request types based on templateId +- **In-memory state**: Tracks processed contracts in a Set +- **Mock API**: `custodianApi` object provides 6 mock approval methods + +### Key Functions + +- `initializeCustodian()` - Initializes SDK and allocates custodian party +- `pollForRequests()` - Queries activeContracts for all watched template IDs +- `handleRequest()` - Processes a single request (mock API + accept on ledger) +- `startCustodianService()` - Main service loop with setInterval + +### Mock External API + +The `custodianApi` object provides mock methods for each request type: + +```typescript +const custodianApi = { + approveMint, + approveTransfer, + approveBurn, + approveBondMint, + approveBondTransfer, + approveBondLifecycleClaim, +}; +``` + +Each method: +1. Logs request details (receiver, amount, etc.) +2. Simulates a 1-second API delay +3. Returns approval + +**In production**, these methods would be replaced with real external API calls to a custodian system. + +## Scripts + +- `pnpm dev` - Start service with auto-reload on file changes +- `pnpm start` - Start service (no auto-reload) +- `pnpm build` - Compile TypeScript +- `pnpm lint` - Check for linting errors +- `pnpm lint:fix` - Auto-fix linting errors + +## Future Enhancements + +1. **Participant Query Store Integration** - Replace polling with real-time event subscriptions for better performance +2. **Advanced Approval Logic** - Configurable rules (amount limits, party whitelists, time restrictions) +3. **File-Based State Persistence** - Remember processed contracts across service restarts +4. **Metrics Export** - Track approval counts, failure rates, processing times + +## Troubleshooting + +### "CUSTODIAN_PRIVATE_KEY environment variable is required" + +Make sure you've created a `.env` file with your custodian private key: + +```bash +cd packages/custodian-sdk +cp .env.example .env +# Edit .env and add your private key +``` + +### No requests being detected + +1. Check that the localnet is running: `pnpm start:localnet` +2. Check that the token-app is running: `pnpm -C packages/token-app dev` +3. Make sure you're creating requests via the web UI +4. Wait up to 60 seconds for the next poll cycle +5. Check the custodian service logs for any errors + +### "Failed to generate custodian party" + +This usually means: +- The localnet is not running +- The Canton ledger is not accessible +- There's a network connectivity issue + +Make sure the localnet is running and accessible on `localhost:7575`. + +## Development + +The custodian service is built with: + +- **TypeScript** - Type-safe development +- **tsx** - Fast TypeScript execution with auto-reload +- **dotenv** - Environment variable management +- **tweetnacl** - Cryptographic key derivation +- **@denotecapital/token-sdk** - Wrapped SDK functions for Canton ledger +- **@canton-network/wallet-sdk** - Low-level Canton SDK + +## License + +MIT diff --git a/packages/custodian-sdk/esbuild.config.mjs b/packages/custodian-sdk/esbuild.config.mjs new file mode 100644 index 0000000..23e34a6 --- /dev/null +++ b/packages/custodian-sdk/esbuild.config.mjs @@ -0,0 +1,3 @@ +import { buildLib } from "@veraswap/esbuild-config"; + +await buildLib(); diff --git a/packages/custodian-sdk/eslint.config.mjs b/packages/custodian-sdk/eslint.config.mjs new file mode 100644 index 0000000..d49eb8f --- /dev/null +++ b/packages/custodian-sdk/eslint.config.mjs @@ -0,0 +1,8 @@ +import { typecheckedConfigs } from "@veraswap/eslint-config" + +export default [ + ...typecheckedConfigs, + { + files: ["index.mjs"], + } +] diff --git a/packages/custodian-sdk/package.json b/packages/custodian-sdk/package.json new file mode 100644 index 0000000..d45ec8e --- /dev/null +++ b/packages/custodian-sdk/package.json @@ -0,0 +1,41 @@ +{ + "name": "@denotecapital/custodian-sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "build": "npm-run-all -p tsc esbuild", + "build:watch": "npm-run-all -p tsc:watch esbuild:watch", + "clean": "rimraf _cjs _esm _types .turbo", + "depcheck": "depcheck", + "esbuild": "node esbuild.config.mjs", + "esbuild:watch": "ESBUILD_WATCH=true node esbuild.config.mjs", + "lint": "eslint 'src/**/*.ts'", + "lint:fix": "eslint --fix 'src/**/*.ts'", + "madge": "madge src/index.ts -c", + "tsc": "tsc", + "tsc:trace": "tsc --generateTrace lib/trace && analyze-trace lib/trace", + "tsc:watch": "tsc -w" + }, + "dependencies": { + "@canton-network/wallet-sdk": "^0.16.0", + "@denotecapital/token-sdk": "workspace:*", + "dotenv": "^16.4.7", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@veraswap/esbuild-config": "latest", + "@veraswap/eslint-config": "latest", + "@veraswap/tsconfig": "latest", + "depcheck": "^1.4.7", + "madge": "^8.0.0", + "npm-run-all": "^4.1.5", + "rimraf": "^6.0.1", + "tsx": "^4.19.2", + "typescript": "5.8.2" + } +} diff --git a/packages/custodian-sdk/src/custodianApi.ts b/packages/custodian-sdk/src/custodianApi.ts new file mode 100644 index 0000000..95c8102 --- /dev/null +++ b/packages/custodian-sdk/src/custodianApi.ts @@ -0,0 +1,118 @@ +import { + BondIssuerMintRequestParams, + BondLifecycleClaimRequestParams, + BondTransferRequestParams, + IssuerBurnRequestParams, + IssuerMintRequestParams, + TransferRequestParams, +} from "@denotecapital/token-sdk"; +import { Request } from "./types/Request.js"; +import { API_MOCK_DELAY_MS } from "./index.js"; + +// Mock external custodian API SDK +export const custodianApi = { + async approveMint(request: Request) { + console.log(`[CUSTODIAN_API] Calling external API to approve mint...`); + console.log(`[CUSTODIAN_API] Request ID: ${request.contractId}`); + console.log( + `[CUSTODIAN_API] Receiver: ${request.createArgument.receiver}` + ); + console.log( + `[CUSTODIAN_API] Amount: ${request.createArgument.amount}` + ); + + // Simulate API delay + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY_MS)); + + console.log(`[CUSTODIAN_API] ✓ Mint approved`); + return true; + }, + + async approveTransfer(request: Request) { + console.log( + `[CUSTODIAN_API] Calling external API to approve transfer...` + ); + console.log(`[CUSTODIAN_API] Request ID: ${request.contractId}`); + console.log( + `[CUSTODIAN_API] Sender: ${request.createArgument.transfer?.sender}` + ); + console.log( + `[CUSTODIAN_API] Receiver: ${request.createArgument.transfer?.receiver}` + ); + console.log( + `[CUSTODIAN_API] Amount: ${request.createArgument.transfer?.amount}` + ); + + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY_MS)); + + console.log(`[CUSTODIAN_API] ✓ Transfer approved`); + return true; + }, + + async approveBurn(request: Request) { + console.log(`[CUSTODIAN_API] Calling external API to approve burn...`); + console.log(`[CUSTODIAN_API] Request ID: ${request.contractId}`); + console.log(`[CUSTODIAN_API] Owner: ${request.createArgument.owner}`); + console.log( + `[CUSTODIAN_API] Amount: ${request.createArgument.amount}` + ); + + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY_MS)); + + console.log(`[CUSTODIAN_API] ✓ Burn approved`); + return true; + }, + + async approveBondMint(request: Request) { + console.log( + `[CUSTODIAN_API] Calling external API to approve bond mint...` + ); + console.log(`[CUSTODIAN_API] Request ID: ${request.contractId}`); + console.log( + `[CUSTODIAN_API] Receiver: ${request.createArgument.receiver}` + ); + console.log( + `[CUSTODIAN_API] Amount: ${request.createArgument.amount}` + ); + + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY_MS)); + + console.log(`[CUSTODIAN_API] ✓ Bond mint approved`); + return true; + }, + + async approveBondTransfer(request: Request) { + console.log( + `[CUSTODIAN_API] Calling external API to approve bond transfer...` + ); + console.log(`[CUSTODIAN_API] Request ID: ${request.contractId}`); + console.log( + `[CUSTODIAN_API] Sender: ${request.createArgument.transfer?.sender}` + ); + console.log( + `[CUSTODIAN_API] Receiver: ${request.createArgument.transfer?.receiver}` + ); + + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY_MS)); + + console.log(`[CUSTODIAN_API] ✓ Bond transfer approved`); + return true; + }, + + async approveBondLifecycleClaim( + request: Request + ) { + console.log( + `[CUSTODIAN_API] Calling external API to approve bond lifecycle claim...` + ); + console.log(`[CUSTODIAN_API] Request ID: ${request.contractId}`); + console.log( + `[CUSTODIAN_API] Holder: ${request.createArgument.holder}` + ); + + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY_MS)); + + console.log(`[CUSTODIAN_API] ✓ Bond lifecycle claim approved`); + return true; + }, +}; diff --git a/packages/custodian-sdk/src/index.ts b/packages/custodian-sdk/src/index.ts new file mode 100644 index 0000000..f8e5524 --- /dev/null +++ b/packages/custodian-sdk/src/index.ts @@ -0,0 +1,295 @@ +import type { WalletSDK } from "@canton-network/wallet-sdk"; +import { signTransactionHash } from "@canton-network/wallet-sdk"; +import { + ActiveContractResponse, + BondIssuerMintRequestParams, + bondIssuerMintRequestTemplateId, + BondLifecycleClaimRequestParams, + bondLifecycleClaimRequestTemplateId, + BondTransferRequestParams, + bondTransferRequestTemplateId, + getDefaultSdkAndConnect, + getWrappedSdkWithKeyPairForParty, + IssuerBurnRequestParams, + issuerBurnRequestTemplateId, + IssuerMintRequestParams, + issuerMintRequestTemplateId, + MINIMAL_TOKEN_PACKAGE_ID, + TransferRequestParams, + transferRequestTemplateId, + WrappedSdkWithKeyPair, + type UserKeyPair, +} from "@denotecapital/token-sdk"; +import dotenv from "dotenv"; +import nacl from "tweetnacl"; +import naclUtil from "tweetnacl-util"; +import { custodianApi } from "./custodianApi.js"; +import { Request } from "./types/Request.js"; + +// Load environment variables +dotenv.config(); + +// Hard-coded configuration constants +const POLLING_FREQUENCY_MS = 10000; // 10 seconds +export const API_MOCK_DELAY_MS = 1000; // 1 second + +// Validate environment variables +// NOTE: CUSTODIAN_PRIVATE_KEY should be a base64-encoded string +const CUSTODIAN_PRIVATE_KEY = process.env.CUSTODIAN_PRIVATE_KEY; +if (!CUSTODIAN_PRIVATE_KEY) { + throw new Error("CUSTODIAN_PRIVATE_KEY environment variable is required"); +} + +// In-memory set to track processed contracts +const processedContracts = new Set(); + +// Initialize SDK and custodian party +async function initializeCustodian() { + const sdk = await getDefaultSdkAndConnect(); + + // Optional party hint from env + const custodianPartyHint = process.env.CUSTODIAN_PARTY_HINT; + + // Derive public key from private key using tweetnacl + const secretKeyBase64 = CUSTODIAN_PRIVATE_KEY!; + + const secretKey = naclUtil.decodeBase64(secretKeyBase64); + const keyPairDerived = nacl.sign.keyPair.fromSecretKey(secretKey); + + const keyPair: UserKeyPair = { + privateKey: secretKeyBase64, + publicKey: naclUtil.encodeBase64(keyPairDerived.publicKey), + }; + + // Allocate custodian party + const custodianParty = await sdk.userLedger!.generateExternalParty( + keyPair.publicKey, + custodianPartyHint + ); + + if (!custodianParty) throw new Error("Failed to generate custodian party"); + + // Sign and allocate party + const signedHash = signTransactionHash( + custodianParty.multiHash, + keyPair.privateKey + ); + const allocatedParty = await sdk.userLedger!.allocateExternalParty( + signedHash, + custodianParty + ); + + console.log("[CUSTODIAN] Allocated party ID: ", allocatedParty.partyId); + + // Set party ID on SDK + await sdk.setPartyId(allocatedParty.partyId); + + // Get wrapped SDK + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + allocatedParty.partyId, + keyPair + ); + + return { sdk, wrappedSdk, custodianParty: allocatedParty }; +} + +// Poll for new requests +async function pollForRequests(sdk: WalletSDK, custodianPartyId: string) { + // NOTE: Currently using polling approach (activeContracts every 60s). + // In production, migrate to Canton Participant Query Store for + // real-time event subscriptions and better performance. + + const newRequests: Request[] = []; + + const { offset } = await sdk.userLedger!.ledgerEnd(); + + // Query all watched template IDs in a single call + const response = (await sdk.userLedger!.activeContracts({ + // templateIds: WATCHED_TEMPLATE_IDS, + filterByParty: true, + parties: [custodianPartyId], + offset, + })) as ActiveContractResponse[]; + + // Process each contract based on its template ID + response.forEach(({ contractEntry }) => { + if (!contractEntry.JsActiveContract) return; + + const { contractId, templateId, createArgument } = + contractEntry.JsActiveContract.createdEvent; + + if (!processedContracts.has(contractId)) { + newRequests.push({ + contractId, + templateId, + createArgument, + }); + } + }); + + return newRequests; +} + +// Type for a handler function +type RequestHandler> = ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair +) => Promise; + +const REQUEST_HANDLER = { + [issuerMintRequestTemplateId]: async ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair + ) => { + // Call external API for approval + await custodianApi.approveMint(request); + // Accept on ledger + await wrappedSdk.issuerMintRequest.accept(request.contractId); + console.log(`[CUSTODIAN] ✓ Accepted IssuerMintRequest`); + }, + + [transferRequestTemplateId]: async ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair + ) => { + await custodianApi.approveTransfer(request); + await wrappedSdk.transferRequest.accept(request.contractId); + console.log(`[CUSTODIAN] ✓ Accepted TransferRequest`); + }, + + [issuerBurnRequestTemplateId]: async ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair + ) => { + await custodianApi.approveBurn(request); + await wrappedSdk.issuerBurnRequest.accept(request.contractId); + console.log(`[CUSTODIAN] ✓ Accepted IssuerBurnRequest`); + }, + + [bondIssuerMintRequestTemplateId]: async ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair + ) => { + await custodianApi.approveBondMint(request); + await wrappedSdk.bonds.issuerMintRequest.accept(request.contractId); + console.log(`[CUSTODIAN] ✓ Accepted BondIssuerMintRequest`); + }, + + [bondTransferRequestTemplateId]: async ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair + ) => { + await custodianApi.approveBondTransfer(request); + await wrappedSdk.bonds.transferRequest.accept(request.contractId); + console.log(`[CUSTODIAN] ✓ Accepted BondTransferRequest`); + }, + + [bondLifecycleClaimRequestTemplateId]: async ( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair + ) => { + await custodianApi.approveBondLifecycleClaim(request); + await wrappedSdk.bonds.lifecycleClaimRequest.accept(request.contractId); + console.log(`[CUSTODIAN] ✓ Accepted BondLifecycleClaimRequest`); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} satisfies Record>; + +// NOTE: We assume that any template IDs not listed in REQUEST_HANDLER are ignored +// const WATCHED_TEMPLATE_IDS = Object.keys(REQUEST_HANDLER); + +// Handle request based on template ID +async function handleRequest( + request: Request, + wrappedSdk: WrappedSdkWithKeyPair +) { + const { contractId, templateId } = request; + + console.log(`[CUSTODIAN] Processing ${templateId}`); + console.log(`[CUSTODIAN] Contract ID: ${contractId}`); + + const templateIdFormatted = templateId.replace( + MINIMAL_TOKEN_PACKAGE_ID as string, + "#minimal-token" + ); + + // Switch on template ID to determine which handler to use + if (!(templateIdFormatted in REQUEST_HANDLER)) { + console.warn(`[CUSTODIAN] No handler for template ID: ${templateId}`); + processedContracts.add(contractId); + return; + } + + const handler = + REQUEST_HANDLER[templateIdFormatted as keyof typeof REQUEST_HANDLER]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await handler(request as Request, wrappedSdk); + + // Mark as processed + processedContracts.add(contractId); +} + +// Main service loop +async function startCustodianService() { + const { sdk, wrappedSdk, custodianParty } = await initializeCustodian(); + + console.log(`[CUSTODIAN] Service started`); + console.log(`[CUSTODIAN] Party ID: ${custodianParty.partyId}`); + console.log(`[CUSTODIAN] Polling every ${POLLING_FREQUENCY_MS}ms`); + + // Polling loop + async function startPollingLoop() { + while (true) { + // Keep polling indefinitely + try { + console.log(`[CUSTODIAN] Polling for new requests...`); + + const requests = await pollForRequests( + sdk, + custodianParty.partyId + ); + + if (requests.length > 0) { + console.log( + `[CUSTODIAN] Found ${requests.length} new request(s)` + ); + + for (const request of requests) { + try { + await handleRequest(request, wrappedSdk); + } catch (error) { + console.error( + `[CUSTODIAN] Error handling ${request.contractId}:`, + error + ); + // Don't mark as processed - will retry next poll + } + // The interval happens after every handleRequest call + await new Promise((resolve) => + setTimeout(resolve, POLLING_FREQUENCY_MS) + ); + } + } else { + console.log(`[CUSTODIAN] No new requests`); + // If no requests, still wait for the interval before the next poll + await new Promise((resolve) => + setTimeout(resolve, POLLING_FREQUENCY_MS) + ); + } + } catch (error) { + console.error(`[CUSTODIAN] Polling error:`, error); + // If polling itself fails, wait for the interval before retrying the poll + await new Promise((resolve) => + setTimeout(resolve, POLLING_FREQUENCY_MS) + ); + } + } + } + + // Start the polling process + await startPollingLoop(); +} + +// Start the service +startCustodianService().catch(console.error); diff --git a/packages/custodian-sdk/src/types/Request.ts b/packages/custodian-sdk/src/types/Request.ts new file mode 100644 index 0000000..4f6cc0d --- /dev/null +++ b/packages/custodian-sdk/src/types/Request.ts @@ -0,0 +1,7 @@ +import { ContractId } from "@denotecapital/token-sdk"; + +export interface Request> { + contractId: ContractId; + templateId: string; + createArgument: ContractParams; +} diff --git a/packages/custodian-sdk/src/types/index.ts b/packages/custodian-sdk/src/types/index.ts new file mode 100644 index 0000000..2416fee --- /dev/null +++ b/packages/custodian-sdk/src/types/index.ts @@ -0,0 +1 @@ +export * from "./Request.js"; diff --git a/packages/custodian-sdk/tsconfig.json b/packages/custodian-sdk/tsconfig.json new file mode 100644 index 0000000..9614e97 --- /dev/null +++ b/packages/custodian-sdk/tsconfig.json @@ -0,0 +1,6 @@ +{ + "include": [ + "src" + ], + "extends": "@veraswap/tsconfig" +} \ No newline at end of file diff --git a/packages/custodian-sdk/vite.config.ts b/packages/custodian-sdk/vite.config.ts new file mode 100644 index 0000000..3bef263 --- /dev/null +++ b/packages/custodian-sdk/vite.config.ts @@ -0,0 +1,16 @@ +/// +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [], + test: { + //environment: "jsdom", + globals: false, + globalSetup: "vitest.setup.ts", + testTimeout: 60000, + threads: false, + watch: true, + include: ["src/**/*.test.ts"], + //setupFiles: "./src/test/setup.ts", + }, +}); diff --git a/packages/minimal-token/daml/Bond/BondFactory.daml b/packages/minimal-token/daml/Bond/BondFactory.daml index bf53267..201adba 100644 --- a/packages/minimal-token/daml/Bond/BondFactory.daml +++ b/packages/minimal-token/daml/Bond/BondFactory.daml @@ -9,7 +9,6 @@ template BondFactory instrumentId : Text -- e.g., show issuer <> "#Bond" where signatory issuer - -- | Create a bond instrument definition with all terms. -- This is called once by the issuer to define the bond instrument. nonconsuming choice CreateInstrument : ContractId BondInstrument diff --git a/packages/minimal-token/daml/Bond/BondLifecycleClaimRequest.daml b/packages/minimal-token/daml/Bond/BondLifecycleClaimRequest.daml index 5d18cd2..ca09ff4 100644 --- a/packages/minimal-token/daml/Bond/BondLifecycleClaimRequest.daml +++ b/packages/minimal-token/daml/Bond/BondLifecycleClaimRequest.daml @@ -142,7 +142,6 @@ template BondLifecycleClaimRequest Redemption -> do now <- getTime - (lockedBondCid, _, _) <- exercise bondRulesCid BondRules_LockForRedemption with holder = bond.owner inputBondCid = bondHoldingCid diff --git a/packages/minimal-token/daml/Bond/BondLifecycleInstruction.daml b/packages/minimal-token/daml/Bond/BondLifecycleInstruction.daml index 0c6870d..59392e6 100644 --- a/packages/minimal-token/daml/Bond/BondLifecycleInstruction.daml +++ b/packages/minimal-token/daml/Bond/BondLifecycleInstruction.daml @@ -63,7 +63,6 @@ template BondLifecycleInstruction -- Fetch the locked bond locked <- fetch lockedBond - -- Branch based on event type case eventType of CouponPayment -> do @@ -103,7 +102,6 @@ template BondLifecycleInstruction Some ctx -> assertMsg "Bond must be locked for redemption" (ctx == "redemption") None -> fail "Bond lock context missing" - let totalPrincipalAmount = amount * locked.bond.amount -- Unlock the bond diff --git a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml index a752c50..6bd07c0 100644 --- a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml +++ b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml @@ -327,6 +327,11 @@ testBondFullLifecycle = script do -- Verify effect amount: should be 1025 per bond (notional 1000 + final coupon 25) assertMsg "Effect amount should be 1025.0 per bond" (effect2.amount == 1025.0) + Some effect2 <- queryContractId @BondLifecycleEffect charlie effectCid2 + let redemptionDate = effect2.eventDate + -- Verify effect amount: should be 1025 per bond (notional 1000 + final coupon 25) + assertMsg "Effect amount should be 1025.0 per bond" (effect2.amount == 1025.0) + claimRequestCid2 <- submit bob do createCmd BondLifecycleClaimRequest.BondLifecycleClaimRequest with effectCid = effectCid2 diff --git a/packages/token-app/app/api/wallet/bond/disclosure/route.ts b/packages/token-app/app/api/wallet/bond/disclosure/route.ts new file mode 100644 index 0000000..94ff407 --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/disclosure/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getWrappedSdkWithKeyPairForParty, + keyPairFromSeed, +} from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const bondInstrumentCid = searchParams.get("bondInstrumentCid"); + const adminPartyId = searchParams.get("adminPartyId"); + + if (!bondInstrumentCid || !adminPartyId) { + return NextResponse.json( + { error: "Missing bondInstrumentCid or adminPartyId" }, + { status: 400 } + ); + } + + // TODO: change to not hardcode the custodian seed + const keyPair = keyPairFromSeed("custodian"); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + adminPartyId, + keyPair + ); + + const disclosure = + await wrappedSdk.bonds.disclosure.getInstrumentDisclosure( + bondInstrumentCid + ); + + return NextResponse.json({ disclosure }); + } catch (error) { + console.error("Error getting bond disclosure:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/factory/instrument/route.ts b/packages/token-app/app/api/wallet/bond/factory/instrument/route.ts new file mode 100644 index 0000000..615f2aa --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/factory/instrument/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyPairFromSeed, + getWrappedSdkWithKeyPairForParty, +} from "@denotecapital/token-sdk"; + +export async function POST(request: NextRequest) { + try { + const { + instrumentId, + notional, + couponRate, + couponFrequency, + maturityDate, + partyId, + seed, + } = await request.json(); + + if ( + !instrumentId || + notional === undefined || + couponRate === undefined || + couponFrequency === undefined || + !maturityDate || + !partyId || + !seed + ) { + return NextResponse.json( + { + error: "Missing required fields", + }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + partyId, + keyPair + ); + + const bondFactoryCid = await wrappedSdk.bonds.factory.getOrCreate( + instrumentId + ); + + const bondInstrumentCid = + await wrappedSdk.bonds.factory.createInstrument( + bondFactoryCid, + instrumentId, + { + depository: partyId, + notional, + couponRate, + couponFrequency, + maturityDate, + } + ); + + return NextResponse.json({ + bondInstrumentCid, + bondFactoryCid, + }); + } catch (error) { + console.error("Error creating bond instrument:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/factory/instruments/route.ts b/packages/token-app/app/api/wallet/bond/factory/instruments/route.ts new file mode 100644 index 0000000..cfc61fb --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/factory/instruments/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getSdkForParty, + bondInstrumentTemplateId, + ActiveContractResponse, +} from "@denotecapital/token-sdk"; + +interface BondInstrumentParams { + issuer: string; + instrumentId: string; + maturityDate: string; + couponRate: number; + couponFrequency: number; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const custodianPartyId = searchParams.get("custodianPartyId"); + + if (!custodianPartyId) { + return NextResponse.json( + { error: "Missing custodianPartyId" }, + { status: 400 } + ); + } + + const sdk = await getSdkForParty(custodianPartyId); + const ledger = sdk.userLedger!; + const end = await ledger.ledgerEnd(); + + const activeContracts = (await ledger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [custodianPartyId], + templateIds: [bondInstrumentTemplateId], + })) as ActiveContractResponse[]; + + const instruments = activeContracts + .map((contract) => { + const jsActive = contract.contractEntry.JsActiveContract; + if (!jsActive) return null; + + const createArg = jsActive.createdEvent.createArgument; + const contractId = jsActive.createdEvent.contractId; + + if (createArg.issuer !== custodianPartyId) return null; + + const instrumentId = createArg.instrumentId; + const nameMatch = instrumentId.match(/^[^#]+#(.+)$/); + const name = nameMatch ? nameMatch[1] : instrumentId; + + return { + name, + instrumentId, + custodianPartyId, + bondInstrumentCid: contractId, + maturityDate: createArg.maturityDate, + couponRate: createArg.couponRate, + couponFrequency: createArg.couponFrequency, + }; + }) + .filter((inst): inst is NonNullable => inst !== null); + + return NextResponse.json({ instruments }); + } catch (error) { + console.error("Error getting bond instruments:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/lifecycle/claim-request/route.ts b/packages/token-app/app/api/wallet/bond/lifecycle/claim-request/route.ts new file mode 100644 index 0000000..b39d87e --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/lifecycle/claim-request/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyPairFromSeed, + getWrappedSdkWithKeyPairForParty, + getSdkForParty, + bondLifecycleClaimRequestTemplateId, + ActiveContractResponse, +} from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + const issuer = searchParams.get("issuer"); + + if (!partyId || !issuer) { + return NextResponse.json( + { error: "Missing partyId or issuer" }, + { status: 400 } + ); + } + + const sdk = await getSdkForParty(partyId); + const ledger = sdk.userLedger!; + const end = await ledger.ledgerEnd(); + + const activeContracts = (await ledger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [bondLifecycleClaimRequestTemplateId], + })) as ActiveContractResponse[]; + + const requests = activeContracts + .map((contract) => { + const jsActive = contract.contractEntry.JsActiveContract; + if (!jsActive) return null; + + const createArg = jsActive.createdEvent.createArgument as { + effectCid: string; + bondHoldingCid: string; + holder: string; + issuer: string; + }; + const contractId = jsActive.createdEvent.contractId; + + if (createArg.issuer !== issuer) return null; + + return { + contractId, + effectCid: createArg.effectCid, + bondHoldingCid: createArg.bondHoldingCid, + holder: createArg.holder, + issuer: createArg.issuer, + }; + }) + .filter((req): req is NonNullable => req !== null); + + return NextResponse.json({ requests }); + } catch (error) { + console.error("Error getting lifecycle claim requests:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { + effectCid, + bondHoldingCid, + bondRulesCid, + bondInstrumentCid, + currencyTransferFactoryCid, + issuerCurrencyHoldingCid, + holder, + issuer, + seed, + disclosure, + } = await request.json(); + + if ( + !effectCid || + !bondHoldingCid || + !bondRulesCid || + !bondInstrumentCid || + !currencyTransferFactoryCid || + !issuerCurrencyHoldingCid || + !holder || + !issuer || + !seed + ) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + holder, + keyPair + ); + + await wrappedSdk.bonds.lifecycleClaimRequest.create( + { + effectCid, + bondHoldingCid, + bondRulesCid, + bondInstrumentCid, + currencyTransferFactoryCid, + issuerCurrencyHoldingCid, + holder, + issuer, + }, + disclosure ? [disclosure] : undefined + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error creating lifecycle claim request:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/lifecycle/effect/route.ts b/packages/token-app/app/api/wallet/bond/lifecycle/effect/route.ts new file mode 100644 index 0000000..e51dbf7 --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/lifecycle/effect/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getWrappedSdkForParty } from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + + if (!partyId) { + return NextResponse.json( + { error: "Missing partyId" }, + { status: 400 } + ); + } + + const wrappedSdk = await getWrappedSdkForParty(partyId); + + try { + const effects = await wrappedSdk.bonds.lifecycleEffect.getAll( + partyId + ); + return NextResponse.json(effects); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Bond lifecycle effect not found") + ) { + return NextResponse.json([], { status: 200 }); + } + throw error; + } + } catch (error) { + console.error("Error getting lifecycle effect:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/lifecycle/infrastructure/route.ts b/packages/token-app/app/api/wallet/bond/lifecycle/infrastructure/route.ts new file mode 100644 index 0000000..3507e31 --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/lifecycle/infrastructure/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyPairFromSeed, + getWrappedSdkWithKeyPairForParty, +} from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + const seed = searchParams.get("seed"); + const currencyInstrumentId = searchParams.get("currencyInstrumentId"); + + if (!partyId || !seed || !currencyInstrumentId) { + return NextResponse.json( + { error: "Missing partyId, seed, or currencyInstrumentId" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + partyId, + keyPair + ); + + const bondRulesCid = await wrappedSdk.bonds.bondRules.getLatest(); + if (!bondRulesCid) { + throw new Error( + "Bond rules not found. Please run setup script first." + ); + } + + const currencyRulesCid = await wrappedSdk.tokenRules.getLatest(); + if (!currencyRulesCid) { + throw new Error( + "Currency rules not found. Please run setup script first." + ); + } + + const currencyTransferFactoryCid = + await wrappedSdk.transferFactory.getLatest(currencyRulesCid); + if (!currencyTransferFactoryCid) { + throw new Error( + "Currency transfer factory not found. Please run setup script first." + ); + } + + const currencyBalance = await wrappedSdk.balances.getByInstrumentId({ + owner: partyId, + instrumentId: { admin: partyId, id: currencyInstrumentId }, + }); + + const currencyHoldings = currencyBalance.utxos.map((u) => u.contractId); + + return NextResponse.json({ + bondRulesCid, + currencyTransferFactoryCid, + currencyHoldings, + }); + } catch (error) { + console.error("Error getting bond lifecycle infrastructure:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/lifecycle/instruction/route.ts b/packages/token-app/app/api/wallet/bond/lifecycle/instruction/route.ts new file mode 100644 index 0000000..bebda5c --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/lifecycle/instruction/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyPairFromSeed, + getWrappedSdkWithKeyPairForParty, + getWrappedSdkForParty, +} from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + + if (!partyId) { + return NextResponse.json( + { error: "Missing partyId" }, + { status: 400 } + ); + } + + const wrappedSdk = await getWrappedSdkForParty(partyId); + const instructions = await wrappedSdk.bonds.lifecycleInstruction.getAll( + partyId + ); + return NextResponse.json(instructions); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Bond lifecycle instruction not found") + ) { + return NextResponse.json([], { status: 200 }); + } + console.error("Error getting lifecycle instruction:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { contractId, partyId, seed, disclosure } = await request.json(); + + if (!contractId || !partyId || !seed) { + return NextResponse.json( + { error: "Missing contractId, partyId, or seed" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + partyId, + keyPair + ); + + await wrappedSdk.bonds.lifecycleInstruction.process( + contractId, + disclosure ? [disclosure] : undefined + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error processing lifecycle instruction:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/lifecycle/process/route.ts b/packages/token-app/app/api/wallet/bond/lifecycle/process/route.ts new file mode 100644 index 0000000..50be4c7 --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/lifecycle/process/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyPairFromSeed, + getWrappedSdkWithKeyPairForParty, +} from "@denotecapital/token-sdk"; + +export async function POST(request: NextRequest) { + let eventType: string | undefined; + let targetInstrumentId: string | undefined; + let targetVersion: string | undefined; + let bondCid: string | undefined; + + try { + const { + lifecycleRuleCid, + eventType: eventTypeParam, + targetInstrumentId: targetInstrumentIdParam, + targetVersion: targetVersionParam, + bondCid: bondCidParam, + partyId, + seed, + } = await request.json(); + + eventType = eventTypeParam; + targetInstrumentId = targetInstrumentIdParam; + targetVersion = targetVersionParam; + bondCid = bondCidParam; + + if ( + !lifecycleRuleCid || + !eventTypeParam || + !targetInstrumentIdParam || + !targetVersionParam || + !bondCidParam || + !partyId || + !seed + ) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + if (eventTypeParam !== "coupon" && eventTypeParam !== "redemption") { + return NextResponse.json( + { error: "Event type must be 'coupon' or 'redemption'" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + partyId, + keyPair + ); + + if (eventTypeParam === "coupon") { + await wrappedSdk.bonds.lifecycleRule.processCouponPaymentEvent( + lifecycleRuleCid, + { + targetInstrumentId: targetInstrumentIdParam, + targetVersion: targetVersionParam, + bondCid: bondCidParam, + } + ); + } else { + await wrappedSdk.bonds.lifecycleRule.processRedemptionEvent( + lifecycleRuleCid, + { + targetInstrumentId: targetInstrumentIdParam, + targetVersion: targetVersionParam, + bondCid: bondCidParam, + } + ); + } + + const effect = await wrappedSdk.bonds.lifecycleEffect.getLatest( + partyId + ); + + return NextResponse.json({ + effectCid: effect.contractId, + producedVersion: effect.producedVersion, + }); + } catch (error) { + console.error("Error processing lifecycle event:", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error("Full error details:", { + errorMessage, + eventType, + targetInstrumentId, + targetVersion, + bondCid, + }); + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/packages/token-app/app/api/wallet/bond/lifecycle/rule/route.ts b/packages/token-app/app/api/wallet/bond/lifecycle/rule/route.ts new file mode 100644 index 0000000..9a79e28 --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/lifecycle/rule/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyPairFromSeed, + getWrappedSdkWithKeyPairForParty, +} from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + const seed = searchParams.get("seed"); + + if (!partyId || !seed) { + return NextResponse.json( + { error: "Missing partyId or seed" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + partyId, + keyPair + ); + + // TODO: change to not hardcode the currency instrument id + const currencyInstrumentId = `${partyId}#Currency`; + + const lifecycleRuleCid = await wrappedSdk.bonds.lifecycleRule.getLatest( + { + depository: partyId, + currencyInstrumentId: { + admin: partyId, + id: currencyInstrumentId, + }, + } + ); + + return NextResponse.json({ + lifecycleRuleCid: lifecycleRuleCid || null, + }); + } catch (error) { + console.error("Error getting lifecycle rule:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/mint-request/route.ts b/packages/token-app/app/api/wallet/bond/mint-request/route.ts new file mode 100644 index 0000000..b5a0b90 --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/mint-request/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyPairFromSeed, + getWrappedSdkWithKeyPairForParty, + getSdkForParty, + bondIssuerMintRequestTemplateId, + ActiveContractResponse, +} from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + const issuer = searchParams.get("issuer"); + + if (!partyId || !issuer) { + return NextResponse.json( + { error: "Missing partyId or issuer" }, + { status: 400 } + ); + } + + const sdk = await getSdkForParty(partyId); + const ledger = sdk.userLedger!; + const end = await ledger.ledgerEnd(); + + const activeContracts = (await ledger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [bondIssuerMintRequestTemplateId], + })) as ActiveContractResponse[]; + + const requests = activeContracts + .map((contract) => { + const jsActive = contract.contractEntry.JsActiveContract; + if (!jsActive) return null; + + const createArg = jsActive.createdEvent.createArgument as { + instrumentCid: string; + issuer: string; + receiver: string; + amount: number; + }; + const contractId = jsActive.createdEvent.contractId; + + // Filter by issuer if provided + if (createArg.issuer !== issuer) return null; + + return { + contractId, + instrumentCid: createArg.instrumentCid, + issuer: createArg.issuer, + receiver: createArg.receiver, + amount: createArg.amount, + }; + }) + .filter((req): req is NonNullable => req !== null); + + return NextResponse.json({ requests }); + } catch (error) { + console.error("Error getting bond mint requests:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { instrumentCid, issuer, receiver, amount, seed } = + await request.json(); + + if ( + !instrumentCid || + !issuer || + !receiver || + amount === undefined || + !seed + ) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + receiver, + keyPair + ); + + await wrappedSdk.bonds.issuerMintRequest.create({ + instrumentCid, + issuer, + receiver, + amount, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error creating bond mint request:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/bond/version/route.ts b/packages/token-app/app/api/wallet/bond/version/route.ts new file mode 100644 index 0000000..be6b1e1 --- /dev/null +++ b/packages/token-app/app/api/wallet/bond/version/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getWrappedSdkForParty } from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + const contractId = searchParams.get("contractId"); + + if (!partyId || !contractId) { + return NextResponse.json( + { error: "Missing partyId or contractId" }, + { status: 400 } + ); + } + + const wrappedSdk = await getWrappedSdkForParty(partyId); + const bond = await wrappedSdk.bonds.bond.get(contractId); + + if (!bond) { + return NextResponse.json( + { error: "Bond contract not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ version: bond.version }); + } catch (error) { + console.error("Error getting bond version:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/bond/page.tsx b/packages/token-app/app/bond/page.tsx new file mode 100644 index 0000000..f468042 --- /dev/null +++ b/packages/token-app/app/bond/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import { ConnectionStatus } from "@/components/ConnectionStatus"; +import { BondPartyView } from "@/components/BondPartyView"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; + +const PARTIES = ["custodian", "alice"] as const; + +export default function BondPage() { + const [selectedParty, setSelectedParty] = useState("custodian"); + const [partyIds, setPartyIds] = useState>({ + custodian: null, + alice: null, + }); + + const handlePartyCreated = (partyId: string, partyName: string) => { + setPartyIds((prev) => ({ + ...prev, + [partyName]: partyId, + })); + }; + + return ( +
+
+
+
+

+ Bond Lifecycle Demo +

+ +
+ +
+
+ {PARTIES.map((party) => ( + + ))} +
+ + + +
+
+ + + + +
+
+ ); +} diff --git a/packages/token-app/app/layout.tsx b/packages/token-app/app/layout.tsx index fbe38d8..a275434 100644 --- a/packages/token-app/app/layout.tsx +++ b/packages/token-app/app/layout.tsx @@ -14,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Token Management Demo", + description: "Created by Denote Capital", }; export default function RootLayout({ diff --git a/packages/token-app/app/page.tsx b/packages/token-app/app/page.tsx index 85b63c2..03d16e3 100644 --- a/packages/token-app/app/page.tsx +++ b/packages/token-app/app/page.tsx @@ -6,6 +6,7 @@ import { PartyView } from "@/components/PartyView"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import Link from "next/link"; const PARTIES = ["custodian", "alice", "bob"] as const; @@ -35,24 +36,29 @@ export default function Home() { -
- {PARTIES.map((party) => ( - - ))} +
+
+ {PARTIES.map((party) => ( + + ))} +
+ + +
diff --git a/packages/token-app/components/BondCustodianView.tsx b/packages/token-app/components/BondCustodianView.tsx new file mode 100644 index 0000000..3b675bd --- /dev/null +++ b/packages/token-app/components/BondCustodianView.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useBondInstruments } from "@/lib/queries/bondInstruments"; +import { + useBondLifecycle, + useAllLifecycleEffects, +} from "@/lib/queries/bondLifecycle"; +import { + useQuery, + useQueries, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; + +interface BondCustodianViewProps { + partyId: string; + partyName: string; +} + +export function BondCustodianView({ + partyId, + partyName, +}: BondCustodianViewProps) { + const [bondName, setBondName] = useState("Bond"); + const [notional, setNotional] = useState("1000"); + const [couponRate, setCouponRate] = useState("0.05"); + const [couponFrequency, setCouponFrequency] = useState("2"); + const [maturityDays, setMaturityDays] = useState("10"); + + const queryClient = useQueryClient(); + const bondInstrumentsQuery = useBondInstruments(partyId); + const bondInstruments = bondInstrumentsQuery.data || []; + + const createInstrument = useMutation({ + mutationFn: async (params: { + instrumentId: string; + notional: number; + couponRate: number; + couponFrequency: number; + maturityDate: string; + partyId: string; + seed: string; + currencyInstrumentId?: string; + }) => { + const response = await fetch( + "/api/wallet/bond/factory/instrument", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to create bond instrument" + ); + } + + return response.json() as Promise<{ + bondInstrumentCid: string; + bondFactoryCid: string; + }>; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["bondFactory"], + }); + queryClient.invalidateQueries({ + queryKey: ["bondInstruments"], + }); + queryClient.invalidateQueries({ + queryKey: ["lifecycleRule"], + }); + }, + }); + const bondLifecycle = useBondLifecycle(); + const allLifecycleEffects = useAllLifecycleEffects(partyId); + + const allBalanceQueries = useQueries({ + queries: bondInstruments.map((instrument) => ({ + queryKey: ["allBalances", partyId, instrument.instrumentId], + queryFn: async () => { + const params = new URLSearchParams({ + partyId, + admin: partyId, + id: instrument.instrumentId, + }); + const response = await fetch( + `/api/wallet/balances/all?${params}` + ); + return response.json() as Promise<{ + balances: Array<{ + party: string; + total: number; + utxos: Array<{ amount: number; contractId: string }>; + }>; + }>; + }, + enabled: !!partyId && !!instrument.instrumentId, + })), + }); + + const lifecycleRuleQuery = useQuery({ + queryKey: ["lifecycleRule", partyId], + queryFn: async () => { + const params = new URLSearchParams({ + partyId, + seed: partyName, + }); + const response = await fetch( + `/api/wallet/bond/lifecycle/rule?${params}` + ); + const data = await response.json(); + return data.lifecycleRuleCid || null; + }, + enabled: !!partyId, + }); + + const handleCreateBondInstrument = async () => { + try { + const maturityDate = new Date(); + maturityDate.setSeconds( + maturityDate.getSeconds() + parseInt(maturityDays) + ); + const instrumentId = `${partyId}#${bondName.trim()}`; + + await createInstrument.mutateAsync({ + instrumentId, + notional: parseFloat(notional), + couponRate: parseFloat(couponRate), + couponFrequency: parseInt(couponFrequency), + maturityDate: maturityDate.toISOString(), + partyId, + seed: partyName, + }); + queryClient.invalidateQueries({ + queryKey: ["allBalances"], + }); + toast.success("Bond instrument created!"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to create bond instrument" + ); + } + }; + + const readyToProcess = useMemo(() => { + if (!lifecycleRuleQuery.data) return []; + + const items: Array<{ + instrumentId: string; + instrumentName: string; + bondCid: string; + totalOutstanding: number; + holders: number; + eventType: "coupon" | "redemption"; + maturityDate: Date; + }> = []; + + bondInstruments.forEach((instrument, index) => { + const balanceData = allBalanceQueries[index]?.data; + if (!balanceData?.balances?.length) return; + + const firstBond = balanceData.balances + .flatMap((b) => b.utxos) + .find((utxo) => utxo); + if (!firstBond) return; + + const isMatured = new Date(instrument.maturityDate) <= new Date(); + const eventType = isMatured ? "Redemption" : "CouponPayment"; + + const hasEffect = allLifecycleEffects.data?.some( + (e) => + e.targetInstrumentId === instrument.instrumentId && + e.eventType === eventType + ); + if (hasEffect) return; + + const totalOutstanding = balanceData.balances.reduce( + (sum, b) => sum + b.total, + 0 + ); + const holders = balanceData.balances.filter( + (b) => b.total > 0 + ).length; + + items.push({ + instrumentId: instrument.instrumentId, + instrumentName: instrument.name, + bondCid: firstBond.contractId, + totalOutstanding, + holders, + eventType: isMatured ? "redemption" : "coupon", + maturityDate: new Date(instrument.maturityDate), + }); + }); + + return items; + }, [ + bondInstruments, + allBalanceQueries, + lifecycleRuleQuery.data, + allLifecycleEffects.data, + ]); + + const handleProcessLifecycleEvent = async ( + instrumentId: string, + bondCid: string, + eventType: "coupon" | "redemption" + ) => { + try { + // Fetch bond version on-demand + const versionResponse = await fetch( + `/api/wallet/bond/version?partyId=${partyId}&contractId=${bondCid}` + ); + const { version } = versionResponse.ok + ? await versionResponse.json() + : { version: "0" }; + + await bondLifecycle.processEvent.mutateAsync({ + lifecycleRuleCid: lifecycleRuleQuery.data!, + eventType, + targetInstrumentId: instrumentId, + targetVersion: version, + bondCid, + partyId, + seed: partyName, + }); + + queryClient.invalidateQueries({ + queryKey: ["allBalances"], + }); + queryClient.invalidateQueries({ + queryKey: ["allLifecycleEffects", partyId], + }); + queryClient.invalidateQueries({ + queryKey: ["bondFactory"], + }); + + toast.success( + `${ + eventType === "coupon" ? "Coupon payment" : "Redemption" + } processed` + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to process lifecycle event" + ); + } + }; + + return ( +
+ + + Create Bond Instrument + + +
+ + setBondName(e.target.value)} + /> +
+
+
+ + setNotional(e.target.value)} + /> +
+
+ + setCouponRate(e.target.value)} + /> +
+
+ + + setCouponFrequency(e.target.value) + } + /> +
+
+ + + setMaturityDays(e.target.value) + } + /> +
+
+ +
+
+ + + + Issued Bonds + + +
+ {bondInstruments.map((instrument) => ( +
+

{instrument.name}

+

+ Rate:{" "} + {(instrument.couponRate * 100).toFixed(2)}% + | Freq: {instrument.couponFrequency}x | + Maturity:{" "} + {new Date( + instrument.maturityDate + ).toLocaleDateString()} +

+
+ ))} +
+
+
+ + + + Process Lifecycle Events + + + {readyToProcess.length > 0 ? ( +
+ {readyToProcess.map((item) => ( +
+
+
+

+ {item.instrumentName} +

+

+ {item.totalOutstanding} units + outstanding • {item.holders}{" "} + {item.holders === 1 + ? "holder" + : "holders"} +

+

+ Maturity:{" "} + {item.maturityDate.toLocaleDateString()} +

+
+ +
+
+ ))} +
+ ) : ( +

+ No bonds ready for processing. +

+ )} +
+
+
+ ); +} diff --git a/packages/token-app/components/BondPartyView.tsx b/packages/token-app/components/BondPartyView.tsx new file mode 100644 index 0000000..33938e4 --- /dev/null +++ b/packages/token-app/components/BondPartyView.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, Copy, User, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { BondCustodianView } from "./BondCustodianView"; +import { BondUserView } from "./BondUserView"; + +interface BondPartyViewProps { + partyName: string; + partyId: string | null; + allPartyIds: Record; + onPartyCreated?: (partyId: string, partyName: string) => void; +} + +export function BondPartyView({ + partyName, + partyId, + allPartyIds, + onPartyCreated, +}: BondPartyViewProps) { + const createPartyMutation = useMutation({ + mutationFn: async (name: string) => { + const response = await fetch("/api/wallet/party", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to create party"); + } + + return response.json(); + }, + onSuccess: (data, name) => { + onPartyCreated?.(data.partyId, name); + toast.success(`Successfully created ${name} party`); + }, + onError: (error, name) => { + toast.error( + `Failed to create ${name} party: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + }, + }); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + }; + + if (!partyId) { + return ( + + + + + {partyName.charAt(0).toUpperCase() + partyName.slice(1)} + + + Create a party to start managing bonds + + + + + + + ); + } + + const custodianPartyId = allPartyIds.custodian; + const isCustodian = partyId === custodianPartyId; + + const PartyInfoCard = () => ( + + +
+
+ + + {partyName.charAt(0).toUpperCase() + + partyName.slice(1)} + + + Party information and status + +
+ + + Active + +
+
+ +
+ +
+ + {partyId} + + +
+
+
+
+ ); + + return ( +
+ + {isCustodian ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/token-app/components/BondUserView.tsx b/packages/token-app/components/BondUserView.tsx new file mode 100644 index 0000000..3d1f210 --- /dev/null +++ b/packages/token-app/components/BondUserView.tsx @@ -0,0 +1,551 @@ +"use client"; + +import { useState } from "react"; +import { useBalance } from "@/lib/queries/balance"; +import { useBondMintRequest } from "@/lib/queries/bondMintRequest"; +import { + useAllLifecycleEffects, + useLifecycleClaimRequest, + useLifecycleInstruction, +} from "@/lib/queries/bondLifecycle"; +import type { BondLifecycleInstruction } from "@denotecapital/token-sdk"; +import { useBondInstruments } from "@/lib/queries/bondInstruments"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTransferInstruction } from "@/lib/queries/transferInstruction"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +interface BondUserViewProps { + partyId: string; + partyName: string; + custodianPartyId: string | null; +} + +export function BondUserView({ + partyId, + partyName, + custodianPartyId, +}: BondUserViewProps) { + const [selectedBondInstrumentId, setSelectedBondInstrumentId] = + useState(""); + const [mintAmount, setMintAmount] = useState(1); + + const queryClient = useQueryClient(); + const bondInstrumentsQuery = useBondInstruments(custodianPartyId); + const bondInstruments = bondInstrumentsQuery.data || []; + const bondMintRequest = useBondMintRequest(); + const lifecycleClaimRequest = useLifecycleClaimRequest( + partyId, + custodianPartyId + ); + const lifecycleInstruction = useLifecycleInstruction(partyId); + const transferInstruction = useTransferInstruction(partyId); + + const selectedInstrument = bondInstruments.find( + (inst) => inst.instrumentId === selectedBondInstrumentId + ); + + const { data: selectedBalance } = useBalance( + partyId, + selectedInstrument && custodianPartyId + ? { admin: custodianPartyId, id: selectedInstrument.instrumentId } + : null + ); + + const allLifecycleEffects = useAllLifecycleEffects(custodianPartyId); + + const currencyInstrumentId = custodianPartyId + ? `${custodianPartyId}#Currency` + : null; + const { data: currencyBalance } = useBalance( + partyId, + custodianPartyId && currencyInstrumentId + ? { admin: custodianPartyId, id: currencyInstrumentId } + : null + ); + + const handleCreateBondMintRequest = async () => { + if (!selectedInstrument) { + toast.error("Please select a bond instrument"); + return; + } + + try { + await bondMintRequest.create.mutateAsync({ + instrumentCid: selectedInstrument.bondInstrumentCid, + issuer: custodianPartyId!, + receiver: partyId, + amount: mintAmount, + seed: partyName, + }); + queryClient.invalidateQueries({ + queryKey: ["balances", partyId], + }); + toast.success( + `Bond mint request created for ${mintAmount} bond(s)` + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to create bond mint request" + ); + } + }; + + const handleClaimLifecycleEvent = async (effectCid: string) => { + if (!selectedInstrument) { + toast.error("Please select a bond first"); + return; + } + + const effect = allLifecycleEffects.data?.find( + (e) => e.contractId === effectCid + ); + if (!effect) { + toast.error("Invalid effect"); + return; + } + + try { + const params = new URLSearchParams({ + owner: partyId, + admin: custodianPartyId!, + id: selectedInstrument.instrumentId, + }); + const balanceResponse = await fetch( + `/api/wallet/balances?${params}` + ); + if (!balanceResponse.ok) { + const error = await balanceResponse.json().catch(() => ({})); + toast.error(error.error || "Failed to fetch balance"); + return; + } + const balance = await balanceResponse.json(); + if (!balance?.utxos?.length) { + toast.error("No bonds available"); + return; + } + + let matchingBond = null; + for (const utxo of balance.utxos) { + const versionRes = await fetch( + `/api/wallet/bond/version?partyId=${partyId}&contractId=${utxo.contractId}` + ); + const { version } = versionRes.ok + ? await versionRes.json() + : { version: "0" }; + if (version === effect.targetVersion) { + matchingBond = utxo; + break; + } + } + + if (!matchingBond) { + toast.error("No bonds with matching version"); + return; + } + + const currencyInstrumentId = `${custodianPartyId}#Currency`; + const [disclosureRes, infraRes] = await Promise.all([ + fetch( + `/api/wallet/bond/disclosure?bondInstrumentCid=${selectedInstrument.bondInstrumentCid}&adminPartyId=${custodianPartyId}` + ), + fetch( + `/api/wallet/bond/lifecycle/infrastructure?partyId=${encodeURIComponent( + custodianPartyId! + )}¤cyInstrumentId=${encodeURIComponent( + currencyInstrumentId + )}&seed=custodian` + ), + ]); + + const { disclosure } = await disclosureRes.json(); + const infrastructure = await infraRes.json(); + + await lifecycleClaimRequest.create.mutateAsync({ + effectCid, + bondHoldingCid: matchingBond.contractId, + bondRulesCid: infrastructure.bondRulesCid, + bondInstrumentCid: selectedInstrument.bondInstrumentCid, + currencyTransferFactoryCid: + infrastructure.currencyTransferFactoryCid, + issuerCurrencyHoldingCid: infrastructure.currencyHoldings[0], + holder: partyId, + issuer: custodianPartyId!, + seed: partyName, + disclosure, + }); + + queryClient.invalidateQueries({ + queryKey: ["lifecycleInstruction"], + }); + queryClient.invalidateQueries({ + queryKey: ["lifecycleClaimRequests"], + }); + queryClient.invalidateQueries({ + queryKey: ["allLifecycleEffects"], + }); + toast.success("Claim request created!"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to claim" + ); + } + }; + + const handleProcessInstruction = async ( + instruction: BondLifecycleInstruction + ) => { + try { + let disclosure = undefined; + if ( + instruction.eventType === "CouponPayment" && + instruction.bondInstrumentCid + ) { + const res = await fetch( + `/api/wallet/bond/disclosure?bondInstrumentCid=${instruction.bondInstrumentCid}&adminPartyId=${custodianPartyId}` + ); + disclosure = (await res.json()).disclosure; + } + + await lifecycleInstruction.process.mutateAsync({ + contractId: instruction.contractId, + partyId, + seed: partyName, + disclosure, + }); + + if (custodianPartyId) { + const transferRes = await fetch( + `/api/wallet/transfer-instruction?partyId=${partyId}` + ); + if (transferRes.ok) { + const { instructions } = await transferRes.json(); + const currencyTransfer = instructions?.find( + (inst: { + transfer: { + instrumentId: { admin: string; id: string }; + }; + }) => + inst.transfer.instrumentId.admin === + custodianPartyId && + inst.transfer.instrumentId.id.includes("Currency") + ); + + if (currencyTransfer) { + const disclosureRes = + await transferInstruction.getDisclosure.mutateAsync( + { + transferInstructionCid: + currencyTransfer.contractId, + adminPartyId: custodianPartyId, + } + ); + + await transferInstruction.accept.mutateAsync({ + contractId: currencyTransfer.contractId, + disclosure: disclosureRes.disclosure, + receiverPartyId: partyId, + seed: partyName, + }); + } + } + } + + queryClient.invalidateQueries({ + queryKey: ["allLifecycleInstructions"], + }); + queryClient.invalidateQueries({ queryKey: ["balances"] }); + queryClient.invalidateQueries({ + queryKey: ["transferInstructions"], + }); + toast.success("Instruction processed!"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to process" + ); + } + }; + + if (!custodianPartyId) { + return ( + + + Setup Required + + Please create the custodian party first + + + + ); + } + + return ( +
+ + + Select Bond + + +
+ + {bondInstruments.length > 0 ? ( + + ) : ( + + setSelectedBondInstrumentId(e.target.value) + } + placeholder={`${custodianPartyId}#Bond`} + /> + )} +
+
+
+ + {!selectedInstrument && ( + + +

+ Please select a bond to continue +

+
+
+ )} + + {selectedInstrument && ( + <> + + + Balances + + + {selectedBalance && ( +
+

Bonds

+

+ {selectedBalance.total || 0} +

+
+ )} + {currencyBalance && ( +
+

+ Currency +

+

+ {currencyBalance.total || 0} +

+
+ )} +
+
+ + + + Mint Bonds + + +
+ + + setMintAmount( + parseFloat(e.target.value) || 0 + ) + } + /> +
+ +
+
+ + + + Claim Lifecycle Events + + + {!allLifecycleEffects.data?.length ? ( +

+ No lifecycle effects available. +

+ ) : ( +
+ {allLifecycleEffects.data + .filter((e) => { + // Must match selected bond + if ( + e.targetInstrumentId !== + selectedBondInstrumentId + ) + return false; + + // Check if instruction exists (indicates effect was already claimed/processed) + const hasInstruction = + lifecycleInstruction.getAll.data?.some( + (i) => + i.eventType === + e.eventType && + i.eventDate === + e.eventDate && + i.holder === partyId + ); + if (hasInstruction) return false; + + // Check if there's a pending claim request + const hasPending = + lifecycleClaimRequest.get.data?.requests?.some( + (r) => + r.effectCid === + e.contractId + ); + if (hasPending) return false; + + return true; + }) + .map((effect) => ( +
+
+

+ {effect.eventType === + "CouponPayment" + ? "Coupon" + : "Redemption"} +

+

+ {effect.amount} per unit +

+
+ +
+ ))} +
+ )} +
+
+ + + + + Process Lifecycle Instructions + + + + {!selectedInstrument ? ( +

+ Please select a bond first. +

+ ) : !lifecycleInstruction.getAll.data?.length ? ( +

+ No instructions available. +

+ ) : ( +
+ {lifecycleInstruction.getAll.data + ?.filter((i) => i.holder === partyId) + .map((instruction) => ( +
+
+

+ {instruction.eventType === + "CouponPayment" + ? "Coupon" + : "Redemption"} +

+

+ {instruction.amount} per + unit +

+
+ +
+ ))} +
+ )} +
+
+ + )} +
+ ); +} diff --git a/packages/token-app/lib/queries/bondInstruments.ts b/packages/token-app/lib/queries/bondInstruments.ts new file mode 100644 index 0000000..00a6bb7 --- /dev/null +++ b/packages/token-app/lib/queries/bondInstruments.ts @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +export interface BondInstrument { + name: string; + instrumentId: string; + custodianPartyId: string; + bondInstrumentCid: string; + maturityDate: string; + couponRate: number; + couponFrequency: number; +} + +export function useBondInstruments(custodianPartyId: string | null) { + return useQuery({ + queryKey: ["bondInstruments", custodianPartyId], + queryFn: async () => { + if (!custodianPartyId) + throw new Error("Custodian party ID required"); + + const params = new URLSearchParams({ + custodianPartyId: custodianPartyId, + }); + const response = await fetch( + `/api/wallet/bond/factory/instruments?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to get bond instruments" + ); + } + + const data = await response.json(); + return data.instruments as BondInstrument[]; + }, + enabled: !!custodianPartyId, + refetchInterval: 5000, + }); +} diff --git a/packages/token-app/lib/queries/bondLifecycle.ts b/packages/token-app/lib/queries/bondLifecycle.ts new file mode 100644 index 0000000..1eec447 --- /dev/null +++ b/packages/token-app/lib/queries/bondLifecycle.ts @@ -0,0 +1,262 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { + BondLifecycleEffect, + BondLifecycleInstruction, +} from "@denotecapital/token-sdk"; + +export interface LifecycleClaimRequest { + contractId: string; + effectCid: string; + bondHoldingCid: string; + holder: string; + issuer: string; +} + +export function useLifecycleEffect(partyId: string | null) { + return useQuery({ + queryKey: ["lifecycleEffect", partyId], + queryFn: async () => { + if (!partyId) throw new Error("Party ID required"); + + const params = new URLSearchParams({ partyId }); + const response = await fetch( + `/api/wallet/bond/lifecycle/effect?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to get lifecycle effect" + ); + } + + return response.json(); + }, + enabled: !!partyId, + refetchInterval: 5000, + }); +} + +export function useAllLifecycleEffects(partyId: string | null) { + return useQuery({ + queryKey: ["allLifecycleEffects", partyId], + queryFn: async () => { + if (!partyId) throw new Error("Party ID required"); + + const params = new URLSearchParams({ partyId }); + const response = await fetch( + `/api/wallet/bond/lifecycle/effect?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to get lifecycle effects" + ); + } + + const data = await response.json(); + return data; + }, + enabled: !!partyId, + refetchInterval: 5000, + }); +} + +export function useBondLifecycle() { + const queryClient = useQueryClient(); + + const processEvent = useMutation< + { effectCid: string; producedVersion: string | null }, + Error, + { + lifecycleRuleCid: string; + eventType: "coupon" | "redemption"; + targetInstrumentId: string; + targetVersion: string; + bondCid: string; + partyId: string; + seed: string; + } + >({ + mutationFn: async (params) => { + const response = await fetch("/api/wallet/bond/lifecycle/process", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to process lifecycle event" + ); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["lifecycleEffect"], + }); + }, + }); + + return { processEvent }; +} + +export function useLifecycleClaimRequest( + holder: string | null, + issuer: string | null +) { + const queryClient = useQueryClient(); + + const get = useQuery<{ requests: LifecycleClaimRequest[] }>({ + queryKey: ["lifecycleClaimRequests", holder, issuer], + queryFn: async () => { + if (!holder || !issuer) + throw new Error("Holder and issuer required"); + + const params = new URLSearchParams({ + partyId: holder, + issuer, + }); + + const response = await fetch( + `/api/wallet/bond/lifecycle/claim-request?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to get lifecycle claim requests" + ); + } + + return response.json(); + }, + enabled: !!holder && !!issuer, + refetchInterval: 5000, + }); + + const create = useMutation({ + mutationFn: async (params: { + effectCid: string; + bondHoldingCid: string; + bondRulesCid: string; + bondInstrumentCid: string; + currencyTransferFactoryCid: string; + issuerCurrencyHoldingCid: string; + holder: string; + issuer: string; + seed: string; + disclosure?: unknown; + }) => { + const response = await fetch( + "/api/wallet/bond/lifecycle/claim-request", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to create lifecycle claim request" + ); + } + + return response.json(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: [ + "lifecycleClaimRequests", + variables.holder, + variables.issuer, + ], + }); + }, + }); + + return { + get, + create, + }; +} + +export function useLifecycleInstruction(partyId: string | null) { + const queryClient = useQueryClient(); + + const getAll = useQuery({ + queryKey: ["allLifecycleInstructions", partyId], + queryFn: async () => { + if (!partyId) throw new Error("Party ID required"); + + const params = new URLSearchParams({ partyId }); + const response = await fetch( + `/api/wallet/bond/lifecycle/instruction?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to get lifecycle instructions" + ); + } + + const data = await response.json(); + return data; + }, + enabled: !!partyId, + refetchInterval: 5000, + }); + + const process = useMutation({ + mutationFn: async (params: { + contractId: string; + partyId: string; + seed: string; + disclosure?: unknown; + }) => { + const response = await fetch( + "/api/wallet/bond/lifecycle/instruction", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to process lifecycle instruction" + ); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["lifecycleInstruction"], + }); + queryClient.invalidateQueries({ + queryKey: ["allLifecycleInstructions"], + }); + queryClient.invalidateQueries({ + queryKey: ["balances"], + }); + queryClient.invalidateQueries({ + queryKey: ["transferInstructions"], + }); + }, + }); + + return { + getAll, + process, + }; +} diff --git a/packages/token-app/lib/queries/bondMintRequest.ts b/packages/token-app/lib/queries/bondMintRequest.ts new file mode 100644 index 0000000..b2991d6 --- /dev/null +++ b/packages/token-app/lib/queries/bondMintRequest.ts @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +export interface BondMintRequest { + contractId: string; + instrumentCid: string; + issuer: string; + receiver: string; + amount: number; +} + +export function useBondMintRequest() { + const queryClient = useQueryClient(); + + const create = useMutation({ + mutationFn: async (params: { + instrumentCid: string; + issuer: string; + receiver: string; + amount: number; + seed: string; + }) => { + const response = await fetch("/api/wallet/bond/mint-request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to create bond mint request" + ); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["balances"], + }); + }, + }); + + return { create }; +} diff --git a/packages/token-app/next-env.d.ts b/packages/token-app/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/packages/token-app/next-env.d.ts +++ b/packages/token-app/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/token-app/package.json b/packages/token-app/package.json index 5110b62..9a1c1e9 100644 --- a/packages/token-app/package.json +++ b/packages/token-app/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "next dev", + "setup:bond-demo": "tsx scripts/setupBondDemo.ts", + "dev:setup": "npm run setup:bond-demo && npm run dev", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/packages/token-app/scripts/setupBondDemo.ts b/packages/token-app/scripts/setupBondDemo.ts new file mode 100644 index 0000000..54011d4 --- /dev/null +++ b/packages/token-app/scripts/setupBondDemo.ts @@ -0,0 +1,225 @@ +/** + * Setup script for Bond Demo + * + * This script automatically sets up: + * 1. Custodian party (if it doesn't exist) + * 2. Bond infrastructure (bond rules, lifecycle rule, currency) + * 3. Default bond instrument + */ + +import { + keyPairFromSeed, + getDefaultSdkAndConnect, + getWrappedSdkWithKeyPairForParty, +} from "@denotecapital/token-sdk"; +import { signTransactionHash } from "@canton-network/wallet-sdk"; + +const CUSTODIAN_SEED = "custodian"; +const ALICE_SEED = "alice"; +const BOB_SEED = "bob"; +const DEFAULT_BOND_NAME = "TestBond"; +const DEFAULT_NOTIONAL = 1000; +const DEFAULT_COUPON_RATE = 0.05; +const DEFAULT_COUPON_FREQUENCY = 2; +const DEFAULT_MATURITY_SECONDS = 120; + +async function createParty(seed: string) { + console.log(`Creating ${seed} party...`); + + const keyPair = keyPairFromSeed(seed); + const sdk = await getDefaultSdkAndConnect(); + + if (!sdk.userLedger) { + throw new Error("SDK not connected"); + } + + const generatedParty = await sdk.userLedger.generateExternalParty( + keyPair.publicKey, + seed + ); + + if (!generatedParty) { + throw new Error(`Error creating ${seed} party`); + } + + const signedHash = signTransactionHash( + generatedParty.multiHash, + keyPair.privateKey + ); + + try { + const allocatedParty = await sdk.userLedger.allocateExternalParty( + signedHash, + generatedParty + ); + + if (!allocatedParty) { + throw new Error(`Error allocating ${seed} party`); + } + + console.log(`${seed} party created: ${allocatedParty.partyId}`); + return allocatedParty.partyId; + } catch (error) { + if ( + error instanceof Error && + (error.message.includes("already exists") || + error.message.includes("ALREADY_EXISTS")) + ) { + console.log( + `${seed} party already exists: ${generatedParty.partyId}` + ); + return generatedParty.partyId; + } + throw error; + } +} + +async function setupInfrastructure(custodianPartyId: string) { + console.log("Setting up bond infrastructure..."); + + const keyPair = keyPairFromSeed(CUSTODIAN_SEED); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + custodianPartyId, + keyPair + ); + + const currencyInstrumentId = `${custodianPartyId}#Currency`; + + console.log("Creating bond rules..."); + const bondRulesCid = await wrappedSdk.bonds.bondRules.getOrCreate(); + console.log(`Bond rules: ${bondRulesCid.slice(0, 20)}...`); + + console.log("Creating lifecycle rule..."); + const lifecycleRuleCid = await wrappedSdk.bonds.lifecycleRule.getOrCreate({ + depository: custodianPartyId, + currencyInstrumentId: { + admin: custodianPartyId, + id: currencyInstrumentId, + }, + }); + console.log(`Lifecycle rule: ${lifecycleRuleCid.slice(0, 20)}...`); + + console.log("Setting up currency infrastructure..."); + const currencyRulesCid = await wrappedSdk.tokenRules.getOrCreate(); + const currencyTransferFactoryCid = + await wrappedSdk.transferFactory.getOrCreate(currencyRulesCid); + const currencyTokenFactoryCid = await wrappedSdk.tokenFactory.getOrCreate( + currencyInstrumentId + ); + console.log(`Currency infrastructure ready`); + + const existingCurrencyBalance = await wrappedSdk.balances.getByInstrumentId( + { + owner: custodianPartyId, + instrumentId: { + admin: custodianPartyId, + id: currencyInstrumentId, + }, + } + ); + + if (!existingCurrencyBalance || existingCurrencyBalance.total === 0) { + console.log("Minting initial currency..."); + await wrappedSdk.tokenFactory.mintToken(currencyTokenFactoryCid, { + amount: 10000.0, + receiver: custodianPartyId, + }); + console.log("Minted 10,000 currency units"); + } else { + console.log( + `Currency already exists (${existingCurrencyBalance.total} units)` + ); + } + + console.log("Infrastructure setup complete"); +} + +async function createDefaultBondInstrument(custodianPartyId: string) { + console.log("📝 Creating default bond instrument..."); + + const keyPair = keyPairFromSeed(CUSTODIAN_SEED); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + custodianPartyId, + keyPair + ); + + const instrumentId = `${custodianPartyId}#${DEFAULT_BOND_NAME}`; + + const existingInstrumentCid = + await wrappedSdk.bonds.factory.getLatestInstrument(instrumentId); + + if (existingInstrumentCid) { + console.log( + `Default bond instrument already exists: ${instrumentId} (${existingInstrumentCid.slice( + 0, + 20 + )}...)` + ); + return; + } + + const bondFactoryCid = await wrappedSdk.bonds.factory.getOrCreate( + instrumentId + ); + + const maturityDate = new Date(); + maturityDate.setSeconds( + maturityDate.getSeconds() + DEFAULT_MATURITY_SECONDS + ); + + await wrappedSdk.bonds.factory.createInstrument( + bondFactoryCid, + instrumentId, + { + depository: custodianPartyId, + notional: DEFAULT_NOTIONAL, + couponRate: DEFAULT_COUPON_RATE, + couponFrequency: DEFAULT_COUPON_FREQUENCY, + maturityDate: maturityDate.toISOString(), + } + ); + + console.log(`Default bond instrument created: ${instrumentId}`); + console.log( + ` Parameters: Notional=${DEFAULT_NOTIONAL}, Rate=${ + DEFAULT_COUPON_RATE * 100 + }%, Frequency=${DEFAULT_COUPON_FREQUENCY}x, Maturity=${DEFAULT_MATURITY_SECONDS}s` + ); +} + +async function main() { + try { + console.log("Starting bond demo setup...\n"); + + const custodianPartyId = await createParty(CUSTODIAN_SEED); + console.log(""); + const alicePartyId = await createParty(ALICE_SEED); + console.log(""); + const bobPartyId = await createParty(BOB_SEED); + console.log(""); + + await setupInfrastructure(custodianPartyId); + console.log(""); + + await createDefaultBondInstrument(custodianPartyId); + console.log(""); + + console.log("Bond demo setup complete!"); + console.log(`Custodian Party: ${custodianPartyId}`); + console.log(`Alice Party: ${alicePartyId}`); + console.log(`Bob Party: ${bobPartyId}`); + console.log( + `Default Instrument: ${custodianPartyId}#${DEFAULT_BOND_NAME}` + ); + + process.exit(0); + } catch (error) { + console.error("Setup failed:", error); + if (error instanceof Error) { + console.error(` Error: ${error.message}`); + } + process.exit(1); + } +} + +main(); diff --git a/packages/token-sdk/package.json b/packages/token-sdk/package.json index becbca4..e37ccf3 100644 --- a/packages/token-sdk/package.json +++ b/packages/token-sdk/package.json @@ -64,6 +64,7 @@ "madge": "^8.0.0", "npm-run-all": "^4.1.5", "rimraf": "^6.0.1", + "tsx": "^4.19.2", "typescript": "5.8.2", "vite": "^6.2.2", "vitest": "^3.0.8" diff --git a/packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts b/packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts new file mode 100644 index 0000000..dd8d3ac --- /dev/null +++ b/packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts @@ -0,0 +1,4 @@ +// Obtained from runnning: +// `pnpm get:minimal-token-id` +export const MINIMAL_TOKEN_PACKAGE_ID = + "939f8696a355b2018a497a570efc7ba8960026e8c1f2621ac5aa184bf288caca"; diff --git a/packages/token-sdk/src/constants/index.ts b/packages/token-sdk/src/constants/index.ts index b1760bf..6032bea 100644 --- a/packages/token-sdk/src/constants/index.ts +++ b/packages/token-sdk/src/constants/index.ts @@ -1 +1,2 @@ +export * from "./MINIMAL_TOKEN_PACKAGE_ID.js"; export * from "./templateIds.js"; diff --git a/packages/token-sdk/src/constants/templateIds.ts b/packages/token-sdk/src/constants/templateIds.ts index 395e409..c383b84 100644 --- a/packages/token-sdk/src/constants/templateIds.ts +++ b/packages/token-sdk/src/constants/templateIds.ts @@ -6,6 +6,8 @@ export const transferPreapprovalTemplateId = export const tokenTransferInstructionTemplateId = "#minimal-token:MyTokenTransferInstruction:MyTransferInstruction"; export const lockedMyTokenTemplateId = "#minimal-token:MyToken:LockedMyToken"; +export const transferRequestTemplateId = + "#minimal-token:MyToken.TransferRequest:TransferRequest"; export const bondIssuerMintRequestTemplateId = "#minimal-token:Bond.IssuerMintRequest:IssuerMintRequest"; @@ -29,3 +31,4 @@ export const bondLifecycleInstructionTemplateId = export const bondLifecycleEffectTemplateId = "#minimal-token:Bond.BondLifecycleEffect:BondLifecycleEffect"; export const lockedBondTemplateId = "#minimal-token:Bond.Bond:LockedBond"; +export const bondTemplateId = "#minimal-token:Bond.Bond:Bond"; diff --git a/packages/token-sdk/src/uploadDars.ts b/packages/token-sdk/src/uploadDars.ts index b79ef0f..6148942 100644 --- a/packages/token-sdk/src/uploadDars.ts +++ b/packages/token-sdk/src/uploadDars.ts @@ -6,6 +6,7 @@ import { } from "@canton-network/wallet-sdk"; import fs from "fs/promises"; import path from "path"; +import { MINIMAL_TOKEN_PACKAGE_ID } from "./constants/MINIMAL_TOKEN_PACKAGE_ID.js"; const sdk = new WalletSDKImpl().configure({ logger: console, @@ -19,11 +20,6 @@ export async function uploadDars() { await sdk.connectAdmin(); await sdk.connectTopology(new URL("http://localhost:2000/api/validator")); - // Obtained from runnning: - // `pnpm get:minimal-token-id` - const MINIMAL_TOKEN_PACKAGE_ID = - "72a07c72a9af38316e3f222a12888931f816cf22dabb9f22f3b474d966fea325"; - const isDarUploaded = await sdk.userLedger?.isPackageUploaded( MINIMAL_TOKEN_PACKAGE_ID ); diff --git a/packages/token-sdk/src/wrappedSdk/bonds/bond.ts b/packages/token-sdk/src/wrappedSdk/bonds/bond.ts new file mode 100644 index 0000000..02f876c --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/bonds/bond.ts @@ -0,0 +1,38 @@ +import { LedgerController } from "@canton-network/wallet-sdk"; +import { ContractId } from "../../types/daml.js"; +import { bondTemplateId } from "../../constants/templateIds.js"; +import { getCreatedEventByCid } from "../../helpers/getCreatedEventByCid.js"; + +export interface BondParams { + issuer: string; + depository: string; + owner: string; + instrumentId: string; + version: string; + notional: number; + amount: number; + maturityDate: string; + couponRate: number; + couponFrequency: number; + issueDate: string; + lastEventTimestamp: string; +} + +export async function getBondContract( + ledger: LedgerController, + contractId: ContractId +): Promise { + try { + const event = await getCreatedEventByCid( + ledger, + contractId, + bondTemplateId + ); + return event?.createArgument; + } catch (error) { + if (error instanceof Error) { + return undefined; + } + return undefined; + } +} diff --git a/packages/token-sdk/src/wrappedSdk/bonds/index.ts b/packages/token-sdk/src/wrappedSdk/bonds/index.ts index 0e9d492..c8d06ee 100644 --- a/packages/token-sdk/src/wrappedSdk/bonds/index.ts +++ b/packages/token-sdk/src/wrappedSdk/bonds/index.ts @@ -1,9 +1,10 @@ +export * from "./bondRules.js"; export * from "./factory.js"; export * from "./issuerMintRequest.js"; +export * from "./lifecycleClaimRequest.js"; +export * from "./lifecycleEffect.js"; +export * from "./lifecycleInstruction.js"; export * from "./lifecycleRule.js"; -export * from "./bondRules.js"; export * from "./transferFactory.js"; -export * from "./transferRequest.js"; export * from "./transferInstruction.js"; -export * from "./lifecycleInstruction.js"; -export * from "./lifecycleEffect.js"; +export * from "./transferRequest.js"; diff --git a/packages/token-sdk/src/wrappedSdk/bonds/lifecycleEffect.ts b/packages/token-sdk/src/wrappedSdk/bonds/lifecycleEffect.ts index eebcb9e..e175c53 100644 --- a/packages/token-sdk/src/wrappedSdk/bonds/lifecycleEffect.ts +++ b/packages/token-sdk/src/wrappedSdk/bonds/lifecycleEffect.ts @@ -3,6 +3,25 @@ import { ActiveContractResponse } from "../../types/ActiveContractResponse.js"; import { ContractId, Party } from "../../types/daml.js"; import { bondLifecycleEffectTemplateId } from "../../constants/templateIds.js"; +export interface BondLifecycleEffectParams { + producedVersion: string | null; + eventType: "CouponPayment" | "Redemption"; + targetInstrumentId: string; + targetVersion: string; + eventDate: string; + amount: number; +} + +export interface BondLifecycleEffect { + contractId: ContractId; + producedVersion: string | null; + eventType: "CouponPayment" | "Redemption"; + targetInstrumentId: string; + targetVersion: string; + eventDate: string; + amount: number; +} + export async function getLatestBondLifecycleEffect( ledger: LedgerController, party: Party @@ -35,3 +54,38 @@ export async function getLatestBondLifecycleEffect( producedVersion: params.producedVersion, }; } + +export async function getAllBondLifecycleEffects( + ledger: LedgerController, + party: Party +): Promise { + const end = await ledger.ledgerEnd(); + const effects = (await ledger.activeContracts({ + offset: end.offset, + templateIds: [bondLifecycleEffectTemplateId], + filterByParty: true, + parties: [party], + })) as ActiveContractResponse[]; + + return effects + .map((contract) => { + const jsActive = contract.contractEntry.JsActiveContract; + if (!jsActive) return null; + + const createArg = jsActive.createdEvent.createArgument; + const contractId = jsActive.createdEvent.contractId; + + return { + contractId, + producedVersion: createArg.producedVersion, + eventType: createArg.eventType, + targetInstrumentId: createArg.targetInstrumentId, + targetVersion: createArg.targetVersion, + eventDate: createArg.eventDate, + amount: createArg.amount, + }; + }) + .filter( + (effect): effect is NonNullable => effect !== null + ); +} diff --git a/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts b/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts index f635ac4..54546d9 100644 --- a/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts +++ b/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts @@ -112,6 +112,66 @@ export async function getBondLifecycleInstruction( return instruction.contractEntry.JsActiveContract.createdEvent; } +export interface BondLifecycleInstruction { + contractId: ContractId; + eventType: string; + lockedBond: ContractId; + bondInstrumentCid: ContractId | null; + producedVersion: string | null; + issuer: string; + holder: string; + eventDate: string; + amount: number; +} + +export async function getAllBondLifecycleInstructions( + ledger: LedgerController, + party: Party +): Promise { + const end = await ledger.ledgerEnd(); + const instructions = (await ledger.activeContracts({ + offset: end.offset, + templateIds: [bondLifecycleInstructionTemplateId], + filterByParty: true, + parties: [party], + })) as ActiveContractResponse<{ + eventType: unknown; + lockedBond: ContractId; + bondInstrumentCid: ContractId | null; + producedVersion: string | null; + issuer: string; + holder: string; + eventDate: string; + amount: number; + currencyInstrumentId: unknown; + }>[]; + + return instructions + .map((contract) => { + const jsActive = contract.contractEntry.JsActiveContract; + if (!jsActive) return null; + + const createArg = jsActive.createdEvent.createArgument; + const contractId = jsActive.createdEvent.contractId; + + return { + contractId, + eventType: createArg.eventType as string, + lockedBond: createArg.lockedBond, + bondInstrumentCid: createArg.bondInstrumentCid, + producedVersion: createArg.producedVersion, + issuer: createArg.issuer, + holder: createArg.holder, + eventDate: createArg.eventDate, + amount: createArg.amount, + }; + }) + .filter( + (instruction): instruction is NonNullable => + instruction !== null + ); +} + export async function getBondLifecycleInstructionDisclosure( issuerLedger: LedgerController, lifecycleInstructionCid: ContractId diff --git a/packages/token-sdk/src/wrappedSdk/transferRequest.ts b/packages/token-sdk/src/wrappedSdk/transferRequest.ts index a7733a9..710cdc4 100644 --- a/packages/token-sdk/src/wrappedSdk/transferRequest.ts +++ b/packages/token-sdk/src/wrappedSdk/transferRequest.ts @@ -6,6 +6,7 @@ import { ContractId, Party } from "../types/daml.js"; import { getCreateCommand } from "../helpers/getCreateCommand.js"; import { getExerciseCommand } from "../helpers/getExerciseCommand.js"; import { InstrumentId } from "../types/InstrumentId.js"; +import { transferRequestTemplateId } from "../constants/templateIds.js"; export type Metadata = Record; @@ -34,9 +35,6 @@ export interface TransferRequestParams { extraArgs: ExtraArgs; } -export const transferRequestTemplateId = - "#minimal-token:MyToken.TransferRequest:TransferRequest"; - const getCreateTransferRequestCommand = (params: TransferRequestParams) => getCreateCommand({ templateId: transferRequestTemplateId, params }); diff --git a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts index 02eb438..d172002 100644 --- a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts +++ b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts @@ -148,9 +148,14 @@ import { getBondLifecycleInstruction, getBondLifecycleInstructionDisclosure, getLatestBondLifecycleInstruction, + getAllBondLifecycleInstructions, processBondLifecycleInstruction, } from "./bonds/lifecycleInstruction.js"; -import { getLatestBondLifecycleEffect } from "./bonds/lifecycleEffect.js"; +import { + getLatestBondLifecycleEffect, + getAllBondLifecycleEffects, +} from "./bonds/lifecycleEffect.js"; +import { getBondContract } from "./bonds/bond.js"; export const getWrappedSdk = (sdk: WalletSDK) => { if (!sdk.userLedger) { @@ -420,6 +425,8 @@ export const getWrappedSdk = (sdk: WalletSDK) => { getBondLifecycleInstruction(userLedger, contractId), getLatest: (party: Party) => getLatestBondLifecycleInstruction(userLedger, party), + getAll: (party: Party) => + getAllBondLifecycleInstructions(userLedger, party), getDisclosure: (contractId: ContractId) => getBondLifecycleInstructionDisclosure( userLedger, @@ -429,6 +436,12 @@ export const getWrappedSdk = (sdk: WalletSDK) => { lifecycleEffect: { getLatest: (party: Party) => getLatestBondLifecycleEffect(userLedger, party), + getAll: (party: Party) => + getAllBondLifecycleEffects(userLedger, party), + }, + bond: { + get: (contractId: ContractId) => + getBondContract(userLedger, contractId), }, }, tokenFactory: { @@ -846,6 +859,8 @@ export const getWrappedSdkWithKeyPair = ( getBondLifecycleInstruction(userLedger, contractId), getLatest: (party: Party) => getLatestBondLifecycleInstruction(userLedger, party), + getAll: (party: Party) => + getAllBondLifecycleInstructions(userLedger, party), getDisclosure: (contractId: ContractId) => getBondLifecycleInstructionDisclosure( userLedger, @@ -855,6 +870,12 @@ export const getWrappedSdkWithKeyPair = ( lifecycleEffect: { getLatest: (party: Party) => getLatestBondLifecycleEffect(userLedger, party), + getAll: (party: Party) => + getAllBondLifecycleEffects(userLedger, party), + }, + bond: { + get: (contractId: ContractId) => + getBondContract(userLedger, contractId), }, }, tokenFactory: { @@ -1011,3 +1032,6 @@ export const getWrappedSdkWithKeyPairForParty = async ( const sdk = await getSdkForParty(partyId); return getWrappedSdkWithKeyPair(sdk, userKeyPair); }; + +export type WrappedSdk = ReturnType; +export type WrappedSdkWithKeyPair = ReturnType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 302c1a7..96967e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,55 @@ importers: specifier: ^1.4.7 version: 1.4.7 + packages/custodian-sdk: + dependencies: + '@canton-network/wallet-sdk': + specifier: ^0.16.0 + version: 0.16.0 + '@denotecapital/token-sdk': + specifier: workspace:* + version: link:../token-sdk + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + tweetnacl-util: + specifier: ^0.15.1 + version: 0.15.1 + devDependencies: + '@types/node': + specifier: ^22.13.10 + version: 22.19.1 + '@veraswap/esbuild-config': + specifier: latest + version: 0.1.2(@esbuild-plugins/node-resolve@0.2.2(esbuild@0.27.0))(esbuild@0.27.0)(glob@13.0.0) + '@veraswap/eslint-config': + specifier: latest + version: 0.1.4(@eslint/js@9.39.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@2.8.8))(eslint@9.39.1(jiti@2.6.1))(globals@16.5.0)(prettier@2.8.8)(typescript-eslint@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.2)) + '@veraswap/tsconfig': + specifier: latest + version: 0.1.1 + depcheck: + specifier: ^1.4.7 + version: 1.4.7 + madge: + specifier: ^8.0.0 + version: 8.0.0(typescript@5.8.2) + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + rimraf: + specifier: ^6.0.1 + version: 6.1.2 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: 5.8.2 + version: 5.8.2 + packages/minimal-token: {} packages/token-app: @@ -209,6 +258,9 @@ importers: rimraf: specifier: ^6.0.1 version: 6.1.2 + tsx: + specifier: ^4.19.2 + version: 4.21.0 typescript: specifier: 5.8.2 version: 5.8.2 @@ -10419,7 +10471,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.19.1 + '@types/node': 24.10.1 long: 5.3.2 proxy-agent@6.4.0: