From 55d76ca1ae92bfb7a0c4a77fae99eb275f2b0c7c Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Fri, 5 Dec 2025 17:25:39 +0100 Subject: [PATCH 01/15] Add first version of custodian service --- packages/custodian-sdk/.env.example | 2 + packages/custodian-sdk/.gitignore | 4 + packages/custodian-sdk/CLAUDE.md | 595 ++++++++++++++++++ packages/custodian-sdk/README.md | 246 ++++++++ packages/custodian-sdk/esbuild.config.mjs | 3 + packages/custodian-sdk/eslint.config.mjs | 8 + packages/custodian-sdk/package.json | 41 ++ packages/custodian-sdk/src/custodianApi.ts | 118 ++++ packages/custodian-sdk/src/index.ts | 264 ++++++++ packages/custodian-sdk/src/types/Request.ts | 7 + packages/custodian-sdk/src/types/index.ts | 1 + packages/custodian-sdk/tsconfig.json | 6 + packages/custodian-sdk/vite.config.ts | 16 + packages/token-sdk/package.json | 1 + .../token-sdk/src/wrappedSdk/bonds/index.ts | 9 +- .../token-sdk/src/wrappedSdk/wrappedSdk.ts | 3 + pnpm-lock.yaml | 54 +- 17 files changed, 1373 insertions(+), 5 deletions(-) create mode 100644 packages/custodian-sdk/.env.example create mode 100644 packages/custodian-sdk/.gitignore create mode 100644 packages/custodian-sdk/CLAUDE.md create mode 100644 packages/custodian-sdk/README.md create mode 100644 packages/custodian-sdk/esbuild.config.mjs create mode 100644 packages/custodian-sdk/eslint.config.mjs create mode 100644 packages/custodian-sdk/package.json create mode 100644 packages/custodian-sdk/src/custodianApi.ts create mode 100644 packages/custodian-sdk/src/index.ts create mode 100644 packages/custodian-sdk/src/types/Request.ts create mode 100644 packages/custodian-sdk/src/types/index.ts create mode 100644 packages/custodian-sdk/tsconfig.json create mode 100644 packages/custodian-sdk/vite.config.ts diff --git a/packages/custodian-sdk/.env.example b/packages/custodian-sdk/.env.example new file mode 100644 index 0000000..931557f --- /dev/null +++ b/packages/custodian-sdk/.env.example @@ -0,0 +1,2 @@ +# Custodian private key (64-char hex string) +CUSTODIAN_PRIVATE_KEY=your_private_key_here 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..0ac9dcb --- /dev/null +++ b/packages/custodian-sdk/CLAUDE.md @@ -0,0 +1,595 @@ +# 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 switch statement +- 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` +- Type-safe request handling with proper casting in switch cases + +### 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`) + - Switch on `request.templateId` to determine request type + - Cast request to appropriate typed `Request` for each case + - Call corresponding `custodianApi.approve*()` method (mock external API) + - Call `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 (64-char hex 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 { 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. Type-Safe Request Handling + +Each switch case casts the generic `Request` to the specific typed request: + +```typescript +async function handleRequest(request: Request, wrappedSdk: WrappedSdkWithKeyPair) { + const { contractId, templateId } = request; // Destructure at start + + console.log(`[CUSTODIAN] Processing ${templateId}`); + console.log(`[CUSTODIAN] Contract ID: ${contractId}`); + + switch (templateId) { + case issuerMintRequestTemplateId: + await custodianApi.approveMint( + request as unknown as Request + ); + await wrappedSdk.issuerMintRequest.accept(contractId); + break; + } +} +``` + +This provides type safety while maintaining a generic polling interface. The function uses early destructuring of `contractId` and `templateId` for cleaner code. + +### 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 +const secretKey = Buffer.from(CUSTODIAN_PRIVATE_KEY!, "hex"); +const keyPairDerived = nacl.box.keyPair.fromSecretKey(secretKey); + +const keyPair: UserKeyPair = { + privateKey: encodeBase64(secretKey), + publicKey: encodeBase64(keyPairDerived.publicKey), +}; +``` + +**Important:** Both keys must be 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 switch cases + +**Cause:** Missing type casts for generic `Request` type + +**Solution:** Cast each request in switch cases: +```typescript +case issuerMintRequestTemplateId: + await custodianApi.approveMint( + request as unknown as Request + ); + break; +``` + +## 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 (265 lines) + - Well-organized imports (Canton SDK → Token SDK → External libs → Local) + - 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. **Type safety** - Proper casting in switch cases +5. **Code style** - Use early destructuring for cleaner code +6. **Avoid dynamic imports** - Import directly at top for better tree-shaking +7. **Mock API** - Keep external API calls in `custodianApi.ts` +8. **Logging** - Use `[CUSTODIAN]` and `[CUSTODIAN_API]` prefixes +9. **Error handling** - Log errors, don't crash, retry on next poll +10. **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..1d4bdeb --- /dev/null +++ b/packages/custodian-sdk/src/index.ts @@ -0,0 +1,264 @@ +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, + TransferRequestParams, + transferRequestTemplateId, + WrappedSdkWithKeyPair, + type UserKeyPair, +} from "@denotecapital/token-sdk"; +import dotenv from "dotenv"; +import nacl from "tweetnacl"; +import { encodeBase64 } 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 = 60000; // 1 minute +export const API_MOCK_DELAY_MS = 1000; // 1 second + +// Validate environment variables +const CUSTODIAN_PRIVATE_KEY = process.env.CUSTODIAN_PRIVATE_KEY; +if (!CUSTODIAN_PRIVATE_KEY) { + throw new Error("CUSTODIAN_PRIVATE_KEY environment variable is required"); +} + +// Watched template IDs +const WATCHED_TEMPLATE_IDS = [ + issuerMintRequestTemplateId, + transferRequestTemplateId, + issuerBurnRequestTemplateId, + bondIssuerMintRequestTemplateId, + bondTransferRequestTemplateId, + bondLifecycleClaimRequestTemplateId, +]; + +// In-memory set to track processed contracts +const processedContracts = new Set(); + +// Initialize SDK and custodian party +async function initializeCustodian() { + const sdk = await getDefaultSdkAndConnect(); + + // Derive public key from private key using tweetnacl + const secretKey = Buffer.from(CUSTODIAN_PRIVATE_KEY!, "hex"); + const keyPairDerived = nacl.box.keyPair.fromSecretKey(secretKey); + + const keyPair: UserKeyPair = { + privateKey: encodeBase64(secretKey), + publicKey: encodeBase64(keyPairDerived.publicKey), + }; + + // Allocate custodian party + const custodianParty = await sdk.userLedger!.generateExternalParty( + keyPair.publicKey + ); + 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 + ); + + // 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; +} + +// 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}`); + + // Switch on template ID to determine which handler to use + switch (templateId) { + case issuerMintRequestTemplateId: + // Call external API for approval + await custodianApi.approveMint( + request as unknown as Request + ); + // Accept on ledger + await wrappedSdk.issuerMintRequest.accept(contractId); + console.log(`[CUSTODIAN] ✓ Accepted IssuerMintRequest`); + break; + + case transferRequestTemplateId: + await custodianApi.approveTransfer( + request as unknown as Request + ); + await wrappedSdk.transferRequest.accept(contractId); + console.log(`[CUSTODIAN] ✓ Accepted TransferRequest`); + break; + + case issuerBurnRequestTemplateId: + await custodianApi.approveBurn( + request as unknown as Request + ); + await wrappedSdk.issuerBurnRequest.accept(contractId); + console.log(`[CUSTODIAN] ✓ Accepted IssuerBurnRequest`); + break; + + case bondIssuerMintRequestTemplateId: + await custodianApi.approveBondMint( + request as unknown as Request + ); + await wrappedSdk.bonds.issuerMintRequest.accept(contractId); + console.log(`[CUSTODIAN] ✓ Accepted BondIssuerMintRequest`); + break; + + case bondTransferRequestTemplateId: + await custodianApi.approveBondTransfer( + request as unknown as Request + ); + await wrappedSdk.bonds.transferRequest.accept(contractId); + console.log(`[CUSTODIAN] ✓ Accepted BondTransferRequest`); + break; + + case bondLifecycleClaimRequestTemplateId: + await custodianApi.approveBondLifecycleClaim( + request as unknown as Request + ); + await wrappedSdk.bonds.lifecycleClaimRequest.accept(contractId); + console.log(`[CUSTODIAN] ✓ Accepted BondLifecycleClaimRequest`); + break; + + default: + console.warn(`[CUSTODIAN] Unknown template ID: ${templateId}`); + } + + // 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/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/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/wrappedSdk.ts b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts index 02eb438..f321423 100644 --- a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts +++ b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts @@ -1011,3 +1011,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: From 8c99146a14b49c14cf782517eec8fda7cd7db68a Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 8 Dec 2025 11:22:56 +0100 Subject: [PATCH 02/15] Fix private key decoding --- packages/custodian-sdk/src/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/custodian-sdk/src/index.ts b/packages/custodian-sdk/src/index.ts index 1d4bdeb..06102d1 100644 --- a/packages/custodian-sdk/src/index.ts +++ b/packages/custodian-sdk/src/index.ts @@ -21,7 +21,7 @@ import { } from "@denotecapital/token-sdk"; import dotenv from "dotenv"; import nacl from "tweetnacl"; -import { encodeBase64 } from "tweetnacl-util"; +import { decodeBase64, encodeBase64 } from "tweetnacl-util"; import { custodianApi } from "./custodianApi.js"; import { Request } from "./types/Request.js"; @@ -33,6 +33,7 @@ const POLLING_FREQUENCY_MS = 60000; // 1 minute 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"); @@ -56,11 +57,12 @@ async function initializeCustodian() { const sdk = await getDefaultSdkAndConnect(); // Derive public key from private key using tweetnacl - const secretKey = Buffer.from(CUSTODIAN_PRIVATE_KEY!, "hex"); + const secretKeyBase64 = CUSTODIAN_PRIVATE_KEY!; + const secretKey = decodeBase64(secretKeyBase64); const keyPairDerived = nacl.box.keyPair.fromSecretKey(secretKey); const keyPair: UserKeyPair = { - privateKey: encodeBase64(secretKey), + privateKey: secretKeyBase64, publicKey: encodeBase64(keyPairDerived.publicKey), }; From e7b45660f72e50b81d9b247ebb99c6b2a1732a6b Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 8 Dec 2025 11:44:39 +0100 Subject: [PATCH 03/15] Refactor handler --- packages/custodian-sdk/CLAUDE.md | 117 ++++++++++++++++---------- packages/custodian-sdk/src/index.ts | 126 ++++++++++++++++------------ 2 files changed, 148 insertions(+), 95 deletions(-) diff --git a/packages/custodian-sdk/CLAUDE.md b/packages/custodian-sdk/CLAUDE.md index 0ac9dcb..499e523 100644 --- a/packages/custodian-sdk/CLAUDE.md +++ b/packages/custodian-sdk/CLAUDE.md @@ -14,7 +14,7 @@ The custodian-sdk is an automated service that polls the Canton ledger for pendi - 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 switch statement +- Handles 6 different request types via handler map pattern - Uses async/await polling loop instead of setInterval **Custodian API Mock (`src/custodianApi.ts`)** @@ -26,7 +26,7 @@ The custodian-sdk is an automated service that polls the Canton ledger for pendi **Type Definitions (`src/types/Request.ts`)** - Generic `Request` interface - Typed contract parameters from `@denotecapital/token-sdk` -- Type-safe request handling with proper casting in switch cases +- Handler map pattern with typed request handlers for each template ID ### Request Types Handled @@ -55,10 +55,10 @@ The service automatically approves 6 request types: - **Important**: Includes comment about future Canton Participant Query Store (PQS) integration 3. **Request Handling** (`handleRequest`) - - Switch on `request.templateId` to determine request type - - Cast request to appropriate typed `Request` for each case - - Call corresponding `custodianApi.approve*()` method (mock external API) - - Call `wrappedSdk.*.accept()` to accept on ledger + - 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`) @@ -72,7 +72,7 @@ The service automatically approves 6 request types: ### Environment Variables **Required:** -- `CUSTODIAN_PRIVATE_KEY` - Custodian's Ed25519 private key (64-char hex string) +- `CUSTODIAN_PRIVATE_KEY` - Custodian's Ed25519 private key (base64-encoded string) **Loading:** ```typescript @@ -136,7 +136,7 @@ The `src/index.ts` file follows a consistent import organization pattern for bet ```typescript import dotenv from "dotenv"; import nacl from "tweetnacl"; - import { encodeBase64 } from "tweetnacl-util"; + import { decodeBase64, encodeBase64 } from "tweetnacl-util"; ``` 5. **Local imports** - Project-specific modules @@ -212,29 +212,51 @@ async function startPollingLoop() { - Easier to add delays between operations - Natural async/await flow -### 4. Type-Safe Request Handling +### 4. Handler Map Pattern for Type-Safe Request Handling -Each switch case casts the generic `Request` to the specific typed request: +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; // Destructure at start - - console.log(`[CUSTODIAN] Processing ${templateId}`); - console.log(`[CUSTODIAN] Contract ID: ${contractId}`); - - switch (templateId) { - case issuerMintRequestTemplateId: - await custodianApi.approveMint( - request as unknown as Request - ); - await wrappedSdk.issuerMintRequest.accept(contractId); - break; + 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); } ``` -This provides type safety while maintaining a generic polling interface. The function uses early destructuring of `contractId` and `templateId` for cleaner code. +**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 @@ -352,16 +374,21 @@ pnpm clean The service uses `tweetnacl` for key derivation: ```typescript -const secretKey = Buffer.from(CUSTODIAN_PRIVATE_KEY!, "hex"); +// 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: encodeBase64(secretKey), + privateKey: secretKeyBase64, publicKey: encodeBase64(keyPairDerived.publicKey), }; ``` -**Important:** Both keys must be base64-encoded for Canton SDK compatibility. +**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 @@ -527,17 +554,21 @@ curl http://localhost:7575/health 3. Wait up to 60 seconds for next poll cycle 4. Check custodian logs for errors -### Type errors in switch cases +### Type errors in handler map -**Cause:** Missing type casts for generic `Request` type +**Cause:** Missing or incorrect handler function type annotations -**Solution:** Cast each request in switch cases: +**Solution:** Ensure each handler in `REQUEST_HANDLER` has proper type annotations: ```typescript -case issuerMintRequestTemplateId: - await custodianApi.approveMint( - request as unknown as Request - ); - break; +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 @@ -564,8 +595,9 @@ case issuerMintRequestTemplateId: ## Key Files -- `src/index.ts` - Main service implementation (265 lines) +- `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) @@ -586,10 +618,11 @@ When modifying the custodian service: - Token SDK imports (alphabetically) - External libraries - Local imports -4. **Type safety** - Proper casting in switch cases -5. **Code style** - Use early destructuring for cleaner code -6. **Avoid dynamic imports** - Import directly at top for better tree-shaking -7. **Mock API** - Keep external API calls in `custodianApi.ts` -8. **Logging** - Use `[CUSTODIAN]` and `[CUSTODIAN_API]` prefixes -9. **Error handling** - Log errors, don't crash, retry on next poll -10. **Documentation** - Update README.md and CLAUDE.md for significant changes +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/src/index.ts b/packages/custodian-sdk/src/index.ts index 06102d1..bcf0a9e 100644 --- a/packages/custodian-sdk/src/index.ts +++ b/packages/custodian-sdk/src/index.ts @@ -131,6 +131,71 @@ async function pollForRequests(sdk: WalletSDK, custodianPartyId: string) { 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>; + // Handle request based on template ID async function handleRequest( request: Request, @@ -142,61 +207,16 @@ async function handleRequest( console.log(`[CUSTODIAN] Contract ID: ${contractId}`); // Switch on template ID to determine which handler to use - switch (templateId) { - case issuerMintRequestTemplateId: - // Call external API for approval - await custodianApi.approveMint( - request as unknown as Request - ); - // Accept on ledger - await wrappedSdk.issuerMintRequest.accept(contractId); - console.log(`[CUSTODIAN] ✓ Accepted IssuerMintRequest`); - break; - - case transferRequestTemplateId: - await custodianApi.approveTransfer( - request as unknown as Request - ); - await wrappedSdk.transferRequest.accept(contractId); - console.log(`[CUSTODIAN] ✓ Accepted TransferRequest`); - break; - - case issuerBurnRequestTemplateId: - await custodianApi.approveBurn( - request as unknown as Request - ); - await wrappedSdk.issuerBurnRequest.accept(contractId); - console.log(`[CUSTODIAN] ✓ Accepted IssuerBurnRequest`); - break; - - case bondIssuerMintRequestTemplateId: - await custodianApi.approveBondMint( - request as unknown as Request - ); - await wrappedSdk.bonds.issuerMintRequest.accept(contractId); - console.log(`[CUSTODIAN] ✓ Accepted BondIssuerMintRequest`); - break; - - case bondTransferRequestTemplateId: - await custodianApi.approveBondTransfer( - request as unknown as Request - ); - await wrappedSdk.bonds.transferRequest.accept(contractId); - console.log(`[CUSTODIAN] ✓ Accepted BondTransferRequest`); - break; - - case bondLifecycleClaimRequestTemplateId: - await custodianApi.approveBondLifecycleClaim( - request as unknown as Request - ); - await wrappedSdk.bonds.lifecycleClaimRequest.accept(contractId); - console.log(`[CUSTODIAN] ✓ Accepted BondLifecycleClaimRequest`); - break; - - default: - console.warn(`[CUSTODIAN] Unknown template ID: ${templateId}`); + 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]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await handler(request as Request, wrappedSdk); + // Mark as processed processedContracts.add(contractId); } From 7c89720dc0b4aa0e16f3dd6d75384b1019dfc1b4 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 8 Dec 2025 15:00:56 +0100 Subject: [PATCH 04/15] Update minimal token package id --- packages/token-sdk/src/uploadDars.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/token-sdk/src/uploadDars.ts b/packages/token-sdk/src/uploadDars.ts index b79ef0f..dc152ce 100644 --- a/packages/token-sdk/src/uploadDars.ts +++ b/packages/token-sdk/src/uploadDars.ts @@ -22,7 +22,7 @@ export async function uploadDars() { // Obtained from runnning: // `pnpm get:minimal-token-id` const MINIMAL_TOKEN_PACKAGE_ID = - "72a07c72a9af38316e3f222a12888931f816cf22dabb9f22f3b474d966fea325"; + "ad9c5643bbcc725d457dfca291a50fbca0c00c2ba6a7d4e8a8c89e8693550889"; const isDarUploaded = await sdk.userLedger?.isPackageUploaded( MINIMAL_TOKEN_PACKAGE_ID From b56311c77be13a0289d223a2ec1bf46bb8133d02 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 8 Dec 2025 15:59:49 +0100 Subject: [PATCH 05/15] Fix custodian private key handling --- packages/custodian-sdk/.env.example | 3 ++- packages/custodian-sdk/src/index.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/custodian-sdk/.env.example b/packages/custodian-sdk/.env.example index 931557f..0983885 100644 --- a/packages/custodian-sdk/.env.example +++ b/packages/custodian-sdk/.env.example @@ -1,2 +1,3 @@ # Custodian private key (64-char hex string) -CUSTODIAN_PRIVATE_KEY=your_private_key_here +# Derived with keyPairFromSeed("custodian").privateKey from @denotecapital/token-sdk +CUSTODIAN_PRIVATE_KEY=Y3VzdG9kaWFuMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDD7eWwX55oMQAt1WT0dLYtosRG9jl/SMO3BlA7R+qvYQw== diff --git a/packages/custodian-sdk/src/index.ts b/packages/custodian-sdk/src/index.ts index bcf0a9e..a21d0fa 100644 --- a/packages/custodian-sdk/src/index.ts +++ b/packages/custodian-sdk/src/index.ts @@ -21,7 +21,7 @@ import { } from "@denotecapital/token-sdk"; import dotenv from "dotenv"; import nacl from "tweetnacl"; -import { decodeBase64, encodeBase64 } from "tweetnacl-util"; +import naclUtil from "tweetnacl-util"; import { custodianApi } from "./custodianApi.js"; import { Request } from "./types/Request.js"; @@ -58,12 +58,12 @@ async function initializeCustodian() { // Derive public key from private key using tweetnacl const secretKeyBase64 = CUSTODIAN_PRIVATE_KEY!; - const secretKey = decodeBase64(secretKeyBase64); - const keyPairDerived = nacl.box.keyPair.fromSecretKey(secretKey); + const secretKey = naclUtil.decodeBase64(secretKeyBase64); + const keyPairDerived = nacl.sign.keyPair.fromSecretKey(secretKey); const keyPair: UserKeyPair = { privateKey: secretKeyBase64, - publicKey: encodeBase64(keyPairDerived.publicKey), + publicKey: naclUtil.encodeBase64(keyPairDerived.publicKey), }; // Allocate custodian party From 83827ef6a71cca2d3020a378e756fb65b7b40e3b Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 8 Dec 2025 19:00:38 +0100 Subject: [PATCH 06/15] Properly handle request template ids, start investigating transfer requests --- packages/custodian-sdk/.env.example | 1 + packages/custodian-sdk/src/index.ts | 23 +++++++++++++++---- packages/token-app/next-env.d.ts | 2 +- .../src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 4 ++++ packages/token-sdk/src/constants/index.ts | 1 + .../token-sdk/src/constants/templateIds.ts | 2 ++ packages/token-sdk/src/uploadDars.ts | 6 +---- .../src/wrappedSdk/transferRequest.ts | 4 +--- 8 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts diff --git a/packages/custodian-sdk/.env.example b/packages/custodian-sdk/.env.example index 0983885..fed4dab 100644 --- a/packages/custodian-sdk/.env.example +++ b/packages/custodian-sdk/.env.example @@ -1,3 +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/src/index.ts b/packages/custodian-sdk/src/index.ts index a21d0fa..2997eab 100644 --- a/packages/custodian-sdk/src/index.ts +++ b/packages/custodian-sdk/src/index.ts @@ -14,6 +14,7 @@ import { issuerBurnRequestTemplateId, IssuerMintRequestParams, issuerMintRequestTemplateId, + MINIMAL_TOKEN_PACKAGE_ID, TransferRequestParams, transferRequestTemplateId, WrappedSdkWithKeyPair, @@ -29,7 +30,7 @@ import { Request } from "./types/Request.js"; dotenv.config(); // Hard-coded configuration constants -const POLLING_FREQUENCY_MS = 60000; // 1 minute +const POLLING_FREQUENCY_MS = 10000; // 10 seconds export const API_MOCK_DELAY_MS = 1000; // 1 second // Validate environment variables @@ -56,8 +57,12 @@ const processedContracts = new Set(); 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); @@ -68,8 +73,10 @@ async function initializeCustodian() { // Allocate custodian party const custodianParty = await sdk.userLedger!.generateExternalParty( - keyPair.publicKey + keyPair.publicKey, + custodianPartyHint ); + if (!custodianParty) throw new Error("Failed to generate custodian party"); // Sign and allocate party @@ -82,6 +89,8 @@ async function initializeCustodian() { custodianParty ); + console.log("[CUSTODIAN] Allocated party ID: ", allocatedParty.partyId); + // Set party ID on SDK await sdk.setPartyId(allocatedParty.partyId); @@ -206,13 +215,19 @@ async function handleRequest( 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 (!(templateId in REQUEST_HANDLER)) { + if (!(templateIdFormatted in REQUEST_HANDLER)) { console.warn(`[CUSTODIAN] No handler for template ID: ${templateId}`); return; } - const handler = REQUEST_HANDLER[templateId as keyof typeof REQUEST_HANDLER]; + 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); 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-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..06154e1 --- /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 = + "ad9c5643bbcc725d457dfca291a50fbca0c00c2ba6a7d4e8a8c89e8693550889"; 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..d0c1244 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"; diff --git a/packages/token-sdk/src/uploadDars.ts b/packages/token-sdk/src/uploadDars.ts index dc152ce..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 = - "ad9c5643bbcc725d457dfca291a50fbca0c00c2ba6a7d4e8a8c89e8693550889"; - const isDarUploaded = await sdk.userLedger?.isPackageUploaded( MINIMAL_TOKEN_PACKAGE_ID ); 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 }); From 7e442c7d5f0f70d3d35660ef61a988555eb4b30b Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 8 Dec 2025 19:22:35 +0100 Subject: [PATCH 07/15] Better handle ignored template ids --- packages/custodian-sdk/src/index.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/custodian-sdk/src/index.ts b/packages/custodian-sdk/src/index.ts index 2997eab..f8e5524 100644 --- a/packages/custodian-sdk/src/index.ts +++ b/packages/custodian-sdk/src/index.ts @@ -40,16 +40,6 @@ if (!CUSTODIAN_PRIVATE_KEY) { throw new Error("CUSTODIAN_PRIVATE_KEY environment variable is required"); } -// Watched template IDs -const WATCHED_TEMPLATE_IDS = [ - issuerMintRequestTemplateId, - transferRequestTemplateId, - issuerBurnRequestTemplateId, - bondIssuerMintRequestTemplateId, - bondTransferRequestTemplateId, - bondLifecycleClaimRequestTemplateId, -]; - // In-memory set to track processed contracts const processedContracts = new Set(); @@ -115,7 +105,7 @@ async function pollForRequests(sdk: WalletSDK, custodianPartyId: string) { // Query all watched template IDs in a single call const response = (await sdk.userLedger!.activeContracts({ - templateIds: WATCHED_TEMPLATE_IDS, + // templateIds: WATCHED_TEMPLATE_IDS, filterByParty: true, parties: [custodianPartyId], offset, @@ -205,6 +195,9 @@ const REQUEST_HANDLER = { // 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, @@ -223,6 +216,7 @@ async function handleRequest( // 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; } From 352a2f7ab5063fa921a41163d82646b66405d7c3 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 8 Dec 2025 19:25:01 +0100 Subject: [PATCH 08/15] Update demo title --- packages/token-app/app/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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({ From aab1941564063370592a623c94639f65060c302c Mon Sep 17 00:00:00 2001 From: tota79 Date: Tue, 2 Dec 2025 18:46:03 +0800 Subject: [PATCH 09/15] [wip] bond contracts --- .../minimal-token/daml/Bond/BondFactory.daml | 2 +- .../daml/Bond/BondLifecycleClaim.daml | 212 ++++++++++++++++++ .../daml/Bond/BondLifecycleInstruction.daml | 2 - 3 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 packages/minimal-token/daml/Bond/BondLifecycleClaim.daml diff --git a/packages/minimal-token/daml/Bond/BondFactory.daml b/packages/minimal-token/daml/Bond/BondFactory.daml index bf53267..aa93977 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 @@ -35,3 +34,4 @@ template BondFactory pure instrumentCid + diff --git a/packages/minimal-token/daml/Bond/BondLifecycleClaim.daml b/packages/minimal-token/daml/Bond/BondLifecycleClaim.daml new file mode 100644 index 0000000..1e23e0b --- /dev/null +++ b/packages/minimal-token/daml/Bond/BondLifecycleClaim.daml @@ -0,0 +1,212 @@ +-- | Claim processor for lifecycle effects (coupon payments and redemption). +-- +-- This module implements the claim pattern from Daml-Finance, where holders claim lifecycle +-- effects and generate settlement instructions. It follows the propose/accept pattern: +-- holders create claim requests, issuers accept them (locking bonds and creating instructions). +-- +-- Daml-Finance Reference: +-- https://github.com/digital-asset/daml-finance/blob/main/src/main/daml/Daml/Finance/Lifecycle/V4/Rule/Claim.daml +-- +-- Key differences from Daml-Finance: +-- - Simplified to use propose/accept pattern (BondLifecycleClaimRequest) instead of direct +-- Claim.ClaimEffect choice, avoiding submitMulti requirements +-- - Directly creates BondLifecycleInstruction and currency transfer instructions (factory +-- logic inlined, matching Daml-Finance pattern where Rule.Claim creates settlement instructions) +-- - Uses CIP-0056 TransferFactory interface for currency payments (modular, works with any token) +module Bond.BondLifecycleClaim where + +import Splice.Api.Token.HoldingV1 as H +import Splice.Api.Token.TransferInstructionV1 as TI +import Splice.Api.Token.MetadataV1 as MD +import Bond.Bond +import Bond.BondRules +import Bond.BondLifecycleEffect +import Bond.BondLifecycleInstruction +import Bond.BondFactory +import DA.Time + +-- | Result of creating a coupon payment instruction +data CouponPaymentInstructionResult = CouponPaymentInstructionResult with + instructionCid : ContractId BondLifecycleInstruction + couponAmount : Decimal + currencyTransferInstructionCid : Optional (ContractId TI.TransferInstruction) + meta : MD.Metadata + deriving (Show, Eq) + +-- | Result of creating a redemption instruction +data RedemptionInstructionResult = RedemptionInstructionResult with + instructionCid : ContractId BondLifecycleInstruction + principalAmount : Decimal + currencyTransferInstructionCid : Optional (ContractId TI.TransferInstruction) + meta : MD.Metadata + deriving (Show, Eq) + +-- | Sum type for lifecycle instruction results +data LifecycleInstructionResult + = ClaimResult_CouponPayment CouponPaymentInstructionResult + | ClaimResult_Redemption RedemptionInstructionResult + deriving (Show, Eq) + +-- | Request to claim a lifecycle effect (coupon payment or redemption). +-- Holder creates this request, issuer accepts it. +-- Follows propose/accept pattern. +-- When issuer accepts, it locks the bond and creates the appropriate lifecycle instruction. +template BondLifecycleClaimRequest + with + effectCid : ContractId BondLifecycleEffect + bondHoldingCid : ContractId H.Holding + bondRulesCid : ContractId BondRules + bondFactoryCid : ContractId BondFactory + currencyTransferFactoryCid : ContractId TI.TransferFactory + issuerCurrencyHoldingCid : ContractId H.Holding + holder : Party + issuer : Party + where + signatory holder + observer issuer + + choice Accept : LifecycleInstructionResult + controller issuer + do + effect <- fetch effectCid + let bondCid : ContractId Bond = fromInterfaceContractId bondHoldingCid + bond <- fetch bondCid + + -- Validate issuer matches effect + assertMsg "Issuer must match effect issuer" (this.issuer == effect.issuer) + + -- Validate bond matches effect target + assertMsg "Bond instrument ID must match effect target" (bond.instrumentId == effect.targetInstrumentId) + assertMsg "Bond version must match effect target version" (bond.version == effect.targetVersion) + assertMsg "Bond issuer must match effect issuer" (bond.issuer == effect.issuer) + assertMsg "Bond depository must match effect depository" (bond.depository == effect.depository) + + case effect.eventType of + CouponPayment -> do + assertMsg "Coupon payment date must be after last event timestamp" (effect.eventDate > bond.lastEventTimestamp) + assertMsg "Coupon payment date must be before maturity date" (effect.eventDate < bond.maturityDate) + + (lockedBondCid, _, _) <- exercise bondRulesCid BondRules_LockForCoupon with + holder = bond.owner + inputBondCid = bondHoldingCid + couponPaymentDate = effect.eventDate + contextText = "coupon_payment" + + let lockedBondCidTyped : ContractId LockedBond = lockedBondCid + + locked <- fetch lockedBondCidTyped + assertMsg "Bond must belong to this issuer" (locked.bond.issuer == issuer) + assertMsg "Coupon payment date must be after issue date" (effect.eventDate >= locked.bond.issueDate) + assertMsg "Coupon payment date must be after last event timestamp" (effect.eventDate > locked.bond.lastEventTimestamp) + assertMsg "Coupon payment date must be before maturity date" (effect.eventDate < locked.bond.maturityDate) + assertMsg "Coupon amount must be positive" (effect.amount > 0.0) + + now <- getTime + let requestedAt = addRelTime now (seconds (-1)) + let executeBefore = addRelTime now (days 365) + let transfer = TI.Transfer with + sender = issuer + receiver = locked.bond.owner + amount = effect.amount + instrumentId = effect.currencyInstrumentId + requestedAt = requestedAt + executeBefore = executeBefore + inputHoldingCids = [issuerCurrencyHoldingCid] + meta = MD.emptyMetadata + + currencyTransferResult <- exercise currencyTransferFactoryCid TI.TransferFactory_Transfer with + expectedAdmin = issuer + transfer + extraArgs = MD.ExtraArgs with + context = MD.emptyChoiceContext + meta = MD.emptyMetadata + + currencyTransferInstructionCid <- case currencyTransferResult.output of + TI.TransferInstructionResult_Pending { transferInstructionCid } -> pure transferInstructionCid + _ -> fail "Failed to create currency transfer instruction" + + instructionCid <- create BondLifecycleInstruction with + eventType = CouponPayment + lockedBond = lockedBondCidTyped + bondFactoryCid = Some bondFactoryCid + producedVersion = effect.producedVersion + issuer = locked.bond.issuer + holder = locked.bond.owner + eventDate = effect.eventDate + amount = effect.amount + currencyInstrumentId = effect.currencyInstrumentId + + pure $ ClaimResult_CouponPayment $ CouponPaymentInstructionResult with + instructionCid + couponAmount = effect.amount + currencyTransferInstructionCid = Some currencyTransferInstructionCid + meta = MD.emptyMetadata + + Redemption -> do + now <- getTime + assertMsg "Redemption date must be on or after maturity date" (effect.eventDate >= bond.maturityDate) + assertMsg "Redemption date must be in the past or present" (effect.eventDate <= now) + + (lockedBondCid, _, _) <- exercise bondRulesCid BondRules_LockForRedemption with + holder = bond.owner + inputBondCid = bondHoldingCid + redemptionDate = effect.eventDate + contextText = "redemption" + + let lockedBondCidTyped : ContractId LockedBond = lockedBondCid + + locked <- fetch lockedBondCidTyped + assertMsg "Bond must belong to this issuer" (locked.bond.issuer == issuer) + assertMsg "Redemption date must be on or after maturity date" (effect.eventDate >= locked.bond.maturityDate) + assertMsg "Principal amount must be positive" (effect.amount > 0.0) + + let requestedAt = addRelTime now (seconds (-1)) + let executeBefore = addRelTime now (days 365) + let transfer = TI.Transfer with + sender = issuer + receiver = locked.bond.owner + amount = effect.amount + instrumentId = effect.currencyInstrumentId + requestedAt = requestedAt + executeBefore = executeBefore + inputHoldingCids = [issuerCurrencyHoldingCid] + meta = MD.emptyMetadata + + currencyTransferResult <- exercise currencyTransferFactoryCid TI.TransferFactory_Transfer with + expectedAdmin = issuer + transfer + extraArgs = MD.ExtraArgs with + context = MD.emptyChoiceContext + meta = MD.emptyMetadata + + currencyTransferInstructionCid <- case currencyTransferResult.output of + TI.TransferInstructionResult_Pending { transferInstructionCid } -> pure transferInstructionCid + _ -> fail "Failed to create currency transfer instruction" + + instructionCid <- create BondLifecycleInstruction with + eventType = Redemption + lockedBond = lockedBondCidTyped + bondFactoryCid = None + producedVersion = effect.producedVersion + issuer = locked.bond.issuer + holder = locked.bond.owner + eventDate = effect.eventDate + amount = effect.amount + currencyInstrumentId = effect.currencyInstrumentId + + pure $ ClaimResult_Redemption $ RedemptionInstructionResult with + instructionCid + principalAmount = effect.amount + currencyTransferInstructionCid = Some currencyTransferInstructionCid + meta = MD.emptyMetadata + + choice Decline : () + controller issuer + do + pure () + + choice Withdraw : () + controller holder + do + pure () + 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 From e937dcf2991c786693e81384fd93c35a73d3d44d Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Wed, 3 Dec 2025 18:19:00 +0100 Subject: [PATCH 10/15] Start adding bond methods to wrapped sdk --- .../daml/Bond/BondLifecycleClaim.daml | 212 ------------------ .../daml/Bond/BondLifecycleClaimRequest.daml | 1 - 2 files changed, 213 deletions(-) delete mode 100644 packages/minimal-token/daml/Bond/BondLifecycleClaim.daml diff --git a/packages/minimal-token/daml/Bond/BondLifecycleClaim.daml b/packages/minimal-token/daml/Bond/BondLifecycleClaim.daml deleted file mode 100644 index 1e23e0b..0000000 --- a/packages/minimal-token/daml/Bond/BondLifecycleClaim.daml +++ /dev/null @@ -1,212 +0,0 @@ --- | Claim processor for lifecycle effects (coupon payments and redemption). --- --- This module implements the claim pattern from Daml-Finance, where holders claim lifecycle --- effects and generate settlement instructions. It follows the propose/accept pattern: --- holders create claim requests, issuers accept them (locking bonds and creating instructions). --- --- Daml-Finance Reference: --- https://github.com/digital-asset/daml-finance/blob/main/src/main/daml/Daml/Finance/Lifecycle/V4/Rule/Claim.daml --- --- Key differences from Daml-Finance: --- - Simplified to use propose/accept pattern (BondLifecycleClaimRequest) instead of direct --- Claim.ClaimEffect choice, avoiding submitMulti requirements --- - Directly creates BondLifecycleInstruction and currency transfer instructions (factory --- logic inlined, matching Daml-Finance pattern where Rule.Claim creates settlement instructions) --- - Uses CIP-0056 TransferFactory interface for currency payments (modular, works with any token) -module Bond.BondLifecycleClaim where - -import Splice.Api.Token.HoldingV1 as H -import Splice.Api.Token.TransferInstructionV1 as TI -import Splice.Api.Token.MetadataV1 as MD -import Bond.Bond -import Bond.BondRules -import Bond.BondLifecycleEffect -import Bond.BondLifecycleInstruction -import Bond.BondFactory -import DA.Time - --- | Result of creating a coupon payment instruction -data CouponPaymentInstructionResult = CouponPaymentInstructionResult with - instructionCid : ContractId BondLifecycleInstruction - couponAmount : Decimal - currencyTransferInstructionCid : Optional (ContractId TI.TransferInstruction) - meta : MD.Metadata - deriving (Show, Eq) - --- | Result of creating a redemption instruction -data RedemptionInstructionResult = RedemptionInstructionResult with - instructionCid : ContractId BondLifecycleInstruction - principalAmount : Decimal - currencyTransferInstructionCid : Optional (ContractId TI.TransferInstruction) - meta : MD.Metadata - deriving (Show, Eq) - --- | Sum type for lifecycle instruction results -data LifecycleInstructionResult - = ClaimResult_CouponPayment CouponPaymentInstructionResult - | ClaimResult_Redemption RedemptionInstructionResult - deriving (Show, Eq) - --- | Request to claim a lifecycle effect (coupon payment or redemption). --- Holder creates this request, issuer accepts it. --- Follows propose/accept pattern. --- When issuer accepts, it locks the bond and creates the appropriate lifecycle instruction. -template BondLifecycleClaimRequest - with - effectCid : ContractId BondLifecycleEffect - bondHoldingCid : ContractId H.Holding - bondRulesCid : ContractId BondRules - bondFactoryCid : ContractId BondFactory - currencyTransferFactoryCid : ContractId TI.TransferFactory - issuerCurrencyHoldingCid : ContractId H.Holding - holder : Party - issuer : Party - where - signatory holder - observer issuer - - choice Accept : LifecycleInstructionResult - controller issuer - do - effect <- fetch effectCid - let bondCid : ContractId Bond = fromInterfaceContractId bondHoldingCid - bond <- fetch bondCid - - -- Validate issuer matches effect - assertMsg "Issuer must match effect issuer" (this.issuer == effect.issuer) - - -- Validate bond matches effect target - assertMsg "Bond instrument ID must match effect target" (bond.instrumentId == effect.targetInstrumentId) - assertMsg "Bond version must match effect target version" (bond.version == effect.targetVersion) - assertMsg "Bond issuer must match effect issuer" (bond.issuer == effect.issuer) - assertMsg "Bond depository must match effect depository" (bond.depository == effect.depository) - - case effect.eventType of - CouponPayment -> do - assertMsg "Coupon payment date must be after last event timestamp" (effect.eventDate > bond.lastEventTimestamp) - assertMsg "Coupon payment date must be before maturity date" (effect.eventDate < bond.maturityDate) - - (lockedBondCid, _, _) <- exercise bondRulesCid BondRules_LockForCoupon with - holder = bond.owner - inputBondCid = bondHoldingCid - couponPaymentDate = effect.eventDate - contextText = "coupon_payment" - - let lockedBondCidTyped : ContractId LockedBond = lockedBondCid - - locked <- fetch lockedBondCidTyped - assertMsg "Bond must belong to this issuer" (locked.bond.issuer == issuer) - assertMsg "Coupon payment date must be after issue date" (effect.eventDate >= locked.bond.issueDate) - assertMsg "Coupon payment date must be after last event timestamp" (effect.eventDate > locked.bond.lastEventTimestamp) - assertMsg "Coupon payment date must be before maturity date" (effect.eventDate < locked.bond.maturityDate) - assertMsg "Coupon amount must be positive" (effect.amount > 0.0) - - now <- getTime - let requestedAt = addRelTime now (seconds (-1)) - let executeBefore = addRelTime now (days 365) - let transfer = TI.Transfer with - sender = issuer - receiver = locked.bond.owner - amount = effect.amount - instrumentId = effect.currencyInstrumentId - requestedAt = requestedAt - executeBefore = executeBefore - inputHoldingCids = [issuerCurrencyHoldingCid] - meta = MD.emptyMetadata - - currencyTransferResult <- exercise currencyTransferFactoryCid TI.TransferFactory_Transfer with - expectedAdmin = issuer - transfer - extraArgs = MD.ExtraArgs with - context = MD.emptyChoiceContext - meta = MD.emptyMetadata - - currencyTransferInstructionCid <- case currencyTransferResult.output of - TI.TransferInstructionResult_Pending { transferInstructionCid } -> pure transferInstructionCid - _ -> fail "Failed to create currency transfer instruction" - - instructionCid <- create BondLifecycleInstruction with - eventType = CouponPayment - lockedBond = lockedBondCidTyped - bondFactoryCid = Some bondFactoryCid - producedVersion = effect.producedVersion - issuer = locked.bond.issuer - holder = locked.bond.owner - eventDate = effect.eventDate - amount = effect.amount - currencyInstrumentId = effect.currencyInstrumentId - - pure $ ClaimResult_CouponPayment $ CouponPaymentInstructionResult with - instructionCid - couponAmount = effect.amount - currencyTransferInstructionCid = Some currencyTransferInstructionCid - meta = MD.emptyMetadata - - Redemption -> do - now <- getTime - assertMsg "Redemption date must be on or after maturity date" (effect.eventDate >= bond.maturityDate) - assertMsg "Redemption date must be in the past or present" (effect.eventDate <= now) - - (lockedBondCid, _, _) <- exercise bondRulesCid BondRules_LockForRedemption with - holder = bond.owner - inputBondCid = bondHoldingCid - redemptionDate = effect.eventDate - contextText = "redemption" - - let lockedBondCidTyped : ContractId LockedBond = lockedBondCid - - locked <- fetch lockedBondCidTyped - assertMsg "Bond must belong to this issuer" (locked.bond.issuer == issuer) - assertMsg "Redemption date must be on or after maturity date" (effect.eventDate >= locked.bond.maturityDate) - assertMsg "Principal amount must be positive" (effect.amount > 0.0) - - let requestedAt = addRelTime now (seconds (-1)) - let executeBefore = addRelTime now (days 365) - let transfer = TI.Transfer with - sender = issuer - receiver = locked.bond.owner - amount = effect.amount - instrumentId = effect.currencyInstrumentId - requestedAt = requestedAt - executeBefore = executeBefore - inputHoldingCids = [issuerCurrencyHoldingCid] - meta = MD.emptyMetadata - - currencyTransferResult <- exercise currencyTransferFactoryCid TI.TransferFactory_Transfer with - expectedAdmin = issuer - transfer - extraArgs = MD.ExtraArgs with - context = MD.emptyChoiceContext - meta = MD.emptyMetadata - - currencyTransferInstructionCid <- case currencyTransferResult.output of - TI.TransferInstructionResult_Pending { transferInstructionCid } -> pure transferInstructionCid - _ -> fail "Failed to create currency transfer instruction" - - instructionCid <- create BondLifecycleInstruction with - eventType = Redemption - lockedBond = lockedBondCidTyped - bondFactoryCid = None - producedVersion = effect.producedVersion - issuer = locked.bond.issuer - holder = locked.bond.owner - eventDate = effect.eventDate - amount = effect.amount - currencyInstrumentId = effect.currencyInstrumentId - - pure $ ClaimResult_Redemption $ RedemptionInstructionResult with - instructionCid - principalAmount = effect.amount - currencyTransferInstructionCid = Some currencyTransferInstructionCid - meta = MD.emptyMetadata - - choice Decline : () - controller issuer - do - pure () - - choice Withdraw : () - controller holder - do - pure () - 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 From 4e3608e179f23a83187db84fb9850b3ed2fbf0e9 Mon Sep 17 00:00:00 2001 From: tota79 Date: Thu, 4 Dec 2025 14:23:58 +0800 Subject: [PATCH 11/15] use ledger time --- packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml index a752c50..97171e2 100644 --- a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml +++ b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml @@ -327,6 +327,9 @@ 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 + claimRequestCid2 <- submit bob do createCmd BondLifecycleClaimRequest.BondLifecycleClaimRequest with effectCid = effectCid2 From 5c666443ff7b9b4a932236aca7a517faabd9eb4f Mon Sep 17 00:00:00 2001 From: tota79 Date: Thu, 4 Dec 2025 16:28:54 +0800 Subject: [PATCH 12/15] Refactor bonds: separate notional from amount (fungible bonds) --- .../minimal-token/daml/Bond/BondFactory.daml | 3 + .../daml/Bond/Test/BondLifecycleTest.daml | 5 ++ .../src/testScripts/bondLifecycleTest.ts | 5 +- .../token-sdk/src/wrappedSdk/bonds/factory.ts | 25 +++++++- .../token-sdk/src/wrappedSdk/wrappedSdk.ts | 58 ++++++++++++++++--- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/packages/minimal-token/daml/Bond/BondFactory.daml b/packages/minimal-token/daml/Bond/BondFactory.daml index aa93977..2b56e9b 100644 --- a/packages/minimal-token/daml/Bond/BondFactory.daml +++ b/packages/minimal-token/daml/Bond/BondFactory.daml @@ -7,6 +7,9 @@ template BondFactory with issuer : Party instrumentId : Text -- e.g., show issuer <> "#Bond" + notional : Decimal + couponRate : Decimal + couponFrequency : Int where signatory issuer -- | Create a bond instrument definition with all terms. diff --git a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml index 97171e2..88e8fca 100644 --- a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml +++ b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml @@ -47,6 +47,9 @@ testBondFullLifecycle = script do createCmd BondFactory with issuer = charlie instrumentId = instrumentId + notional = 1000.0 + couponRate = couponRate + couponFrequency = couponFrequency let currencyInstrumentId = show charlie <> "#Currency" currencyRulesCid <- submit charlie do @@ -329,6 +332,8 @@ testBondFullLifecycle = script do 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 diff --git a/packages/token-sdk/src/testScripts/bondLifecycleTest.ts b/packages/token-sdk/src/testScripts/bondLifecycleTest.ts index 1c2ba6a..48a5b2e 100644 --- a/packages/token-sdk/src/testScripts/bondLifecycleTest.ts +++ b/packages/token-sdk/src/testScripts/bondLifecycleTest.ts @@ -151,7 +151,10 @@ async function bondLifecycleTest() { const bondRulesCid = await charlieWrappedSdk.bonds.bondRules.getOrCreate(); const bondFactoryCid = await charlieWrappedSdk.bonds.factory.getOrCreate( - bondInstrumentId + bondInstrumentId, + 1000.0, // notional + 0.05, // couponRate (5% annual) + 2 // couponFrequency (semi-annual) ); if (!bondFactoryCid) { throw new Error("Bond factory contract ID not found after getOrCreate"); diff --git a/packages/token-sdk/src/wrappedSdk/bonds/factory.ts b/packages/token-sdk/src/wrappedSdk/bonds/factory.ts index f2d5c77..c874c22 100644 --- a/packages/token-sdk/src/wrappedSdk/bonds/factory.ts +++ b/packages/token-sdk/src/wrappedSdk/bonds/factory.ts @@ -31,6 +31,9 @@ export interface BondFactoryParams { issuer: Party; /** Unique identifier for the bond instrument (e.g., "party123#Bond") */ instrumentId: string; + notional: number; + couponRate: number; + couponFrequency: number; } const getCreateBondFactoryCommand = (params: BondFactoryParams) => @@ -51,12 +54,18 @@ const getCreateBondFactoryCommand = (params: BondFactoryParams) => export async function createBondFactory( userLedger: LedgerController, userKeyPair: UserKeyPair, - instrumentId: string + instrumentId: string, + notional: number, + couponRate: number, + couponFrequency: number ) { const issuer = userLedger.getPartyId(); const createBondFactoryCommand = getCreateBondFactoryCommand({ instrumentId, issuer, + notional, + couponRate, + couponFrequency, }); await userLedger.prepareSignExecuteAndWaitFor( [createBondFactoryCommand], @@ -107,12 +116,22 @@ export async function getLatestBondFactory( export async function getOrCreateBondFactory( userLedger: LedgerController, userKeyPair: UserKeyPair, - instrumentId: string + instrumentId: string, + notional: number, + couponRate: number, + couponFrequency: number ) { const contractId = await getLatestBondFactory(userLedger, instrumentId); if (contractId) return contractId; - await createBondFactory(userLedger, userKeyPair, instrumentId); + await createBondFactory( + userLedger, + userKeyPair, + instrumentId, + notional, + couponRate, + couponFrequency + ); return (await getLatestBondFactory(userLedger, instrumentId))!; } diff --git a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts index f321423..baf5899 100644 --- a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts +++ b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts @@ -162,15 +162,37 @@ export const getWrappedSdk = (sdk: WalletSDK) => { return { bonds: { factory: { - create: (userKeyPair: UserKeyPair, instrumentId: string) => - createBondFactory(userLedger, userKeyPair, instrumentId), + create: ( + userKeyPair: UserKeyPair, + instrumentId: string, + notional: number, + couponRate: number, + couponFrequency: number + ) => + createBondFactory( + userLedger, + userKeyPair, + instrumentId, + notional, + couponRate, + couponFrequency + ), getLatest: (instrumentId: string) => getLatestBondFactory(userLedger, instrumentId), - getOrCreate: (userKeyPair: UserKeyPair, instrumentId: string) => + getOrCreate: ( + userKeyPair: UserKeyPair, + instrumentId: string, + notional: number, + couponRate: number, + couponFrequency: number + ) => getOrCreateBondFactory( userLedger, userKeyPair, - instrumentId + instrumentId, + notional, + couponRate, + couponFrequency ), createInstrument: ( userKeyPair: UserKeyPair, @@ -607,15 +629,35 @@ export const getWrappedSdkWithKeyPair = ( return { bonds: { factory: { - create: (instrumentId: string) => - createBondFactory(userLedger, userKeyPair, instrumentId), + create: ( + instrumentId: string, + notional: number, + couponRate: number, + couponFrequency: number + ) => + createBondFactory( + userLedger, + userKeyPair, + instrumentId, + notional, + couponRate, + couponFrequency + ), getLatest: (instrumentId: string) => getLatestBondFactory(userLedger, instrumentId), - getOrCreate: (instrumentId: string) => + getOrCreate: ( + instrumentId: string, + notional: number, + couponRate: number, + couponFrequency: number + ) => getOrCreateBondFactory( userLedger, userKeyPair, - instrumentId + instrumentId, + notional, + couponRate, + couponFrequency ), createInstrument: ( bondFactoryCid: ContractId, From b05e277b7aa72dadd88ca682d9fbde61882dc9de Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Thu, 4 Dec 2025 17:27:12 +0100 Subject: [PATCH 13/15] Update docs and comments --- packages/token-sdk/src/wrappedSdk/bonds/factory.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/token-sdk/src/wrappedSdk/bonds/factory.ts b/packages/token-sdk/src/wrappedSdk/bonds/factory.ts index c874c22..11b7bc2 100644 --- a/packages/token-sdk/src/wrappedSdk/bonds/factory.ts +++ b/packages/token-sdk/src/wrappedSdk/bonds/factory.ts @@ -31,8 +31,11 @@ export interface BondFactoryParams { issuer: Party; /** Unique identifier for the bond instrument (e.g., "party123#Bond") */ instrumentId: string; + /** Face value per bond unit (e.g., 1000 means each bond has $1000 face value) */ notional: number; + /** Annual coupon rate as a decimal (e.g., 0.05 = 5% annual interest) */ couponRate: number; + /** Number of coupon payments per year (e.g., 2 = semi-annual, 4 = quarterly) */ couponFrequency: number; } From 4f0549feca0917ced926125c441575e0eb27116f Mon Sep 17 00:00:00 2001 From: tota79 Date: Fri, 5 Dec 2025 14:08:57 +0800 Subject: [PATCH 14/15] refactor: add BondInstrument --- .../minimal-token/daml/Bond/BondFactory.daml | 4 -- .../daml/Bond/Test/BondLifecycleTest.daml | 3 - .../src/testScripts/bondLifecycleTest.ts | 5 +- .../token-sdk/src/wrappedSdk/bonds/factory.ts | 28 +-------- .../token-sdk/src/wrappedSdk/wrappedSdk.ts | 58 +++---------------- 5 files changed, 12 insertions(+), 86 deletions(-) diff --git a/packages/minimal-token/daml/Bond/BondFactory.daml b/packages/minimal-token/daml/Bond/BondFactory.daml index 2b56e9b..201adba 100644 --- a/packages/minimal-token/daml/Bond/BondFactory.daml +++ b/packages/minimal-token/daml/Bond/BondFactory.daml @@ -7,9 +7,6 @@ template BondFactory with issuer : Party instrumentId : Text -- e.g., show issuer <> "#Bond" - notional : Decimal - couponRate : Decimal - couponFrequency : Int where signatory issuer -- | Create a bond instrument definition with all terms. @@ -37,4 +34,3 @@ template BondFactory pure instrumentCid - diff --git a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml index 88e8fca..6bd07c0 100644 --- a/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml +++ b/packages/minimal-token/daml/Bond/Test/BondLifecycleTest.daml @@ -47,9 +47,6 @@ testBondFullLifecycle = script do createCmd BondFactory with issuer = charlie instrumentId = instrumentId - notional = 1000.0 - couponRate = couponRate - couponFrequency = couponFrequency let currencyInstrumentId = show charlie <> "#Currency" currencyRulesCid <- submit charlie do diff --git a/packages/token-sdk/src/testScripts/bondLifecycleTest.ts b/packages/token-sdk/src/testScripts/bondLifecycleTest.ts index 48a5b2e..1c2ba6a 100644 --- a/packages/token-sdk/src/testScripts/bondLifecycleTest.ts +++ b/packages/token-sdk/src/testScripts/bondLifecycleTest.ts @@ -151,10 +151,7 @@ async function bondLifecycleTest() { const bondRulesCid = await charlieWrappedSdk.bonds.bondRules.getOrCreate(); const bondFactoryCid = await charlieWrappedSdk.bonds.factory.getOrCreate( - bondInstrumentId, - 1000.0, // notional - 0.05, // couponRate (5% annual) - 2 // couponFrequency (semi-annual) + bondInstrumentId ); if (!bondFactoryCid) { throw new Error("Bond factory contract ID not found after getOrCreate"); diff --git a/packages/token-sdk/src/wrappedSdk/bonds/factory.ts b/packages/token-sdk/src/wrappedSdk/bonds/factory.ts index 11b7bc2..f2d5c77 100644 --- a/packages/token-sdk/src/wrappedSdk/bonds/factory.ts +++ b/packages/token-sdk/src/wrappedSdk/bonds/factory.ts @@ -31,12 +31,6 @@ export interface BondFactoryParams { issuer: Party; /** Unique identifier for the bond instrument (e.g., "party123#Bond") */ instrumentId: string; - /** Face value per bond unit (e.g., 1000 means each bond has $1000 face value) */ - notional: number; - /** Annual coupon rate as a decimal (e.g., 0.05 = 5% annual interest) */ - couponRate: number; - /** Number of coupon payments per year (e.g., 2 = semi-annual, 4 = quarterly) */ - couponFrequency: number; } const getCreateBondFactoryCommand = (params: BondFactoryParams) => @@ -57,18 +51,12 @@ const getCreateBondFactoryCommand = (params: BondFactoryParams) => export async function createBondFactory( userLedger: LedgerController, userKeyPair: UserKeyPair, - instrumentId: string, - notional: number, - couponRate: number, - couponFrequency: number + instrumentId: string ) { const issuer = userLedger.getPartyId(); const createBondFactoryCommand = getCreateBondFactoryCommand({ instrumentId, issuer, - notional, - couponRate, - couponFrequency, }); await userLedger.prepareSignExecuteAndWaitFor( [createBondFactoryCommand], @@ -119,22 +107,12 @@ export async function getLatestBondFactory( export async function getOrCreateBondFactory( userLedger: LedgerController, userKeyPair: UserKeyPair, - instrumentId: string, - notional: number, - couponRate: number, - couponFrequency: number + instrumentId: string ) { const contractId = await getLatestBondFactory(userLedger, instrumentId); if (contractId) return contractId; - await createBondFactory( - userLedger, - userKeyPair, - instrumentId, - notional, - couponRate, - couponFrequency - ); + await createBondFactory(userLedger, userKeyPair, instrumentId); return (await getLatestBondFactory(userLedger, instrumentId))!; } diff --git a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts index baf5899..f321423 100644 --- a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts +++ b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts @@ -162,37 +162,15 @@ export const getWrappedSdk = (sdk: WalletSDK) => { return { bonds: { factory: { - create: ( - userKeyPair: UserKeyPair, - instrumentId: string, - notional: number, - couponRate: number, - couponFrequency: number - ) => - createBondFactory( - userLedger, - userKeyPair, - instrumentId, - notional, - couponRate, - couponFrequency - ), + create: (userKeyPair: UserKeyPair, instrumentId: string) => + createBondFactory(userLedger, userKeyPair, instrumentId), getLatest: (instrumentId: string) => getLatestBondFactory(userLedger, instrumentId), - getOrCreate: ( - userKeyPair: UserKeyPair, - instrumentId: string, - notional: number, - couponRate: number, - couponFrequency: number - ) => + getOrCreate: (userKeyPair: UserKeyPair, instrumentId: string) => getOrCreateBondFactory( userLedger, userKeyPair, - instrumentId, - notional, - couponRate, - couponFrequency + instrumentId ), createInstrument: ( userKeyPair: UserKeyPair, @@ -629,35 +607,15 @@ export const getWrappedSdkWithKeyPair = ( return { bonds: { factory: { - create: ( - instrumentId: string, - notional: number, - couponRate: number, - couponFrequency: number - ) => - createBondFactory( - userLedger, - userKeyPair, - instrumentId, - notional, - couponRate, - couponFrequency - ), + create: (instrumentId: string) => + createBondFactory(userLedger, userKeyPair, instrumentId), getLatest: (instrumentId: string) => getLatestBondFactory(userLedger, instrumentId), - getOrCreate: ( - instrumentId: string, - notional: number, - couponRate: number, - couponFrequency: number - ) => + getOrCreate: (instrumentId: string) => getOrCreateBondFactory( userLedger, userKeyPair, - instrumentId, - notional, - couponRate, - couponFrequency + instrumentId ), createInstrument: ( bondFactoryCid: ContractId, From 3b0eb87ca6cf0573f8c47f7910f1debebaa51469 Mon Sep 17 00:00:00 2001 From: tota79 Date: Fri, 12 Dec 2025 13:27:09 +0700 Subject: [PATCH 15/15] bond minimal demo frontend --- .../app/api/wallet/bond/disclosure/route.ts | 40 ++ .../wallet/bond/factory/instrument/route.ts | 70 +++ .../wallet/bond/factory/instruments/route.ts | 73 +++ .../bond/lifecycle/claim-request/route.ts | 129 ++++ .../api/wallet/bond/lifecycle/effect/route.ts | 39 ++ .../bond/lifecycle/infrastructure/route.ts | 68 +++ .../bond/lifecycle/instruction/route.ts | 70 +++ .../wallet/bond/lifecycle/process/route.ts | 98 ++++ .../api/wallet/bond/lifecycle/rule/route.ts | 49 ++ .../app/api/wallet/bond/mint-request/route.ts | 109 ++++ .../app/api/wallet/bond/version/route.ts | 35 ++ packages/token-app/app/bond/page.tsx | 75 +++ packages/token-app/app/page.tsx | 42 +- .../components/BondCustodianView.tsx | 421 +++++++++++++ .../token-app/components/BondPartyView.tsx | 160 +++++ .../token-app/components/BondUserView.tsx | 551 ++++++++++++++++++ .../token-app/lib/queries/bondInstruments.ts | 40 ++ .../token-app/lib/queries/bondLifecycle.ts | 262 +++++++++ .../token-app/lib/queries/bondMintRequest.ts | 45 ++ packages/token-app/package.json | 2 + packages/token-app/scripts/setupBondDemo.ts | 225 +++++++ .../src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 2 +- .../token-sdk/src/constants/templateIds.ts | 1 + .../token-sdk/src/wrappedSdk/bonds/bond.ts | 38 ++ .../src/wrappedSdk/bonds/lifecycleEffect.ts | 54 ++ .../wrappedSdk/bonds/lifecycleInstruction.ts | 60 ++ .../token-sdk/src/wrappedSdk/wrappedSdk.ts | 23 +- 27 files changed, 2761 insertions(+), 20 deletions(-) create mode 100644 packages/token-app/app/api/wallet/bond/disclosure/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/factory/instrument/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/factory/instruments/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/lifecycle/claim-request/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/lifecycle/effect/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/lifecycle/infrastructure/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/lifecycle/instruction/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/lifecycle/process/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/lifecycle/rule/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/mint-request/route.ts create mode 100644 packages/token-app/app/api/wallet/bond/version/route.ts create mode 100644 packages/token-app/app/bond/page.tsx create mode 100644 packages/token-app/components/BondCustodianView.tsx create mode 100644 packages/token-app/components/BondPartyView.tsx create mode 100644 packages/token-app/components/BondUserView.tsx create mode 100644 packages/token-app/lib/queries/bondInstruments.ts create mode 100644 packages/token-app/lib/queries/bondLifecycle.ts create mode 100644 packages/token-app/lib/queries/bondMintRequest.ts create mode 100644 packages/token-app/scripts/setupBondDemo.ts create mode 100644 packages/token-sdk/src/wrappedSdk/bonds/bond.ts 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/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/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/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts b/packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts index 06154e1..dd8d3ac 100644 --- a/packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts +++ b/packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts @@ -1,4 +1,4 @@ // Obtained from runnning: // `pnpm get:minimal-token-id` export const MINIMAL_TOKEN_PACKAGE_ID = - "ad9c5643bbcc725d457dfca291a50fbca0c00c2ba6a7d4e8a8c89e8693550889"; + "939f8696a355b2018a497a570efc7ba8960026e8c1f2621ac5aa184bf288caca"; diff --git a/packages/token-sdk/src/constants/templateIds.ts b/packages/token-sdk/src/constants/templateIds.ts index d0c1244..c383b84 100644 --- a/packages/token-sdk/src/constants/templateIds.ts +++ b/packages/token-sdk/src/constants/templateIds.ts @@ -31,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/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/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/wrappedSdk.ts b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts index f321423..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: {