diff --git a/packages/minimal-token/.gitignore b/packages/minimal-token/.gitignore index 5cb051a..5465a8b 100644 --- a/packages/minimal-token/.gitignore +++ b/packages/minimal-token/.gitignore @@ -1,3 +1,4 @@ +.metals/ .daml/ .lib/ .vscode/ diff --git a/packages/minimal-token/CLAUDE.md b/packages/minimal-token/CLAUDE.md index a449973..e0ba40f 100644 --- a/packages/minimal-token/CLAUDE.md +++ b/packages/minimal-token/CLAUDE.md @@ -102,6 +102,80 @@ This pattern is used by both: - Orchestrates atomic execution of multiple allocation legs - ExecuteAll choice exercises all legs atomically (requires all senders + receivers + executor) +### ETF Components + +The implementation includes Exchange-Traded Fund (ETF) contracts that enable minting composite tokens backed by underlying assets: + +**PortfolioComposition** (`daml/ETF/PortfolioComposition.daml`) +- Defines a named collection of assets with weights for ETF composition +- Contains `owner`, `name`, and `items` (list of PortfolioItem) +- PortfolioItem specifies `instrumentId` and `weight` (proportion) for each underlying asset +- Reusable across multiple ETF mint recipes + +**MyMintRecipe** (`daml/ETF/MyMintRecipe.daml`) +- Defines how to mint ETF tokens based on a portfolio composition +- References a `PortfolioComposition` contract (no separate token factory - creates tokens directly) +- Maintains list of `authorizedMinters` who can request ETF minting +- Issuer can update composition and manage authorized minters +- **Security Note**: MyMintRecipe_Mint creates MyToken directly (no factory field), preventing issuers from bypassing validation to mint unbacked ETF tokens +- Choices: + - `MyMintRecipe_Mint` - Creates ETF tokens directly (called by MyMintRequest after validation) + - `MyMintRecipe_CreateAndUpdateComposition` - Create new composition, optionally archive old + - `MyMintRecipe_UpdateComposition` - Update composition reference + - `MyMintRecipe_AddAuthorizedMinter` / `MyMintRecipe_RemoveAuthorizedMinter` - Manage minters + +**MyMintRequest** (`daml/ETF/MyMintRequest.daml`) +- Request contract for minting ETF tokens with backing assets +- Requester provides transfer instructions for all underlying assets +- Follows request/accept pattern for authorization +- Validation ensures: + - Transfer instruction count matches portfolio composition items + - Each transfer sender is the requester, receiver is the issuer + - InstrumentId matches portfolio item + - Transfer amount equals `portfolioItem.weight × ETF amount` +- Choices: + - `MintRequest_Accept` - Validates transfers, accepts all transfer instructions (transferring underlying assets to issuer custody), mints ETF tokens + - `MintRequest_Decline` - Issuer declines request + - `MintRequest_Withdraw` - Requester withdraws request + +**MyBurnRequest** (`daml/ETF/MyBurnRequest.daml`) +- Request contract for burning ETF tokens and returning underlying assets +- Requester provides ETF token to burn and issuer provides transfer instructions for underlying assets +- Follows request/accept pattern for authorization (reverse of mint) +- Validation ensures: + - Transfer instruction count matches portfolio composition items + - Each transfer sender is the issuer, receiver is the requester + - InstrumentId matches portfolio item + - Transfer amount equals `portfolioItem.weight × ETF amount` +- Choices: + - `BurnRequest_Accept` - Validates transfers, accepts all transfer instructions (transferring underlying assets from issuer custody back to requester), burns ETF tokens + - `BurnRequest_Decline` - Issuer declines request + - `BurnRequest_Withdraw` - Requester withdraws request + +**ETF Minting Workflow:** +1. Issuer creates `PortfolioComposition` defining underlying assets and weights +2. Issuer creates `MyMintRecipe` referencing the portfolio and authorizing minters (no token factory needed) +3. Authorized party acquires underlying tokens (via minting or transfer) +4. Authorized party creates transfer requests for each underlying asset (sender → issuer) +5. Issuer accepts transfer requests, creating transfer instructions +6. Authorized party creates `MyMintRequest` with all transfer instruction CIDs +7. Issuer accepts `MyMintRequest`, which: + - Validates transfer instructions match portfolio composition + - Executes all transfer instructions (custody of underlying assets to issuer) + - Creates ETF tokens directly via MyMintRecipe_Mint choice (no factory bypass possible) + - Transfers minted ETF tokens to requester + +**ETF Burning Workflow:** +1. ETF token holder creates transfer requests for underlying assets (issuer → holder) +2. Issuer accepts transfer requests, creating transfer instructions +3. ETF token holder creates `MyBurnRequest` with ETF token CID and transfer instruction CIDs +4. Issuer accepts `MyBurnRequest`, which: + - Validates transfer instructions match portfolio composition + - Executes all transfer instructions (custody of underlying assets back to holder) + - Burns ETF tokens from holder + +This pattern ensures ETF tokens are always backed by the correct underlying assets in issuer custody, and burning returns the correct proportions of underlying assets to the holder. + ### Request/Accept Pattern The codebase uses a consistent request/accept authorization pattern: @@ -111,9 +185,11 @@ The codebase uses a consistent request/accept authorization pattern: 3. This ensures both sender and admin authorize the operation Examples: -- `MyToken.IssuerMintRequest` -> Issuer accepts -> Mints token -- `MyToken.TransferRequest` -> Issuer accepts -> Creates `MyTransferInstruction` -- `MyToken.AllocationRequest` -> Admin accepts -> Creates `MyAllocation` +- `MyToken.IssuerMintRequest` → Issuer accepts → Mints token +- `MyToken.TransferRequest` → Issuer accepts → Creates `MyTransferInstruction` +- `MyToken.AllocationRequest` → Admin accepts → Creates `MyAllocation` +- `ETF.MyMintRequest` → Issuer accepts → Validates transfers, executes transfer instructions, mints ETF token +- `ETF.MyBurnRequest` → Issuer accepts → Validates transfers, executes transfer instructions, burns ETF token ### Registry API Pattern @@ -163,7 +239,7 @@ CIP-0056 interfaces use `ExtraArgs` and `Metadata` extensively for extensibility ## Test Organization -The test suite is organized by feature area for clarity and maintainability (**17 tests total, all passing**): +The test suite is organized by feature area for clarity and maintainability (**20 tests total, all passing**): ### `Test/TestUtils.daml` Common helpers to reduce test duplication: @@ -197,6 +273,14 @@ Common helpers to reduce test duplication: ### `Test/TransferPreapproval.daml` - `testTransferPreapproval` - Transfer preapproval pattern +### `ETF/Test/ETFTest.daml` +- `mintToSelfTokenETF` - ETF minting where issuer mints underlying tokens to themselves, creates transfer instructions, and mints ETF +- `mintToOtherTokenETF` - ETF minting where Alice acquires underlying tokens, transfers to issuer, and mints ETF (demonstrates authorized minter pattern) +- `burnTokenETF` - ETF burning where Alice mints ETF token, then issuer transfers underlying tokens back to Alice and burns the ETF (demonstrates complete mint-burn cycle) + +### `Bond/Test/BondLifecycleTest.daml` +- `testBondFullLifecycle` - Complete bond lifecycle including minting, coupon payments, transfers, and redemption + ### `Scripts/Holding.daml` - `setupHolding` - Utility function for setting up holdings diff --git a/packages/minimal-token/ETF_IMPROVEMENTS.md b/packages/minimal-token/ETF_IMPROVEMENTS.md new file mode 100644 index 0000000..0a0984a --- /dev/null +++ b/packages/minimal-token/ETF_IMPROVEMENTS.md @@ -0,0 +1,1180 @@ +# ETF Implementation Improvement Roadmap + +This document tracks potential improvements and fixes for the ETF (Exchange-Traded Fund) implementation based on critical analysis of the current design. + +## 🔴 Critical Issues (Security & Correctness) + +### Issue #1: No NAV (Net Asset Value) Tracking or Validation +**Priority:** High +**Status:** ❌ Not Started + +**Problem:** +The implementation has no mechanism to ensure fair pricing. ETF tokens are minted/burned at a fixed 1:1 ratio with underlying assets based purely on portfolio weights, with no concept of market value or NAV. + +**Impact:** +- If Token1 is worth $100 and Token2 is worth $1, but both have weight 1.0, an ETF token incorrectly treats them as equal +- No price discovery mechanism +- Enables immediate arbitrage opportunities + +**Current Behavior:** +```daml +-- 1 ETF = 1.0 of each underlying (regardless of market value) +``` + +**Proposed Solution:** +- Add price oracle integration or manual NAV updates before mint/burn operations +- Store NAV per share in PortfolioComposition or separate pricing contract +- Validate mint/burn requests against current NAV + +**Files to Modify:** +- `daml/ETF/PortfolioComposition.daml` - Add price/NAV fields +- `daml/ETF/MyMintRequest.daml` - Add NAV validation +- `daml/ETF/MyBurnRequest.daml` - Add NAV validation + +--- + +### Issue #2: Race Condition - Transfer Instructions Can Be Accepted by Others +**Priority:** Critical +**Status:** ❌ Not Started + +**Problem:** +In `MyMintRequest.MintRequest_Accept` (lines 46-50), the code accepts transfer instructions that are already created and pending. Nothing prevents the receiver from accepting these instructions directly before the ETF mint completes, breaking atomicity guarantees. + +**Attack Scenario:** +``` +1. Alice creates 3 transfer instructions (Alice → Issuer) +2. Alice creates ETF mint request with those CIDs +3. Issuer accepts transfer instruction #1 directly (gets underlying token) +4. Issuer tries to accept ETF mint request +5. ETF acceptance fails (transfer instruction #1 already consumed) +6. Issuer now has 1 underlying asset but hasn't minted ETF +``` + +**Impact:** +- Breaks atomicity guarantees +- Enables griefing attacks +- Can cause partial state (some assets transferred, no ETF minted) + +**Proposed Solutions (choose one):** + +**Option A: Lock Transfer Instructions When ETF Request Created** (Recommended) + +This is the most practical solution that preserves existing authorization flow while preventing the race condition. + +**Implementation Steps:** + +1. **Add Lock State to MyTransferInstruction** +```daml +template MyTransferInstruction + with + -- ... existing fields + lockedForEtf : Optional (ContractId MyMintRequest) + -- ^ If Some, this transfer instruction is locked for ETF minting + where + signatory sender, receiver + observer issuer + + choice TransferInstruction_Accept : AcceptResult + controller receiver + do + -- CHANGED: Prevent direct acceptance if locked for ETF + assertMsg "Transfer instruction is locked for ETF minting" + (isNone lockedForEtf) + -- ... rest of accept logic +``` + +2. **Add Locking Choices to MyTransferInstruction** +```daml +-- Lock this transfer instruction for ETF minting +choice TransferInstruction_LockForEtfMint : ContractId MyTransferInstruction + with + etfMintRequestCid : ContractId MyMintRequest + controller sender, issuer -- Both parties authorize lock + do + assertMsg "Already locked" (isNone lockedForEtf) + create this with lockedForEtf = Some etfMintRequestCid + +-- Accept locked transfer instruction (only callable within ETF minting) +choice TransferInstruction_AcceptLocked : AcceptResult + with + etfMintRequestCid : ContractId MyMintRequest + controller issuer -- Issuer accepts on behalf of ETF minting + do + assertMsg "Not locked for this ETF request" + (lockedForEtf == Some etfMintRequestCid) + -- ... execute transfer logic + -- Return unlocked tokens to receiver + +-- Unlock transfer instruction (if ETF mint is declined/withdrawn) +choice TransferInstruction_Unlock : ContractId MyTransferInstruction + controller sender, issuer + do + assertMsg "Not locked" (isSome lockedForEtf) + create this with lockedForEtf = None +``` + +3. **Update MyMintRequest Creation to Lock Instructions** +```daml +-- When Alice creates MyMintRequest, lock all transfer instructions first +createMintRequestWithLocks : ... -> Update (ContractId MyMintRequest) +createMintRequestWithLocks transferInstructionCids mintRecipeCid requester amount issuer = do + -- Lock all transfer instructions for this mint request + lockedInstructionCids <- forA transferInstructionCids $ \tiCid -> + exercise tiCid TransferInstruction_LockForEtfMint with + etfMintRequestCid = self -- Will be the new mint request CID + + -- Create the mint request with locked instruction CIDs + create MyMintRequest with + mintRecipeCid + requester + amount + transferInstructionCids = lockedInstructionCids + issuer +``` + +4. **Update MintRequest_Accept to Accept Locked Instructions** +```daml +choice MintRequest_Accept : ContractId MyToken + controller issuer + do + mintRecipe <- fetch mintRecipeCid + portfolioComp <- fetch (mintRecipe.composition) + + -- Validate transfer instructions + forA_ (zip transferInstructionCids portfolioComp.items) $ + \(tiCid, portfolioItem) -> do + ti <- fetch tiCid + -- Verify locked for THIS mint request + assertMsg "Transfer instruction not locked for this request" + (ti.lockedForEtf == Some self) + validateTransferInstruction tiCid portfolioItem amount issuer requester + + -- Accept all locked transfer instructions + forA_ transferInstructionCids $ \tiCid -> + exercise tiCid TransferInstruction_AcceptLocked with + etfMintRequestCid = self + + -- Mint ETF tokens + exercise mintRecipeCid MyMintRecipe.MyMintRecipe_Mint with + receiver = requester + amount = amount +``` + +5. **Add Unlock on Decline/Withdraw** +```daml +choice MintRequest_Decline : () + controller issuer + do + -- Unlock all transfer instructions so Alice can use them elsewhere + forA_ transferInstructionCids $ \tiCid -> + exercise tiCid TransferInstruction_Unlock + +choice MintRequest_Withdraw : () + controller requester + do + -- Unlock all transfer instructions + forA_ transferInstructionCids $ \tiCid -> + exercise tiCid TransferInstruction_Unlock +``` + +**Pros:** +✅ Clear state management (locked vs unlocked) +✅ Prevents race condition completely +✅ Preserves existing authorization flow (Alice creates transfers, issuer accepts) +✅ Minimal architectural changes +✅ Lock can be released if ETF mint is declined/withdrawn +✅ Explicit authorization from both parties to lock + +**Cons:** +❌ Requires adding state to MyTransferInstruction template +❌ Need new choices: LockForEtfMint, AcceptLocked, Unlock +❌ Slightly more complex MyTransferInstruction contract + +**Files to Modify:** +- `daml/MyTokenTransferInstruction.daml` - Add lockedForEtf field and locking choices +- `daml/ETF/MyMintRequest.daml` - Update creation flow to lock instructions, update Accept to use locked instructions +- `daml/ETF/MyBurnRequest.daml` - Same changes for burn flow +- `daml/ETF/Test/ETFTest.daml` - Update tests to lock instructions before creating requests +- `packages/token-sdk/src/wrappedSdk/transferInstruction.ts` - Add lock/unlock functions +- `packages/token-sdk/src/wrappedSdk/etf/mintRequest.ts` - Update to lock before creating request +- `packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts` - Update to lock before creating request + +--- + +**Option B: Use Disclosure-Based Access Control** +- Make transfer instructions not visible to receiver until after ETF minting completes +- Requires architectural changes to visibility model +- Not recommended due to complexity + +--- + +### Issue #3: Issuer Can Bypass MyMintRecipe and Mint ETF Tokens Directly ✅ +**Priority:** Critical +**Status:** ✅ **COMPLETED** + +**Problem:** +The ETF factory is a regular `MyTokenFactory`, allowing the issuer to mint unbacked ETF tokens by calling `Mint` choice directly, completely bypassing the backing asset requirements. + +**Current Code:** +```daml +-- Issuer can mint unbacked ETF tokens: +exercise etfFactoryCid MyTokenFactory.Mint with + receiver = issuer + amount = 1000000.0 -- No underlying assets transferred! +``` + +**Impact:** +- Defeats the entire purpose of requiring backing assets +- Allows issuer to inflate ETF supply without custody of underlying assets +- Breaks trust model + +**Proposed Solutions (choose one):** + +**Option A: Create Dedicated ETFTokenFactory** (Recommended) +- Create new `ETFTokenFactory` template that only allows minting via `MyMintRecipe` +- Remove direct `Mint` choice or restrict controller to recipe contract +```daml +template ETFTokenFactory + with + issuer : Party + instrumentId : Text + where + signatory issuer + + -- Only callable by MyMintRecipe, not directly + nonconsuming choice RecipeMint : ContractId MyToken + with + recipe : ContractId MyMintRecipe + receiver : Party + amount : Decimal + controller issuer + do + -- Verify recipe owns this factory + recipeData <- fetch recipe + assertMsg "Factory mismatch" (recipeData.tokenFactory == self) + create MyToken with ... +``` + +**Option B: Embed Factory Logic in MyMintRecipe** +- Remove `tokenFactory` field from `MyMintRecipe` +- Create tokens directly in `MyMintRecipe_Mint` choice +- No separate factory contract needed + +**Fix Applied:** +Implemented **Option B: Embed Factory Logic in MyMintRecipe** (simpler and more secure): + +1. **Removed `tokenFactory` field** from `MyMintRecipe` template (daml/ETF/MyMintRecipe.daml:11-14) +2. **MyMintRecipe_Mint choice now creates MyToken directly** (daml/ETF/MyMintRecipe.daml:32-36): + ```daml + create MyToken with + issuer = issuer + owner = receiver + instrumentId = instrumentId + amount = amount + ``` +3. **Updated TypeScript SDK** (`packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts:11-16`) - Removed tokenFactory from `MintRecipeParams` +4. **Updated test scripts** (etfMint.ts, etfBurn.ts) - No longer pass tokenFactory when creating mint recipe + +**Result:** Issuer can no longer bypass MyMintRecipe to mint unbacked ETF tokens. All ETF minting must go through MyMintRequest validation flow, ensuring proper backing. + +--- + +## 🟠 Major Issues (Functionality & Robustness) + +### Issue #4: No Partial Mint/Burn Support +**Priority:** Medium +**Status:** ❌ Not Started + +**Problem:** +Must mint/burn exact portfolio weights. If you own 2.5 units of Token1 but portfolio requires 1.0, you can't mint 2.5 ETF tokens without splitting tokens first. + +**Impact:** +- Poor capital efficiency +- Users forced to acquire exact fractional amounts +- Extra transactions needed for splitting + +**Proposed Solution:** +- Implement splitting logic similar to `MyTokenRules` lock/split pattern +- Add `splitIfNeeded` logic in validation phase +- Return change tokens to requester + +**Files to Modify:** +- `daml/ETF/MyMintRequest.daml` - Add splitting logic +- `daml/ETF/MyBurnRequest.daml` - Add splitting logic + +--- + +### Issue #5: Static Portfolio Composition with Breaking Changes +**Priority:** High +**Status:** ❌ Not Started + +**Problem:** +`MyMintRecipe_UpdateComposition` allows changing the portfolio, but existing ETF tokens don't track which composition version they were minted with. Burning becomes ambiguous. + +**Scenario:** +``` +1. Mint 100 ETF tokens with composition v1 (Token A, B, C with weights 1.0, 1.0, 1.0) +2. Update composition to v2 (Token D, E, F with weights 2.0, 2.0, 2.0) +3. User tries to burn 50 ETF tokens: + - Which composition applies? + - Should they receive A/B/C or D/E/F? + - What quantities? +``` + +**Impact:** +- Burning becomes ambiguous and potentially unfair +- Users can't verify their backing +- No audit trail of composition changes +- Potential disputes between issuer and token holders + +**Proposed Solutions (choose one):** + +**Option A: Version ETF Tokens with Composition Snapshot** (Recommended) +```daml +template MyToken + with + issuer : Party + owner : Party + instrumentId : Text + amount : Decimal + compositionSnapshot : [PortfolioItem] -- NEW: Snapshot at mint time + compositionVersion : Int -- NEW: Version tracking +``` +- Each ETF token stores its composition at mint time +- Burning uses the snapshot, not current recipe composition +- Clear, unambiguous redemption rights + +**Option B: Separate Mint Recipes Per Composition Version** +- Create new `MyMintRecipe` contract for each composition change +- Archive old recipe (can't mint with old composition anymore) +- ETF tokens reference specific recipe CID +- Burning fetches referenced recipe's composition + +**Option C: Disallow Composition Changes After Any Minting** +- Add check in `MyMintRecipe_UpdateComposition`: +```daml +choice MyMintRecipe_UpdateComposition : ContractId MyMintRecipe + controller issuer + do + -- Query for any tokens minted with this recipe + tokens <- query @MyToken + assertMsg "Cannot update composition after minting" (null tokens) + create this with composition = newComposition +``` +- Simplest solution but least flexible +- Requires creating new instrument for composition changes + +**Files to Modify:** +- `daml/MyToken.daml` - Add composition fields (Option A) +- `daml/ETF/MyMintRecipe.daml` - Add versioning or restrictions +- `daml/ETF/MyBurnRequest.daml` - Use composition snapshot for burning +- `daml/ETF/Test/ETFTest.daml` - Add composition update tests + +--- + +### Issue #6: No Decimal Precision Validation +**Priority:** Medium +**Status:** ❌ Not Started + +**Problem:** +Weight multiplication can cause rounding errors with no precision limits. Exact equality check fails for valid operations with fractional weights. + +**Current Code:** +```daml +-- ETF/MyMintRequest.daml:79 +assertMsg "Transfer instruction amount does not match expected amount" + (tiView.transfer.amount == (portfolioItem.weight * amount)) +``` + +**Impact:** +- `weight = 0.333333...` × `amount = 1.0` = rounding errors +- Validation failures for valid operations +- Decimal precision issues in Daml (28-29 decimal places) + +**Example Failure:** +```daml +-- Portfolio: Token A with weight 0.333333 +-- Mint: 1.0 ETF +-- Required: 0.333333 of Token A +-- User provides: 0.333333 (but represented as 0.33333300000...) +-- Validation: FAILS due to trailing precision differences +``` + +**Proposed Solution:** +Use threshold-based comparison instead of exact equality: +```daml +validateTransferInstruction tiCid portfolioItem amount issuer requester = do + ti <- fetch tiCid + let tiView = view ti + let expectedAmount = portfolioItem.weight * amount + let tolerance = 0.0000001 -- 7 decimal places tolerance + + assertMsg "Transfer instruction sender does not match requester" + (tiView.transfer.sender == requester) + assertMsg "Transfer instruction receiver does not match issuer" + (tiView.transfer.receiver == issuer) + assertMsg ("Transfer instruction instrumentId does not match portfolio item") + (tiView.transfer.instrumentId == portfolioItem.instrumentId) + assertMsg "Transfer instruction amount does not match expected amount" + (abs (tiView.transfer.amount - expectedAmount) < tolerance) +``` + +**Files to Modify:** +- `daml/ETF/MyMintRequest.daml` - Update `validateTransferInstruction` +- `daml/ETF/MyBurnRequest.daml` - Update `validateTransferInstruction` +- Add tests with fractional weights (0.333, 0.142857, etc.) + +--- + +### Issue #7: Array Ordering Dependency is Fragile +**Priority:** Medium +**Status:** ❌ Not Started + +**Problem:** +Both mint and burn require `transferInstructionCids` in exact same order as `portfolioComp.items` (via `zip` function). Easy to get wrong, fails with confusing error message. + +**Current Code:** +```daml +-- ETF/MyMintRequest.daml:42-43 +forA_ (zip transferInstructionCids portfolioComp.items) $ + \(tiCid, portfolioItem) -> validateTransferInstruction tiCid portfolioItem amount issuer requester +``` + +**Impact:** +- Easy to get wrong in SDK/client code +- No clear error message indicates ordering issue +- Fails with misleading "instrumentId does not match" error +- Documented as "critical pattern" in SDK due to fragility + +**Example Error:** +``` +User provides: [Token B CID, Token A CID, Token C CID] +Portfolio expects: [Token A, Token B, Token C] +Error: "Transfer instruction instrumentId does not match portfolio item: B, A" +^ Confusing - user might think B is wrong, not that ordering is wrong +``` + +**Proposed Solution:** +Use a map/dictionary structure keyed by instrumentId: +```daml +template MyMintRequest + with + -- OLD: transferInstructionCids: [ContractId TI.TransferInstruction] + -- NEW: + transferInstructions : [(InstrumentId, ContractId TI.TransferInstruction)] + where + -- ... + +choice MintRequest_Accept : ContractId MyToken + controller issuer + do + mintRecipe <- fetch mintRecipeCid + portfolioComp <- fetch (mintRecipe.composition) + + -- Validate all portfolio items have matching transfer instructions + forA_ portfolioComp.items $ \portfolioItem -> do + case lookup portfolioItem.instrumentId transferInstructions of + None -> abort ("Missing transfer instruction for " <> show portfolioItem.instrumentId) + Some tiCid -> validateTransferInstruction tiCid portfolioItem amount issuer requester + + -- Accept all transfer instructions + forA_ transferInstructions $ \(_, tiCid) -> + exercise tiCid TI.TransferInstruction_Accept with ... +``` + +**Alternative (Less Disruptive):** +Keep array structure but add better validation error messages: +```daml +-- Validate that all instrumentIds are present before checking order +let tiInstrumentIds = ... -- extract from transfer instructions +let portfolioInstrumentIds = map (.instrumentId) portfolioComp.items +assertMsg "Missing transfer instructions" + (all (`elem` tiInstrumentIds) portfolioInstrumentIds) +assertMsg ("Transfer instructions in wrong order. Expected: " <> show portfolioInstrumentIds <> ", Got: " <> show tiInstrumentIds) + (tiInstrumentIds == portfolioInstrumentIds) +``` + +**Files to Modify:** +- `daml/ETF/MyMintRequest.daml` - Change data structure or validation +- `daml/ETF/MyBurnRequest.daml` - Change data structure or validation +- `packages/token-sdk/src/wrappedSdk/etf/mintRequest.ts` - Update TypeScript interface +- `packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts` - Update TypeScript interface +- `packages/token-sdk/src/testScripts/etfMint.ts` - Update test script +- `packages/token-sdk/src/testScripts/etfBurn.ts` - Update test script + +--- + +## 🟡 Design Concerns (User Experience & Maintenance) + +### Issue #8: No Authorization Check on MyMintRecipe_Mint ✅ +**Priority:** Medium +**Status:** ✅ **COMPLETED** + +**Fix Applied:** +Line 38 in `daml/ETF/MyMintRequest.daml` now includes: +```daml +assertMsg "Mint recipe requester must be an authorized minter" + (requester `elem` mintRecipe.authorizedMinters) +``` + +Authorization is properly enforced in the `MintRequest_Accept` choice. + +--- + +### Issue #9: No Slippage Protection or Deadline +**Priority:** Medium +**Status:** ❌ Not Started + +**Problem:** +Mint/burn requests have no expiration time. Markets can move significantly between request creation and acceptance, leading to unfair pricing. + +**Scenario:** +``` +1. Alice creates mint request when ETF NAV is $100 +2. Market moves significantly +3. ETF NAV drops to $80 (20% decline) +4. Issuer accepts request +5. Alice gets ETF at stale $100 price, immediate 20% profit +``` +Or vice versa for burns (issuer profits, user loses). + +**Impact:** +- No protection against market movement +- Unfair pricing for one party +- Issuer or user can game timing of acceptance + +**Proposed Solution:** +Add deadline field like transfer instructions: +```daml +template MyMintRequest + with + mintRecipeCid: ContractId MyMintRecipe + requester: Party + amount: Decimal + transferInstructionCids: [ContractId TI.TransferInstruction] + issuer: Party + requestedAt: Time -- NEW: Request creation time + executeBefore: Time -- NEW: Expiration deadline + where + signatory requester + observer issuer + + ensure amount > 0.0 + ensure requestedAt < executeBefore -- Deadline must be in future + +choice MintRequest_Accept : ContractId MyToken + controller issuer + do + now <- getTime + assertMsg "Request created in future" (requestedAt <= now) + assertMsg "Request expired" (now < executeBefore) + -- ... rest of validation +``` + +**Additional Consideration:** +- Consider adding max slippage tolerance (e.g., NAV can't move more than 1% from request time) +- Requires NAV tracking (see Issue #1) + +**Files to Modify:** +- `daml/ETF/MyMintRequest.daml` - Add time fields and validation +- `daml/ETF/MyBurnRequest.daml` - Add time fields and validation +- `daml/ETF/Test/ETFTest.daml` - Update tests with timestamps +- `packages/token-sdk/src/wrappedSdk/etf/mintRequest.ts` - Update TypeScript interface +- `packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts` - Update TypeScript interface + +--- + +### Issue #10: Missing Events/Observability +**Priority:** Low +**Status:** ❌ Not Started + +**Problem:** +No events emitted for ETF minting/burning, making it hard to track total supply, monitor backing asset custody, or audit mint/burn history. + +**Impact:** +- Can't easily calculate total ETF supply +- No audit trail for compliance +- Difficult to track backing asset custody +- Can't monitor NAV changes over time +- No off-ledger indexing/analytics + +**Proposed Solution:** +Add interface events or separate audit contracts: + +**Option A: Daml Interface Events** (Recommended) +```daml +-- ETF/MyMintRequest.daml +choice MintRequest_Accept : ContractId MyToken + controller issuer + do + -- ... validation and minting logic + + emitEvent ETFMintEvent with + etfInstrumentId = mintRecipe.instrumentId + requester + amount + underlyingAssets = map (\item -> (item.instrumentId, item.weight * amount)) portfolioComp.items + timestamp = now + etfTokenCid = result +``` + +**Option B: Audit Contract Log** +```daml +template ETFAuditLog + with + issuer : Party + entries : [AuditEntry] + where + signatory issuer + +data AuditEntry = AuditEntry + with + timestamp : Time + operation : Text -- "MINT" or "BURN" + requester : Party + amount : Decimal + etfTokenCid : ContractId MyToken +``` + +**Files to Modify:** +- `daml/ETF/MyMintRequest.daml` - Add event emission +- `daml/ETF/MyBurnRequest.daml` - Add event emission +- `daml/ETF/ETFEvents.daml` - New file for event definitions (Option A) +- `daml/ETF/ETFAuditLog.daml` - New file for audit log (Option B) + +--- + +### Issue #11: No Fees or Incentive Mechanism +**Priority:** Low +**Status:** ❌ Not Started + +**Problem:** +Issuer has no economic incentive to operate the ETF. No management fees, creation fees, or redemption fees. + +**Real-World Comparison:** +Real ETFs charge: +- Management fees: 0.03% - 1% annually (deducted from NAV) +- Creation/redemption fees: 0.01% - 0.05% per transaction for authorized participants + +**Impact:** +- No sustainable business model for ETF issuer +- Issuer must subsidize operational costs +- May discourage professional ETF management + +**Proposed Solution:** + +**Option A: Mint/Burn Fees** (Simple) +```daml +template MyMintRequest + with + -- ... existing fields + fee : Decimal -- Fee in ETF tokens (e.g., 0.001 = 0.1%) + where + -- ... + +choice MintRequest_Accept : MintResult + controller issuer + do + -- ... validation + + let netAmount = amount * (1.0 - fee) + let feeAmount = amount * fee + + -- Mint ETF to requester (minus fee) + requesterToken <- exercise mintRecipeCid MyMintRecipe.MyMintRecipe_Mint with + receiver = requester + amount = netAmount + + -- Mint fee portion to issuer + feeToken <- exercise mintRecipeCid MyMintRecipe.MyMintRecipe_Mint with + receiver = issuer + amount = feeAmount + + return MintResult with + etfTokenCid = requesterToken + feeCid = feeToken +``` + +**Option B: Management Fee via Separate Lifecycle Rule** (Advanced) +- Similar to bond coupon payments, but in reverse +- Periodically dilute all ETF token holders by X% annually +- Issue diluted tokens to issuer as management fee +- Requires ETF token lifecycle management + +**Files to Modify:** +- `daml/ETF/MyMintRequest.daml` - Add fee calculation +- `daml/ETF/MyBurnRequest.daml` - Add fee calculation +- `daml/ETF/MyMintRecipe.daml` - Add fee configuration +- `daml/ETF/Test/ETFTest.daml` - Add fee tests + +--- + +### Issue #12: Burn Requires Pre-Created Transfer Instructions (Awkward UX) +**Priority:** Medium +**Status:** ❌ Not Started + +**Problem:** +The burn flow requires issuer to create transfer instructions *before* accepting burn request. This is awkward and error-prone: + +**Current Flow:** +``` +1. Alice creates burn request +2. Issuer queries Alice's burn request +3. Issuer creates 3 transfer requests (one per underlying asset) +4. Issuer accepts 3 transfer requests → gets 3 transfer instruction CIDs +5. Issuer accepts burn request WITH those 3 CIDs as parameter +``` + +**Issues with Current Flow:** +- Issuer must manually create transfer instructions +- Easy to make mistakes (wrong amounts, wrong order) +- Multiple transactions required +- Not atomic (issuer could create transfers but not accept burn) +- Transfer instructions could be accepted by Alice before burn completes (same race condition as Issue #2) + +**Proposed Solution:** +Create transfer instructions atomically inside `BurnRequest_Accept` choice: + +```daml +template MyBurnRequest + with + mintRecipeCid: ContractId MyMintRecipe + requester: Party + amount: Decimal + tokenFactoryCid: ContractId MyTokenFactory + inputHoldingCid: ContractId MyToken + issuer: Party + -- REMOVED: No need for pre-created transfer instructions + -- NEW: Add transfer factory references + underlyingTransferFactories: [(InstrumentId, ContractId MyTransferFactory)] + where + signatory requester + observer issuer + +choice BurnRequest_Accept : BurnResult + controller issuer + do + mintRecipe <- fetch mintRecipeCid + portfolioComp <- fetch (mintRecipe.composition) + + -- Create transfer instructions atomically for each underlying asset + transferInstructionCids <- forA portfolioComp.items $ \item -> do + -- Find corresponding transfer factory + let maybeFactory = lookup item.instrumentId underlyingTransferFactories + factory <- case maybeFactory of + None -> abort ("Missing transfer factory for " <> show item.instrumentId) + Some f -> pure f + + -- Create transfer request + now <- getTime + let transfer = Transfer with + sender = issuer + receiver = requester + amount = item.weight * amount + instrumentId = item.instrumentId + requestedAt = addRelTime now (seconds (-1)) + executeBefore = addRelTime now (hours 1) + inputHoldingCids = [] -- Query issuer's holdings + + -- Create and immediately accept transfer instruction + transferRequestCid <- create TransferRequest with + transferFactoryCid = factory + expectedAdmin = issuer + transfer + extraArgs = MD.emptyExtraArgs + + acceptResult <- exercise transferRequestCid TransferRequest.Accept + pure acceptResult.output.transferInstructionCid + + -- Now accept all transfer instructions (transfer underlying assets back to requester) + forA_ transferInstructionCids $ \tiCid -> + exercise tiCid TI.TransferInstruction_Accept with + extraArgs = MD.ExtraArgs with + context = MD.emptyChoiceContext + meta = MD.emptyMetadata + + -- Finally, burn the ETF token + exercise tokenFactoryCid MyTokenFactory.Burn with + owner = requester + amount = amount + inputHoldingCid = inputHoldingCid +``` + +**Benefits:** +- ✅ Single atomic transaction +- ✅ No race conditions (transfers created and accepted in same transaction) +- ✅ Simpler UX (issuer just accepts burn request, no manual transfer creation) +- ✅ Less error-prone (no manual CID collection) +- ✅ Consistent with how MyMintRequest should work (see Issue #2) + +**Files to Modify:** +- `daml/ETF/MyBurnRequest.daml` - Remove `transferInstructionCids` parameter, add atomic creation logic +- `daml/ETF/Test/ETFTest.daml` - Simplify burn test (remove manual transfer instruction creation) +- `packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts` - Update TypeScript interface (remove `transferInstructionCids` parameter from accept function) +- `packages/token-sdk/src/testScripts/etfBurn.ts` - Simplify test script + +**Note:** This is the **recommended pattern** and should be implemented alongside Issue #2 for consistency. + +--- + +## 🟢 Minor Issues (Code Quality & Completeness) + +### Issue #13: Duplicate Code Between Mint and Burn Validation +**Priority:** Low +**Status:** ❌ Not Started + +**Problem:** +`validateTransferInstruction` function is duplicated in both `MyMintRequest.daml` and `MyBurnRequest.daml` with only sender/receiver swapped. + +**Current Code:** + +`ETF/MyMintRequest.daml` (lines 67-78): +```daml +validateTransferInstruction tiCid portfolioItem amount issuer requester = do + ti <- fetch tiCid + let tiView = view ti + assertMsg "Transfer instruction sender does not match requester" (tiView.transfer.sender == requester) + assertMsg "Transfer instruction receiver does not match issuer" (tiView.transfer.receiver == issuer) + assertMsg "..." (tiView.transfer.instrumentId == portfolioItem.instrumentId) + assertMsg "..." (tiView.transfer.amount == (portfolioItem.weight * amount)) +``` + +`ETF/MyBurnRequest.daml` (lines 72-83): +```daml +validateTransferInstruction tiCid portfolioItem amount issuer requester = do + ti <- fetch tiCid + let tiView = view ti + assertMsg "Transfer instruction sender does not match issuer" (tiView.transfer.sender == issuer) -- ONLY DIFFERENCE + assertMsg "Transfer instruction receiver does not match requester" (tiView.transfer.receiver == requester) -- ONLY DIFFERENCE + assertMsg "..." (tiView.transfer.instrumentId == portfolioItem.instrumentId) + assertMsg "..." (tiView.transfer.amount == (portfolioItem.weight * amount)) +``` + +**Impact:** +- Code duplication +- Maintenance burden (bug fixes need to be applied twice) +- Risk of inconsistency between mint and burn validation + +**Proposed Solution:** +Extract to shared module with direction parameter: +```daml +-- ETF/Validation.daml (new file) +module ETF.Validation where + +import Splice.Api.Token.TransferInstructionV1 as TI +import ETF.PortfolioComposition (PortfolioItem) + +data TransferDirection = MintDirection | BurnDirection + +validateTransferInstruction + : TransferDirection + -> ContractId TI.TransferInstruction + -> PortfolioItem + -> Decimal + -> Party + -> Party + -> Update () +validateTransferInstruction direction tiCid portfolioItem amount issuer requester = do + ti <- fetch tiCid + let tiView = view ti + + case direction of + MintDirection -> do + assertMsg "Transfer instruction sender does not match requester" + (tiView.transfer.sender == requester) + assertMsg "Transfer instruction receiver does not match issuer" + (tiView.transfer.receiver == issuer) + BurnDirection -> do + assertMsg "Transfer instruction sender does not match issuer" + (tiView.transfer.sender == issuer) + assertMsg "Transfer instruction receiver does not match requester" + (tiView.transfer.receiver == requester) + + assertMsg ("Transfer instruction instrumentId does not match portfolio item: " + <> show tiView.transfer.instrumentId <> ", " <> show portfolioItem.instrumentId) + (tiView.transfer.instrumentId == portfolioItem.instrumentId) + assertMsg "Transfer instruction amount does not match expected amount" + (tiView.transfer.amount == (portfolioItem.weight * amount)) +``` + +Then use in both contracts: +```daml +-- ETF/MyMintRequest.daml +import ETF.Validation (validateTransferInstruction, TransferDirection(..)) + +forA_ (zip transferInstructionCids portfolioComp.items) $ + \(tiCid, portfolioItem) -> + validateTransferInstruction MintDirection tiCid portfolioItem amount issuer requester +``` + +**Files to Modify:** +- `daml/ETF/Validation.daml` - New shared module +- `daml/ETF/MyMintRequest.daml` - Remove duplicate function, import shared version +- `daml/ETF/MyBurnRequest.daml` - Remove duplicate function, import shared version + +--- + +### Issue #14: No Metadata Support +**Priority:** Low +**Status:** ❌ Not Started + +**Problem:** +Portfolio items and compositions have no metadata fields for additional information like: +- Asset descriptions +- Asset class categorization +- Issuer reputation scores +- Risk ratings +- Regulatory classifications +- External reference IDs + +**Current Data Structure:** +```daml +data PortfolioItem = PortfolioItem + with + instrumentId: InstrumentId + weight: Decimal + deriving (Eq, Show) +``` + +**Impact:** +- Limited information for users making mint/burn decisions +- No way to store asset categorization +- Difficult to implement compliance or risk management features +- Can't track external references (e.g., ISIN, CUSIP) + +**Proposed Solution:** +Add optional metadata fields: +```daml +data PortfolioItem = PortfolioItem + with + instrumentId: InstrumentId + weight: Decimal + metadata: Optional PortfolioItemMetadata + deriving (Eq, Show) + +data PortfolioItemMetadata = PortfolioItemMetadata + with + description: Text + assetClass: Optional Text -- "equity", "fixed-income", "commodity", etc. + issuerName: Optional Text + externalReferenceId: Optional Text -- ISIN, CUSIP, etc. + additionalInfo: [(Text, Text)] -- Key-value pairs for extensibility + deriving (Eq, Show) + +template PortfolioComposition + with + owner : Party + name : Text + items : [PortfolioItem] + description: Optional Text -- NEW: Overall portfolio description + category: Optional Text -- NEW: "equity", "balanced", "fixed-income", etc. + where + signatory owner +``` + +**Files to Modify:** +- `daml/ETF/PortfolioComposition.daml` - Add metadata fields +- `daml/ETF/Test/ETFTest.daml` - Update tests (metadata can be None for existing tests) +- `packages/token-sdk/src/wrappedSdk/etf/portfolioComposition.ts` - Update TypeScript interfaces + +--- + +### Issue #15: Test Coverage Gaps +**Priority:** Low +**Status:** ❌ Not Started + +**Problem:** +Current test suite (`daml/ETF/Test/ETFTest.daml`) has limited coverage. Missing critical test cases for edge cases and error conditions. + +**Current Tests:** +- ✅ `mintToSelfTokenETF` - Basic mint with issuer as minter +- ✅ `mintToOtherTokenETF` - Mint with authorized third party +- ✅ `burnTokenETF` - Basic burn flow + +**Missing Test Cases:** + +#### 15.1: Weight-Based Amount Calculations with Fractional Weights +```daml +testFractionalWeights : Script () +testFractionalWeights = script do + -- Portfolio with fractional weights (0.333, 0.5, 1.5) + -- Mint 1.0 ETF + -- Verify required amounts: 0.333, 0.5, 1.5 + -- Test decimal precision handling +``` + +#### 15.2: Composition Update Mid-Lifecycle +```daml +testCompositionUpdate : Script () +testCompositionUpdate = script do + -- Mint ETF with composition v1 + -- Update composition to v2 + -- Attempt to burn ETF (should use which composition?) + -- Expected behavior depends on Issue #5 resolution +``` + +#### 15.3: Multiple Concurrent Mint Requests +```daml +testConcurrentMints : Script () +testConcurrentMints = script do + -- Alice creates mint request #1 + -- Bob creates mint request #2 (uses same underlying transfer instructions?) + -- Issuer accepts both + -- Verify both succeed or proper failure handling +``` + +#### 15.4: Partial Burns (When Issue #4 Implemented) +```daml +testPartialBurn : Script () +testPartialBurn = script do + -- Alice owns 5.0 ETF tokens + -- Burn 2.0 ETF tokens + -- Verify: 2.0 burned, 3.0 remaining + -- Verify correct proportion of underlying assets returned +``` + +#### 15.5: Error Cases - Wrong InstrumentId Order +```daml +testWrongInstrumentIdOrder : Script () +testWrongInstrumentIdOrder = script do + -- Portfolio expects: [Token A, Token B, Token C] + -- Provide transfers: [Token B, Token A, Token C] -- Wrong order + -- Expected: Validation fails with clear error message + submitMustFail issuer $ exerciseCmd mintRequestCid MintRequest_Accept +``` + +#### 15.6: Error Cases - Insufficient Amounts +```daml +testInsufficientAmounts : Script () +testInsufficientAmounts = script do + -- Portfolio requires: [1.0 Token A, 1.0 Token B, 1.0 Token C] + -- Provide transfers: [0.5 Token A, 1.0 Token B, 1.0 Token C] -- Insufficient Token A + -- Expected: Validation fails + submitMustFail issuer $ exerciseCmd mintRequestCid MintRequest_Accept +``` + +#### 15.7: Error Cases - Unauthorized Minter +```daml +testUnauthorizedMinter : Script () +testUnauthorizedMinter = script do + -- Bob is NOT in authorizedMinters list + -- Bob creates mint request + -- Expected: Validation fails in MintRequest_Accept + submitMustFail issuer $ exerciseCmd bobMintRequestCid MintRequest_Accept +``` + +#### 15.8: Error Cases - Duplicate Transfer Instructions +```daml +testDuplicateTransferInstructions : Script () +testDuplicateTransferInstructions = script do + -- Provide same transfer instruction CID multiple times + -- transferInstructionCids = [ti1, ti1, ti1] -- Duplicate ti1 + -- Expected: Validation fails or proper error handling +``` + +#### 15.9: Mint Request Decline and Withdraw +```daml +testMintRequestDecline : Script () +testMintRequestDecline = script do + -- Alice creates mint request + -- Issuer declines + -- Verify: Request archived, transfer instructions still pending + -- Alice can still accept or withdraw transfer instructions + +testMintRequestWithdraw : Script () +testMintRequestWithdraw = script do + -- Alice creates mint request + -- Alice withdraws before issuer accepts + -- Verify: Request archived, transfer instructions still pending +``` + +#### 15.10: Burn Request Decline and Withdraw +```daml +testBurnRequestDecline : Script () +testBurnRequestDecline = script do + -- Alice creates burn request with ETF token + -- Issuer declines + -- Verify: Request archived, ETF token still owned by Alice + +testBurnRequestWithdraw : Script () +testBurnRequestWithdraw = script do + -- Alice creates burn request + -- Alice withdraws before issuer accepts + -- Verify: Request archived, ETF token still owned by Alice +``` + +#### 15.11: Composition Management +```daml +testAddAuthorizedMinter : Script () +testAddAuthorizedMinter = script do + -- Initial: authorizedMinters = [issuer] + -- Add Bob + -- Verify: Bob can now create mint requests + +testRemoveAuthorizedMinter : Script () +testRemoveAuthorizedMinter = script do + -- Initial: authorizedMinters = [issuer, alice] + -- Remove Alice + -- Verify: Alice can no longer create mint requests (fails authorization check) + +testUpdatePortfolioComposition : Script () +testUpdatePortfolioComposition = script do + -- Create composition v1 + -- Create mint recipe with v1 + -- Update to composition v2 + -- Verify: Mint recipe now uses v2 + -- Edge case: What happens to tokens minted with v1? +``` + +**Implementation Plan:** +1. Create comprehensive test suite in `daml/ETF/Test/ETFTestSuite.daml` +2. Run tests with `daml test --all` +3. Fix any discovered bugs +4. Add tests to CI/CD pipeline + +**Files to Modify:** +- `daml/ETF/Test/ETFTestSuite.daml` - New comprehensive test file +- `daml.yaml` - Add test suite to test configuration + +--- + +## Implementation Priority Recommendations + +### Phase 1: Critical Security Fixes (Do First) +1. ✅ **Issue #8**: Authorization check in MintRequest_Accept (COMPLETED) +2. ✅ **Issue #3**: Block direct ETF factory minting (COMPLETED - prevents unbacked tokens) +3. **Issue #2**: Fix race condition on transfer instructions (ensures atomicity) + +### Phase 2: Core Functionality (Do Before Production) +4. **Issue #5**: Add composition versioning (prevents burning ambiguity) +5. **Issue #12**: Improve burn UX with atomic transfer creation (consistency with mint) +6. **Issue #6**: Fix decimal precision validation (prevents false failures) + +### Phase 3: Enhanced Features (Do For Production Readiness) +7. **Issue #1**: Add NAV tracking and validation (fair pricing) +8. **Issue #9**: Add slippage protection and deadlines (user protection) +9. **Issue #7**: Fix array ordering dependency (better UX, fewer errors) +10. **Issue #15**: Expand test coverage (quality assurance) + +### Phase 4: Nice-to-Have (Future Enhancements) +11. **Issue #4**: Add partial mint/burn support (capital efficiency) +12. **Issue #10**: Add events/observability (monitoring and compliance) +13. **Issue #11**: Add fees and incentives (business model) +14. **Issue #13**: Refactor duplicate validation code (code quality) +15. **Issue #14**: Add metadata support (rich information) + +--- + +## Notes + +- This document captures the current state of the ETF implementation as of analysis date +- Some issues may have dependencies (e.g., Issue #12 depends on Issue #2 resolution) +- Priority rankings are based on security impact, user experience, and production readiness +- Test coverage (Issue #15) should be expanded as other issues are fixed + +## References + +- Main implementation: `daml/ETF/` +- Test suite: `daml/ETF/Test/ETFTest.daml` +- TypeScript SDK: `packages/token-sdk/src/wrappedSdk/etf/` +- Related documentation: `CLAUDE.md`, `README.md` diff --git a/packages/minimal-token/daml/ETF/MyBurnRequest.daml b/packages/minimal-token/daml/ETF/MyBurnRequest.daml new file mode 100644 index 0000000..0566dd4 --- /dev/null +++ b/packages/minimal-token/daml/ETF/MyBurnRequest.daml @@ -0,0 +1,110 @@ +module ETF.MyBurnRequest where + +import DA.Foldable (forA_) + +import Splice.Api.Token.TransferInstructionV1 as TI +import Splice.Api.Token.MetadataV1 as MD + +import ETF.MyMintRecipe +import ETF.PortfolioComposition (PortfolioItem) +import MyToken + +template MyBurnRequest + with + mintRecipeCid: ContractId MyMintRecipe + -- ^ The mint recipe to be used for burning + requester: Party + -- ^ The party requesting the burning + amount: Decimal + -- ^ The amount of ETF tokens to burn + inputHoldingCid: ContractId MyToken + -- ^ The token holding to use as input. Will be split if needed. + issuer: Party + -- ^ The ETF issuer + where + signatory requester + observer issuer + + ensure amount > 0.0 + + choice BurnRequest_Accept : BurnResult + -- ^ Accept the burn request and burn the tokens. + with + transferInstructionCids: [ContractId TI.TransferInstruction] + -- ^ The transfer instructions to be executed as part of the burning process. Expected to be in the same order as the portfolio composition + controller issuer + do + mintRecipe <- fetch mintRecipeCid + portfolioComp <- fetch (mintRecipe.composition) + + assertMsg "Transfer instructions must be of same length as portfolio composition items" $ (length transferInstructionCids) == (length portfolioComp.items) + + -- Validate input token + inputToken <- fetch inputHoldingCid + assertMsg "Token owner must match" (inputToken.owner == requester) + assertMsg "Token issuer must match factory issuer" (inputToken.issuer == issuer) + assertMsg "Token instrument ID must match" (inputToken.instrumentId == mintRecipe.instrumentId) + assertMsg "Insufficient tokens to burn" (inputToken.amount >= amount) + + forA_ (zip transferInstructionCids portfolioComp.items) $ + \(tiCid, portfolioItem) -> validateTransferInstruction tiCid portfolioItem amount issuer requester + -- For each transfer instruction: + -- Accept each transfer instruction + forA_ transferInstructionCids $ + \tiCid -> exercise tiCid TI.TransferInstruction_Accept with + extraArgs = MD.ExtraArgs with + context = MD.emptyChoiceContext + meta = MD.emptyMetadata + + + -- Split and burn: use a self-transfer to split off the exact amount, then archive it + -- This follows the splice splitAndBurn pattern + if inputToken.amount == amount then do + -- Exact match: archive the entire token + archive inputHoldingCid + pure BurnResult with + burnedCid = inputHoldingCid + leftoverCid = None + meta = MD.emptyMetadata + else do + transferResult <- exercise inputHoldingCid MyToken_Transfer with + receiver = requester + amount = amount + -- Archive the newly created token (the transferred portion to be burned) + archive transferResult.transferredCid + pure BurnResult with + burnedCid = transferResult.transferredCid + leftoverCid = transferResult.remainderCid + meta = MD.emptyMetadata + + + choice BurnRequest_Decline : () + -- ^ Decline the request. + controller issuer + do + pure () + + choice BurnRequest_Withdraw : () + -- ^ Withdraw the request. + controller requester + do + pure () + +-- | Result of burning tokens +data BurnResult = BurnResult with + burnedCid : ContractId MyToken + leftoverCid : Optional (ContractId MyToken) + meta : MD.Metadata + deriving (Show, Eq) + +validateTransferInstruction : ContractId TI.TransferInstruction -> PortfolioItem -> Decimal -> Party -> Party -> Update () +validateTransferInstruction tiCid portfolioItem amount issuer requester = do + ti <- fetch tiCid + + let tiView = view ti + + assertMsg "Transfer instruction sender does not match issuer" (tiView.transfer.sender == issuer) + + assertMsg "Transfer instruction receiver does not match requester" (tiView.transfer.receiver == requester) + assertMsg ("Transfer instruction instrumentId does not match portfolio item: " <> show tiView.transfer.instrumentId <> ", " <> show portfolioItem.instrumentId) (tiView.transfer.instrumentId == portfolioItem.instrumentId) + assertMsg "Transfer instruction amount does not match expected amount" (tiView.transfer.amount == (portfolioItem.weight * amount)) diff --git a/packages/minimal-token/daml/MyMintRecipe.daml b/packages/minimal-token/daml/ETF/MyMintRecipe.daml similarity index 96% rename from packages/minimal-token/daml/MyMintRecipe.daml rename to packages/minimal-token/daml/ETF/MyMintRecipe.daml index e0229f9..71f3e58 100644 --- a/packages/minimal-token/daml/MyMintRecipe.daml +++ b/packages/minimal-token/daml/ETF/MyMintRecipe.daml @@ -1,8 +1,7 @@ -module MyMintRecipe where +module ETF.MyMintRecipe where import MyToken -import MyTokenFactory -import PortfolioComposition +import ETF.PortfolioComposition -- | MyMintRecipe defines how to mint MyToken tokens based on a portfolio composition. -- It allows an issuer to authorize multiple parties to mint tokens according to the defined composition. @@ -11,7 +10,6 @@ template MyMintRecipe with issuer : Party instrumentId : Text - tokenFactory : ContractId MyTokenFactory authorizedMinters: [Party] composition: ContractId PortfolioComposition where diff --git a/packages/minimal-token/daml/ETF/MyMintRequest.daml b/packages/minimal-token/daml/ETF/MyMintRequest.daml new file mode 100644 index 0000000..dd316b6 --- /dev/null +++ b/packages/minimal-token/daml/ETF/MyMintRequest.daml @@ -0,0 +1,79 @@ +module ETF.MyMintRequest where + +import DA.Foldable (forA_) + +import Splice.Api.Token.MetadataV1 as MD +import Splice.Api.Token.TransferInstructionV1 as TI + +import ETF.MyMintRecipe as MyMintRecipe +import ETF.PortfolioComposition (PortfolioItem) +import MyToken + +template MyMintRequest + with + mintRecipeCid: ContractId MyMintRecipe + -- ^ The mint recipe to be used for minting + requester: Party + -- ^ The party requesting the minting + amount: Decimal + -- ^ The amount of ETF tokens to mint + transferInstructionCids: [ContractId TI.TransferInstruction] + -- ^ The transfer instructions to be executed as part of the minting process. Expected to be in the same order as the portfolio composition. + issuer:Party + -- ^ The ETF issuer + + where + signatory requester + observer issuer + + ensure amount > 0.0 + + choice MintRequest_Accept : ContractId MyToken + -- ^ Accept the mint request and mint the tokens. + controller issuer + do + mintRecipe <- fetch mintRecipeCid + portfolioComp <- fetch (mintRecipe.composition) + + assertMsg "Mint recipe requester must be an authorized minter" (requester `elem` mintRecipe.authorizedMinters) + + assertMsg "Transfer instructions must be of same length as portfolio composition items" $ (length transferInstructionCids) == (length portfolioComp.items) + + forA_ (zip transferInstructionCids portfolioComp.items) $ + \(tiCid, portfolioItem) -> validateTransferInstruction tiCid portfolioItem amount issuer requester + -- For each transfer instruction: + -- Accept each transfer instruction + forA_ transferInstructionCids $ + \tiCid -> exercise tiCid TI.TransferInstruction_Accept with + extraArgs = MD.ExtraArgs with + context = MD.emptyChoiceContext + meta = MD.emptyMetadata + + exercise mintRecipeCid MyMintRecipe.MyMintRecipe_Mint with + receiver = requester + amount = amount + + choice MintRequest_Decline : () + -- ^ Decline the request. + controller issuer + do + pure () + + choice MintRequest_Withdraw : () + -- ^ Withdraw the request. + controller requester + do + pure () + + +validateTransferInstruction : ContractId TI.TransferInstruction -> PortfolioItem -> Decimal -> Party -> Party -> Update () +validateTransferInstruction tiCid portfolioItem amount issuer requester = do + ti <- fetch tiCid + + let tiView = view ti + + assertMsg "Transfer instruction sender does not match requester" (tiView.transfer.sender == requester) + + assertMsg "Transfer instruction receiver does not match issuer" (tiView.transfer.receiver == issuer) + assertMsg ("Transfer instruction instrumentId does not match portfolio item: " <> show tiView.transfer.instrumentId <> ", " <> show portfolioItem.instrumentId) (tiView.transfer.instrumentId == portfolioItem.instrumentId) + assertMsg "Transfer instruction amount does not match expected amount" (tiView.transfer.amount == (portfolioItem.weight * amount)) diff --git a/packages/minimal-token/daml/PortfolioComposition.daml b/packages/minimal-token/daml/ETF/PortfolioComposition.daml similarity index 70% rename from packages/minimal-token/daml/PortfolioComposition.daml rename to packages/minimal-token/daml/ETF/PortfolioComposition.daml index 839a906..ef09a57 100644 --- a/packages/minimal-token/daml/PortfolioComposition.daml +++ b/packages/minimal-token/daml/ETF/PortfolioComposition.daml @@ -1,8 +1,10 @@ -module PortfolioComposition where +module ETF.PortfolioComposition where + +import Splice.Api.Token.HoldingV1 (InstrumentId) data PortfolioItem = PortfolioItem with - instrumentId: Text + instrumentId: InstrumentId weight: Decimal deriving (Eq, Show) diff --git a/packages/minimal-token/daml/ETF/Test/ETFTest.daml b/packages/minimal-token/daml/ETF/Test/ETFTest.daml new file mode 100644 index 0000000..42fd861 --- /dev/null +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -0,0 +1,286 @@ +{-# LANGUAGE ApplicativeDo #-} +module ETF.Test.ETFTest where + +import Daml.Script +import DA.Time + +import Splice.Api.Token.HoldingV1 as H + +import ETF.MyBurnRequest as MyBurnRequest +import ETF.MyMintRecipe as MyMintRecipe +import ETF.MyMintRequest as MyMintRequest +import ETF.PortfolioComposition as PortfolioComposition +import MyToken +import MyTokenFactory +import MyToken.TransferRequest as TransferRequest +import MyToken.IssuerMintRequest as IssuerMintRequest +import Test.TestUtils (createTransferRequest, setupTokenInfrastructureWithInstrumentId) + +mintToSelfTokenETF : Script () +mintToSelfTokenETF = script do + -- Allocate parties + issuer <- allocatePartyByHint (PartyIdHint "Issuer") + + let instrumentId1 = show issuer <> "#MyToken1" + let instrumentId2 = show issuer <> "#MyToken2" + let instrumentId3 = show issuer <> "#MyToken3" + let + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] = + map (\id -> H.InstrumentId with admin = issuer, id = id) + [instrumentId1, instrumentId2, instrumentId3] + + let etfInstrumentId = show issuer <> "#ThreeTokenETF" + + -- Issuer creates token factories (for minting tokens) + infras <- forA [instrumentId1, instrumentId2, instrumentId3] + $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId + + -- Issuer creates portfolio composition + let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] + + portfolioCid <- submit issuer do + createCmd PortfolioComposition.PortfolioComposition with + owner = issuer + name = "Three Token ETF" + items = portfolioItems + + mintRecipeCid <- submit issuer do + createCmd MyMintRecipe with + issuer + instrumentId = etfInstrumentId + authorizedMinters = [issuer] + composition = portfolioCid + + -- Mint all three tokens + tokenCids <- submit issuer do + forA infras $ \infra -> do + mintResult <- exerciseCmd infra.tokenFactoryCid MyTokenFactory.Mint with + receiver = issuer + amount = 1.0 + + pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + + -- create a transfer instruction of each portfolio item to the etf owner (issuer) + now <- getTime + let requestedAtPast = addRelTime now (seconds (-1)) + let future = addRelTime now (hours 1) + + tokenTransferRequests <- forA + (zip infras tokenCids) + \(infra, tokenCid) -> createTransferRequest + issuer issuer issuer 1.0 requestedAtPast future + infra.transferFactoryCid tokenCid + + tokenTransferResults <- submit issuer do + forA tokenTransferRequests $ \tokenTransferRequestCid -> do + exerciseCmd tokenTransferRequestCid TransferRequest.Accept + + let transferInstructionCids = map (\result -> result.output.transferInstructionCid) tokenTransferResults + + -- ETF mint should check that there is a transfer instruction for each underlying asset, then accept each transfer instruction, and finally mint the ETF token + etfMintRequestCid <- submit issuer do + createCmd MyMintRequest with + mintRecipeCid + requester = issuer + amount = 1.0 + transferInstructionCids + issuer + + etfCid <- submit issuer do + exerciseCmd etfMintRequestCid MyMintRequest.MintRequest_Accept + + pure () + +mintToOtherTokenETF : Script () +mintToOtherTokenETF = script do + -- Allocate parties + issuer <- allocatePartyByHint (PartyIdHint "Issuer") + alice <- allocatePartyByHint (PartyIdHint "Alice") + + let instrumentId1 = show issuer <> "#MyToken1" + let instrumentId2 = show issuer <> "#MyToken2" + let instrumentId3 = show issuer <> "#MyToken3" + let + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] = + map (\id -> H.InstrumentId with admin = issuer, id = id) + [instrumentId1, instrumentId2, instrumentId3] + + let etfInstrumentId = show issuer <> "#ThreeTokenETF" + + -- Issuer creates token factories (for minting tokens) + infras <- forA [instrumentId1, instrumentId2, instrumentId3] + $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId + + -- Issuer creates portfolio composition + let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] + + portfolioCid <- submit issuer do + createCmd PortfolioComposition.PortfolioComposition with + owner = issuer + name = "Three Token ETF" + items = portfolioItems + + mintRecipeCid <- submit issuer do + createCmd MyMintRecipe with + issuer + instrumentId = etfInstrumentId + authorizedMinters = [issuer, alice] + composition = portfolioCid + + -- Mint all three tokens + mintRequests <- submit alice do + forA infras $ \infra -> do + createCmd IssuerMintRequest with + tokenFactoryCid = infra.tokenFactoryCid + issuer + receiver = alice + amount = 1.0 + + tokenCids <- submit issuer do + forA mintRequests $ \mintRequestCid -> do + mintResult <- exerciseCmd mintRequestCid IssuerMintRequest.Accept + pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + + -- create a transfer instruction of each portfolio item to the etf owner (issuer) + now <- getTime + let requestedAtPast = addRelTime now (seconds (-1)) + let future = addRelTime now (hours 1) + + tokenTransferRequests <- forA (zip infras tokenCids) \(infra, tokenCid) -> createTransferRequest + alice issuer issuer 1.0 requestedAtPast future + infra.transferFactoryCid tokenCid + + tokenTransferResults <- submit issuer do + forA tokenTransferRequests $ \tokenTransferRequestCid -> + exerciseCmd tokenTransferRequestCid TransferRequest.Accept + + let transferInstructionCids = map (\result -> result.output.transferInstructionCid) tokenTransferResults + + -- ETF mint should check that there is a transfer instruction for each underlying asset, then accept each transfer instruction, and finally mint the ETF token + etfMintRequestCid <- submit alice do + createCmd MyMintRequest with + mintRecipeCid + requester = alice + amount = 1.0 + transferInstructionCids + issuer + + etfCid <- submit issuer do + exerciseCmd etfMintRequestCid MyMintRequest.MintRequest_Accept + + pure () + +burnTokenETF : Script () +burnTokenETF = script do + -- Allocate parties + issuer <- allocatePartyByHint (PartyIdHint "Issuer") + alice <- allocatePartyByHint (PartyIdHint "Alice") + + let instrumentId1 = show issuer <> "#MyToken1" + let instrumentId2 = show issuer <> "#MyToken2" + let instrumentId3 = show issuer <> "#MyToken3" + let + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] = + map (\id -> H.InstrumentId with admin = issuer, id = id) + [instrumentId1, instrumentId2, instrumentId3] + + let etfInstrumentId = show issuer <> "#ThreeTokenETF" + + -- Issuer creates token factories (for minting tokens) + infras <- forA [instrumentId1, instrumentId2, instrumentId3] + $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId + + -- Issuer creates portfolio composition + let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] + + portfolioCid <- submit issuer do + createCmd PortfolioComposition.PortfolioComposition with + owner = issuer + name = "Three Token ETF" + items = portfolioItems + + mintRecipeCid <- submit issuer do + createCmd MyMintRecipe with + issuer + instrumentId = etfInstrumentId + authorizedMinters = [issuer, alice] + composition = portfolioCid + + -- Mint all three tokens + mintRequests <- submit alice do + forA infras $ \infra -> do + createCmd IssuerMintRequest with + tokenFactoryCid = infra.tokenFactoryCid + issuer + receiver = alice + amount = 1.0 + + tokenCids <- submit issuer do + forA mintRequests $ \mintRequestCid -> do + mintResult <- exerciseCmd mintRequestCid IssuerMintRequest.Accept + pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + + -- create a transfer instruction of each portfolio item to the etf owner (issuer) + now <- getTime + let requestedAtPast = addRelTime now (seconds (-1)) + let future = addRelTime now (hours 1) + + tokenTransferRequests <- forA (zip infras tokenCids) \(infra, tokenCid) -> createTransferRequest + alice issuer issuer 1.0 requestedAtPast future + infra.transferFactoryCid tokenCid + + tokenTransferResults <- submit issuer do + forA tokenTransferRequests $ \tokenTransferRequestCid -> + exerciseCmd tokenTransferRequestCid TransferRequest.Accept + + let transferInstructionCids = map (\result -> result.output.transferInstructionCid) tokenTransferResults + + -- ETF mint should check that there is a transfer instruction for each underlying asset, then accept each transfer instruction, and finally mint the ETF token + etfMintRequestCid <- submit alice do + createCmd MyMintRequest with + mintRecipeCid + requester = alice + amount = 1.0 + transferInstructionCids + issuer + + etfCid <- submit issuer do + exerciseCmd etfMintRequestCid MyMintRequest.MintRequest_Accept + + -- Now burn the ETF token + -- In reverse, Alice cretaes a burn request, issuer creates transfer instructions, and then accepts the burn request with those transfer instructions + etfBurnRequestCid <- submit alice do + createCmd MyBurnRequest with + mintRecipeCid + requester = alice + issuer + amount = 1.0 + inputHoldingCid = etfCid + + issuerTokenCids <- forA + [instrumentId1, instrumentId2, instrumentId3] + $ \id -> do + [(tokenResultCid, _)] <- queryFilter @MyToken issuer + (\t -> t.owner == issuer && t.instrumentId == id && t.issuer == issuer) + pure (toInterfaceContractId @H.Holding tokenResultCid) + + issuerTokenTransferRequests <- forA + (zip infras issuerTokenCids) + \(infra, tokenCid) -> createTransferRequest + issuer alice issuer 1.0 requestedAtPast future + infra.transferFactoryCid tokenCid + + issuerTokenTransferResults <- submit issuer do + forA issuerTokenTransferRequests $ \tokenTransferRequestCid -> do + exerciseCmd tokenTransferRequestCid TransferRequest.Accept + + let issuerTransferInstructionCids = map (\result -> result.output.transferInstructionCid) issuerTokenTransferResults + + burnResult <- submit issuer do + exerciseCmd etfBurnRequestCid MyBurnRequest.BurnRequest_Accept with + transferInstructionCids = issuerTransferInstructionCids + + pure () diff --git a/packages/minimal-token/daml/MyTokenFactory.daml b/packages/minimal-token/daml/MyTokenFactory.daml index 988eb02..8ac589c 100644 --- a/packages/minimal-token/daml/MyTokenFactory.daml +++ b/packages/minimal-token/daml/MyTokenFactory.daml @@ -59,7 +59,8 @@ template MyTokenFactory -- Exact match: archive the entire token archive inputHoldingCid pure BurnResult with - burnedCids = [inputHoldingCid] + burnedCid = inputHoldingCid + leftoverCid = None meta = MD.emptyMetadata else do transferResult <- exercise inputHoldingCid MyToken_Transfer with @@ -68,7 +69,8 @@ template MyTokenFactory -- Archive the newly created token (the transferred portion to be burned) archive transferResult.transferredCid pure BurnResult with - burnedCids = [transferResult.transferredCid] + burnedCid = transferResult.transferredCid + leftoverCid = transferResult.remainderCid meta = MD.emptyMetadata -- | Result of minting tokens @@ -79,7 +81,8 @@ data MintResult = MintResult with -- | Result of burning tokens data BurnResult = BurnResult with - burnedCids : [ContractId MyToken] + burnedCid : ContractId MyToken + leftoverCid : Optional (ContractId MyToken) meta : MD.Metadata deriving (Show, Eq) diff --git a/packages/minimal-token/daml/Test/TestUtils.daml b/packages/minimal-token/daml/Test/TestUtils.daml index 05d6ec3..a810ab1 100644 --- a/packages/minimal-token/daml/Test/TestUtils.daml +++ b/packages/minimal-token/daml/Test/TestUtils.daml @@ -1,3 +1,4 @@ +{-# LANGUAGE ApplicativeDo #-} module Test.TestUtils where import Daml.Script @@ -44,6 +45,11 @@ data TokenInfrastructure = TokenInfrastructure with setupTokenInfrastructure : Party -> Script TokenInfrastructure setupTokenInfrastructure issuer = script do let instrumentId = show issuer <> "#MyToken" + setupTokenInfrastructureWithInstrumentId issuer instrumentId + +-- | Setup complete token infrastructure for an issuer +setupTokenInfrastructureWithInstrumentId : Party -> Text -> Script TokenInfrastructure +setupTokenInfrastructureWithInstrumentId issuer instrumentId = script do -- Create rules for locking tokens rulesCid <- submit issuer do @@ -106,12 +112,15 @@ createTransferRequest -> ContractId H.Holding -- input holding -> Script (ContractId TransferRequest) createTransferRequest sender receiver issuer amount requestedAt executeBefore factoryCid inputHoldingCid = script do - let instrumentId = show issuer <> "#MyToken" + Some holding <- queryInterfaceContractId @H.Holding sender inputHoldingCid + + -- let instrumentId = show issuer <> "#MyToken" + let instrumentId = holding.instrumentId let transfer = TI.Transfer with sender receiver amount - instrumentId = H.InstrumentId with admin = issuer, id = instrumentId + instrumentId requestedAt executeBefore inputHoldingCids = [inputHoldingCid] diff --git a/packages/token-sdk/CLAUDE.md b/packages/token-sdk/CLAUDE.md index 1ae7e9e..c5a2e09 100644 --- a/packages/token-sdk/CLAUDE.md +++ b/packages/token-sdk/CLAUDE.md @@ -1,733 +1,242 @@ -# CLAUDE.md +# CLAUDE.md - TypeScript SDK -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +TypeScript SDK for Canton Network token operations via Wallet SDK. Built on `../minimal-token` Daml contracts. -## Project Overview +## Quick Reference -This is a TypeScript SDK for interacting with Canton Network's token system via the Wallet SDK. The SDK provides helper functions for creating token factories, minting tokens, transferring tokens, and querying balances on a Canton ledger, built on top of the minimal-token Daml application located in the sibling `../minimal-token` directory. +**Setup**: Compile DAR (`cd ../minimal-token && daml build`), fetch deps (`pnpm fetch:localnet`), start localnet (`pnpm start:localnet`) -## Additional Documentation +**Build**: `pnpm build` | `pnpm test` | `pnpm lint:fix` -- **[Splice Wallet Kernel Reference](notes/SPLICE-WALLET-KERNEL.md)** - Key learnings about exercising Daml interface choices through the Canton Ledger HTTP API, including template ID formats, common errors, and patterns from the splice-wallet-kernel implementation. +**Scripts**: +- `tsx src/uploadDars.ts` - Upload DAR (run once) +- `tsx src/hello.ts` - Basic token operations +- `tsx src/testScripts/threePartyTransfer.ts` - Three-party transfer demo +- `tsx src/testScripts/bondLifecycleTest.ts` - Bond lifecycle demo +- `tsx src/testScripts/etfMint.ts` / `etfBurn.ts` - ETF operations -## Prerequisites +## Architecture -Before running this SDK: -1. Compile the `minimal-token` DAR file in the sibling directory: `cd ../minimal-token && daml build` -2. Fetch localnet dependencies from the monorepo root: `pnpm fetch:localnet` -3. Start the localnet from the monorepo root: `pnpm start:localnet` +### SDK Wrappers (`src/wrappedSdk/`) -## Development Commands +All wrappers follow CRUD pattern: `create`, `getLatest`, `getOrCreate`, `accept`/`decline`/`withdraw`. -### Building -- `pnpm build` - Compile TypeScript and bundle with esbuild -- `pnpm build:watch` - Watch mode for continuous building -- `pnpm tsc` - TypeScript compilation only -- `pnpm esbuild` - esbuild bundling only +**Core Token Operations**: +- `tokenFactory.ts` - Manage MyTokenFactory (mint authority) +- `issuerMintRequest.ts` - Two-step minting (receiver proposes, issuer accepts) +- `tokenRules.ts` - Locking rules for transfers +- `transferFactory.ts` - Create transfer instructions +- `transferRequest.ts` - Transfer request/accept pattern +- `transferInstruction.ts` - Accept/reject transfers +- `balances.ts` - Query token holdings -### Testing -- `pnpm test` - Run tests once (CI mode) -- `pnpm test:watch` - Run tests in watch mode +**Bond Operations** (`wrappedSdk/bonds/`): +- `factory.ts` - Bond factory (stores notional, couponRate, couponFrequency) +- `issuerMintRequest.ts` - Two-step bond minting +- `bondRules.ts` - Bond locking for transfers +- `lifecycleRule.ts` - Lifecycle event definitions (coupon/redemption) +- `lifecycleEffect.ts` - Query lifecycle effects +- `lifecycleClaimRequest.ts` - Claim lifecycle events +- `lifecycleInstruction.ts` - Execute lifecycle +- `transferFactory.ts` / `transferRequest.ts` / `transferInstruction.ts` - Bond transfers -### Linting -- `pnpm lint` - Check for linting errors -- `pnpm lint:fix` - Auto-fix linting errors +**ETF Operations** (`wrappedSdk/etf/`): +- `portfolioComposition.ts` - Define basket of assets with weights +- `mintRecipe.ts` - ETF minting rules (no tokenFactory - creates directly) +- `mintRequest.ts` - Mint ETF with backing assets (requester→issuer custody) +- `burnRequest.ts` - Burn ETF, return assets (issuer→requester custody) -### Running Scripts -- `tsx src/uploadDars.ts` - Upload the minimal-token DAR to the ledger (run once before using scripts) -- `tsx src/hello.ts` - Basic demo script showing token operations -- `tsx src/testScripts/threePartyTransfer.ts` - Comprehensive three-party transfer demonstration -- `tsx src/testScripts/transferWithPreapproval.ts` - Transfer with preapproval pattern -- `tsx src/testScripts/bondLifecycleTest.ts` - Complete bond lifecycle demonstration (mint, coupon, transfer, redemption) +### Key Patterns -### Other Commands -- `pnpm clean` - Remove build artifacts -- `pnpm ledger-schema` - Regenerate OpenAPI types from local ledger (requires ledger running on localhost:7575) -- `pnpm get:minimal-token-id` - Extract package ID from compiled DAR file +**Request/Accept**: Sender creates request → Admin accepts → Operation completes (avoids multi-party signing complexity) -## Architecture +**Disclosure**: Use `getTransferInstructionDisclosure()` for receiver visibility (required for three-party transfers) -### Core Components - -**SDK Initialization (`src/hello.ts`, `src/sdkHelpers.ts`)** -- Configures `WalletSDKImpl` with localnet defaults for auth, ledger, and token standard -- Connects to the Canton ledger and topology service -- Creates external parties (Alice, Bob, Charlie) using Ed25519 keypairs generated from seeds -- Allocates parties on the ledger via signed multi-hashes - -**Wrapped SDK Helpers (`src/wrappedSdk/`)** - -All helpers follow a consistent pattern with single-party perspective and template IDs centralized in `src/constants/templateIds.ts`: - -**`tokenFactory.ts`** - Token factory management -- `createTokenFactory()` / `getLatestTokenFactory()` / `getOrCreateTokenFactory()` - Manage MyTokenFactory contracts -- `mintToken()` - Direct Mint choice (requires multi-party signing - use IssuerMintRequest pattern instead) -- `getMintTokenCommand()` - Create mint command - -**`issuerMintRequest.ts`** - Two-step minting pattern (recommended) -- `createIssuerMintRequest()` - Receiver creates mint request -- `getLatestIssuerMintRequest()` - Query for mint requests -- `acceptIssuerMintRequest()` - Issuer accepts request, mints tokens -- `declineIssuerMintRequest()` - Issuer declines request -- `withdrawIssuerMintRequest()` - Issuer withdraws request -- **Pattern**: Receiver proposes → Issuer accepts → Tokens minted (avoids multi-party signing) - -**`tokenRules.ts`** - Token locking rules -- `createTokenRules()` / `getLatestTokenRules()` / `getOrCreateTokenRules()` - Manage MyTokenRules contracts -- Required for transfer operations that need token locking - -**`transferFactory.ts`** - Transfer factory management -- `createTransferFactory()` / `getLatestTransferFactory()` / `getOrCreateTransferFactory()` - Manage MyTransferFactory contracts -- Requires `rulesCid` parameter (from tokenRules) - -**`transferRequest.ts`** - Transfer request/accept pattern -- `createTransferRequest()` - Sender creates transfer request -- `getLatestTransferRequest()` - Query for transfer requests -- `acceptTransferRequest()` - Admin accepts request (locks tokens, creates instruction) -- `declineTransferRequest()` - Admin declines request -- `withdrawTransferRequest()` - Sender withdraws request -- `buildTransfer()` - Helper to construct Transfer objects with proper timestamps -- `emptyMetadata()` / `emptyChoiceContext()` / `emptyExtraArgs()` - Metadata helpers -- Type definitions: `Transfer`, `InstrumentId`, `Metadata`, `ExtraArgs` - -**`contractDisclosure.ts`** - Generic disclosure helper -- `getContractDisclosure()` - Get disclosure for any contract by template ID and contract ID -- Returns `DisclosedContract` with `contractId`, `createdEventBlob`, `templateId`, `synchronizerId` -- Used by other disclosure functions - -**`disclosure.ts`** - Transfer-specific disclosure helpers -- `getTransferInstruction()` - Query MyTransferInstruction contract -- `getTransferInstructionDisclosure()` - Get disclosure for receiver (includes locked token) -- `getMyTokenDisclosure()` - Get disclosure for MyToken contracts -- Required for three-party transfers where receiver needs to see locked tokens - -**`balances.ts`** - Token balance queries -- `getBalances()` - Get all token balances for a party (aggregated UTXO model) -- `getBalanceByInstrumentId()` - Get balance for specific instrument -- `formatHoldingUtxo()` - Format holding UTXO data -- Re-exports `getContractDisclosure` for backward compatibility - -**`transferPreapproval.ts` / `transferPreapprovalProposal.ts`** - Transfer preapproval patterns -- Support for preapproved transfer workflows - -**`wrappedSdk.ts`** - SDK wrapper convenience functions -- `getWrappedSdkWithKeyPair()` - Create wrapped SDK with key pair - -**DAR Upload (`src/uploadDars.ts`)** -- Checks if the minimal-token package is already uploaded via `isPackageUploaded()` -- Uploads the DAR file from `../minimal-token/.daml/dist/minimal-token-0.1.0.dar` if not present -- Package ID is hardcoded but can be regenerated with `pnpm get:minimal-token-id` - -**Template ID Constants (`src/constants/templateIds.ts`)** -- Centralized template ID definitions -- Template IDs are prefixed with `#minimal-token:` (e.g., `#minimal-token:MyTokenFactory:MyTokenFactory`) - -### Canton Ledger Interaction Pattern - -The SDK uses a consistent pattern for ledger operations: - -1. **Command Creation**: Create a `WrappedCommand` (CreateCommand or ExerciseCommand) -2. **Submission**: Use `prepareSignExecuteAndWaitFor()` with: - - Command array - - Private key for signing - - UUID correlation ID -3. **Contract Queries**: Use `activeContracts()` filtered by party and template ID - -### Key Architectural Notes - -- **Single-Party Design**: All SDK wrapper functions operate from a single party's perspective (one ledger controller, one key pair at a time) -- **External Party Management**: Parties are created externally with Ed25519 keypairs, then allocated on-ledger by signing their multi-hash -- **InstrumentId Format**: Tokens are identified by `{admin: partyId, id: fullInstrumentId}` where fullInstrumentId is typically `partyId#TokenName` -- **UTXO Model**: Token balances are tracked as separate contracts (UTXOs); the SDK aggregates them in `getBalances()` -- **Party Context Switching**: `sdk.setPartyId()` is used to switch context when querying different parties' holdings -- **Request/Accept Pattern**: Multi-party operations use a two-step pattern to avoid multi-party signing limitations - -### Three-Party Transfer Flow - -The `threePartyTransfer.ts` script demonstrates the complete three-party authorization pattern: - -**Setup Phase (Charlie as issuer/admin):** -```typescript -const rulesCid = await getOrCreateTokenRules(charlieLedger, charlieKeyPair); -const transferFactoryCid = await getOrCreateTransferFactory(charlieLedger, charlieKeyPair, rulesCid); -const tokenFactoryCid = await getOrCreateTokenFactory(charlieLedger, charlieKeyPair, instrumentId); -``` +**Template IDs**: Centralized in `src/constants/templateIds.ts` with `#minimal-token:` prefix -**Minting Phase (Two-step: Alice proposes, Charlie accepts):** +## Token Operations + +### Basic Minting (Two-Step Pattern) ```typescript -// Step 1: Alice creates mint request -await createIssuerMintRequest(aliceLedger, aliceKeyPair, { - tokenFactoryCid, - issuer: charlie, - receiver: alice, - amount: 100, +// 1. Receiver creates mint request +await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid, issuer, receiver: alice, amount: 100 }); -// Step 2: Alice queries for the request CID -const mintRequestCid = await getLatestIssuerMintRequest(aliceLedger, charlie); - -// Step 3: Charlie accepts the request (mints tokens) -await acceptIssuerMintRequest(charlieLedger, charlieKeyPair, mintRequestCid); +// 2. Issuer accepts +const requestCid = await issuerWrappedSdk.issuerMintRequest.getLatest(alice); +await issuerWrappedSdk.issuerMintRequest.accept(requestCid); ``` -**Transfer Request Phase (Alice proposes transfer to Bob):** +### Transfer Pattern ```typescript +// 1. Setup infrastructure (issuer) +const rulesCid = await issuerWrappedSdk.tokenRules.getOrCreate(); +const transferFactoryCid = await issuerWrappedSdk.transferFactory.getOrCreate(rulesCid); + +// 2. Sender creates transfer request const transfer = buildTransfer({ - sender: alice, - receiver: bob, - amount: 50, - instrumentId: { admin: charlie, id: instrumentId }, - requestedAt: new Date(Date.now() - 1000), // Past - executeBefore: new Date(Date.now() + 3600000), // Future - inputHoldingCids: [aliceTokenCid], + sender: alice, receiver: bob, amount: 50, instrumentId, + requestedAt: new Date(Date.now() - 1000), + executeBefore: new Date(Date.now() + 3600000), + inputHoldingCids: [aliceTokenCid] }); +await aliceWrappedSdk.transferRequest.create({ transferFactoryCid, expectedAdmin: issuer, transfer }); -await createTransferRequest(aliceLedger, aliceKeyPair, { - transferFactoryCid, - expectedAdmin: charlie, - transfer, - extraArgs: emptyExtraArgs(), -}); -``` +// 3. Issuer accepts (locks tokens, creates instruction) +const requestCid = await issuerWrappedSdk.transferRequest.getLatest(alice); +await issuerWrappedSdk.transferRequest.accept(requestCid); -**Approval Phase (Charlie accepts, locks tokens):** -```typescript -const requestCid = await getLatestTransferRequest(aliceLedger, charlie); -await acceptTransferRequest(charlieLedger, charlieKeyPair, requestCid); -// Creates MyTransferInstruction with locked tokens +// 4. Receiver accepts instruction (with disclosure for three-party) +const instructionCid = await bobLedger.transferInstruction.getLatest(bob); +const disclosure = await issuerWrappedSdk.disclosure.getTransferInstructionDisclosure(instructionCid); +await bobWrappedSdk.transferInstruction.accept(instructionCid, disclosure); ``` -**Disclosure Phase (Charlie provides disclosure to Bob):** -```typescript -const disclosure = await getTransferInstructionDisclosure( - charlieLedger, - transferInstructionCid -); -// Returns: { lockedTokenDisclosure, transferInstruction } -``` +## Bond Operations -**Acceptance Phase (Bob accepts - requires multi-party API):** -- Bob needs to accept the MyTransferInstruction -- Requires lower-level LedgerClient API with disclosed contracts -- See Multi-Party Transaction Workarounds below for implementation details +**Architecture**: Fungible bonds (single contract holds multiple units), per-unit coupon payments, version tracking, ledger time security. -### Bond Operations +**Lifecycle**: Mint → Coupon payments → Transfers → Redemption -The SDK provides comprehensive support for bond instruments following CIP-0056 standards and daml-finance patterns. The bond implementation demonstrates: -- Fungible bond architecture with lifecycle management -- Coupon payment processing -- Bond transfers with partial amount support -- Redemption at maturity - -#### Bond Architecture Overview - -**Fungible Bond Model:** - -Bonds use a fungible design where a single contract can hold multiple bond units: -- **notional**: Face value per bond unit (e.g., $1000 per bond) -- **amount**: Number of bond units held (e.g., 3 bonds) -- **Total face value**: `notional × amount` (e.g., $1000 × 3 = $3000) - -This allows efficient portfolio management - you can hold 100 bonds in a single contract rather than 100 separate contracts. - -**BondFactory Stores Bond Terms:** - -Unlike token factories, BondFactory stores the bond's terms (`notional`, `couponRate`, `couponFrequency`). All bonds minted from a specific factory share: -- Same notional (face value per unit) -- Same coupon rate (e.g., 5% annual) -- Same coupon frequency (e.g., 2 = semi-annual payments) - -Only the maturity date varies per bond issuance. - -**Per-Unit Payment Calculations:** - -Lifecycle events (coupons, redemption) calculate payments on a per-unit basis: -- **Coupon payment**: `(notional × couponRate / couponFrequency) × amount` - - Example: `(1000 × 0.05 / 2) × 3 = 25 × 3 = $75` -- **Redemption payment**: `(notional × amount) + final coupon` - - Example: `(1000 × 3) + 75 = $3075` - -**Partial Transfer Support:** - -BondRules supports splitting bonds for partial transfers: -- If you hold 3 bonds and transfer 1, the system automatically: - - Locks 1 bond for transfer - - Creates a "change" bond with the remaining 2 bonds - - Returns the change bond to you immediately - -#### Bond Wrapper Modules - -**`bonds/bondRules.ts`** - Centralized locking authority -- `createBondRules()` - Create issuer-signed bond rules (required for all bond operations) -- `getLatestBondRules()` - Query latest bond rules by issuer -- `getOrCreateBondRules()` - Get existing or create new bond rules - -Pattern: Issuer-signed, owner-controlled. The issuer signs the rules, but the bond owner controls when to invoke locking operations. - -**`bonds/factory.ts`** - Bond minting factory -- `createBondFactory(userLedger, userKeyPair, instrumentId, notional, couponRate, couponFrequency)` - Create factory with bond terms -- `getLatestBondFactory()` - Query latest factory by instrument ID -- `getOrCreateBondFactory()` - Get existing or create new factory - -**Important**: The factory stores `notional`, `couponRate`, and `couponFrequency`. These terms are shared by all bonds minted from this factory. Only `amount` and `maturityDate` vary per mint. - -**`bonds/issuerMintRequest.ts`** - Two-step bond minting -- `createBondIssuerMintRequest(receiverLedger, receiverKeyPair, params)` - Receiver proposes bond mint - - `params.amount` - Number of bond units (NOT principal amount) - - `params.maturityDate` - ISO string date when bond matures -- `getLatestBondIssuerMintRequest()` - Query latest mint request -- `getAllBondIssuerMintRequests()` - Query all mint requests for issuer -- `acceptBondIssuerMintRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer accepts, mints bonds -- `declineBondIssuerMintRequest()` - Issuer declines request -- `withdrawBondIssuerMintRequest()` - Receiver withdraws request - -**Pattern**: Receiver proposes → Issuer accepts → Bonds minted (avoids three-party signing: issuer, depository, owner) - -**`bonds/lifecycleRule.ts`** - Lifecycle event processing -- `createBondLifecycleRule(userLedger, userKeyPair, params)` - Create lifecycle rule - - `params.depository` - Depository party (often same as issuer) - - `params.currencyInstrumentId` - Currency used for payments -- `getLatestBondLifecycleRule()` - Query latest rule -- `getOrCreateBondLifecycleRule()` - Get existing or create new rule -- `processCouponPaymentEvent(userLedger, userKeyPair, contractId, params)` - Process coupon payment - - `params.targetInstrumentId` - Bond instrument ID - - `params.targetVersion` - Current bond version (for version tracking) - - `params.bondCid` - Sample bond contract (used to infer bond terms) -- `processRedemptionEvent(userLedger, userKeyPair, contractId, params)` - Process redemption at maturity - - `params.bondCid` - Sample bond contract (used to infer bond terms) - -**Security**: Uses ledger time (`getTime`) directly instead of accepting dates as parameters. This prevents time manipulation attacks where an issuer could backdate or future-date lifecycle events. - -**Term Inference**: Lifecycle rules infer bond terms (`notional`, `couponRate`, `couponFrequency`) from a sample bond contract, ensuring consistency and reducing redundant parameters. - -**`bonds/lifecycleEffect.ts`** - Effect query helper -- `getLatestBondLifecycleEffect(ledger, party)` - Query latest lifecycle effect - - Returns: `{ contractId, producedVersion }` where `producedVersion` is the new bond version after coupon payment (or `null` for redemption) - -**Pattern**: Lifecycle effects are single source of truth. The issuer creates one effect contract that all holders of the target bond version can claim. - -**`bonds/lifecycleClaimRequest.ts`** - Claim lifecycle events -- `createBondLifecycleClaimRequest(holderLedger, holderKeyPair, params)` - Holder creates claim for coupon/redemption - - `params.effectCid` - Lifecycle effect contract to claim - - `params.bondHoldingCid` - Holder's bond contract - - `params.bondRulesCid` - Bond rules for locking - - `params.bondFactoryCid` - Bond factory (for creating new version after coupon) - - `params.currencyTransferFactoryCid` - Currency transfer factory (for payment) - - `params.issuerCurrencyHoldingCid` - Issuer's currency holding to use for payment -- `getLatestBondLifecycleClaimRequest()` - Query latest claim request -- `getAllBondLifecycleClaimRequests()` - Query all claim requests for issuer -- `acceptBondLifecycleClaimRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer accepts claim - - Creates `BondLifecycleInstruction` for holder to process - - Creates currency `TransferInstruction` for payment -- `declineBondLifecycleClaimRequest()` - Issuer declines claim -- `withdrawBondLifecycleClaimRequest()` - Holder withdraws claim - -**Payment Calculation**: The system multiplies `effect.amount` (per-unit amount) by `bond.amount` (number of units) to calculate total payment. Example: If effect amount is $25 per bond and holder has 3 bonds, total payment is $75. - -**`bonds/lifecycleInstruction.ts`** - Execute lifecycle events -- `processBondLifecycleInstruction(holderLedger, holderKeyPair, contractId, disclosures?)` - Process lifecycle instruction - - For coupon payments: Archives old bond, creates new bond version, updates `lastEventTimestamp` - - For redemption: Archives bond (no new version created) - - Requires BondFactory disclosure for coupon payments (to create new version) -- `getBondLifecycleInstruction()` - Query lifecycle instruction -- `getBondLifecycleInstructionDisclosure()` - Get BondFactory disclosure for coupon processing - -**Disclosure Requirements**: -- **Coupon payments**: Requires BondFactory disclosure (holder needs to see factory to create new bond version) -- **Redemption**: No disclosure required (bond is simply archived) - -**`bonds/transferFactory.ts`** - Bond transfer factory -- `createBondTransferFactory(userLedger, userKeyPair, rulesCid)` - Create transfer factory with bond rules reference -- `getLatestBondTransferFactory()` - Query latest factory -- `getOrCreateBondTransferFactory()` - Get existing or create new factory - -**`bonds/transferRequest.ts`** - Bond transfer request/accept -- `createBondTransferRequest(senderLedger, senderKeyPair, params)` - Sender creates transfer request - - `params.transfer.amount` - Number of bond units to transfer (can be partial) -- `getLatestBondTransferRequest()` - Query latest transfer request -- `acceptBondTransferRequest(adminLedger, adminKeyPair, contractId)` - Admin accepts request - - Locks bonds via BondRules (automatically creates change bond if partial transfer) - - Creates `BondTransferInstruction` in pending state -- `declineBondTransferRequest()` - Admin declines request -- `withdrawBondTransferRequest()` - Sender withdraws request - -**Partial Transfers**: If transferring less than total holdings, BondRules automatically splits the bond: -- Locks the requested amount -- Creates a change bond with the remainder -- Returns change bond to sender immediately - -**`bonds/transferInstruction.ts`** - Bond transfer acceptance -- `acceptBondTransferInstruction(receiverLedger, receiverKeyPair, contractId, disclosures, params?)` - Receiver accepts transfer - - Requires LockedBond disclosure (receiver needs to see locked bond) -- `rejectBondTransferInstruction()` - Receiver rejects (returns locked bond to sender) -- `withdrawBondTransferInstruction()` - Sender withdraws (unlocks bond back to sender) -- `getLatestBondTransferInstruction()` - Query latest transfer instruction -- `getBondTransferInstructionDisclosure(adminLedger, transferInstructionCid)` - Get LockedBond disclosure for receiver - -**Disclosure Requirements**: Receiver must obtain LockedBond disclosure from admin before accepting transfer (LockedBond is not visible to receiver by default). - -#### Complete Bond Lifecycle Flow - -**Phase 1: Infrastructure Setup (Issuer)** -```typescript -const bondRulesCid = await charlieWrappedSdk.bonds.bondRules.getOrCreate(); -const bondFactoryCid = await charlieWrappedSdk.bonds.factory.getOrCreate( - bondInstrumentId, - 1000.0, // notional (face value per bond) - 0.05, // couponRate (5% annual) - 2 // couponFrequency (semi-annual) -); -const lifecycleRuleCid = await charlieWrappedSdk.bonds.lifecycleRule.getOrCreate({ - depository: charlie.partyId, - currencyInstrumentId: { admin: charlie.partyId, id: currencyInstrumentId }, -}); -``` +**Key Concepts**: +- Bond factory stores terms (notional, couponRate, couponFrequency) +- BondRules provides centralized locking (issuer-signed, owner-controlled) +- LifecycleRule defines events using ledger time (prevents manipulation) +- LifecycleEffect is single source of truth (queries filter by version) +- Coupon payment = `(notional × rate / frequency) × amount` +- Versions increment with each coupon event -**Phase 2: Bond Minting (Receiver proposes, Issuer accepts)** +### Bond Workflow Summary ```typescript -// Alice creates mint request for 3 bond units -await aliceWrappedSdk.bonds.issuerMintRequest.create({ - bondFactoryCid, - issuer: charlie.partyId, - depository: charlie.partyId, - receiver: alice.partyId, - amount: 3.0, // 3 bonds, each with $1000 notional = $3000 total face value - maturityDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), -}); - -// Issuer accepts mint request -const mintRequestCid = await aliceWrappedSdk.bonds.issuerMintRequest.getLatest(charlie.partyId); -await charlieWrappedSdk.bonds.issuerMintRequest.accept(mintRequestCid); +// 1. Setup: Create bond factory with terms, bond rules, lifecycle rule +// 2. Mint: Receiver proposes → Depository accepts +// 3. Coupon: Holder claims → Depository accepts → Execute instruction +// 4. Transfer: Request → Accept → Receiver accepts with disclosure +// 5. Redeem: Same as coupon but final event ``` -**Phase 3: Coupon Payment (Issuer processes, Holder claims)** -```typescript -// Wait 6 months (for semi-annual coupon) - -// Issuer processes coupon event (uses ledger time) -await charlieWrappedSdk.bonds.lifecycleRule.processCouponPaymentEvent( - lifecycleRuleCid, - { - targetInstrumentId: bondInstrumentId, - targetVersion: "0", // Initial version - bondCid: aliceBondCid, // Sample bond for term inference - } -); +See `src/testScripts/bondLifecycleTest.ts` for complete implementation. -// Get the effect and new version -const { contractId: effectCid, producedVersion } = - await charlieWrappedSdk.bonds.lifecycleEffect.getLatest(charlie.partyId); - -// Holder creates claim request -await aliceWrappedSdk.bonds.lifecycleClaimRequest.create({ - effectCid, - bondHoldingCid: aliceBondCid, - bondRulesCid, - bondFactoryCid, - currencyTransferFactoryCid, - issuerCurrencyHoldingCid: currencyHolding1, - holder: alice.partyId, - issuer: charlie.partyId, -}); +## ETF Operations -// Issuer accepts claim (creates instruction + currency transfer) -const claimCid = await aliceWrappedSdk.bonds.lifecycleClaimRequest.getLatest(charlie.partyId); -await charlieWrappedSdk.bonds.lifecycleClaimRequest.accept(claimCid); +**Architecture**: ETF tokens backed by basket of underlying assets in issuer custody. No separate factory (MyMintRecipe creates tokens directly to prevent unbacked minting). -// Holder processes instruction (with BondFactory disclosure) -const instructionCid = await charlieWrappedSdk.bonds.lifecycleInstruction.getLatest(charlie.partyId); -const bondFactoryDisclosure = await charlieWrappedSdk.bonds.lifecycleInstruction.getDisclosure(instructionCid); -await aliceWrappedSdk.bonds.lifecycleInstruction.process( - instructionCid, - bondFactoryDisclosure ? [bondFactoryDisclosure] : undefined -); - -// Holder accepts currency transfer (with LockedToken disclosure) -const transferCid = await charlieWrappedSdk.transferInstruction.getLatest(charlie.partyId); -if (transferCid) { - const disclosure = await charlieWrappedSdk.transferInstruction.getDisclosure(transferCid); - await aliceWrappedSdk.transferInstruction.accept(transferCid, [disclosure.lockedTokenDisclosure]); -} - -// Result: Alice receives coupon payment (3 bonds × $25 = $75) and has new bond version -``` - -**Phase 4: Bond Transfer (Sender proposes, Admin accepts, Receiver accepts)** +### ETF Minting Workflow ```typescript -// Alice transfers 1 bond out of 3 to Bob - -// Create bond transfer factory -const bondTransferFactoryCid = await charlieWrappedSdk.bonds.transferFactory.getOrCreate(bondRulesCid); - -// Alice creates transfer request -await aliceWrappedSdk.bonds.transferRequest.create({ - transferFactoryCid: bondTransferFactoryCid, - expectedAdmin: charlie.partyId, - transfer: buildTransfer({ - sender: alice.partyId, - receiver: bob.partyId, - amount: 1.0, // Transfer 1 bond (out of 3) - instrumentId: { admin: charlie.partyId, id: bondInstrumentId }, - requestedAt: new Date(Date.now() - 1000), - executeBefore: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000), - inputHoldingCids: [aliceBondCid], - }), - extraArgs: emptyExtraArgs(), +// 1. Create portfolio composition (issuer) +await issuerWrappedSdk.etf.portfolioComposition.create({ + owner: issuer, name: "My ETF", + items: [ + { instrumentId: { admin: issuer, id: "Token1" }, weight: 1.0 }, + { instrumentId: { admin: issuer, id: "Token2" }, weight: 1.0 }, + { instrumentId: { admin: issuer, id: "Token3" }, weight: 1.0 } + ] }); -// Admin accepts transfer request (locks 1 bond, creates change bond with 2 remaining) -const transferRequestCid = await aliceWrappedSdk.bonds.transferRequest.getLatest(charlie.partyId); -await charlieWrappedSdk.bonds.transferRequest.accept(transferRequestCid); - -// Receiver gets disclosure and accepts transfer -const transferInstrCid = await charlieWrappedSdk.bonds.transferInstruction.getLatest(charlie.partyId); -const disclosure = await charlieWrappedSdk.bonds.transferInstruction.getDisclosure(transferInstrCid); -await bobWrappedSdk.bonds.transferInstruction.accept(transferInstrCid, [disclosure]); - -// Result: Bob has 1 bond, Alice has 2 bonds (as change) -``` - -**Phase 5: Redemption at Maturity (Issuer processes, Holder claims)** -```typescript -// Wait until maturity date - -// Issuer processes redemption event (uses ledger time) -await charlieWrappedSdk.bonds.lifecycleRule.processRedemptionEvent( - lifecycleRuleCid, - { - targetInstrumentId: bondInstrumentId, - targetVersion: producedVersion, // Current version after coupon - bondCid: bobBondCid, // Sample bond for term inference - } -); - -// Get the redemption effect -const { contractId: effectCid2 } = - await charlieWrappedSdk.bonds.lifecycleEffect.getLatest(charlie.partyId); - -// Bob creates claim request -await bobWrappedSdk.bonds.lifecycleClaimRequest.create({ - effectCid: effectCid2, - bondHoldingCid: bobBondCid, - bondRulesCid, - bondFactoryCid, - currencyTransferFactoryCid, - issuerCurrencyHoldingCid: currencyHolding2, - holder: bob.partyId, - issuer: charlie.partyId, +// 2. Create mint recipe (issuer) +await issuerWrappedSdk.etf.mintRecipe.create({ + issuer, instrumentId: etfInstrumentId, + authorizedMinters: [alice], composition: portfolioCid + // No tokenFactory - creates directly to prevent bypass }); -// Issuer accepts claim -const claimCid2 = await bobWrappedSdk.bonds.lifecycleClaimRequest.getLatest(charlie.partyId); -await charlieWrappedSdk.bonds.lifecycleClaimRequest.accept(claimCid2); +// 3. Acquire underlying tokens (alice) +// ... mint or acquire Token1, Token2, Token3 ... -// Bob processes instruction (no disclosure needed for redemption) -const instructionCid2 = await charlieWrappedSdk.bonds.lifecycleInstruction.getLatest(charlie.partyId); -await bobWrappedSdk.bonds.lifecycleInstruction.process(instructionCid2); +// 4. Transfer underlying to issuer (alice creates, issuer accepts) +// ... create transfer requests for each token (alice → issuer) ... +// Capture each transfer instruction CID immediately after accept -// Bob accepts currency transfer (principal + final coupon: 1 bond × $1025 = $1025) -const transferCid2 = await charlieWrappedSdk.transferInstruction.getLatest(charlie.partyId); -if (transferCid2) { - const disclosure2 = await charlieWrappedSdk.transferInstruction.getDisclosure(transferCid2); - await bobWrappedSdk.transferInstruction.accept(transferCid2, [disclosure2.lockedTokenDisclosure]); -} - -// Result: Bob receives redemption payment ($1000 principal + $25 final coupon = $1025), bond is archived -``` - -#### Key Differences from Token Operations - -1. **Three-Party Authorization**: Bonds require `issuer`, `depository`, and `owner` signatures (tokens only require `issuer` and `owner`) - -2. **Lifecycle Events**: Bonds have lifecycle events (coupons, redemption) that create effects claimable by all holders - -3. **Fungible Architecture**: A single bond contract can hold multiple bond units, enabling efficient portfolio management - -4. **Term Storage**: BondFactory stores bond terms (`notional`, `couponRate`, `couponFrequency`) shared by all bonds from that factory - -5. **Per-Unit Payments**: Payment calculations are per-unit and multiplied by the number of bonds held - -6. **Partial Transfers**: BondRules automatically splits bonds for partial transfers, creating change bonds - -7. **Version Tracking**: Bonds have version strings that increment with each coupon payment, preventing double-claiming of lifecycle events - -8. **Ledger Time Security**: Lifecycle events use ledger time directly to prevent time manipulation attacks - -9. **Term Inference**: Lifecycle rules infer bond terms from sample bond contracts, ensuring consistency - -### Known Issues and Multi-Party Authorization - -#### Multi-Party Signing Challenge +// 5. Create ETF mint request (alice) +await aliceWrappedSdk.etf.mintRequest.create({ + mintRecipeCid, requester: alice, amount: 1.0, + transferInstructionCids: [ti1Cid, ti2Cid, ti3Cid], // MUST be in portfolio order + issuer +}); -The `MyToken` contract in `../minimal-token/daml/MyToken.daml` defines: -```daml -template MyToken - with - issuer : Party - owner : Party - ... - where - signatory issuer, owner +// 6. Accept mint request (issuer) +await issuerWrappedSdk.etf.mintRequest.accept(mintRequestCid); +// Validates transfers → Executes transfers → Mints ETF ``` -This means **both the issuer and owner must sign** any transaction that creates a MyToken contract. - -**✅ SOLVED for Minting**: Use the `IssuerMintRequest` pattern (implemented in `issuerMintRequest.ts`): -- Receiver creates `IssuerMintRequest` (receiver signs) -- Issuer accepts request, which exercises the Mint choice (issuer signs) -- This two-step pattern avoids the multi-party signing requirement - -**❌ REMAINING ISSUE for Transfer Acceptance**: When Bob accepts a MyTransferInstruction: -1. The Accept choice unlocks LockedMyToken and creates new MyToken for receiver -2. Creating the new MyToken requires both issuer (Charlie) and new owner (Bob) signatures -3. The high-level Wallet SDK API doesn't support multi-party external signing -4. Requires lower-level LedgerClient API (see workarounds below) - -#### Wallet SDK Limitations - -The high-level Wallet SDK API does not support multi-party external signing: -- `prepareSignExecuteAndWaitFor()` only accepts a single private key -- `prepareSubmission()` uses the party set on the LedgerController (via `setPartyId()`) -- The `actAs` field in commands is not exposed at the high-level API - -#### Multi-Party Transaction Workarounds - -To submit multi-party transactions with external signing, you must use the lower-level `LedgerClient` API directly: - -**Prepare Request Structure:** +### ETF Burning Workflow ```typescript -await client.postWithRetry("/v2/interactive-submission/prepare", { - commands: [{ ExerciseCommand: ... }], - commandId: "unique-id", - actAs: ["party1", "party2"], // Multiple parties - readAs: [], - userId: "ledger-api-user", - synchronizerId: "...", - disclosedContracts: [], - packageIdSelectionPreference: [], - verboseHashing: false, +// 1. Create transfer requests for underlying (issuer → alice) +// ... issuer creates transfer requests for Token1, Token2, Token3 ... + +// 2. Accept transfers, capture CIDs (issuer) +const ti1Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer); +// ... accept remaining transfers, capture CIDs ... + +// 3. Create burn request (alice) +await aliceWrappedSdk.etf.burnRequest.create({ + mintRecipeCid, requester: alice, amount: 1.0, + tokenFactoryCid: etfFactoryCid, + inputHoldingCid: aliceEtfTokenCid, + issuer }); -``` -**Execute Request Structure:** -```typescript -await client.postWithRetry("/v2/interactive-submission/execute", { - preparedTransaction: preparedTxHash, - partySignatures: { - signatures: [ - { - party: "party1", - signatures: [{ - format: "SIGNATURE_FORMAT_RAW", - signature: "...", - signedBy: "publicKey1", - signingAlgorithmSpec: "SIGNING_ALGORITHM_SPEC_ED25519" - }] - }, - { - party: "party2", - signatures: [{ - format: "SIGNATURE_FORMAT_RAW", - signature: "...", - signedBy: "publicKey2", - signingAlgorithmSpec: "SIGNING_ALGORITHM_SPEC_ED25519" - }] - } - ] - }, - deduplicationPeriod: ..., - submissionId: "...", - userId: "ledger-api-user", - hashingSchemeVersion: "HASHING_SCHEME_VERSION_V2" -}); +// 4. Accept burn request (issuer) +await issuerWrappedSdk.etf.burnRequest.accept( + burnRequestCid, + [ti1Cid, ti2Cid, ti3Cid] // MUST be in portfolio order +); +// Validates transfers → Executes transfers → Burns ETF ``` -**Note:** The private `client` property on `LedgerController` can be accessed at runtime (TypeScript's `private` is compile-time only), but this is not a supported pattern. +**Critical**: Transfer instruction CIDs must be in same order as portfolio composition items. Capture each CID immediately after accepting each transfer request. -#### Alternative Approaches +## Multi-Party Authorization -1. **Use Internal Parties**: Internal parties use Canton's built-in keys and don't require the interactive submission flow -2. **Use Lower-Level API**: Implement transfer acceptance using LedgerClient API with multi-party signatures and disclosed contracts -3. **Redesign the Contract**: Modify the Daml template to only require the current owner's signature for transfers -4. **Implement Request/Accept for Transfers**: Similar to minting, create a two-step transfer acceptance pattern +**Challenge**: MyToken requires `signatory issuer, owner`. High-level Wallet SDK only supports single-party signing. -## SDK Wrapper Design Principles +**Solution**: Use request/accept pattern (sender creates request, admin accepts) instead of direct multi-party operations. -When creating new SDK wrapper functions, follow these patterns: +**Lower-Level Alternative**: Use `LedgerClient` API with `/v2/interactive-submission/prepare` and `/v2/interactive-submission/execute` for explicit multi-party signing (not supported in high-level SDK). -### Single-Party Perspective -- Each function operates from a single party's perspective -- Takes one `LedgerController` and one `UserKeyPair` parameter -- No functions should require multiple parties' credentials simultaneously -- Example: `createIssuerMintRequest(receiverLedger, receiverKeyPair, params)` - only receiver's perspective +## Canton Ledger Interaction -### Consistent Naming Patterns -- `create{Contract}` - Create a new contract -- `getLatest{Contract}` - Query for most recent contract -- `getOrCreate{Contract}` - Get existing or create new -- `accept{Request}` - Accept a request contract -- `decline{Request}` - Decline a request contract -- `withdraw{Request}` - Withdraw a request contract +**Pattern**: Create `WrappedCommand` → `prepareSignExecuteAndWaitFor()` with private key and UUID → Query via `activeContracts()` -### Template ID Management -- All template IDs centralized in `src/constants/templateIds.ts` -- Import and use constants rather than hardcoding strings -- Format: `#minimal-token:ModuleName:TemplateName` +**Party Context**: Use `sdk.setPartyId()` to switch query perspective -### Query Patterns -- Use `activeContracts()` filtered by party and template ID -- Filter results by relevant parameters (issuer, receiver, etc.) -- Return most recent contract with `[filteredEntries.length - 1]` -- Return `undefined` if no matching contracts found +**InstrumentId Format**: `{admin: partyId, id: fullInstrumentId}` where fullInstrumentId is typically `partyId#TokenName` -### Command Creation -- Use `getCreateCommand()` helper for CreateCommand -- Use `getExerciseCommand()` helper for ExerciseCommand -- Use `prepareSignExecuteAndWaitFor()` for submission -- Generate UUID with `v4()` for correlation IDs +## Known Issues -### Type Definitions -- Define parameter interfaces for each contract/choice -- Use `Party` and `ContractId` types from `src/types/daml.js` -- Export types for use in other modules +### Transfer Array Ordering (ETF) +**Issue**: `transferInstructionCids` must match exact order of `portfolioComp.items` +**Error**: "instrumentId does not match" if order wrong +**Solution**: Track portfolio order when creating transfers, capture CIDs immediately after each accept -#### Other TODOs +### Multi-Party Signing +**Issue**: High-level API doesn't support multi-party external signing +**Workaround**: Use request/accept pattern or lower-level `LedgerClient` API -- The codebase includes TODOs around using `submitCommand` vs `prepareSignExecuteAndWaitFor` with notes about synchronizer submission errors +## Template IDs Reference -## Testing +All template IDs in `src/constants/templateIds.ts`: +- `myTokenTemplateId` = `#minimal-token:MyToken:MyToken` +- `tokenFactoryTemplateId` = `#minimal-token:MyTokenFactory:MyTokenFactory` +- `issuerMintRequestTemplateId` = `#minimal-token:MyToken.IssuerMintRequest:IssuerMintRequest` +- `tokenRulesTemplateId` = `#minimal-token:MyTokenRules:MyTokenRules` +- `transferFactoryTemplateId` = `#minimal-token:MyTransferFactory:MyTransferFactory` +- `transferRequestTemplateId` = `#minimal-token:MyToken.TransferRequest:TransferRequest` +- `transferInstructionTemplateId` = `#minimal-token:MyTokenTransferInstruction:MyTransferInstruction` +- ETF: `portfolioCompositionTemplateId`, `mintRecipeTemplateId`, `etfMintRequestTemplateId`, `etfBurnRequestTemplateId` +- Bonds: `bondFactoryTemplateId`, `bondRulesTemplateId`, `lifecycleRuleTemplateId`, etc. -### Unit Tests -Tests are located in `src/index.test.ts` and use Vitest. The test setup is in `vitest.setup.ts`. - -### Integration Test Scripts - -**`src/testScripts/threePartyTransfer.ts`** - Comprehensive three-party transfer demonstration -- Demonstrates complete flow: Charlie (issuer) → Alice (sender) → Bob (receiver) -- Covers: infrastructure setup, two-step minting, transfer request/accept, disclosure -- Shows how to use all new SDK wrapper functions -- Documents the final multi-party acceptance limitation -- Run with: `tsx src/testScripts/threePartyTransfer.ts` - -**`src/testScripts/transferWithPreapproval.ts`** - Transfer with preapproval pattern -- Demonstrates transfer preapproval workflow -- Shows proposal/accept pattern for transfers -- Run with: `tsx src/testScripts/transferWithPreapproval.ts` - -**`src/testScripts/bondLifecycleTest.ts`** - Complete bond lifecycle demonstration -- Demonstrates fungible bond architecture (mint 3 bonds, transfer 1, redeem separately) -- Covers: infrastructure setup, bond minting, coupon payment, partial transfer, redemption -- Shows bond-specific patterns: version tracking, term inference, per-unit payments, disclosure requirements -- Run with: `tsx src/testScripts/bondLifecycleTest.ts` +## Testing -**`src/hello.ts`** - Basic token operations -- Simple demo of token factory creation and minting -- Good starting point for understanding the SDK -- Run with: `tsx src/hello.ts` +Tests in `src/index.test.ts` using Vitest. Setup in `vitest.setup.ts`. ## Build Output -The package builds to three formats: - `_cjs/` - CommonJS modules - `_esm/` - ES modules -- `_types/` - TypeScript declaration files +- `_types/` - TypeScript declarations + +## Additional Documentation + +See `notes/SPLICE-WALLET-KERNEL.md` for Daml interface choice patterns and Canton Ledger HTTP API reference. 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..ebd7119 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"; + "b13bc87eaf3d6574b064bd61e38804caff038704b2097aa63962867602b9a0b6"; diff --git a/packages/token-sdk/src/constants/templateIds.ts b/packages/token-sdk/src/constants/templateIds.ts index d0c1244..1ba4c8b 100644 --- a/packages/token-sdk/src/constants/templateIds.ts +++ b/packages/token-sdk/src/constants/templateIds.ts @@ -31,3 +31,13 @@ export const bondLifecycleInstructionTemplateId = export const bondLifecycleEffectTemplateId = "#minimal-token:Bond.BondLifecycleEffect:BondLifecycleEffect"; export const lockedBondTemplateId = "#minimal-token:Bond.Bond:LockedBond"; + +// ETF template IDs +export const portfolioCompositionTemplateId = + "#minimal-token:ETF.PortfolioComposition:PortfolioComposition"; +export const mintRecipeTemplateId = + "#minimal-token:ETF.MyMintRecipe:MyMintRecipe"; +export const etfMintRequestTemplateId = + "#minimal-token:ETF.MyMintRequest:MyMintRequest"; +export const etfBurnRequestTemplateId = + "#minimal-token:ETF.MyBurnRequest:MyBurnRequest"; diff --git a/packages/token-sdk/src/testScripts/etfBurn.ts b/packages/token-sdk/src/testScripts/etfBurn.ts new file mode 100644 index 0000000..0e283ff --- /dev/null +++ b/packages/token-sdk/src/testScripts/etfBurn.ts @@ -0,0 +1,775 @@ +import { signTransactionHash } from "@canton-network/wallet-sdk"; +import { getDefaultSdkAndConnect } from "../sdkHelpers.js"; +import { keyPairFromSeed } from "../helpers/keyPairFromSeed.js"; +import { getWrappedSdkWithKeyPair } from "../wrappedSdk/wrappedSdk.js"; +import { buildTransfer, emptyExtraArgs } from "../wrappedSdk/index.js"; + +/** + * ETF Burn Test Script (following burnTokenETF Daml test pattern) + * + * Demonstrates: + * 1. Charlie (issuer) creates infrastructure for 3 underlying tokens and 1 ETF token + * 2. Charlie creates portfolio composition (3 items with 1.0 weight each) + * 3. Charlie creates mint recipe (authorizes Alice as minter) + * 4. Alice mints 3 underlying tokens via IssuerMintRequest + * 5. Alice transfers 3 underlying tokens to Charlie (issuer custody) + * 6. Alice creates ETF mint request with transfer instructions + * 7. Charlie accepts ETF mint request (validates, executes transfers, mints ETF) + * 8. Alice now owns 1.0 ETF token backed by underlying assets in Charlie's custody + * 9. Charlie creates transfer requests for 3 underlying tokens back to Alice + * 10. Charlie accepts transfer requests (creates 3 transfer instructions) + * 11. Alice creates ETF burn request with transfer instructions + * 12. Charlie accepts ETF burn request (validates, executes transfers, burns ETF) + * 13. Alice now owns 3 underlying tokens again, ETF token is burned + */ +async function etfBurn() { + console.info("=== ETF Burn Test (burnTokenETF pattern) ===\n"); + + // Initialize SDKs for two parties + const charlieSdk = await getDefaultSdkAndConnect(); + const aliceSdk = await getDefaultSdkAndConnect(); + + // NOTE: this is for testing only - use proper key management in production + const charlieKeyPair = keyPairFromSeed("charlie-etf-burn"); + const aliceKeyPair = keyPairFromSeed("alice-etf-burn"); + + const charlieLedger = charlieSdk.userLedger!; + const aliceLedger = aliceSdk.userLedger!; + + const charlieWrappedSdk = getWrappedSdkWithKeyPair( + charlieSdk, + charlieKeyPair + ); + const aliceWrappedSdk = getWrappedSdkWithKeyPair(aliceSdk, aliceKeyPair); + + // === PHASE 1: PARTY ALLOCATION === + console.info("1. Allocating parties..."); + + // Allocate Charlie (issuer/admin) + const charlieParty = await charlieLedger.generateExternalParty( + charlieKeyPair.publicKey + ); + if (!charlieParty) throw new Error("Error creating Charlie party"); + + const charlieSignedHash = signTransactionHash( + charlieParty.multiHash, + charlieKeyPair.privateKey + ); + const charlieAllocatedParty = await charlieLedger.allocateExternalParty( + charlieSignedHash, + charlieParty + ); + + // Allocate Alice (authorized minter) + const aliceParty = await aliceLedger.generateExternalParty( + aliceKeyPair.publicKey + ); + if (!aliceParty) throw new Error("Error creating Alice party"); + + const aliceSignedHash = signTransactionHash( + aliceParty.multiHash, + aliceKeyPair.privateKey + ); + const aliceAllocatedParty = await aliceLedger.allocateExternalParty( + aliceSignedHash, + aliceParty + ); + + // Set party IDs + await charlieSdk.setPartyId(charlieAllocatedParty.partyId); + await aliceSdk.setPartyId(aliceAllocatedParty.partyId); + + console.info("✓ Parties allocated:"); + console.info(` Charlie (issuer): ${charlieAllocatedParty.partyId}`); + console.info(` Alice (auth minter): ${aliceAllocatedParty.partyId}\n`); + + // === PHASE 2: INFRASTRUCTURE SETUP === + console.info("2. Setting up infrastructure (underlying tokens + ETF)..."); + + // Instrument IDs for 3 underlying tokens + const instrumentId1 = charlieAllocatedParty.partyId + "#MyToken1"; + const instrumentId2 = charlieAllocatedParty.partyId + "#MyToken2"; + const instrumentId3 = charlieAllocatedParty.partyId + "#MyToken3"; + const etfInstrumentId = charlieAllocatedParty.partyId + "#ThreeTokenETF"; + + // Create token rules (shared for all transfers) + const rulesCid = await charlieWrappedSdk.tokenRules.getOrCreate(); + console.info(`✓ MyTokenRules created: ${rulesCid}`); + + // Create token factories for underlying assets + const tokenFactory1Cid = await charlieWrappedSdk.tokenFactory.getOrCreate( + instrumentId1 + ); + console.info(`✓ Token1 factory created: ${tokenFactory1Cid}`); + + const tokenFactory2Cid = await charlieWrappedSdk.tokenFactory.getOrCreate( + instrumentId2 + ); + console.info(`✓ Token2 factory created: ${tokenFactory2Cid}`); + + const tokenFactory3Cid = await charlieWrappedSdk.tokenFactory.getOrCreate( + instrumentId3 + ); + console.info(`✓ Token3 factory created: ${tokenFactory3Cid}`); + + // Create transfer factories for underlying assets + const transferFactory1Cid = + await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 1 created: ${transferFactory1Cid}`); + + const transferFactory2Cid = + await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 2 created: ${transferFactory2Cid}`); + + const transferFactory3Cid = + await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 3 created: ${transferFactory3Cid}`); + + // === PHASE 3: PORTFOLIO COMPOSITION CREATION === + console.info("3. Creating portfolio composition..."); + + const portfolioItems = [ + { + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + weight: 1.0, + }, + { + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + weight: 1.0, + }, + { + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + weight: 1.0, + }, + ]; + + await charlieWrappedSdk.etf.portfolioComposition.create({ + owner: charlieAllocatedParty.partyId, + name: "Three Token ETF", + items: portfolioItems, + }); + + const portfolioCid = + await charlieWrappedSdk.etf.portfolioComposition.getLatest( + "Three Token ETF" + ); + if (!portfolioCid) { + throw new Error("Portfolio composition not found after creation"); + } + console.info(`✓ Portfolio composition created: ${portfolioCid}\n`); + + // === PHASE 4: MINT RECIPE CREATION === + console.info("4. Creating mint recipe..."); + + await charlieWrappedSdk.etf.mintRecipe.create({ + issuer: charlieAllocatedParty.partyId, + instrumentId: etfInstrumentId, + authorizedMinters: [ + charlieAllocatedParty.partyId, + aliceAllocatedParty.partyId, + ], + composition: portfolioCid, + }); + + const mintRecipeCid = await charlieWrappedSdk.etf.mintRecipe.getLatest( + etfInstrumentId + ); + if (!mintRecipeCid) { + throw new Error("Mint recipe not found after creation"); + } + console.info(`✓ Mint recipe created: ${mintRecipeCid}`); + console.info(` Authorized minters: [Charlie, Alice]\n`); + + // === PHASE 5: MINT UNDERLYING TOKENS TO ALICE === + console.info( + "5. Minting underlying tokens to Alice (3 IssuerMintRequests)..." + ); + + // Token 1 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory1Cid, + issuer: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + }); + const mintRequest1Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!mintRequest1Cid) { + throw new Error("Mint request 1 not found"); + } + await charlieWrappedSdk.issuerMintRequest.accept(mintRequest1Cid); + console.info(" ✓ Token1 minted to Alice (1.0)"); + + // Token 2 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory2Cid, + issuer: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + }); + const mintRequest2Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!mintRequest2Cid) { + throw new Error("Mint request 2 not found"); + } + await charlieWrappedSdk.issuerMintRequest.accept(mintRequest2Cid); + console.info(" ✓ Token2 minted to Alice (1.0)"); + + // Token 3 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory3Cid, + issuer: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + }); + const mintRequest3Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!mintRequest3Cid) { + throw new Error("Mint request 3 not found"); + } + await charlieWrappedSdk.issuerMintRequest.accept(mintRequest3Cid); + console.info(" ✓ Token3 minted to Alice (1.0)"); + + console.info("✓ All 3 underlying tokens minted to Alice\n"); + + // Get Alice's token balances + const aliceBalance1 = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + }); + const token1Cid = aliceBalance1.utxos[0].contractId; + + const aliceBalance2 = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + }); + const token2Cid = aliceBalance2.utxos[0].contractId; + + const aliceBalance3 = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + }); + const token3Cid = aliceBalance3.utxos[0].contractId; + + console.info(` Token1 CID: ${token1Cid}`); + console.info(` Token2 CID: ${token2Cid}`); + console.info(` Token3 CID: ${token3Cid}\n`); + + // === PHASE 6: TRANSFER UNDERLYING TOKENS TO ISSUER === + console.info( + "6. Transferring underlying tokens to issuer (Alice → Charlie)..." + ); + + const now = new Date(); + const requestedAtPast = new Date(now.getTime() - 1000); // 1 second in the past + const future = new Date(now.getTime() + 3600000); // 1 hour in the future + + // Transfer Token 1 + const transfer1 = buildTransfer({ + sender: aliceAllocatedParty.partyId, + receiver: charlieAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [token1Cid], + }); + + await aliceWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory1Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: transfer1, + extraArgs: emptyExtraArgs(), + }); + + const transferRequest1Cid = await aliceWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferRequest1Cid) { + throw new Error("Transfer request 1 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(transferRequest1Cid); + console.info(" ✓ Transfer request 1 accepted (Token1)"); + + // Get transfer instruction CID 1 immediately + const transferInstruction1Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferInstruction1Cid) { + throw new Error("Transfer instruction 1 not found"); + } + + // Transfer Token 2 + const transfer2 = buildTransfer({ + sender: aliceAllocatedParty.partyId, + receiver: charlieAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [token2Cid], + }); + + await aliceWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory2Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: transfer2, + extraArgs: emptyExtraArgs(), + }); + + const transferRequest2Cid = await aliceWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferRequest2Cid) { + throw new Error("Transfer request 2 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(transferRequest2Cid); + console.info(" ✓ Transfer request 2 accepted (Token2)"); + + // Get transfer instruction CID 2 immediately + const transferInstruction2Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferInstruction2Cid) { + throw new Error("Transfer instruction 2 not found"); + } + + // Transfer Token 3 + const transfer3 = buildTransfer({ + sender: aliceAllocatedParty.partyId, + receiver: charlieAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [token3Cid], + }); + + await aliceWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory3Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: transfer3, + extraArgs: emptyExtraArgs(), + }); + + const transferRequest3Cid = await aliceWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferRequest3Cid) { + throw new Error("Transfer request 3 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(transferRequest3Cid); + console.info(" ✓ Transfer request 3 accepted (Token3)"); + + // Get transfer instruction CID 3 immediately + const transferInstruction3Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferInstruction3Cid) { + throw new Error("Transfer instruction 3 not found"); + } + + console.info( + "✓ All 3 transfer requests accepted (tokens locked, instructions created)\n" + ); + console.info(` Transfer instruction 1: ${transferInstruction1Cid}`); + console.info(` Transfer instruction 2: ${transferInstruction2Cid}`); + console.info(` Transfer instruction 3: ${transferInstruction3Cid}\n`); + + // === PHASE 7: CREATE ETF MINT REQUEST === + console.info( + "7. Creating ETF mint request (with 3 transfer instructions)..." + ); + + await aliceWrappedSdk.etf.mintRequest.create({ + mintRecipeCid, + requester: aliceAllocatedParty.partyId, + amount: 1.0, + transferInstructionCids: [ + transferInstruction1Cid, + transferInstruction2Cid, + transferInstruction3Cid, + ], + issuer: charlieAllocatedParty.partyId, + }); + + const etfMintRequestCid = await aliceWrappedSdk.etf.mintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!etfMintRequestCid) { + throw new Error("ETF mint request not found after creation"); + } + + console.info(`✓ ETF mint request created: ${etfMintRequestCid}`); + console.info(` Amount: 1.0 ETF tokens`); + console.info(` Transfer instructions: [3 underlying tokens]\n`); + + // === PHASE 8: ACCEPT ETF MINT REQUEST === + console.info( + "8. Accepting ETF mint request (Charlie validates and mints)..." + ); + + await charlieWrappedSdk.etf.mintRequest.accept(etfMintRequestCid); + + console.info("✓ ETF mint request accepted!"); + console.info(" - Validated all 3 transfer instructions"); + console.info( + " - Executed all 3 transfers (underlying assets → issuer custody)" + ); + console.info(" - Minted 1.0 ETF tokens to Alice\n"); + + // Verify Alice's ETF balance + const aliceEtfBalance = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: etfInstrumentId, + }, + }); + + const etfTokenCid = aliceEtfBalance.utxos[0].contractId; + + console.info("=== State After ETF Mint =="); + console.info(`✓ Alice owns ${aliceEtfBalance.total} ETF tokens`); + console.info(` ETF Token CID: ${etfTokenCid}`); + console.info(`✓ Charlie (issuer) holds 3 underlying tokens in custody\n`); + + // === PHASE 9: CHARLIE TRANSFERS UNDERLYING BACK TO ALICE === + console.info( + "9. Charlie transferring underlying tokens back to Alice (Charlie → Alice)..." + ); + + // Get Charlie's token balances (the 3 underlying tokens in custody) + const charlieBalance1 = await charlieWrappedSdk.balances.getByInstrumentId({ + owner: charlieAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + }); + const charlieToken1Cid = charlieBalance1.utxos[0].contractId; + + const charlieBalance2 = await charlieWrappedSdk.balances.getByInstrumentId({ + owner: charlieAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + }); + const charlieToken2Cid = charlieBalance2.utxos[0].contractId; + + const charlieBalance3 = await charlieWrappedSdk.balances.getByInstrumentId({ + owner: charlieAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + }); + const charlieToken3Cid = charlieBalance3.utxos[0].contractId; + + console.info(` Charlie's Token1 CID: ${charlieToken1Cid}`); + console.info(` Charlie's Token2 CID: ${charlieToken2Cid}`); + console.info(` Charlie's Token3 CID: ${charlieToken3Cid}\n`); + + // Transfer Token 1 back to Alice + const returnTransfer1 = buildTransfer({ + sender: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [charlieToken1Cid], + }); + + await charlieWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory1Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: returnTransfer1, + extraArgs: emptyExtraArgs(), + }); + + const returnTransferRequest1Cid = + await charlieWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!returnTransferRequest1Cid) { + throw new Error("Return transfer request 1 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(returnTransferRequest1Cid); + console.info(" ✓ Return transfer request 1 accepted (Token1)"); + + // Get return transfer instruction CID 1 immediately + const returnTransferInstruction1Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!returnTransferInstruction1Cid) { + throw new Error("Return transfer instruction 1 not found"); + } + + // Transfer Token 2 back to Alice + const returnTransfer2 = buildTransfer({ + sender: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [charlieToken2Cid], + }); + + await charlieWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory2Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: returnTransfer2, + extraArgs: emptyExtraArgs(), + }); + + const returnTransferRequest2Cid = + await charlieWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!returnTransferRequest2Cid) { + throw new Error("Return transfer request 2 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(returnTransferRequest2Cid); + console.info(" ✓ Return transfer request 2 accepted (Token2)"); + + // Get return transfer instruction CID 2 immediately + const returnTransferInstruction2Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!returnTransferInstruction2Cid) { + throw new Error("Return transfer instruction 2 not found"); + } + + // Transfer Token 3 back to Alice + const returnTransfer3 = buildTransfer({ + sender: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [charlieToken3Cid], + }); + + await charlieWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory3Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: returnTransfer3, + extraArgs: emptyExtraArgs(), + }); + + const returnTransferRequest3Cid = + await charlieWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!returnTransferRequest3Cid) { + throw new Error("Return transfer request 3 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(returnTransferRequest3Cid); + console.info(" ✓ Return transfer request 3 accepted (Token3)"); + + // Get return transfer instruction CID 3 immediately + const returnTransferInstruction3Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!returnTransferInstruction3Cid) { + throw new Error("Return transfer instruction 3 not found"); + } + + console.info( + "✓ All 3 return transfer requests accepted (tokens locked, instructions created)\n" + ); + console.info( + ` Return transfer instruction 1: ${returnTransferInstruction1Cid}` + ); + console.info( + ` Return transfer instruction 2: ${returnTransferInstruction2Cid}` + ); + console.info( + ` Return transfer instruction 3: ${returnTransferInstruction3Cid}\n` + ); + + // === PHASE 10: CREATE ETF BURN REQUEST === + console.info( + "10. Alice creating ETF burn request (with 3 return transfer instructions)..." + ); + + await aliceWrappedSdk.etf.burnRequest.create({ + mintRecipeCid, + requester: aliceAllocatedParty.partyId, + amount: 1.0, + inputHoldingCid: etfTokenCid, + issuer: charlieAllocatedParty.partyId, + }); + + const etfBurnRequestCid = await aliceWrappedSdk.etf.burnRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!etfBurnRequestCid) { + throw new Error("ETF burn request not found after creation"); + } + + console.info(`✓ ETF burn request created: ${etfBurnRequestCid}`); + console.info(` Amount: 1.0 ETF tokens to burn`); + console.info(` Return transfer instructions: [3 underlying tokens]\n`); + + // === PHASE 11: ACCEPT ETF BURN REQUEST === + console.info( + "11. Accepting ETF burn request (Charlie validates and burns)..." + ); + + await charlieWrappedSdk.etf.burnRequest.accept(etfBurnRequestCid, [ + returnTransferInstruction1Cid, + returnTransferInstruction2Cid, + returnTransferInstruction3Cid, + ]); + + console.info("✓ ETF burn request accepted!"); + console.info(" - Validated all 3 return transfer instructions"); + console.info( + " - Executed all 3 transfers (underlying assets → Alice custody)" + ); + console.info(" - Burned 1.0 ETF tokens from Alice\n"); + + // === FINAL VERIFICATION === + console.info("=== Final State =="); + + // Verify Alice's ETF balance (should be 0) + const finalAliceEtfBalance = + await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: etfInstrumentId, + }, + }); + console.info(`✓ Alice owns ${finalAliceEtfBalance.total} ETF tokens`); + + // Verify Alice's underlying balances (should be 1.0 each) + const finalAliceBalance1 = await aliceWrappedSdk.balances.getByInstrumentId( + { + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + } + ); + const finalAliceBalance2 = await aliceWrappedSdk.balances.getByInstrumentId( + { + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + } + ); + const finalAliceBalance3 = await aliceWrappedSdk.balances.getByInstrumentId( + { + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + } + ); + + console.info( + `✓ Alice owns ${finalAliceBalance1.total} Token1, ${finalAliceBalance2.total} Token2, ${finalAliceBalance3.total} Token3` + ); + + // Verify Charlie's balances (should be 0 for all) + const finalCharlieBalance1 = + await charlieWrappedSdk.balances.getByInstrumentId({ + owner: charlieAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + }); + const finalCharlieBalance2 = + await charlieWrappedSdk.balances.getByInstrumentId({ + owner: charlieAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + }); + const finalCharlieBalance3 = + await charlieWrappedSdk.balances.getByInstrumentId({ + owner: charlieAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + }); + + console.info( + `✓ Charlie (issuer) holds ${finalCharlieBalance1.total} Token1, ${finalCharlieBalance2.total} Token2, ${finalCharlieBalance3.total} Token3 in custody` + ); + console.info(`✓ ETF burn complete - underlying assets returned to Alice\n`); +} + +etfBurn() + .then(() => { + console.info("=== ETF Burn Test Completed Successfully ==="); + process.exit(0); + }) + .catch((error) => { + console.error("\n❌ Error in ETF Burn Test:", error); + throw error; + }); diff --git a/packages/token-sdk/src/testScripts/etfMint.ts b/packages/token-sdk/src/testScripts/etfMint.ts new file mode 100644 index 0000000..6f1d4ce --- /dev/null +++ b/packages/token-sdk/src/testScripts/etfMint.ts @@ -0,0 +1,475 @@ +import { signTransactionHash } from "@canton-network/wallet-sdk"; +import { getDefaultSdkAndConnect } from "../sdkHelpers.js"; +import { keyPairFromSeed } from "../helpers/keyPairFromSeed.js"; +import { getWrappedSdkWithKeyPair } from "../wrappedSdk/wrappedSdk.js"; +import { buildTransfer, emptyExtraArgs } from "../wrappedSdk/index.js"; + +/** + * ETF Mint Test Script (following mintToOtherTokenETF Daml test pattern) + * + * Demonstrates: + * 1. Charlie (issuer) creates infrastructure for 3 underlying tokens and 1 ETF token + * 2. Charlie creates portfolio composition (3 items with 1.0 weight each) + * 3. Charlie creates mint recipe (authorizes Alice as minter) + * 4. Alice mints 3 underlying tokens via IssuerMintRequest + * 5. Alice transfers 3 underlying tokens to Charlie (issuer custody) + * 6. Alice creates ETF mint request with transfer instructions + * 7. Charlie accepts ETF mint request (validates, executes transfers, mints ETF) + * 8. Alice now owns the ETF token backed by underlying assets + */ +async function etfMint() { + console.info("=== ETF Mint Test (mintToOtherTokenETF pattern) ===\n"); + + // Initialize SDKs for two parties + const charlieSdk = await getDefaultSdkAndConnect(); + const aliceSdk = await getDefaultSdkAndConnect(); + + // NOTE: this is for testing only - use proper key management in production + const charlieKeyPair = keyPairFromSeed("charlie-etf"); + const aliceKeyPair = keyPairFromSeed("alice-etf"); + + const charlieLedger = charlieSdk.userLedger!; + const aliceLedger = aliceSdk.userLedger!; + + const charlieWrappedSdk = getWrappedSdkWithKeyPair( + charlieSdk, + charlieKeyPair + ); + const aliceWrappedSdk = getWrappedSdkWithKeyPair(aliceSdk, aliceKeyPair); + + // === PHASE 1: PARTY ALLOCATION === + console.info("1. Allocating parties..."); + + // Allocate Charlie (issuer/admin) + const charlieParty = await charlieLedger.generateExternalParty( + charlieKeyPair.publicKey + ); + if (!charlieParty) throw new Error("Error creating Charlie party"); + + const charlieSignedHash = signTransactionHash( + charlieParty.multiHash, + charlieKeyPair.privateKey + ); + const charlieAllocatedParty = await charlieLedger.allocateExternalParty( + charlieSignedHash, + charlieParty + ); + + // Allocate Alice (authorized minter) + const aliceParty = await aliceLedger.generateExternalParty( + aliceKeyPair.publicKey + ); + if (!aliceParty) throw new Error("Error creating Alice party"); + + const aliceSignedHash = signTransactionHash( + aliceParty.multiHash, + aliceKeyPair.privateKey + ); + const aliceAllocatedParty = await aliceLedger.allocateExternalParty( + aliceSignedHash, + aliceParty + ); + + // Set party IDs + await charlieSdk.setPartyId(charlieAllocatedParty.partyId); + await aliceSdk.setPartyId(aliceAllocatedParty.partyId); + + console.info("✓ Parties allocated:"); + console.info(` Charlie (issuer): ${charlieAllocatedParty.partyId}`); + console.info(` Alice (auth minter): ${aliceAllocatedParty.partyId}\n`); + + // === PHASE 2: INFRASTRUCTURE SETUP === + console.info("2. Setting up infrastructure (underlying tokens + ETF)..."); + + // Instrument IDs for 3 underlying tokens + const instrumentId1 = charlieAllocatedParty.partyId + "#MyToken1"; + const instrumentId2 = charlieAllocatedParty.partyId + "#MyToken2"; + const instrumentId3 = charlieAllocatedParty.partyId + "#MyToken3"; + const etfInstrumentId = charlieAllocatedParty.partyId + "#ThreeTokenETF"; + + // Create token rules (shared for all transfers) + const rulesCid = await charlieWrappedSdk.tokenRules.getOrCreate(); + console.info(`✓ MyTokenRules created: ${rulesCid}`); + + // Create token factories for underlying assets + const tokenFactory1Cid = await charlieWrappedSdk.tokenFactory.getOrCreate( + instrumentId1 + ); + console.info(`✓ Token1 factory created: ${tokenFactory1Cid}`); + + const tokenFactory2Cid = await charlieWrappedSdk.tokenFactory.getOrCreate( + instrumentId2 + ); + console.info(`✓ Token2 factory created: ${tokenFactory2Cid}`); + + const tokenFactory3Cid = await charlieWrappedSdk.tokenFactory.getOrCreate( + instrumentId3 + ); + console.info(`✓ Token3 factory created: ${tokenFactory3Cid}`); + + // Create transfer factories for underlying assets + const transferFactory1Cid = + await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 1 created: ${transferFactory1Cid}`); + + const transferFactory2Cid = + await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 2 created: ${transferFactory2Cid}`); + + const transferFactory3Cid = + await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 3 created: ${transferFactory3Cid}`); + + // === PHASE 3: PORTFOLIO COMPOSITION CREATION === + console.info("3. Creating portfolio composition..."); + + const portfolioItems = [ + { + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + weight: 1.0, + }, + { + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + weight: 1.0, + }, + { + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + weight: 1.0, + }, + ]; + + await charlieWrappedSdk.etf.portfolioComposition.create({ + owner: charlieAllocatedParty.partyId, + name: "Three Token ETF", + items: portfolioItems, + }); + + const portfolioCid = + await charlieWrappedSdk.etf.portfolioComposition.getLatest( + "Three Token ETF" + ); + if (!portfolioCid) { + throw new Error("Portfolio composition not found after creation"); + } + console.info(`✓ Portfolio composition created: ${portfolioCid}\n`); + + // === PHASE 4: MINT RECIPE CREATION === + console.info("4. Creating mint recipe..."); + + await charlieWrappedSdk.etf.mintRecipe.create({ + issuer: charlieAllocatedParty.partyId, + instrumentId: etfInstrumentId, + authorizedMinters: [ + charlieAllocatedParty.partyId, + aliceAllocatedParty.partyId, + ], + composition: portfolioCid, + }); + + const mintRecipeCid = await charlieWrappedSdk.etf.mintRecipe.getLatest( + etfInstrumentId + ); + if (!mintRecipeCid) { + throw new Error("Mint recipe not found after creation"); + } + console.info(`✓ Mint recipe created: ${mintRecipeCid}`); + console.info(` Authorized minters: [Charlie, Alice]\n`); + + // === PHASE 5: MINT UNDERLYING TOKENS TO ALICE === + console.info( + "5. Minting underlying tokens to Alice (3 IssuerMintRequests)..." + ); + + // Token 1 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory1Cid, + issuer: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + }); + const mintRequest1Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!mintRequest1Cid) { + throw new Error("Mint request 1 not found"); + } + await charlieWrappedSdk.issuerMintRequest.accept(mintRequest1Cid); + console.info(" ✓ Token1 minted to Alice (1.0)"); + + // Token 2 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory2Cid, + issuer: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + }); + const mintRequest2Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!mintRequest2Cid) { + throw new Error("Mint request 2 not found"); + } + await charlieWrappedSdk.issuerMintRequest.accept(mintRequest2Cid); + console.info(" ✓ Token2 minted to Alice (1.0)"); + + // Token 3 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory3Cid, + issuer: charlieAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 1.0, + }); + const mintRequest3Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!mintRequest3Cid) { + throw new Error("Mint request 3 not found"); + } + await charlieWrappedSdk.issuerMintRequest.accept(mintRequest3Cid); + console.info(" ✓ Token3 minted to Alice (1.0)"); + + console.info("✓ All 3 underlying tokens minted to Alice\n"); + + // Get Alice's token balances + const aliceBalance1 = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + }); + const token1Cid = aliceBalance1.utxos[0].contractId; + + const aliceBalance2 = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + }); + const token2Cid = aliceBalance2.utxos[0].contractId; + + const aliceBalance3 = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + }); + const token3Cid = aliceBalance3.utxos[0].contractId; + + console.info(` Token1 CID: ${token1Cid}`); + console.info(` Token2 CID: ${token2Cid}`); + console.info(` Token3 CID: ${token3Cid}\n`); + + // === PHASE 6: TRANSFER UNDERLYING TOKENS TO ISSUER === + console.info( + "6. Transferring underlying tokens to issuer (Alice → Charlie)..." + ); + + const now = new Date(); + const requestedAtPast = new Date(now.getTime() - 1000); // 1 second in the past + const future = new Date(now.getTime() + 3600000); // 1 hour in the future + + // Transfer Token 1 + const transfer1 = buildTransfer({ + sender: aliceAllocatedParty.partyId, + receiver: charlieAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId1, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [token1Cid], + }); + + await aliceWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory1Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: transfer1, + extraArgs: emptyExtraArgs(), + }); + + const transferRequest1Cid = await aliceWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferRequest1Cid) { + throw new Error("Transfer request 1 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(transferRequest1Cid); + console.info(" ✓ Transfer request 1 accepted (Token1)"); + + // Get transfer instruction CID 1 immediately + const transferInstruction1Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferInstruction1Cid) { + throw new Error("Transfer instruction 1 not found"); + } + + // Transfer Token 2 + const transfer2 = buildTransfer({ + sender: aliceAllocatedParty.partyId, + receiver: charlieAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId2, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [token2Cid], + }); + + await aliceWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory2Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: transfer2, + extraArgs: emptyExtraArgs(), + }); + + const transferRequest2Cid = await aliceWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferRequest2Cid) { + throw new Error("Transfer request 2 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(transferRequest2Cid); + console.info(" ✓ Transfer request 2 accepted (Token2)"); + + // Get transfer instruction CID 2 immediately + const transferInstruction2Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferInstruction2Cid) { + throw new Error("Transfer instruction 2 not found"); + } + + // Transfer Token 3 + const transfer3 = buildTransfer({ + sender: aliceAllocatedParty.partyId, + receiver: charlieAllocatedParty.partyId, + amount: 1.0, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: instrumentId3, + }, + requestedAt: requestedAtPast, + executeBefore: future, + inputHoldingCids: [token3Cid], + }); + + await aliceWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory3Cid, + expectedAdmin: charlieAllocatedParty.partyId, + transfer: transfer3, + extraArgs: emptyExtraArgs(), + }); + + const transferRequest3Cid = await aliceWrappedSdk.transferRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferRequest3Cid) { + throw new Error("Transfer request 3 not found"); + } + + await charlieWrappedSdk.transferRequest.accept(transferRequest3Cid); + console.info(" ✓ Transfer request 3 accepted (Token3)"); + + // Get transfer instruction CID 3 immediately + const transferInstruction3Cid = + await charlieWrappedSdk.transferInstruction.getLatest( + charlieAllocatedParty.partyId + ); + if (!transferInstruction3Cid) { + throw new Error("Transfer instruction 3 not found"); + } + + console.info( + "✓ All 3 transfer requests accepted (tokens locked, instructions created)\n" + ); + console.info(` Transfer instruction 1: ${transferInstruction1Cid}`); + console.info(` Transfer instruction 2: ${transferInstruction2Cid}`); + console.info(` Transfer instruction 3: ${transferInstruction3Cid}\n`); + + // === PHASE 7: CREATE ETF MINT REQUEST === + console.info( + "7. Creating ETF mint request (with 3 transfer instructions)..." + ); + + await aliceWrappedSdk.etf.mintRequest.create({ + mintRecipeCid, + requester: aliceAllocatedParty.partyId, + amount: 1.0, + transferInstructionCids: [ + transferInstruction1Cid, + transferInstruction2Cid, + transferInstruction3Cid, + ], + issuer: charlieAllocatedParty.partyId, + }); + + const etfMintRequestCid = await aliceWrappedSdk.etf.mintRequest.getLatest( + charlieAllocatedParty.partyId + ); + if (!etfMintRequestCid) { + throw new Error("ETF mint request not found after creation"); + } + + console.info(`✓ ETF mint request created: ${etfMintRequestCid}`); + console.info(` Amount: 1.0 ETF tokens`); + console.info(` Transfer instructions: [3 underlying tokens]\n`); + + // === PHASE 8: ACCEPT ETF MINT REQUEST === + console.info( + "8. Accepting ETF mint request (Charlie validates and mints)..." + ); + + await charlieWrappedSdk.etf.mintRequest.accept(etfMintRequestCid); + + console.info("✓ ETF mint request accepted!"); + console.info(" - Validated all 3 transfer instructions"); + console.info( + " - Executed all 3 transfers (underlying assets → issuer custody)" + ); + console.info(" - Minted 1.0 ETF tokens to Alice\n"); + + // Verify Alice's ETF balance + const aliceEtfBalance = await aliceWrappedSdk.balances.getByInstrumentId({ + owner: aliceAllocatedParty.partyId, + instrumentId: { + admin: charlieAllocatedParty.partyId, + id: etfInstrumentId, + }, + }); + + console.info("=== Final State ==="); + console.info(`✓ Alice owns ${aliceEtfBalance.total} ETF tokens`); + console.info(`✓ Charlie (issuer) holds 3 underlying tokens in custody`); + console.info(`✓ ETF token is backed by portfolio composition\n`); +} + +etfMint() + .then(() => { + console.info("=== ETF Mint Test Completed Successfully ==="); + process.exit(0); + }) + .catch((error) => { + console.error("\n❌ Error in ETF Mint Test:", error); + throw error; + }); diff --git a/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts b/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts new file mode 100644 index 0000000..7a5f589 --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts @@ -0,0 +1,202 @@ +import { LedgerController } from "@canton-network/wallet-sdk"; +import { v4 } from "uuid"; +import { UserKeyPair } from "../../types/UserKeyPair.js"; +import { ActiveContractResponse } from "../../types/ActiveContractResponse.js"; +import { ContractId, Party } from "../../types/daml.js"; +import { getCreateCommand } from "../../helpers/getCreateCommand.js"; +import { getExerciseCommand } from "../../helpers/getExerciseCommand.js"; +import { etfBurnRequestTemplateId } from "../../constants/templateIds.js"; + +export interface EtfBurnRequestParams { + mintRecipeCid: ContractId; + requester: Party; + amount: number; + inputHoldingCid: ContractId; + issuer: Party; +} + +const getCreateEtfBurnRequestCommand = (params: EtfBurnRequestParams) => + getCreateCommand({ templateId: etfBurnRequestTemplateId, params }); + +/** + * Create an ETF burn request (requester proposes to burn ETF tokens) + * This is part of the ETF burning pattern: + * 1. Requester creates ETF burn request with their ETF token holding + * 2. Issuer accepts the request with transfer instructions for underlying assets + * + * @param requesterLedger - The requester's ledger controller + * @param requesterKeyPair - The requester's key pair for signing + * @param params - ETF burn request parameters + */ +export async function createEtfBurnRequest( + requesterLedger: LedgerController, + requesterKeyPair: UserKeyPair, + params: EtfBurnRequestParams +) { + const createBurnRequestCommand = getCreateEtfBurnRequestCommand(params); + + await requesterLedger.prepareSignExecuteAndWaitFor( + [createBurnRequestCommand], + requesterKeyPair.privateKey, + v4() + ); +} + +/** + * Get all ETF burn requests for a given issuer + * @param userLedger - The user's ledger controller + * @param issuer - The issuer party + */ +export async function getAllEtfBurnRequests( + userLedger: LedgerController, + issuer: Party +) { + const partyId = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [etfBurnRequestTemplateId], + })) as ActiveContractResponse[]; + + const filteredEntries = activeContracts.filter(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + const { createArgument } = jsActive.createdEvent; + return ( + (createArgument.requester === partyId && + createArgument.issuer === issuer) || + createArgument.issuer === partyId + ); + }); + + return filteredEntries.map((contract) => { + return contract.contractEntry.JsActiveContract!.createdEvent.contractId; + }); +} + +/** + * Get the latest ETF burn request for a given issuer + * @param userLedger - The user's ledger controller (can be requester or issuer) + * @param issuer - The issuer party + */ +export async function getLatestEtfBurnRequest( + userLedger: LedgerController, + issuer: Party +): Promise { + const partyId = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [etfBurnRequestTemplateId], + })) as ActiveContractResponse[]; + + if (activeContracts.length === 0) { + return; + } + + const filteredEntries = activeContracts.filter(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + const { createArgument } = jsActive.createdEvent; + return ( + (createArgument.requester === partyId && + createArgument.issuer === issuer) || + createArgument.issuer === partyId + ); + }); + + if (filteredEntries.length === 0) { + return; + } + + const contract = filteredEntries[filteredEntries.length - 1]; + return contract.contractEntry.JsActiveContract!.createdEvent.contractId; +} + +/** + * Accept an ETF burn request (issuer action) + * Validates transfer instructions, executes them, and burns ETF tokens + * + * CRITICAL: Unlike ETF mint accept, this function REQUIRES transferInstructionCids parameter. + * The issuer must provide transfer instructions for returning underlying assets to the requester. + * + * @param issuerLedger - The issuer's ledger controller + * @param issuerKeyPair - The issuer's key pair for signing + * @param contractId - The burn request contract ID + * @param transferInstructionCids - Array of transfer instruction CIDs for returning underlying assets (REQUIRED) + */ +export async function acceptEtfBurnRequest( + issuerLedger: LedgerController, + issuerKeyPair: UserKeyPair, + contractId: ContractId, + transferInstructionCids: ContractId[] +) { + const acceptCommand = getExerciseCommand({ + templateId: etfBurnRequestTemplateId, + contractId, + choice: "BurnRequest_Accept", + params: { + transferInstructionCids: transferInstructionCids, + }, + }); + + await issuerLedger.prepareSignExecuteAndWaitFor( + [acceptCommand], + issuerKeyPair.privateKey, + v4() + ); +} + +/** + * Decline an ETF burn request (issuer action) + * @param issuerLedger - The issuer's ledger controller + * @param issuerKeyPair - The issuer's key pair for signing + * @param contractId - The burn request contract ID + */ +export async function declineEtfBurnRequest( + issuerLedger: LedgerController, + issuerKeyPair: UserKeyPair, + contractId: ContractId +) { + const declineCommand = getExerciseCommand({ + templateId: etfBurnRequestTemplateId, + contractId, + choice: "BurnRequest_Decline", + params: {}, + }); + + await issuerLedger.prepareSignExecuteAndWaitFor( + [declineCommand], + issuerKeyPair.privateKey, + v4() + ); +} + +/** + * Withdraw an ETF burn request (requester action) + * @param requesterLedger - The requester's ledger controller + * @param requesterKeyPair - The requester's key pair for signing + * @param contractId - The burn request contract ID + */ +export async function withdrawEtfBurnRequest( + requesterLedger: LedgerController, + requesterKeyPair: UserKeyPair, + contractId: ContractId +) { + const withdrawCommand = getExerciseCommand({ + templateId: etfBurnRequestTemplateId, + contractId, + choice: "BurnRequest_Withdraw", + params: {}, + }); + + await requesterLedger.prepareSignExecuteAndWaitFor( + [withdrawCommand], + requesterKeyPair.privateKey, + v4() + ); +} diff --git a/packages/token-sdk/src/wrappedSdk/etf/index.ts b/packages/token-sdk/src/wrappedSdk/etf/index.ts new file mode 100644 index 0000000..e874f38 --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/index.ts @@ -0,0 +1,4 @@ +export * from "./portfolioComposition.js"; +export * from "./mintRecipe.js"; +export * from "./mintRequest.js"; +export * from "./burnRequest.js"; diff --git a/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts b/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts new file mode 100644 index 0000000..3642251 --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts @@ -0,0 +1,229 @@ +import { LedgerController } from "@canton-network/wallet-sdk"; +import { mintRecipeTemplateId } from "../../constants/templateIds.js"; +import { getCreateCommand } from "../../helpers/getCreateCommand.js"; +import { getExerciseCommand } from "../../helpers/getExerciseCommand.js"; +import { ContractId, Party } from "../../types/daml.js"; +import { UserKeyPair } from "../../types/UserKeyPair.js"; +import { ActiveContractResponse } from "../../types/ActiveContractResponse.js"; +import { v4 } from "uuid"; +import { PortfolioItem } from "./portfolioComposition.js"; + +export interface MintRecipeParams { + issuer: Party; + instrumentId: string; + authorizedMinters: Party[]; + composition: ContractId; +} + +export interface AddAuthorizedMinterParams { + newMinter: Party; +} + +export interface RemoveAuthorizedMinterParams { + minterToRemove: Party; +} + +export interface UpdateCompositionParams { + newComposition: ContractId; +} + +export interface CreateAndUpdateCompositionParams { + newCompositionItems: PortfolioItem[]; + compositionName: string; + archiveOld: boolean; +} + +const getCreateMintRecipeCommand = (params: MintRecipeParams) => + getCreateCommand({ templateId: mintRecipeTemplateId, params }); + +/** + * Create a mint recipe + * @param userLedger - The user's ledger controller + * @param userKeyPair - The user's key pair for signing + * @param params - Mint recipe parameters + */ +export async function createMintRecipe( + userLedger: LedgerController, + userKeyPair: UserKeyPair, + params: MintRecipeParams +) { + const createMintRecipeCommand = getCreateMintRecipeCommand(params); + + await userLedger.prepareSignExecuteAndWaitFor( + [createMintRecipeCommand], + userKeyPair.privateKey, + v4() + ); +} + +/** + * Get the latest mint recipe for a given instrument + * @param userLedger - The user's ledger controller + * @param instrumentId - The instrument ID of the ETF + */ +export async function getLatestMintRecipe( + userLedger: LedgerController, + instrumentId: string +): Promise { + const issuer = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [issuer], + templateIds: [mintRecipeTemplateId], + })) as ActiveContractResponse[]; + + if (activeContracts.length === 0) { + return; + } + + const filteredEntries = activeContracts.filter(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + const { createArgument } = jsActive.createdEvent; + return ( + createArgument.issuer === issuer && + createArgument.instrumentId === instrumentId + ); + }); + + if (filteredEntries.length === 0) { + return; + } + + const contract = filteredEntries[filteredEntries.length - 1]; + return contract.contractEntry.JsActiveContract!.createdEvent.contractId; +} + +/** + * Get existing or create new mint recipe + * @param userLedger - The user's ledger controller + * @param userKeyPair - The user's key pair for signing + * @param params - Mint recipe parameters + */ +export async function getOrCreateMintRecipe( + userLedger: LedgerController, + userKeyPair: UserKeyPair, + params: MintRecipeParams +): Promise { + const existing = await getLatestMintRecipe(userLedger, params.instrumentId); + if (existing) { + return existing; + } + + await createMintRecipe(userLedger, userKeyPair, params); + const created = await getLatestMintRecipe(userLedger, params.instrumentId); + if (!created) { + throw new Error("Failed to create mint recipe"); + } + return created; +} + +/** + * Add an authorized minter to the mint recipe + * @param userLedger - The issuer's ledger controller + * @param userKeyPair - The issuer's key pair for signing + * @param contractId - The mint recipe contract ID + * @param params - Parameters with newMinter + */ +export async function addAuthorizedMinter( + userLedger: LedgerController, + userKeyPair: UserKeyPair, + contractId: ContractId, + params: AddAuthorizedMinterParams +) { + const addMinterCommand = getExerciseCommand({ + templateId: mintRecipeTemplateId, + contractId, + choice: "MyMintRecipe_AddAuthorizedMinter", + params, + }); + + await userLedger.prepareSignExecuteAndWaitFor( + [addMinterCommand], + userKeyPair.privateKey, + v4() + ); +} + +/** + * Remove an authorized minter from the mint recipe + * @param userLedger - The issuer's ledger controller + * @param userKeyPair - The issuer's key pair for signing + * @param contractId - The mint recipe contract ID + * @param params - Parameters with minterToRemove + */ +export async function removeAuthorizedMinter( + userLedger: LedgerController, + userKeyPair: UserKeyPair, + contractId: ContractId, + params: RemoveAuthorizedMinterParams +) { + const removeMinterCommand = getExerciseCommand({ + templateId: mintRecipeTemplateId, + contractId, + choice: "MyMintRecipe_RemoveAuthorizedMinter", + params, + }); + + await userLedger.prepareSignExecuteAndWaitFor( + [removeMinterCommand], + userKeyPair.privateKey, + v4() + ); +} + +/** + * Update the composition reference in the mint recipe + * @param userLedger - The issuer's ledger controller + * @param userKeyPair - The issuer's key pair for signing + * @param contractId - The mint recipe contract ID + * @param params - Parameters with newComposition CID + */ +export async function updateComposition( + userLedger: LedgerController, + userKeyPair: UserKeyPair, + contractId: ContractId, + params: UpdateCompositionParams +) { + const updateCommand = getExerciseCommand({ + templateId: mintRecipeTemplateId, + contractId, + choice: "MyMintRecipe_UpdateComposition", + params, + }); + + await userLedger.prepareSignExecuteAndWaitFor( + [updateCommand], + userKeyPair.privateKey, + v4() + ); +} + +/** + * Create a new composition and update the mint recipe to reference it + * @param userLedger - The issuer's ledger controller + * @param userKeyPair - The issuer's key pair for signing + * @param contractId - The mint recipe contract ID + * @param params - Parameters with new composition items, name, and archiveOld flag + */ +export async function createAndUpdateComposition( + userLedger: LedgerController, + userKeyPair: UserKeyPair, + contractId: ContractId, + params: CreateAndUpdateCompositionParams +) { + const createAndUpdateCommand = getExerciseCommand({ + templateId: mintRecipeTemplateId, + contractId, + choice: "MyMintRecipe_CreateAndUpdateComposition", + params, + }); + + await userLedger.prepareSignExecuteAndWaitFor( + [createAndUpdateCommand], + userKeyPair.privateKey, + v4() + ); +} diff --git a/packages/token-sdk/src/wrappedSdk/etf/mintRequest.ts b/packages/token-sdk/src/wrappedSdk/etf/mintRequest.ts new file mode 100644 index 0000000..cf40f14 --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/mintRequest.ts @@ -0,0 +1,194 @@ +import { LedgerController } from "@canton-network/wallet-sdk"; +import { v4 } from "uuid"; +import { UserKeyPair } from "../../types/UserKeyPair.js"; +import { ActiveContractResponse } from "../../types/ActiveContractResponse.js"; +import { ContractId, Party } from "../../types/daml.js"; +import { getCreateCommand } from "../../helpers/getCreateCommand.js"; +import { getExerciseCommand } from "../../helpers/getExerciseCommand.js"; +import { etfMintRequestTemplateId } from "../../constants/templateIds.js"; + +export interface EtfMintRequestParams { + mintRecipeCid: ContractId; + requester: Party; + amount: number; + transferInstructionCids: ContractId[]; + issuer: Party; +} + +const getCreateEtfMintRequestCommand = (params: EtfMintRequestParams) => + getCreateCommand({ templateId: etfMintRequestTemplateId, params }); + +/** + * Create an ETF mint request (requester proposes to mint ETF tokens) + * This is part of the ETF minting pattern: + * 1. Requester creates ETF mint request with transfer instructions for underlying assets + * 2. Issuer accepts the request to validate transfers and mint ETF tokens + * + * @param requesterLedger - The requester's ledger controller + * @param requesterKeyPair - The requester's key pair for signing + * @param params - ETF mint request parameters + */ +export async function createEtfMintRequest( + requesterLedger: LedgerController, + requesterKeyPair: UserKeyPair, + params: EtfMintRequestParams +) { + const createMintRequestCommand = getCreateEtfMintRequestCommand(params); + + await requesterLedger.prepareSignExecuteAndWaitFor( + [createMintRequestCommand], + requesterKeyPair.privateKey, + v4() + ); +} + +/** + * Get all ETF mint requests for a given issuer + * @param userLedger - The user's ledger controller + * @param issuer - The issuer party + */ +export async function getAllEtfMintRequests( + userLedger: LedgerController, + issuer: Party +) { + const partyId = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [etfMintRequestTemplateId], + })) as ActiveContractResponse[]; + + const filteredEntries = activeContracts.filter(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + const { createArgument } = jsActive.createdEvent; + return ( + (createArgument.requester === partyId && + createArgument.issuer === issuer) || + createArgument.issuer === partyId + ); + }); + + return filteredEntries.map((contract) => { + return contract.contractEntry.JsActiveContract!.createdEvent.contractId; + }); +} + +/** + * Get the latest ETF mint request for a given issuer + * @param userLedger - The user's ledger controller (can be requester or issuer) + * @param issuer - The issuer party + */ +export async function getLatestEtfMintRequest( + userLedger: LedgerController, + issuer: Party +): Promise { + const partyId = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [etfMintRequestTemplateId], + })) as ActiveContractResponse[]; + + if (activeContracts.length === 0) { + return; + } + + const filteredEntries = activeContracts.filter(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + const { createArgument } = jsActive.createdEvent; + return ( + (createArgument.requester === partyId && + createArgument.issuer === issuer) || + createArgument.issuer === partyId + ); + }); + + if (filteredEntries.length === 0) { + return; + } + + const contract = filteredEntries[filteredEntries.length - 1]; + return contract.contractEntry.JsActiveContract!.createdEvent.contractId; +} + +/** + * Accept an ETF mint request (issuer action) + * Validates transfer instructions, executes them, and mints ETF tokens + * @param issuerLedger - The issuer's ledger controller + * @param issuerKeyPair - The issuer's key pair for signing + * @param contractId - The mint request contract ID + */ +export async function acceptEtfMintRequest( + issuerLedger: LedgerController, + issuerKeyPair: UserKeyPair, + contractId: ContractId +) { + const acceptCommand = getExerciseCommand({ + templateId: etfMintRequestTemplateId, + contractId, + choice: "MintRequest_Accept", + params: {}, + }); + + await issuerLedger.prepareSignExecuteAndWaitFor( + [acceptCommand], + issuerKeyPair.privateKey, + v4() + ); +} + +/** + * Decline an ETF mint request (issuer action) + * @param issuerLedger - The issuer's ledger controller + * @param issuerKeyPair - The issuer's key pair for signing + * @param contractId - The mint request contract ID + */ +export async function declineEtfMintRequest( + issuerLedger: LedgerController, + issuerKeyPair: UserKeyPair, + contractId: ContractId +) { + const declineCommand = getExerciseCommand({ + templateId: etfMintRequestTemplateId, + contractId, + choice: "MintRequest_Decline", + params: {}, + }); + + await issuerLedger.prepareSignExecuteAndWaitFor( + [declineCommand], + issuerKeyPair.privateKey, + v4() + ); +} + +/** + * Withdraw an ETF mint request (requester action) + * @param requesterLedger - The requester's ledger controller + * @param requesterKeyPair - The requester's key pair for signing + * @param contractId - The mint request contract ID + */ +export async function withdrawEtfMintRequest( + requesterLedger: LedgerController, + requesterKeyPair: UserKeyPair, + contractId: ContractId +) { + const withdrawCommand = getExerciseCommand({ + templateId: etfMintRequestTemplateId, + contractId, + choice: "MintRequest_Withdraw", + params: {}, + }); + + await requesterLedger.prepareSignExecuteAndWaitFor( + [withdrawCommand], + requesterKeyPair.privateKey, + v4() + ); +} diff --git a/packages/token-sdk/src/wrappedSdk/etf/portfolioComposition.ts b/packages/token-sdk/src/wrappedSdk/etf/portfolioComposition.ts new file mode 100644 index 0000000..c594f0a --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/portfolioComposition.ts @@ -0,0 +1,144 @@ +import { LedgerController } from "@canton-network/wallet-sdk"; +import { portfolioCompositionTemplateId } from "../../constants/templateIds.js"; +import { getCreateCommand } from "../../helpers/getCreateCommand.js"; +import { ContractId, Party } from "../../types/daml.js"; +import { UserKeyPair } from "../../types/UserKeyPair.js"; +import { ActiveContractResponse } from "../../types/ActiveContractResponse.js"; +import { v4 } from "uuid"; + +export interface PortfolioItem { + instrumentId: { + admin: Party; + id: string; + }; + weight: number; +} + +export interface PortfolioCompositionParams { + owner: Party; + name: string; + items: PortfolioItem[]; +} + +const getCreatePortfolioCompositionCommand = ( + params: PortfolioCompositionParams +) => getCreateCommand({ templateId: portfolioCompositionTemplateId, params }); + +/** + * Create a portfolio composition + * @param userLedger - The user's ledger controller + * @param userKeyPair - The user's key pair for signing + * @param params - Portfolio composition parameters + */ +export async function createPortfolioComposition( + userLedger: LedgerController, + userKeyPair: UserKeyPair, + params: PortfolioCompositionParams +) { + const createCommand = getCreatePortfolioCompositionCommand(params); + + await userLedger.prepareSignExecuteAndWaitFor( + [createCommand], + userKeyPair.privateKey, + v4() + ); +} + +/** + * Get the latest portfolio composition for the current party + * @param userLedger - The user's ledger controller + * @param name - Optional name to filter by + */ +export async function getLatestPortfolioComposition( + userLedger: LedgerController, + name?: string +): Promise { + const owner = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [owner], + templateIds: [portfolioCompositionTemplateId], + })) as ActiveContractResponse[]; + + if (activeContracts.length === 0) { + return; + } + + const filteredEntries = activeContracts.filter(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + const { createArgument } = jsActive.createdEvent; + return ( + createArgument.owner === owner && + (!name || createArgument.name === name) + ); + }); + + if (filteredEntries.length === 0) { + return; + } + + const contract = filteredEntries[filteredEntries.length - 1]; + return contract.contractEntry.JsActiveContract!.createdEvent.contractId; +} + +/** + * Get all portfolio compositions owned by the current party + * @param userLedger - The user's ledger controller + */ +export async function getAllPortfolioCompositions( + userLedger: LedgerController +): Promise { + const owner = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [owner], + templateIds: [portfolioCompositionTemplateId], + })) as ActiveContractResponse[]; + + const filteredEntries = activeContracts.filter(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + const { createArgument } = jsActive.createdEvent; + return createArgument.owner === owner; + }); + + return filteredEntries.map((contract) => { + return contract.contractEntry.JsActiveContract!.createdEvent.contractId; + }); +} + +/** + * Get a specific portfolio composition contract details + * @param userLedger - The user's ledger controller + * @param contractId - The portfolio composition contract ID + */ +export async function getPortfolioComposition( + userLedger: LedgerController, + contractId: ContractId +): Promise { + const partyId = userLedger.getPartyId(); + const end = await userLedger.ledgerEnd(); + const activeContracts = (await userLedger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [portfolioCompositionTemplateId], + })) as ActiveContractResponse[]; + + const contract = activeContracts.find(({ contractEntry }) => { + const jsActive = contractEntry.JsActiveContract; + if (!jsActive) return false; + return jsActive.createdEvent.contractId === contractId; + }); + + if (!contract) { + throw new Error(`Portfolio composition ${contractId} not found`); + } + + return contract.contractEntry.JsActiveContract!.createdEvent.createArgument; +} diff --git a/packages/token-sdk/src/wrappedSdk/index.ts b/packages/token-sdk/src/wrappedSdk/index.ts index b75f6ca..af3eccd 100644 --- a/packages/token-sdk/src/wrappedSdk/index.ts +++ b/packages/token-sdk/src/wrappedSdk/index.ts @@ -12,3 +12,4 @@ export * from "./transferPreapprovalProposal.js"; export * from "./transferRequest.js"; export * from "./wrappedSdk.js"; export * from "./bonds/index.js"; +export * from "./etf/index.js"; diff --git a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts index f321423..e3d2f2c 100644 --- a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts +++ b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts @@ -151,6 +151,45 @@ import { processBondLifecycleInstruction, } from "./bonds/lifecycleInstruction.js"; import { getLatestBondLifecycleEffect } from "./bonds/lifecycleEffect.js"; +import { + createPortfolioComposition, + getLatestPortfolioComposition, + getAllPortfolioCompositions, + getPortfolioComposition, + PortfolioCompositionParams, +} from "./etf/portfolioComposition.js"; +import { + createMintRecipe, + getLatestMintRecipe, + getOrCreateMintRecipe, + addAuthorizedMinter, + removeAuthorizedMinter, + updateComposition, + createAndUpdateComposition, + MintRecipeParams, + AddAuthorizedMinterParams, + RemoveAuthorizedMinterParams, + UpdateCompositionParams, + CreateAndUpdateCompositionParams, +} from "./etf/mintRecipe.js"; +import { + createEtfMintRequest, + getLatestEtfMintRequest, + getAllEtfMintRequests, + acceptEtfMintRequest, + declineEtfMintRequest, + withdrawEtfMintRequest, + EtfMintRequestParams, +} from "./etf/mintRequest.js"; +import { + createEtfBurnRequest, + getLatestEtfBurnRequest, + getAllEtfBurnRequests, + acceptEtfBurnRequest, + declineEtfBurnRequest, + withdrawEtfBurnRequest, + EtfBurnRequestParams, +} from "./etf/burnRequest.js"; export const getWrappedSdk = (sdk: WalletSDK) => { if (!sdk.userLedger) { @@ -591,6 +630,115 @@ export const getWrappedSdk = (sdk: WalletSDK) => { params ), }, + etf: { + portfolioComposition: { + create: ( + userKeyPair: UserKeyPair, + params: PortfolioCompositionParams + ) => + createPortfolioComposition(userLedger, userKeyPair, params), + getLatest: (name?: string) => + getLatestPortfolioComposition(userLedger, name), + getAll: () => getAllPortfolioCompositions(userLedger), + get: (contractId: ContractId) => + getPortfolioComposition(userLedger, contractId), + }, + mintRecipe: { + create: (userKeyPair: UserKeyPair, params: MintRecipeParams) => + createMintRecipe(userLedger, userKeyPair, params), + getLatest: (instrumentId: string) => + getLatestMintRecipe(userLedger, instrumentId), + getOrCreate: ( + userKeyPair: UserKeyPair, + params: MintRecipeParams + ) => getOrCreateMintRecipe(userLedger, userKeyPair, params), + addAuthorizedMinter: ( + userKeyPair: UserKeyPair, + contractId: ContractId, + params: AddAuthorizedMinterParams + ) => + addAuthorizedMinter( + userLedger, + userKeyPair, + contractId, + params + ), + removeAuthorizedMinter: ( + userKeyPair: UserKeyPair, + contractId: ContractId, + params: RemoveAuthorizedMinterParams + ) => + removeAuthorizedMinter( + userLedger, + userKeyPair, + contractId, + params + ), + updateComposition: ( + userKeyPair: UserKeyPair, + contractId: ContractId, + params: UpdateCompositionParams + ) => + updateComposition( + userLedger, + userKeyPair, + contractId, + params + ), + createAndUpdateComposition: ( + userKeyPair: UserKeyPair, + contractId: ContractId, + params: CreateAndUpdateCompositionParams + ) => + createAndUpdateComposition( + userLedger, + userKeyPair, + contractId, + params + ), + }, + mintRequest: { + create: ( + userKeyPair: UserKeyPair, + params: EtfMintRequestParams + ) => createEtfMintRequest(userLedger, userKeyPair, params), + getLatest: (issuer: Party) => + getLatestEtfMintRequest(userLedger, issuer), + getAll: (issuer: Party) => + getAllEtfMintRequests(userLedger, issuer), + accept: (userKeyPair: UserKeyPair, contractId: ContractId) => + acceptEtfMintRequest(userLedger, userKeyPair, contractId), + decline: (userKeyPair: UserKeyPair, contractId: ContractId) => + declineEtfMintRequest(userLedger, userKeyPair, contractId), + withdraw: (userKeyPair: UserKeyPair, contractId: ContractId) => + withdrawEtfMintRequest(userLedger, userKeyPair, contractId), + }, + burnRequest: { + create: ( + userKeyPair: UserKeyPair, + params: EtfBurnRequestParams + ) => createEtfBurnRequest(userLedger, userKeyPair, params), + getLatest: (issuer: Party) => + getLatestEtfBurnRequest(userLedger, issuer), + getAll: (issuer: Party) => + getAllEtfBurnRequests(userLedger, issuer), + accept: ( + userKeyPair: UserKeyPair, + contractId: ContractId, + transferInstructionCids: ContractId[] + ) => + acceptEtfBurnRequest( + userLedger, + userKeyPair, + contractId, + transferInstructionCids + ), + decline: (userKeyPair: UserKeyPair, contractId: ContractId) => + declineEtfBurnRequest(userLedger, userKeyPair, contractId), + withdraw: (userKeyPair: UserKeyPair, contractId: ContractId) => + withdrawEtfBurnRequest(userLedger, userKeyPair, contractId), + }, + }, }; }; @@ -996,6 +1144,101 @@ export const getWrappedSdkWithKeyPair = ( params ), }, + etf: { + portfolioComposition: { + create: (params: PortfolioCompositionParams) => + createPortfolioComposition(userLedger, userKeyPair, params), + getLatest: (name?: string) => + getLatestPortfolioComposition(userLedger, name), + getAll: () => getAllPortfolioCompositions(userLedger), + get: (contractId: ContractId) => + getPortfolioComposition(userLedger, contractId), + }, + mintRecipe: { + create: (params: MintRecipeParams) => + createMintRecipe(userLedger, userKeyPair, params), + getLatest: (instrumentId: string) => + getLatestMintRecipe(userLedger, instrumentId), + getOrCreate: (params: MintRecipeParams) => + getOrCreateMintRecipe(userLedger, userKeyPair, params), + addAuthorizedMinter: ( + contractId: ContractId, + params: AddAuthorizedMinterParams + ) => + addAuthorizedMinter( + userLedger, + userKeyPair, + contractId, + params + ), + removeAuthorizedMinter: ( + contractId: ContractId, + params: RemoveAuthorizedMinterParams + ) => + removeAuthorizedMinter( + userLedger, + userKeyPair, + contractId, + params + ), + updateComposition: ( + contractId: ContractId, + params: UpdateCompositionParams + ) => + updateComposition( + userLedger, + userKeyPair, + contractId, + params + ), + createAndUpdateComposition: ( + contractId: ContractId, + params: CreateAndUpdateCompositionParams + ) => + createAndUpdateComposition( + userLedger, + userKeyPair, + contractId, + params + ), + }, + mintRequest: { + create: (params: EtfMintRequestParams) => + createEtfMintRequest(userLedger, userKeyPair, params), + getLatest: (issuer: Party) => + getLatestEtfMintRequest(userLedger, issuer), + getAll: (issuer: Party) => + getAllEtfMintRequests(userLedger, issuer), + accept: (contractId: ContractId) => + acceptEtfMintRequest(userLedger, userKeyPair, contractId), + decline: (contractId: ContractId) => + declineEtfMintRequest(userLedger, userKeyPair, contractId), + withdraw: (contractId: ContractId) => + withdrawEtfMintRequest(userLedger, userKeyPair, contractId), + }, + burnRequest: { + create: (params: EtfBurnRequestParams) => + createEtfBurnRequest(userLedger, userKeyPair, params), + getLatest: (issuer: Party) => + getLatestEtfBurnRequest(userLedger, issuer), + getAll: (issuer: Party) => + getAllEtfBurnRequests(userLedger, issuer), + accept: ( + contractId: ContractId, + transferInstructionCids: ContractId[] + ) => + acceptEtfBurnRequest( + userLedger, + userKeyPair, + contractId, + transferInstructionCids + ), + decline: (contractId: ContractId) => + declineEtfBurnRequest(userLedger, userKeyPair, contractId), + withdraw: (contractId: ContractId) => + withdrawEtfBurnRequest(userLedger, userKeyPair, contractId), + }, + }, }; };