From 0e40be9ceaec1a85d460048bcfaf1ce3b273713b Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Mon, 15 Dec 2025 18:24:00 +0100 Subject: [PATCH 01/13] [WIP] start basic ETF creation --- .../daml/{ => ETF}/MyMintRecipe.daml | 4 +- .../minimal-token/daml/ETF/MyMintRequest.daml | 25 ++++ .../daml/{ => ETF}/PortfolioComposition.daml | 2 +- .../minimal-token/daml/ETF/Test/ETFTest.daml | 124 ++++++++++++++++++ .../minimal-token/daml/Test/TestUtils.daml | 5 + 5 files changed, 157 insertions(+), 3 deletions(-) rename packages/minimal-token/daml/{ => ETF}/MyMintRecipe.daml (97%) create mode 100644 packages/minimal-token/daml/ETF/MyMintRequest.daml rename packages/minimal-token/daml/{ => ETF}/PortfolioComposition.daml (89%) create mode 100644 packages/minimal-token/daml/ETF/Test/ETFTest.daml diff --git a/packages/minimal-token/daml/MyMintRecipe.daml b/packages/minimal-token/daml/ETF/MyMintRecipe.daml similarity index 97% rename from packages/minimal-token/daml/MyMintRecipe.daml rename to packages/minimal-token/daml/ETF/MyMintRecipe.daml index e0229f9..0723780 100644 --- a/packages/minimal-token/daml/MyMintRecipe.daml +++ b/packages/minimal-token/daml/ETF/MyMintRecipe.daml @@ -1,8 +1,8 @@ -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. diff --git a/packages/minimal-token/daml/ETF/MyMintRequest.daml b/packages/minimal-token/daml/ETF/MyMintRequest.daml new file mode 100644 index 0000000..6971124 --- /dev/null +++ b/packages/minimal-token/daml/ETF/MyMintRequest.daml @@ -0,0 +1,25 @@ +module ETF.MyMintRequest where + +import MyToken +import ETF.MyMintRecipe as MyMintRecipe +import Splice.Api.Token.TransferInstructionV1 as TI + +template MyMintRequest + with + mintRecipeCid : ContractId MyMintRecipe + requester : Party + amount : Decimal + transferInstructionCids: [ContractId TI.TransferInstruction] + + where + signatory requester + + choice MintRequest_Accept : ContractId MyToken + with + issuer : Party + controller issuer + do + -- TODO: validate transfer instructions match portfolio composition + exercise mintRecipeCid MyMintRecipe.MyMintRecipe_Mint with + receiver = requester + amount = amount diff --git a/packages/minimal-token/daml/PortfolioComposition.daml b/packages/minimal-token/daml/ETF/PortfolioComposition.daml similarity index 89% rename from packages/minimal-token/daml/PortfolioComposition.daml rename to packages/minimal-token/daml/ETF/PortfolioComposition.daml index 839a906..917f19d 100644 --- a/packages/minimal-token/daml/PortfolioComposition.daml +++ b/packages/minimal-token/daml/ETF/PortfolioComposition.daml @@ -1,4 +1,4 @@ -module PortfolioComposition where +module ETF.PortfolioComposition where data PortfolioItem = PortfolioItem with 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..1a406b7 --- /dev/null +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -0,0 +1,124 @@ +module ETF.Test.ETFTest where + +import Daml.Script +import DA.Time + +import Test.TestUtils (createTransferRequest, setupTokenInfrastructureWithInstrumentId) +import MyTokenFactory +import ETF.PortfolioComposition as PortfolioComposition +import ETF.MyMintRecipe as MyMintRecipe +import MyTokenTransferInstruction +import MyToken.TransferRequest as TransferRequest +import ETF.MyMintRequest as MyMintRequest +import Splice.Api.Token.HoldingV1 as H + +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 etfInstrumentId = show issuer <> "#ThreeTokenETF" + + -- Issuer creates token factories (for minting tokens) + infra1 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId1 + infra2 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId2 + infra3 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId3 + + -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe + etfFactoryCid <- submit issuer do + createCmd MyTokenFactory with + issuer + instrumentId = etfInstrumentId + + -- Issuer creates portfolio composition + let portfolioItems = + [ PortfolioItem with instrumentId = instrumentId1; weight = 1.0 + , PortfolioItem with instrumentId = instrumentId2; weight = 1.0 + , PortfolioItem with instrumentId = instrumentId3; weight = 1.0 + ] + + 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 + tokenFactory = etfFactoryCid + authorizedMinters = [issuer] + composition = portfolioCid + + -- Mint all three tokens + token1Cid <- submit issuer do + -- TODO: FIXME: how do I get the tokenCid? + mintResult <- exerciseCmd infra1.tokenFactoryCid MyTokenFactory.Mint with + receiver = issuer + amount = 1.0 + + pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + + token2Cid <- submit issuer do + mintResult <- exerciseCmd infra2.tokenFactoryCid MyTokenFactory.Mint with + receiver = issuer + amount = 1.0 + + pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + + token3Cid <- submit issuer do + mintResult <- exerciseCmd infra3.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 future = addRelTime now (hours 1) + + token1TransferRequestCid <- createTransferRequest + issuer issuer issuer 1.0 now future + infra1.transferFactoryCid token1Cid + + token1TransferResult <- submit issuer do + exerciseCmd token1TransferRequestCid TransferRequest.Accept + + let transferInstruction1Cid = token1TransferResult.output.transferInstructionCid + + token2TransferRequestCid <- createTransferRequest + issuer issuer issuer 1.0 now future + infra2.transferFactoryCid token2Cid + + token2TransferResult <- submit issuer do + exerciseCmd token1TransferRequestCid TransferRequest.Accept + + let transferInstruction2Cid = token2TransferResult.output.transferInstructionCid + + token3TransferRequestCid <- createTransferRequest + issuer issuer issuer 1.0 now future + infra3.transferFactoryCid token3Cid + + token3TransferResult <- submit issuer do + exerciseCmd token1TransferRequestCid TransferRequest.Accept + + let transferInstruction3Cid = token3TransferResult.output.transferInstructionCid + + -- 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 = [transferInstruction1Cid, transferInstruction2Cid, transferInstruction3Cid] + + etfCid <- submit issuer do + exerciseCmd mintRecipeCid MyMintRequest.MintRequest_Accept + + pure () + diff --git a/packages/minimal-token/daml/Test/TestUtils.daml b/packages/minimal-token/daml/Test/TestUtils.daml index 05d6ec3..7b563bd 100644 --- a/packages/minimal-token/daml/Test/TestUtils.daml +++ b/packages/minimal-token/daml/Test/TestUtils.daml @@ -44,6 +44,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 From daf9b817076fab85289d0277988b3ce785cff4be Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Tue, 16 Dec 2025 15:22:43 +0100 Subject: [PATCH 02/13] Finish ETF minting, test with mint to self --- packages/minimal-token/.gitignore | 1 + .../minimal-token/daml/ETF/MyMintRequest.daml | 68 +++++++++++++++++-- .../daml/ETF/PortfolioComposition.daml | 4 +- .../minimal-token/daml/ETF/Test/ETFTest.daml | 48 +++++++------ .../minimal-token/daml/Test/TestUtils.daml | 8 ++- 5 files changed, 98 insertions(+), 31 deletions(-) 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/daml/ETF/MyMintRequest.daml b/packages/minimal-token/daml/ETF/MyMintRequest.daml index 6971124..dc5ccb5 100644 --- a/packages/minimal-token/daml/ETF/MyMintRequest.daml +++ b/packages/minimal-token/daml/ETF/MyMintRequest.daml @@ -1,25 +1,79 @@ module ETF.MyMintRequest where -import MyToken -import ETF.MyMintRecipe as MyMintRecipe +import DA.Foldable (forA_) +-- import qualified DA.Foldable as F (length) + +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 - requester : Party - amount : Decimal + 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 - with - issuer : Party + -- ^ Accept the mint request and mint the tokens. controller issuer do + mintRecipe <- fetch mintRecipeCid + portfolioComp <- fetch (mintRecipe.composition) + -- TODO: validate transfer instructions match portfolio composition + 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/ETF/PortfolioComposition.daml b/packages/minimal-token/daml/ETF/PortfolioComposition.daml index 917f19d..ef09a57 100644 --- a/packages/minimal-token/daml/ETF/PortfolioComposition.daml +++ b/packages/minimal-token/daml/ETF/PortfolioComposition.daml @@ -1,8 +1,10 @@ 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 index 1a406b7..a38a335 100644 --- a/packages/minimal-token/daml/ETF/Test/ETFTest.daml +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -1,3 +1,4 @@ +{-# LANGUAGE ApplicativeDo #-} module ETF.Test.ETFTest where import Daml.Script @@ -7,7 +8,6 @@ import Test.TestUtils (createTransferRequest, setupTokenInfrastructureWithInstru import MyTokenFactory import ETF.PortfolioComposition as PortfolioComposition import ETF.MyMintRecipe as MyMintRecipe -import MyTokenTransferInstruction import MyToken.TransferRequest as TransferRequest import ETF.MyMintRequest as MyMintRequest import Splice.Api.Token.HoldingV1 as H @@ -18,8 +18,13 @@ mintToSelfTokenETF = script do issuer <- allocatePartyByHint (PartyIdHint "Issuer") let instrumentId1 = show issuer <> "#MyToken1" + let instrumentIdFull1 = H.InstrumentId with admin = issuer, id = instrumentId1 + let instrumentId2 = show issuer <> "#MyToken2" + let instrumentIdFull2 = H.InstrumentId with admin = issuer, id = instrumentId2 + let instrumentId3 = show issuer <> "#MyToken3" + let instrumentIdFull3 = H.InstrumentId with admin = issuer, id = instrumentId3 let etfInstrumentId = show issuer <> "#ThreeTokenETF" @@ -36,9 +41,9 @@ mintToSelfTokenETF = script do -- Issuer creates portfolio composition let portfolioItems = - [ PortfolioItem with instrumentId = instrumentId1; weight = 1.0 - , PortfolioItem with instrumentId = instrumentId2; weight = 1.0 - , PortfolioItem with instrumentId = instrumentId3; weight = 1.0 + [ PortfolioItem with instrumentId = instrumentIdFull1; weight = 1.0 + , PortfolioItem with instrumentId = instrumentIdFull2; weight = 1.0 + , PortfolioItem with instrumentId = instrumentIdFull3; weight = 1.0 ] portfolioCid <- submit issuer do @@ -57,7 +62,6 @@ mintToSelfTokenETF = script do -- Mint all three tokens token1Cid <- submit issuer do - -- TODO: FIXME: how do I get the tokenCid? mintResult <- exerciseCmd infra1.tokenFactoryCid MyTokenFactory.Mint with receiver = issuer amount = 1.0 @@ -80,34 +84,35 @@ mintToSelfTokenETF = script do -- 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) - token1TransferRequestCid <- createTransferRequest - issuer issuer issuer 1.0 now future + tokenTransferRequest1Cid <- createTransferRequest + issuer issuer issuer 1.0 requestedAtPast future infra1.transferFactoryCid token1Cid - token1TransferResult <- submit issuer do - exerciseCmd token1TransferRequestCid TransferRequest.Accept + tokenTransferResult1 <- submit issuer do + exerciseCmd tokenTransferRequest1Cid TransferRequest.Accept - let transferInstruction1Cid = token1TransferResult.output.transferInstructionCid + let transferInstruction1Cid = tokenTransferResult1.output.transferInstructionCid - token2TransferRequestCid <- createTransferRequest - issuer issuer issuer 1.0 now future + tokenTransferRequest2Cid <- createTransferRequest + issuer issuer issuer 1.0 requestedAtPast future infra2.transferFactoryCid token2Cid - token2TransferResult <- submit issuer do - exerciseCmd token1TransferRequestCid TransferRequest.Accept + tokenTransferResult2 <- submit issuer do + exerciseCmd tokenTransferRequest2Cid TransferRequest.Accept - let transferInstruction2Cid = token2TransferResult.output.transferInstructionCid + let transferInstruction2Cid = tokenTransferResult2.output.transferInstructionCid - token3TransferRequestCid <- createTransferRequest - issuer issuer issuer 1.0 now future + tokenTransferRequest3Cid <- createTransferRequest + issuer issuer issuer 1.0 requestedAtPast future infra3.transferFactoryCid token3Cid - token3TransferResult <- submit issuer do - exerciseCmd token1TransferRequestCid TransferRequest.Accept + tokenTransferResult3 <- submit issuer do + exerciseCmd tokenTransferRequest3Cid TransferRequest.Accept - let transferInstruction3Cid = token3TransferResult.output.transferInstructionCid + let transferInstruction3Cid = tokenTransferResult3.output.transferInstructionCid -- 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 @@ -116,9 +121,10 @@ mintToSelfTokenETF = script do requester = issuer amount = 1.0 transferInstructionCids = [transferInstruction1Cid, transferInstruction2Cid, transferInstruction3Cid] + issuer etfCid <- submit issuer do - exerciseCmd mintRecipeCid MyMintRequest.MintRequest_Accept + exerciseCmd etfMintRequestCid MyMintRequest.MintRequest_Accept pure () diff --git a/packages/minimal-token/daml/Test/TestUtils.daml b/packages/minimal-token/daml/Test/TestUtils.daml index 7b563bd..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 @@ -111,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] From 2c1832260421ad4de80372d7c578a17b927ff0f5 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Tue, 16 Dec 2025 15:31:46 +0100 Subject: [PATCH 03/13] Add test for minting to another user --- .../minimal-token/daml/ETF/Test/ETFTest.daml | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/packages/minimal-token/daml/ETF/Test/ETFTest.daml b/packages/minimal-token/daml/ETF/Test/ETFTest.daml index a38a335..4e20afc 100644 --- a/packages/minimal-token/daml/ETF/Test/ETFTest.daml +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -9,6 +9,7 @@ import MyTokenFactory import ETF.PortfolioComposition as PortfolioComposition import ETF.MyMintRecipe as MyMintRecipe import MyToken.TransferRequest as TransferRequest +import MyToken.IssuerMintRequest as IssuerMintRequest import ETF.MyMintRequest as MyMintRequest import Splice.Api.Token.HoldingV1 as H @@ -128,3 +129,135 @@ mintToSelfTokenETF = script do pure () +mintToOtherTokenETF : Script () +mintToOtherTokenETF = script do + -- Allocate parties + issuer <- allocatePartyByHint (PartyIdHint "Issuer") + alice <- allocatePartyByHint (PartyIdHint "Alice") + + let instrumentId1 = show issuer <> "#MyToken1" + let instrumentIdFull1 = H.InstrumentId with admin = issuer, id = instrumentId1 + + let instrumentId2 = show issuer <> "#MyToken2" + let instrumentIdFull2 = H.InstrumentId with admin = issuer, id = instrumentId2 + + let instrumentId3 = show issuer <> "#MyToken3" + let instrumentIdFull3 = H.InstrumentId with admin = issuer, id = instrumentId3 + + let etfInstrumentId = show issuer <> "#ThreeTokenETF" + + -- Issuer creates token factories (for minting tokens) + infra1 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId1 + infra2 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId2 + infra3 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId3 + + -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe + etfFactoryCid <- submit issuer do + createCmd MyTokenFactory with + issuer + instrumentId = etfInstrumentId + + -- Issuer creates portfolio composition + let portfolioItems = + [ PortfolioItem with instrumentId = instrumentIdFull1; weight = 1.0 + , PortfolioItem with instrumentId = instrumentIdFull2; weight = 1.0 + , PortfolioItem with instrumentId = instrumentIdFull3; weight = 1.0 + ] + + 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 + tokenFactory = etfFactoryCid + authorizedMinters = [issuer, alice] + composition = portfolioCid + + -- Mint all three tokens + mintRequest1Cid <- submit alice do + createCmd IssuerMintRequest with + tokenFactoryCid = infra1.tokenFactoryCid + issuer + receiver = alice + amount = 1.0 + + mintRequest2Cid <- submit alice do + createCmd IssuerMintRequest with + tokenFactoryCid = infra2.tokenFactoryCid + issuer + receiver = alice + amount = 1.0 + + mintRequest3Cid <- submit alice do + createCmd IssuerMintRequest with + tokenFactoryCid = infra3.tokenFactoryCid + issuer + receiver = alice + amount = 1.0 + + token1Cid <- submit issuer do + mintResult <- exerciseCmd mintRequest1Cid IssuerMintRequest.Accept + + pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + + token2Cid <- submit issuer do + mintResult <- exerciseCmd mintRequest2Cid IssuerMintRequest.Accept + + pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + + token3Cid <- submit issuer do + mintResult <- exerciseCmd mintRequest3Cid 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) + + tokenTransferRequest1Cid <- createTransferRequest + alice issuer issuer 1.0 requestedAtPast future + infra1.transferFactoryCid token1Cid + + tokenTransferResult1 <- submit issuer do + exerciseCmd tokenTransferRequest1Cid TransferRequest.Accept + + let transferInstruction1Cid = tokenTransferResult1.output.transferInstructionCid + + tokenTransferRequest2Cid <- createTransferRequest + alice issuer issuer 1.0 requestedAtPast future + infra2.transferFactoryCid token2Cid + + tokenTransferResult2 <- submit issuer do + exerciseCmd tokenTransferRequest2Cid TransferRequest.Accept + + let transferInstruction2Cid = tokenTransferResult2.output.transferInstructionCid + + tokenTransferRequest3Cid <- createTransferRequest + alice issuer issuer 1.0 requestedAtPast future + infra3.transferFactoryCid token3Cid + + tokenTransferResult3 <- submit issuer do + exerciseCmd tokenTransferRequest3Cid TransferRequest.Accept + + let transferInstruction3Cid = tokenTransferResult3.output.transferInstructionCid + + -- 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 = [transferInstruction1Cid, transferInstruction2Cid, transferInstruction3Cid] + issuer + + etfCid <- submit issuer do + exerciseCmd etfMintRequestCid MyMintRequest.MintRequest_Accept + + pure () + From 3a94debf8010070e0fb09eb71271bafb2da9de24 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Tue, 16 Dec 2025 16:50:41 +0100 Subject: [PATCH 04/13] Update claude --- packages/minimal-token/CLAUDE.md | 65 ++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/minimal-token/CLAUDE.md b/packages/minimal-token/CLAUDE.md index a449973..bf668f6 100644 --- a/packages/minimal-token/CLAUDE.md +++ b/packages/minimal-token/CLAUDE.md @@ -102,6 +102,55 @@ 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 and `MyTokenFactory` +- Maintains list of `authorizedMinters` who can request ETF minting +- Issuer can update composition and manage authorized minters +- Choices: + - `MyMintRecipe_Mint` - Mint ETF tokens (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 + +**ETF Minting Workflow:** +1. Issuer creates `PortfolioComposition` defining underlying assets and weights +2. Issuer creates `MyMintRecipe` referencing the portfolio and authorizing minters +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) + - Mints ETF tokens to requester + +This pattern ensures ETF tokens are always backed by the correct underlying assets in issuer custody. + ### Request/Accept Pattern The codebase uses a consistent request/accept authorization pattern: @@ -111,9 +160,10 @@ 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 ### Registry API Pattern @@ -163,7 +213,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 +247,13 @@ 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) + +### `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 From a5c752614d918e2826c188b6c9d074b20e144f70 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Tue, 16 Dec 2025 17:11:49 +0100 Subject: [PATCH 05/13] Update package id --- packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..039ebb0 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"; + "e2037d891555c6675e2c7a951fcbbf31894f5540a74da7c5d6f4a719c1bae6d4"; From 73138543b53c8985b2ebb010bb1c79094f3bd3f9 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Tue, 16 Dec 2025 17:58:20 +0100 Subject: [PATCH 06/13] Add etf minting functionality to sdk --- packages/token-sdk/CLAUDE.md | 389 ++++++++++++++ .../token-sdk/src/constants/templateIds.ts | 8 + packages/token-sdk/src/testScripts/etfMint.ts | 482 ++++++++++++++++++ .../token-sdk/src/wrappedSdk/etf/index.ts | 3 + .../src/wrappedSdk/etf/mintRecipe.ts | 230 +++++++++ .../src/wrappedSdk/etf/mintRequest.ts | 194 +++++++ .../wrappedSdk/etf/portfolioComposition.ts | 144 ++++++ packages/token-sdk/src/wrappedSdk/index.ts | 1 + .../token-sdk/src/wrappedSdk/wrappedSdk.ts | 187 +++++++ 9 files changed, 1638 insertions(+) create mode 100644 packages/token-sdk/src/testScripts/etfMint.ts create mode 100644 packages/token-sdk/src/wrappedSdk/etf/index.ts create mode 100644 packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts create mode 100644 packages/token-sdk/src/wrappedSdk/etf/mintRequest.ts create mode 100644 packages/token-sdk/src/wrappedSdk/etf/portfolioComposition.ts diff --git a/packages/token-sdk/CLAUDE.md b/packages/token-sdk/CLAUDE.md index 1ae7e9e..2310f8e 100644 --- a/packages/token-sdk/CLAUDE.md +++ b/packages/token-sdk/CLAUDE.md @@ -39,6 +39,7 @@ Before running this SDK: - `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) +- `tsx src/testScripts/etfMint.ts` - ETF minting demonstration following mintToOtherTokenETF pattern ### Other Commands - `pnpm clean` - Remove build artifacts @@ -110,6 +111,14 @@ All helpers follow a consistent pattern with single-party perspective and templa **`transferPreapproval.ts` / `transferPreapprovalProposal.ts`** - Transfer preapproval patterns - Support for preapproved transfer workflows +**Bond Operations (`src/wrappedSdk/bonds/`)** +- Comprehensive bond instrument support with 8 wrapper modules +- See dedicated Bond Operations section below for complete documentation + +**ETF Operations (`src/wrappedSdk/etf/`)** +- Exchange-Traded Fund token support with 3 wrapper modules (portfolioComposition, mintRecipe, mintRequest) +- See dedicated ETF Operations section below for complete documentation + **`wrappedSdk.ts`** - SDK wrapper convenience functions - `getWrappedSdkWithKeyPair()` - Create wrapped SDK with key pair @@ -121,6 +130,10 @@ All helpers follow a consistent pattern with single-party perspective and templa **Template ID Constants (`src/constants/templateIds.ts`)** - Centralized template ID definitions - Template IDs are prefixed with `#minimal-token:` (e.g., `#minimal-token:MyTokenFactory:MyTokenFactory`) +- **ETF Template IDs** (added for ETF support): + - `portfolioCompositionTemplateId`: `#minimal-token:ETF.PortfolioComposition:PortfolioComposition` + - `mintRecipeTemplateId`: `#minimal-token:ETF.MyMintRecipe:MyMintRecipe` + - `etfMintRequestTemplateId`: `#minimal-token:ETF.MyMintRequest:MyMintRequest` ### Canton Ledger Interaction Pattern @@ -555,6 +568,292 @@ if (transferCid2) { 9. **Term Inference**: Lifecycle rules infer bond terms from sample bond contracts, ensuring consistency +### ETF Operations + +The SDK provides comprehensive support for Exchange-Traded Fund (ETF) tokens backed by underlying assets. The ETF implementation demonstrates composite token creation with atomic asset backing validation. + +#### ETF Architecture Overview + +**Purpose**: ETFs are composite tokens backed by a basket of underlying assets held in issuer custody. + +**Three-Contract Structure**: +1. **PortfolioComposition** - Defines the basket of underlying assets with weights +2. **MyMintRecipe** - Defines minting rules and authorized minters +3. **MyMintRequest** - Request/accept pattern for ETF minting with validation + +**Validation Pattern**: ETF minting validates that all underlying assets are transferred to issuer custody before minting the ETF token, ensuring proper backing. + +#### ETF Components + +**PortfolioComposition** (`etf/portfolioComposition.ts`): +- Data-only contract defining asset basket +- Fields: `owner: Party`, `name: string`, `items: PortfolioItem[]` +- PortfolioItem: `instrumentId: { admin, id }`, `weight: number` +- Reusable across multiple ETF mint recipes +- No choices (pure data contract) + +**MyMintRecipe** (`etf/mintRecipe.ts`): +- Defines ETF minting rules and authorization +- Fields: `issuer`, `instrumentId`, `tokenFactory`, `authorizedMinters: Party[]`, `composition: ContractId` +- Choices: + - `MyMintRecipe_AddAuthorizedMinter(newMinter)` - Add minter to authorized list + - `MyMintRecipe_RemoveAuthorizedMinter(minterToRemove)` - Remove minter from authorized list + - `MyMintRecipe_UpdateComposition(newComposition)` - Update portfolio reference + - `MyMintRecipe_CreateAndUpdateComposition(newCompositionItems, compositionName, archiveOld)` - Create new portfolio and update reference + - `MyMintRecipe_Mint(receiver, amount)` - Internal mint choice (called by MintRequest_Accept) +- Observers: authorizedMinters (allows authorized parties to create mint requests) + +**MyMintRequest** (`etf/mintRequest.ts`): +- Request pattern for ETF minting +- Fields: `mintRecipeCid`, `requester`, `amount`, `transferInstructionCids: ContractId[]`, `issuer` +- Validation ensures: + - Transfer instruction count matches portfolio item count + - Each transfer sender matches requester + - Each transfer receiver matches issuer + - Each transfer instrumentId matches portfolio item + - Each transfer amount equals `portfolioItem.weight × ETF amount` +- Choices: + - `MintRequest_Accept` - Validates transfers, executes all transfer instructions, mints ETF + - `MintRequest_Decline` - Issuer declines the request + - `MintRequest_Withdraw` - Requester withdraws the request + +#### ETF Minting Workflow + +**Phase 1: Infrastructure Setup** (Issuer) +```typescript +// Create underlying token factories (one per asset in portfolio) +const tokenFactory1Cid = await issuerWrappedSdk.tokenFactory.getOrCreate(instrumentId1); +const tokenFactory2Cid = await issuerWrappedSdk.tokenFactory.getOrCreate(instrumentId2); +const tokenFactory3Cid = await issuerWrappedSdk.tokenFactory.getOrCreate(instrumentId3); + +// Create ETF token factory +const etfTokenFactoryCid = await issuerWrappedSdk.tokenFactory.getOrCreate(etfInstrumentId); + +// Create token rules and transfer factories (for transferring underlying assets) +const rulesCid = await issuerWrappedSdk.tokenRules.getOrCreate(); +const transferFactoryCid = await issuerWrappedSdk.transferFactory.getOrCreate(rulesCid); +``` + +**Phase 2: Portfolio Composition Creation** (Issuer) +```typescript +await issuerWrappedSdk.etf.portfolioComposition.create({ + owner: issuer.partyId, + name: "Three Token ETF", + items: [ + { instrumentId: { admin: issuer.partyId, id: instrumentId1 }, weight: 1.0 }, + { instrumentId: { admin: issuer.partyId, id: instrumentId2 }, weight: 1.0 }, + { instrumentId: { admin: issuer.partyId, id: instrumentId3 }, weight: 1.0 }, + ], +}); + +const portfolioCid = await issuerWrappedSdk.etf.portfolioComposition.getLatest("Three Token ETF"); +``` + +**Phase 3: Mint Recipe Creation** (Issuer) +```typescript +await issuerWrappedSdk.etf.mintRecipe.create({ + issuer: issuer.partyId, + instrumentId: etfInstrumentId, + tokenFactory: etfTokenFactoryCid, + authorizedMinters: [issuer.partyId, alice.partyId], + composition: portfolioCid, +}); + +const mintRecipeCid = await issuerWrappedSdk.etf.mintRecipe.getLatest(etfInstrumentId); +``` + +**Phase 4: Acquire Underlying Tokens** (Authorized Minter) +```typescript +// Alice creates mint requests for each underlying token +await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory1Cid, + issuer: issuer.partyId, + receiver: alice.partyId, + amount: 1.0, +}); +// ... repeat for token 2 and 3 + +// Issuer accepts all mint requests +const mintRequestCid = await aliceWrappedSdk.issuerMintRequest.getLatest(issuer.partyId); +await issuerWrappedSdk.issuerMintRequest.accept(mintRequestCid); +// ... repeat for token 2 and 3 + +// Alice now owns 3 underlying tokens +``` + +**Phase 5: Transfer Underlying Tokens to Issuer** (Authorized Minter) +```typescript +// Alice creates transfer request 1 (alice → issuer, 1.0) +const transfer1 = buildTransfer({ + sender: alice.partyId, + receiver: issuer.partyId, + amount: 1.0, + instrumentId: { admin: issuer.partyId, id: instrumentId1 }, + requestedAt: new Date(Date.now() - 1000), + executeBefore: new Date(Date.now() + 3600000), + inputHoldingCids: [token1Cid], +}); + +await aliceWrappedSdk.transferRequest.create({ + transferFactoryCid, + expectedAdmin: issuer.partyId, + transfer: transfer1, + extraArgs: emptyExtraArgs(), +}); + +const transferRequestCid1 = await aliceWrappedSdk.transferRequest.getLatest(issuer.partyId); +await issuerWrappedSdk.transferRequest.accept(transferRequestCid1); + +// IMPORTANT: Get transfer instruction CID immediately after accepting +const transferInstructionCid1 = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); + +// Repeat pattern for token 2 +// ... create transfer2, accept, get instruction CID 2 immediately + +// Repeat pattern for token 3 +// ... create transfer3, accept, get instruction CID 3 immediately + +// Result: Three distinct transfer instruction CIDs in correct order +``` + +**⚠️ Critical Pattern**: You must capture each transfer instruction CID **immediately** after accepting each transfer request. If you wait until all transfers are accepted and then call `getLatest()` three times, you'll get the same CID three times, causing ETF minting validation to fail. + +**Phase 6: Create ETF Mint Request** (Authorized Minter) +```typescript +await aliceWrappedSdk.etf.mintRequest.create({ + mintRecipeCid, + requester: alice.partyId, + amount: 1.0, + transferInstructionCids: [transferInstructionCid1, transferInstructionCid2, transferInstructionCid3], + issuer: issuer.partyId, +}); + +const etfMintRequestCid = await aliceWrappedSdk.etf.mintRequest.getLatest(issuer.partyId); +``` + +**Phase 7: Accept ETF Mint** (Issuer) +```typescript +await issuerWrappedSdk.etf.mintRequest.accept(etfMintRequestCid); + +// Result: +// - Validates all 3 transfer instructions match portfolio composition +// - Executes all 3 transfer instructions (underlying assets → issuer custody) +// - Mints 1.0 ETF tokens to Alice +``` + +#### ETF Wrapper Modules + +**`etf/portfolioComposition.ts`** - Portfolio basket management +- `createPortfolioComposition(userLedger, userKeyPair, params)` - Create portfolio +- `getLatestPortfolioComposition(userLedger, name?)` - Query latest by owner +- `getAllPortfolioCompositions(userLedger)` - Query all by owner +- `getPortfolioComposition(userLedger, contractId)` - Fetch specific portfolio details + +**`etf/mintRecipe.ts`** - Mint recipe management +- `createMintRecipe(userLedger, userKeyPair, params)` - Create recipe +- `getLatestMintRecipe(userLedger, instrumentId)` - Query by issuer and instrumentId +- `getOrCreateMintRecipe(userLedger, userKeyPair, params)` - Convenience function +- `addAuthorizedMinter(userLedger, userKeyPair, contractId, params)` - Add minter to authorized list +- `removeAuthorizedMinter(userLedger, userKeyPair, contractId, params)` - Remove minter +- `updateComposition(userLedger, userKeyPair, contractId, params)` - Update portfolio reference +- `createAndUpdateComposition(userLedger, userKeyPair, contractId, params)` - Create new portfolio and update + +**`etf/mintRequest.ts`** - ETF mint request/accept pattern +- `createEtfMintRequest(requesterLedger, requesterKeyPair, params)` - Requester creates request +- `getLatestEtfMintRequest(userLedger, issuer)` - Query latest +- `getAllEtfMintRequests(userLedger, issuer)` - Query all for issuer +- `acceptEtfMintRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer validates and mints +- `declineEtfMintRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer declines +- `withdrawEtfMintRequest(requesterLedger, requesterKeyPair, contractId)` - Requester withdraws + +#### Key ETF Patterns + +1. **Authorization via authorizedMinters List**: MyMintRecipe maintains a list of parties authorized to create mint requests, enabling flexible minting access control + +2. **Atomic Validation and Execution**: The `MintRequest_Accept` choice validates all transfer instructions before executing any of them, ensuring ETF tokens are only minted when properly backed + +3. **Weight-Based Amount Calculation**: For each underlying asset, the transfer amount must equal `portfolioItem.weight × ETF amount`, ensuring correct proportional backing + +4. **Issuer Custody Model**: All underlying assets are transferred to the issuer during minting, establishing clear custody and backing for the ETF + +5. **Array Ordering Requirement**: Transfer instruction CIDs must be provided in the same order as portfolio composition items for validation to succeed + +6. **Critical Timing Pattern**: When collecting transfer instruction CIDs, you must capture each CID **immediately** after accepting each transfer request. Calling `getLatest()` multiple times after all transfers are accepted will return the same CID, causing validation failure. Pattern: `accept transfer 1 → get CID 1 → accept transfer 2 → get CID 2 → accept transfer 3 → get CID 3` + +7. **No Disclosure Required**: ETF minting doesn't require additional disclosure beyond the transfer instructions (issuer can see all transfer instructions they accepted) + +#### Key Differences from Token and Bond Operations + +1. **Composite Structure**: ETFs are backed by a portfolio of underlying assets, not standalone tokens + +2. **Multi-Asset Validation**: Minting validates multiple transfer instructions atomically + +3. **Authorized Minter Pattern**: Only parties in the authorizedMinters list can create mint requests + +4. **Custody Transfer**: ETF minting requires transferring underlying assets to issuer custody + +5. **Portfolio Management**: Issuers can update portfolio compositions and authorized minters dynamically + +#### ETF Implementation Notes + +**Relationship to Daml Contracts** (`packages/minimal-token/daml/ETF/`) + +The TypeScript SDK wrappers map directly to Daml contracts in the minimal-token package: +- `portfolioComposition.ts` → `ETF.PortfolioComposition.daml` - Pure data contract with no choices +- `mintRecipe.ts` → `ETF.MyMintRecipe.daml` - Factory contract with 5 choices (AddAuthorizedMinter, RemoveAuthorizedMinter, UpdateComposition, CreateAndUpdateComposition, Mint) +- `mintRequest.ts` → `ETF.MyMintRequest.daml` - Request contract with 3 choices (MintRequest_Accept, MintRequest_Decline, MintRequest_Withdraw) + +**Key Design Decisions:** + +1. **Request/Accept Pattern**: ETF minting uses a two-step pattern (requester creates, issuer accepts) to avoid multi-party signing complexity. This mirrors the `IssuerMintRequest` pattern used for basic token minting. + +2. **Observers on MyMintRecipe**: The `authorizedMinters` list is part of the contract's observers, allowing authorized parties to see the mint recipe and create mint requests. This is how authorization is enforced at the Daml level. + +3. **Validation in Daml**: All critical validation (transfer instruction count, instrumentId matching, amount calculation) happens in the Daml `MintRequest_Accept` choice, not in TypeScript. The SDK simply prepares the data and submits the command. + +4. **Array Ordering Enforcement**: The Daml validation iterates through transfer instructions and portfolio items in parallel using `zip`, requiring exact ordering. The SDK must preserve this order when collecting CIDs. + +**Common Pitfalls and Solutions:** + +1. **Issue**: Transfer instruction validation fails with "instrumentId does not match" + - **Cause**: Collected CIDs in wrong order or collected duplicate CIDs + - **Solution**: Capture each transfer instruction CID immediately after accepting each transfer request (see Pattern #6 above) + +2. **Issue**: ETF mint request creation fails with "contract not found" + - **Cause**: Transfer instructions have already been executed or withdrawn + - **Solution**: Transfer instructions are consumed when executed. Create the ETF mint request before accepting any transfer instructions on behalf of the receiver. + +3. **Issue**: Cannot create mint request - "party not authorized" + - **Cause**: Requester is not in the `authorizedMinters` list on MyMintRecipe + - **Solution**: Issuer must add the party using `addAuthorizedMinter()` before they can create mint requests + +4. **Issue**: Amount calculation mismatch + - **Cause**: Transfer amounts don't equal `portfolioItem.weight × ETF amount` + - **Solution**: Use the exact formula for each transfer. Example: If ETF amount is 2.0 and weight is 1.5, transfer amount must be exactly 3.0 + +**Implementation Pattern Consistency:** + +The ETF SDK implementation follows the same patterns as Token and Bond operations: +- All functions take `(userLedger, userKeyPair, params)` for single-party perspective +- Template IDs defined in `constants/templateIds.ts` with format `#minimal-token:ETF.{Contract}:{Contract}` +- Query functions use `activeContracts()` filtered by party and template ID +- Choice execution uses `getExerciseCommand()` helper +- Two wrapper versions: `getWrappedSdk()` (userKeyPair per call) and `getWrappedSdkWithKeyPair()` (pre-bound) + +**Testing Approach:** + +The `etfMint.ts` test script follows the `mintToOtherTokenETF` Daml test pattern exactly, providing a reference implementation: +1. Uses Charlie (issuer) and Alice (authorized minter) as parties +2. Creates 3 underlying token factories and 1 ETF token factory +3. Creates portfolio composition with 3 items (weight 1.0 each) +4. Alice acquires underlying tokens via IssuerMintRequest pattern +5. Alice transfers underlying tokens to Charlie (issuer custody) +6. Alice creates ETF mint request with transfer instruction CIDs +7. Charlie accepts, validating and executing atomically +8. Verifies Alice receives ETF token and Charlie holds underlying assets + +Run `tsx src/testScripts/etfMint.ts` to see the complete workflow in action. + ### Known Issues and Multi-Party Authorization #### Multi-Party Signing Challenge @@ -690,6 +989,89 @@ When creating new SDK wrapper functions, follow these patterns: - Define parameter interfaces for each contract/choice - Use `Party` and `ContractId` types from `src/types/daml.js` - Export types for use in other modules +- Separate param interfaces from contract field interfaces where needed + +### Two Wrapper Versions + +The SDK provides two versions of the wrapped SDK to accommodate different usage patterns: + +**`getWrappedSdk(sdk: WalletSDK)`** - Takes userKeyPair for each call +```typescript +const wrappedSdk = getWrappedSdk(sdk); + +// userKeyPair must be passed to each operation +await wrappedSdk.tokenFactory.create(userKeyPair, { issuer, instrumentId }); +await wrappedSdk.issuerMintRequest.accept(userKeyPair, contractId); +``` + +**`getWrappedSdkWithKeyPair(sdk: WalletSDK, userKeyPair: UserKeyPair)`** - Pre-bound keyPair +```typescript +const wrappedSdk = getWrappedSdkWithKeyPair(sdk, userKeyPair); + +// userKeyPair is pre-bound, omitted from method signatures +await wrappedSdk.tokenFactory.create({ issuer, instrumentId }); +await wrappedSdk.issuerMintRequest.accept(contractId); +``` + +**When to Use Each**: +- Use `getWrappedSdk` when a single SDK instance needs to perform operations with multiple key pairs +- Use `getWrappedSdkWithKeyPair` (recommended) when all operations use the same key pair - cleaner API, less repetition + +### Nested Structure + +Group related contracts in subdirectories and organize methods by operation type: + +```typescript +{ + bonds: { + factory: { create, getLatest, getOrCreate, createInstrument, getLatestInstrument }, + bondRules: { create, getLatest, getOrCreate }, + issuerMintRequest: { create, getLatest, getAll, accept, decline, withdraw }, + lifecycleRule: { create, getLatest, getOrCreate, processCouponPaymentEvent, processRedemptionEvent }, + // ... more subsections + }, + etf: { + portfolioComposition: { create, getLatest, getAll, get }, + mintRecipe: { create, getLatest, getOrCreate, addAuthorizedMinter, removeAuthorizedMinter, updateComposition, createAndUpdateComposition }, + mintRequest: { create, getLatest, getAll, accept, decline, withdraw }, + }, + tokenFactory: { create, getLatest, getOrCreate, mintToken }, + // ... more top-level sections +} +``` + +**Organization Principles**: +1. **Top-level instrument type** (e.g., `bonds`, `etf`) for complex instruments with multiple related contracts +2. **Sub-sections for component contracts** (e.g., `factory`, `rules`, `mintRequest`, `lifecycle`) +3. **Methods grouped by operation type** (create, getLatest, getOrCreate, accept, decline, withdraw) +4. **Consistent method ordering** within each subsection for predictability + +### Module Organization + +**File Structure**: +- One module per contract type (e.g., `tokenFactory.ts`, `issuerMintRequest.ts`) +- Group related contracts in subdirectories (e.g., `bonds/`, `etf/`) +- Index files (`index.ts`) for re-exporting from subdirectories +- Clear separation of concerns - each module handles a single contract type + +**Import/Export Strategy**: +```typescript +// In subdirectory: bonds/index.ts or etf/index.ts +export * from "./factory.js"; +export * from "./issuerMintRequest.js"; +export * from "./lifecycleRule.js"; +// ... more exports + +// In main wrappedSdk/index.ts +export * from "./bonds/index.js"; +export * from "./etf/index.js"; +``` + +**Integration Pattern** (`wrappedSdk.ts`): +1. Import all functions and types from subdirectory modules +2. Create nested object structure in `getWrappedSdk()` return object +3. Duplicate structure in `getWrappedSdkWithKeyPair()` with userKeyPair pre-bound +4. Ensure consistent ordering between both wrapper versions #### Other TODOs @@ -720,6 +1102,13 @@ Tests are located in `src/index.test.ts` and use Vitest. The test setup is in `v - Shows bond-specific patterns: version tracking, term inference, per-unit payments, disclosure requirements - Run with: `tsx src/testScripts/bondLifecycleTest.ts` +**`src/testScripts/etfMint.ts`** - ETF minting demonstration (mintToOtherTokenETF pattern) +- Demonstrates complete ETF minting workflow: Charlie (issuer) and Alice (authorized minter) +- Covers: 3 underlying token factories, portfolio composition, mint recipe, authorized minters +- Shows ETF-specific patterns: atomic multi-asset backing, weight-based validation, issuer custody +- 8-phase workflow: infrastructure → portfolio → recipe → mint underlying → transfer → ETF mint request → accept +- Run with: `tsx src/testScripts/etfMint.ts` + **`src/hello.ts`** - Basic token operations - Simple demo of token factory creation and minting - Good starting point for understanding the SDK diff --git a/packages/token-sdk/src/constants/templateIds.ts b/packages/token-sdk/src/constants/templateIds.ts index d0c1244..261f522 100644 --- a/packages/token-sdk/src/constants/templateIds.ts +++ b/packages/token-sdk/src/constants/templateIds.ts @@ -31,3 +31,11 @@ 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"; diff --git a/packages/token-sdk/src/testScripts/etfMint.ts b/packages/token-sdk/src/testScripts/etfMint.ts new file mode 100644 index 0000000..ac3e842 --- /dev/null +++ b/packages/token-sdk/src/testScripts/etfMint.ts @@ -0,0 +1,482 @@ +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}`); + + // Create ETF token factory + const etfTokenFactoryCid = await charlieWrappedSdk.tokenFactory.getOrCreate( + etfInstrumentId + ); + console.info(`✓ ETF token factory created: ${etfTokenFactoryCid}\n`); + + // === 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, + tokenFactory: etfTokenFactoryCid, + 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/index.ts b/packages/token-sdk/src/wrappedSdk/etf/index.ts new file mode 100644 index 0000000..18a424e --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/index.ts @@ -0,0 +1,3 @@ +export * from "./portfolioComposition.js"; +export * from "./mintRecipe.js"; +export * from "./mintRequest.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..4c84ebf --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts @@ -0,0 +1,230 @@ +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; + tokenFactory: ContractId; + 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..a24deac 100644 --- a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts +++ b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts @@ -151,6 +151,36 @@ 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"; export const getWrappedSdk = (sdk: WalletSDK) => { if (!sdk.userLedger) { @@ -591,6 +621,90 @@ 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), + }, + }, }; }; @@ -996,6 +1110,79 @@ 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), + }, + }, }; }; From c09de8c2a57d3b87f359f3fe1d8bb4f08525bfd4 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Wed, 17 Dec 2025 17:10:48 +0100 Subject: [PATCH 07/13] Refactor ETF Test --- .../minimal-token/daml/ETF/Test/ETFTest.daml | 181 +++++------------- .../src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 2 +- 2 files changed, 52 insertions(+), 131 deletions(-) diff --git a/packages/minimal-token/daml/ETF/Test/ETFTest.daml b/packages/minimal-token/daml/ETF/Test/ETFTest.daml index 4e20afc..08b6cf5 100644 --- a/packages/minimal-token/daml/ETF/Test/ETFTest.daml +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -19,20 +19,18 @@ mintToSelfTokenETF = script do issuer <- allocatePartyByHint (PartyIdHint "Issuer") let instrumentId1 = show issuer <> "#MyToken1" - let instrumentIdFull1 = H.InstrumentId with admin = issuer, id = instrumentId1 - let instrumentId2 = show issuer <> "#MyToken2" - let instrumentIdFull2 = H.InstrumentId with admin = issuer, id = instrumentId2 - let instrumentId3 = show issuer <> "#MyToken3" - let instrumentIdFull3 = H.InstrumentId with admin = issuer, id = instrumentId3 + 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) - infra1 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId1 - infra2 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId2 - infra3 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId3 + infras <- forA [instrumentId1, instrumentId2, instrumentId3] + $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe etfFactoryCid <- submit issuer do @@ -41,11 +39,8 @@ mintToSelfTokenETF = script do instrumentId = etfInstrumentId -- Issuer creates portfolio composition - let portfolioItems = - [ PortfolioItem with instrumentId = instrumentIdFull1; weight = 1.0 - , PortfolioItem with instrumentId = instrumentIdFull2; weight = 1.0 - , PortfolioItem with instrumentId = instrumentIdFull3; weight = 1.0 - ] + let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] portfolioCid <- submit issuer do createCmd PortfolioComposition.PortfolioComposition with @@ -62,58 +57,30 @@ mintToSelfTokenETF = script do composition = portfolioCid -- Mint all three tokens - token1Cid <- submit issuer do - mintResult <- exerciseCmd infra1.tokenFactoryCid MyTokenFactory.Mint with - receiver = issuer - amount = 1.0 - - pure (toInterfaceContractId @H.Holding mintResult.tokenCid) - - token2Cid <- submit issuer do - mintResult <- exerciseCmd infra2.tokenFactoryCid MyTokenFactory.Mint with - receiver = issuer - amount = 1.0 - - pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + tokenCids <- submit issuer do + forA infras $ \infra -> do + mintResult <- exerciseCmd infra.tokenFactoryCid MyTokenFactory.Mint with + receiver = issuer + amount = 1.0 - token3Cid <- submit issuer do - mintResult <- exerciseCmd infra3.tokenFactoryCid MyTokenFactory.Mint with - receiver = issuer - amount = 1.0 - - pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + 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) - tokenTransferRequest1Cid <- createTransferRequest - issuer issuer issuer 1.0 requestedAtPast future - infra1.transferFactoryCid token1Cid - - tokenTransferResult1 <- submit issuer do - exerciseCmd tokenTransferRequest1Cid TransferRequest.Accept + tokenTransferRequests <- forA + (zip infras tokenCids) + \(infra, tokenCid) -> createTransferRequest + issuer issuer issuer 1.0 requestedAtPast future + infra.transferFactoryCid tokenCid - let transferInstruction1Cid = tokenTransferResult1.output.transferInstructionCid + tokenTransferResults <- submit issuer do + forA tokenTransferRequests $ \tokenTransferRequestCid -> do + exerciseCmd tokenTransferRequestCid TransferRequest.Accept - tokenTransferRequest2Cid <- createTransferRequest - issuer issuer issuer 1.0 requestedAtPast future - infra2.transferFactoryCid token2Cid - - tokenTransferResult2 <- submit issuer do - exerciseCmd tokenTransferRequest2Cid TransferRequest.Accept - - let transferInstruction2Cid = tokenTransferResult2.output.transferInstructionCid - - tokenTransferRequest3Cid <- createTransferRequest - issuer issuer issuer 1.0 requestedAtPast future - infra3.transferFactoryCid token3Cid - - tokenTransferResult3 <- submit issuer do - exerciseCmd tokenTransferRequest3Cid TransferRequest.Accept - - let transferInstruction3Cid = tokenTransferResult3.output.transferInstructionCid + 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 @@ -121,7 +88,7 @@ mintToSelfTokenETF = script do mintRecipeCid requester = issuer amount = 1.0 - transferInstructionCids = [transferInstruction1Cid, transferInstruction2Cid, transferInstruction3Cid] + transferInstructionCids issuer etfCid <- submit issuer do @@ -136,20 +103,18 @@ mintToOtherTokenETF = script do alice <- allocatePartyByHint (PartyIdHint "Alice") let instrumentId1 = show issuer <> "#MyToken1" - let instrumentIdFull1 = H.InstrumentId with admin = issuer, id = instrumentId1 - let instrumentId2 = show issuer <> "#MyToken2" - let instrumentIdFull2 = H.InstrumentId with admin = issuer, id = instrumentId2 - let instrumentId3 = show issuer <> "#MyToken3" - let instrumentIdFull3 = H.InstrumentId with admin = issuer, id = instrumentId3 + 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) - infra1 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId1 - infra2 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId2 - infra3 <- setupTokenInfrastructureWithInstrumentId issuer instrumentId3 + infras <- forA [instrumentId1, instrumentId2, instrumentId3] + $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe etfFactoryCid <- submit issuer do @@ -158,11 +123,8 @@ mintToOtherTokenETF = script do instrumentId = etfInstrumentId -- Issuer creates portfolio composition - let portfolioItems = - [ PortfolioItem with instrumentId = instrumentIdFull1; weight = 1.0 - , PortfolioItem with instrumentId = instrumentIdFull2; weight = 1.0 - , PortfolioItem with instrumentId = instrumentIdFull3; weight = 1.0 - ] + let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) + [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] portfolioCid <- submit issuer do createCmd PortfolioComposition.PortfolioComposition with @@ -179,73 +141,33 @@ mintToOtherTokenETF = script do composition = portfolioCid -- Mint all three tokens - mintRequest1Cid <- submit alice do - createCmd IssuerMintRequest with - tokenFactoryCid = infra1.tokenFactoryCid - issuer - receiver = alice - amount = 1.0 - - mintRequest2Cid <- submit alice do - createCmd IssuerMintRequest with - tokenFactoryCid = infra2.tokenFactoryCid - issuer - receiver = alice - amount = 1.0 - - mintRequest3Cid <- submit alice do - createCmd IssuerMintRequest with - tokenFactoryCid = infra3.tokenFactoryCid - issuer - receiver = alice - amount = 1.0 - - token1Cid <- submit issuer do - mintResult <- exerciseCmd mintRequest1Cid IssuerMintRequest.Accept - - pure (toInterfaceContractId @H.Holding mintResult.tokenCid) - - token2Cid <- submit issuer do - mintResult <- exerciseCmd mintRequest2Cid IssuerMintRequest.Accept - - pure (toInterfaceContractId @H.Holding mintResult.tokenCid) - - token3Cid <- submit issuer do - mintResult <- exerciseCmd mintRequest3Cid IssuerMintRequest.Accept - - pure (toInterfaceContractId @H.Holding mintResult.tokenCid) + 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) - tokenTransferRequest1Cid <- createTransferRequest - alice issuer issuer 1.0 requestedAtPast future - infra1.transferFactoryCid token1Cid - - tokenTransferResult1 <- submit issuer do - exerciseCmd tokenTransferRequest1Cid TransferRequest.Accept - - let transferInstruction1Cid = tokenTransferResult1.output.transferInstructionCid - - tokenTransferRequest2Cid <- createTransferRequest + tokenTransferRequests <- forA (zip infras tokenCids) \(infra, tokenCid) -> createTransferRequest alice issuer issuer 1.0 requestedAtPast future - infra2.transferFactoryCid token2Cid + infra.transferFactoryCid tokenCid - tokenTransferResult2 <- submit issuer do - exerciseCmd tokenTransferRequest2Cid TransferRequest.Accept + tokenTransferResults <- submit issuer do + forA tokenTransferRequests $ \tokenTransferRequestCid -> + exerciseCmd tokenTransferRequestCid TransferRequest.Accept - let transferInstruction2Cid = tokenTransferResult2.output.transferInstructionCid - - tokenTransferRequest3Cid <- createTransferRequest - alice issuer issuer 1.0 requestedAtPast future - infra3.transferFactoryCid token3Cid - - tokenTransferResult3 <- submit issuer do - exerciseCmd tokenTransferRequest3Cid TransferRequest.Accept - - let transferInstruction3Cid = tokenTransferResult3.output.transferInstructionCid + 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 @@ -253,11 +175,10 @@ mintToOtherTokenETF = script do mintRecipeCid requester = alice amount = 1.0 - transferInstructionCids = [transferInstruction1Cid, transferInstruction2Cid, transferInstruction3Cid] + transferInstructionCids issuer etfCid <- submit issuer do exerciseCmd etfMintRequestCid MyMintRequest.MintRequest_Accept pure () - 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 039ebb0..f9b64e9 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 = - "e2037d891555c6675e2c7a951fcbbf31894f5540a74da7c5d6f4a719c1bae6d4"; + "194b49c286b92ac50948d93d26ae0b97188c6f91d98db92859880ce83468d0d5"; From 90fd81cdde4acabef22b3f747bf2f4208b8fcd08 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Wed, 17 Dec 2025 18:27:11 +0100 Subject: [PATCH 08/13] Implement etf burn, refactor token factory burn result --- .../minimal-token/daml/ETF/MyBurnRequest.daml | 82 +++++++++++ .../minimal-token/daml/ETF/MyMintRequest.daml | 2 - .../minimal-token/daml/ETF/Test/ETFTest.daml | 134 +++++++++++++++++- .../minimal-token/daml/MyTokenFactory.daml | 9 +- .../src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 2 +- 5 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 packages/minimal-token/daml/ETF/MyBurnRequest.daml diff --git a/packages/minimal-token/daml/ETF/MyBurnRequest.daml b/packages/minimal-token/daml/ETF/MyBurnRequest.daml new file mode 100644 index 0000000..3996fed --- /dev/null +++ b/packages/minimal-token/daml/ETF/MyBurnRequest.daml @@ -0,0 +1,82 @@ +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 +import MyTokenFactory + +template MyBurnRequest + with + mintRecipeCid: ContractId MyMintRecipe + -- ^ The mint recipe to be used for burning + requester: Party + -- ^ The party requesting the burning + amount: Decimal + tokenFactoryCid: ContractId MyTokenFactory + -- ^ 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) + + 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 tokenFactoryCid MyTokenFactory.Burn with + owner = requester + amount = amount + inputHoldingCid = inputHoldingCid + + + choice BurnRequest_Decline : () + -- ^ Decline the request. + controller issuer + do + pure () + + choice BurnRequest_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 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/ETF/MyMintRequest.daml b/packages/minimal-token/daml/ETF/MyMintRequest.daml index dc5ccb5..f007a53 100644 --- a/packages/minimal-token/daml/ETF/MyMintRequest.daml +++ b/packages/minimal-token/daml/ETF/MyMintRequest.daml @@ -1,7 +1,6 @@ module ETF.MyMintRequest where import DA.Foldable (forA_) --- import qualified DA.Foldable as F (length) import Splice.Api.Token.MetadataV1 as MD import Splice.Api.Token.TransferInstructionV1 as TI @@ -36,7 +35,6 @@ template MyMintRequest mintRecipe <- fetch mintRecipeCid portfolioComp <- fetch (mintRecipe.composition) - -- TODO: validate transfer instructions match portfolio composition assertMsg "Transfer instructions must be of same length as portfolio composition items" $ (length transferInstructionCids) == (length portfolioComp.items) forA_ (zip transferInstructionCids portfolioComp.items) $ diff --git a/packages/minimal-token/daml/ETF/Test/ETFTest.daml b/packages/minimal-token/daml/ETF/Test/ETFTest.daml index 08b6cf5..3b9c9f9 100644 --- a/packages/minimal-token/daml/ETF/Test/ETFTest.daml +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -4,14 +4,17 @@ module ETF.Test.ETFTest where import Daml.Script import DA.Time -import Test.TestUtils (createTransferRequest, setupTokenInfrastructureWithInstrumentId) -import MyTokenFactory -import ETF.PortfolioComposition as PortfolioComposition +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 ETF.MyMintRequest as MyMintRequest -import Splice.Api.Token.HoldingV1 as H +import Test.TestUtils (createTransferRequest, setupTokenInfrastructureWithInstrumentId) mintToSelfTokenETF : Script () mintToSelfTokenETF = script do @@ -182,3 +185,124 @@ mintToOtherTokenETF = script 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 + + -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe + etfFactoryCid <- submit issuer do + createCmd MyTokenFactory with + issuer + instrumentId = etfInstrumentId + + -- 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 + tokenFactory = etfFactoryCid + 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 + tokenFactoryCid = etfFactoryCid + 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/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts b/packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts index f9b64e9..b09f987 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 = - "194b49c286b92ac50948d93d26ae0b97188c6f91d98db92859880ce83468d0d5"; + "df79b775ca53cf4edb3f80f1ed5077b79af27332b90360b60ef10a0247f69e2e"; From 5e2e0d060dc6c94e6b4a4d43b298561008069157 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Wed, 17 Dec 2025 20:01:27 +0100 Subject: [PATCH 09/13] Add ETF burn methods to wrapped sdk, and create test script --- packages/minimal-token/CLAUDE.md | 27 +- packages/token-sdk/CLAUDE.md | 78 ++ .../token-sdk/src/constants/templateIds.ts | 2 + packages/token-sdk/src/testScripts/etfBurn.ts | 783 ++++++++++++++++++ .../src/wrappedSdk/etf/burnRequest.ts | 203 +++++ .../token-sdk/src/wrappedSdk/etf/index.ts | 1 + .../token-sdk/src/wrappedSdk/wrappedSdk.ts | 56 ++ 7 files changed, 1149 insertions(+), 1 deletion(-) create mode 100644 packages/token-sdk/src/testScripts/etfBurn.ts create mode 100644 packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts diff --git a/packages/minimal-token/CLAUDE.md b/packages/minimal-token/CLAUDE.md index bf668f6..4bbadec 100644 --- a/packages/minimal-token/CLAUDE.md +++ b/packages/minimal-token/CLAUDE.md @@ -137,6 +137,20 @@ The implementation includes Exchange-Traded Fund (ETF) contracts that enable min - `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 @@ -149,7 +163,16 @@ The implementation includes Exchange-Traded Fund (ETF) contracts that enable min - Executes all transfer instructions (custody of underlying assets to issuer) - Mints ETF tokens to requester -This pattern ensures ETF tokens are always backed by the correct underlying assets in issuer custody. +**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 @@ -164,6 +187,7 @@ Examples: - `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 @@ -250,6 +274,7 @@ Common helpers to reduce test duplication: ### `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 diff --git a/packages/token-sdk/CLAUDE.md b/packages/token-sdk/CLAUDE.md index 2310f8e..9086abb 100644 --- a/packages/token-sdk/CLAUDE.md +++ b/packages/token-sdk/CLAUDE.md @@ -40,6 +40,7 @@ Before running this SDK: - `tsx src/testScripts/transferWithPreapproval.ts` - Transfer with preapproval pattern - `tsx src/testScripts/bondLifecycleTest.ts` - Complete bond lifecycle demonstration (mint, coupon, transfer, redemption) - `tsx src/testScripts/etfMint.ts` - ETF minting demonstration following mintToOtherTokenETF pattern +- `tsx src/testScripts/etfBurn.ts` - ETF burning demonstration following burnTokenETF pattern (complete mint-burn cycle) ### Other Commands - `pnpm clean` - Remove build artifacts @@ -134,6 +135,7 @@ All helpers follow a consistent pattern with single-party perspective and templa - `portfolioCompositionTemplateId`: `#minimal-token:ETF.PortfolioComposition:PortfolioComposition` - `mintRecipeTemplateId`: `#minimal-token:ETF.MyMintRecipe:MyMintRecipe` - `etfMintRequestTemplateId`: `#minimal-token:ETF.MyMintRequest:MyMintRequest` + - `etfBurnRequestTemplateId`: `#minimal-token:ETF.MyBurnRequest:MyBurnRequest` ### Canton Ledger Interaction Pattern @@ -766,6 +768,82 @@ await issuerWrappedSdk.etf.mintRequest.accept(etfMintRequestCid); - `declineEtfMintRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer declines - `withdrawEtfMintRequest(requesterLedger, requesterKeyPair, contractId)` - Requester withdraws +**`etf/burnRequest.ts`** - ETF burn request/accept pattern +- `createEtfBurnRequest(requesterLedger, requesterKeyPair, params)` - Requester creates burn request +- `getLatestEtfBurnRequest(userLedger, issuer)` - Query latest +- `getAllEtfBurnRequests(userLedger, issuer)` - Query all for issuer +- `acceptEtfBurnRequest(issuerLedger, issuerKeyPair, contractId, transferInstructionCids)` - **CRITICAL**: Issuer validates and burns with transfer instruction CIDs +- `declineEtfBurnRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer declines +- `withdrawEtfBurnRequest(requesterLedger, requesterKeyPair, contractId)` - Requester withdraws + +**Key Difference**: Unlike `acceptEtfMintRequest()`, the `acceptEtfBurnRequest()` function MUST include a `transferInstructionCids` array parameter to return underlying assets to the requester. + +#### ETF Burning Workflow + +**Phase 1: ETF Token Holder Creates Transfer Requests** (Issuer → Holder) +```typescript +// Issuer creates transfer requests for underlying assets back to holder +const returnTransfer1 = buildTransfer({ + sender: issuer.partyId, + receiver: holder.partyId, + amount: 1.0, + instrumentId: { admin: issuer.partyId, id: instrumentId1 }, + requestedAt: new Date(Date.now() - 1000), + executeBefore: new Date(Date.now() + 3600000), + inputHoldingCids: [issuerToken1Cid], +}); + +await issuerWrappedSdk.transferRequest.create({ + transferFactoryCid: transferFactory1Cid, + expectedAdmin: issuer.partyId, + transfer: returnTransfer1, + extraArgs: emptyExtraArgs(), +}); + +const returnTransferRequest1Cid = await issuerWrappedSdk.transferRequest.getLatest(issuer.partyId); +// Repeat for token 2 and 3... +``` + +**Phase 2: Issuer Accepts Transfer Requests** (Creates Transfer Instructions) +```typescript +// **CRITICAL**: Capture each transfer instruction CID immediately after each accept +await issuerWrappedSdk.transferRequest.accept(returnTransferRequest1Cid); +const returnTransferInstruction1Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); + +await issuerWrappedSdk.transferRequest.accept(returnTransferRequest2Cid); +const returnTransferInstruction2Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); + +await issuerWrappedSdk.transferRequest.accept(returnTransferRequest3Cid); +const returnTransferInstruction3Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); +``` + +**Phase 3: Holder Creates ETF Burn Request** +```typescript +await holderWrappedSdk.etf.burnRequest.create({ + mintRecipeCid, + requester: holder.partyId, + amount: 1.0, + tokenFactoryCid: etfTokenFactoryCid, + inputHoldingCid: etfTokenCid, + issuer: issuer.partyId, +}); + +const etfBurnRequestCid = await holderWrappedSdk.etf.burnRequest.getLatest(issuer.partyId); +``` + +**Phase 4: Issuer Accepts Burn Request with Transfer Instruction CIDs** +```typescript +await issuerWrappedSdk.etf.burnRequest.accept( + etfBurnRequestCid, + [returnTransferInstruction1Cid, returnTransferInstruction2Cid, returnTransferInstruction3Cid] +); + +// Result: +// - Validates all 3 return transfer instructions match portfolio composition +// - Executes all 3 transfer instructions (underlying assets → holder custody) +// - Burns 1.0 ETF tokens from holder +``` + #### Key ETF Patterns 1. **Authorization via authorizedMinters List**: MyMintRecipe maintains a list of parties authorized to create mint requests, enabling flexible minting access control diff --git a/packages/token-sdk/src/constants/templateIds.ts b/packages/token-sdk/src/constants/templateIds.ts index 261f522..1ba4c8b 100644 --- a/packages/token-sdk/src/constants/templateIds.ts +++ b/packages/token-sdk/src/constants/templateIds.ts @@ -39,3 +39,5 @@ 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..2bb067e --- /dev/null +++ b/packages/token-sdk/src/testScripts/etfBurn.ts @@ -0,0 +1,783 @@ +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}`); + + // Create ETF token factory + const etfTokenFactoryCid = await charlieWrappedSdk.tokenFactory.getOrCreate( + etfInstrumentId + ); + console.info(`✓ ETF token factory created: ${etfTokenFactoryCid}\n`); + + // === 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, + tokenFactory: etfTokenFactoryCid, + 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, + tokenFactoryCid: etfTokenFactoryCid, + 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/wrappedSdk/etf/burnRequest.ts b/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts new file mode 100644 index 0000000..498fa9f --- /dev/null +++ b/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts @@ -0,0 +1,203 @@ +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; + tokenFactoryCid: ContractId; + 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 index 18a424e..e874f38 100644 --- a/packages/token-sdk/src/wrappedSdk/etf/index.ts +++ b/packages/token-sdk/src/wrappedSdk/etf/index.ts @@ -1,3 +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/wrappedSdk.ts b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts index a24deac..e3d2f2c 100644 --- a/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts +++ b/packages/token-sdk/src/wrappedSdk/wrappedSdk.ts @@ -181,6 +181,15 @@ import { 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) { @@ -704,6 +713,31 @@ export const getWrappedSdk = (sdk: WalletSDK) => { 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), + }, }, }; }; @@ -1182,6 +1216,28 @@ export const getWrappedSdkWithKeyPair = ( 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), + }, }, }; }; From 4273694a6b50331099dde92921ec0973f9a901c5 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Thu, 18 Dec 2025 16:23:19 +0100 Subject: [PATCH 10/13] Add authorized minter check in mint request --- packages/minimal-token/daml/ETF/MyMintRequest.daml | 2 ++ packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/minimal-token/daml/ETF/MyMintRequest.daml b/packages/minimal-token/daml/ETF/MyMintRequest.daml index f007a53..dd316b6 100644 --- a/packages/minimal-token/daml/ETF/MyMintRequest.daml +++ b/packages/minimal-token/daml/ETF/MyMintRequest.daml @@ -35,6 +35,8 @@ template MyMintRequest 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) $ 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 b09f987..28b7151 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 = - "df79b775ca53cf4edb3f80f1ed5077b79af27332b90360b60ef10a0247f69e2e"; + "01d0086cad4e032d62529294a98cc273334c97e0f7422b937caaeeeb680a0927"; From a9b0c0f6c2c4b7bbc4f21974b3436d52baf491b3 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Thu, 18 Dec 2025 17:43:11 +0100 Subject: [PATCH 11/13] Remove token factory from the mint recipe --- packages/minimal-token/daml/ETF/MyMintRecipe.daml | 2 -- packages/minimal-token/daml/ETF/Test/ETFTest.daml | 3 --- packages/token-sdk/src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 2 +- packages/token-sdk/src/testScripts/etfBurn.ts | 1 - packages/token-sdk/src/testScripts/etfMint.ts | 1 - packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts | 1 - 6 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/minimal-token/daml/ETF/MyMintRecipe.daml b/packages/minimal-token/daml/ETF/MyMintRecipe.daml index 0723780..71f3e58 100644 --- a/packages/minimal-token/daml/ETF/MyMintRecipe.daml +++ b/packages/minimal-token/daml/ETF/MyMintRecipe.daml @@ -1,7 +1,6 @@ module ETF.MyMintRecipe where import MyToken -import MyTokenFactory import ETF.PortfolioComposition -- | MyMintRecipe defines how to mint MyToken tokens based on a portfolio 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/Test/ETFTest.daml b/packages/minimal-token/daml/ETF/Test/ETFTest.daml index 3b9c9f9..ddd9e00 100644 --- a/packages/minimal-token/daml/ETF/Test/ETFTest.daml +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -55,7 +55,6 @@ mintToSelfTokenETF = script do createCmd MyMintRecipe with issuer instrumentId = etfInstrumentId - tokenFactory = etfFactoryCid authorizedMinters = [issuer] composition = portfolioCid @@ -139,7 +138,6 @@ mintToOtherTokenETF = script do createCmd MyMintRecipe with issuer instrumentId = etfInstrumentId - tokenFactory = etfFactoryCid authorizedMinters = [issuer, alice] composition = portfolioCid @@ -226,7 +224,6 @@ burnTokenETF = script do createCmd MyMintRecipe with issuer instrumentId = etfInstrumentId - tokenFactory = etfFactoryCid authorizedMinters = [issuer, alice] composition = portfolioCid 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 28b7151..025b00e 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 = - "01d0086cad4e032d62529294a98cc273334c97e0f7422b937caaeeeb680a0927"; + "82b8f05a2fa894ceb22df774e3e22222560b7de9739a382562687cdbfbbe0adf"; diff --git a/packages/token-sdk/src/testScripts/etfBurn.ts b/packages/token-sdk/src/testScripts/etfBurn.ts index 2bb067e..25f4d69 100644 --- a/packages/token-sdk/src/testScripts/etfBurn.ts +++ b/packages/token-sdk/src/testScripts/etfBurn.ts @@ -179,7 +179,6 @@ async function etfBurn() { await charlieWrappedSdk.etf.mintRecipe.create({ issuer: charlieAllocatedParty.partyId, instrumentId: etfInstrumentId, - tokenFactory: etfTokenFactoryCid, authorizedMinters: [ charlieAllocatedParty.partyId, aliceAllocatedParty.partyId, diff --git a/packages/token-sdk/src/testScripts/etfMint.ts b/packages/token-sdk/src/testScripts/etfMint.ts index ac3e842..7c3ecc0 100644 --- a/packages/token-sdk/src/testScripts/etfMint.ts +++ b/packages/token-sdk/src/testScripts/etfMint.ts @@ -174,7 +174,6 @@ async function etfMint() { await charlieWrappedSdk.etf.mintRecipe.create({ issuer: charlieAllocatedParty.partyId, instrumentId: etfInstrumentId, - tokenFactory: etfTokenFactoryCid, authorizedMinters: [ charlieAllocatedParty.partyId, aliceAllocatedParty.partyId, diff --git a/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts b/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts index 4c84ebf..3642251 100644 --- a/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts +++ b/packages/token-sdk/src/wrappedSdk/etf/mintRecipe.ts @@ -11,7 +11,6 @@ import { PortfolioItem } from "./portfolioComposition.js"; export interface MintRecipeParams { issuer: Party; instrumentId: string; - tokenFactory: ContractId; authorizedMinters: Party[]; composition: ContractId; } From 1c8c569ca7b8fda710444f915413a6d9c66a9613 Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Fri, 19 Dec 2025 10:48:09 +0100 Subject: [PATCH 12/13] Remove token factory dependency on etf burn --- .../minimal-token/daml/ETF/MyBurnRequest.daml | 40 ++++++++++++++++--- .../minimal-token/daml/ETF/Test/ETFTest.daml | 19 --------- .../src/constants/MINIMAL_TOKEN_PACKAGE_ID.ts | 2 +- packages/token-sdk/src/testScripts/etfBurn.ts | 7 ---- packages/token-sdk/src/testScripts/etfMint.ts | 6 --- .../src/wrappedSdk/etf/burnRequest.ts | 1 - 6 files changed, 35 insertions(+), 40 deletions(-) diff --git a/packages/minimal-token/daml/ETF/MyBurnRequest.daml b/packages/minimal-token/daml/ETF/MyBurnRequest.daml index 3996fed..0566dd4 100644 --- a/packages/minimal-token/daml/ETF/MyBurnRequest.daml +++ b/packages/minimal-token/daml/ETF/MyBurnRequest.daml @@ -8,7 +8,6 @@ import Splice.Api.Token.MetadataV1 as MD import ETF.MyMintRecipe import ETF.PortfolioComposition (PortfolioItem) import MyToken -import MyTokenFactory template MyBurnRequest with @@ -17,7 +16,6 @@ template MyBurnRequest requester: Party -- ^ The party requesting the burning amount: Decimal - tokenFactoryCid: ContractId MyTokenFactory -- ^ The amount of ETF tokens to burn inputHoldingCid: ContractId MyToken -- ^ The token holding to use as input. Will be split if needed. @@ -41,6 +39,13 @@ template MyBurnRequest 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: @@ -51,10 +56,26 @@ template MyBurnRequest context = MD.emptyChoiceContext meta = MD.emptyMetadata - exercise tokenFactoryCid MyTokenFactory.Burn with - owner = requester - amount = amount - inputHoldingCid = inputHoldingCid + + -- 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 : () @@ -69,6 +90,13 @@ template MyBurnRequest 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 diff --git a/packages/minimal-token/daml/ETF/Test/ETFTest.daml b/packages/minimal-token/daml/ETF/Test/ETFTest.daml index ddd9e00..42fd861 100644 --- a/packages/minimal-token/daml/ETF/Test/ETFTest.daml +++ b/packages/minimal-token/daml/ETF/Test/ETFTest.daml @@ -35,12 +35,6 @@ mintToSelfTokenETF = script do infras <- forA [instrumentId1, instrumentId2, instrumentId3] $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId - -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe - etfFactoryCid <- submit issuer do - createCmd MyTokenFactory with - issuer - instrumentId = etfInstrumentId - -- Issuer creates portfolio composition let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] @@ -118,12 +112,6 @@ mintToOtherTokenETF = script do infras <- forA [instrumentId1, instrumentId2, instrumentId3] $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId - -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe - etfFactoryCid <- submit issuer do - createCmd MyTokenFactory with - issuer - instrumentId = etfInstrumentId - -- Issuer creates portfolio composition let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] @@ -204,12 +192,6 @@ burnTokenETF = script do infras <- forA [instrumentId1, instrumentId2, instrumentId3] $ \instId -> setupTokenInfrastructureWithInstrumentId issuer instId - -- NOTE: maybe we shouldn't use a factory for the ETF since you should only mint via the mint recipe - etfFactoryCid <- submit issuer do - createCmd MyTokenFactory with - issuer - instrumentId = etfInstrumentId - -- Issuer creates portfolio composition let portfolioItems = map (\id -> PortfolioItem with instrumentId = id; weight = 1.0) [instrumentIdFull1, instrumentIdFull2, instrumentIdFull3] @@ -274,7 +256,6 @@ burnTokenETF = script do createCmd MyBurnRequest with mintRecipeCid requester = alice - tokenFactoryCid = etfFactoryCid issuer amount = 1.0 inputHoldingCid = etfCid 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 025b00e..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 = - "82b8f05a2fa894ceb22df774e3e22222560b7de9739a382562687cdbfbbe0adf"; + "b13bc87eaf3d6574b064bd61e38804caff038704b2097aa63962867602b9a0b6"; diff --git a/packages/token-sdk/src/testScripts/etfBurn.ts b/packages/token-sdk/src/testScripts/etfBurn.ts index 25f4d69..0e283ff 100644 --- a/packages/token-sdk/src/testScripts/etfBurn.ts +++ b/packages/token-sdk/src/testScripts/etfBurn.ts @@ -125,12 +125,6 @@ async function etfBurn() { await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); console.info(`✓ Transfer factory 3 created: ${transferFactory3Cid}`); - // Create ETF token factory - const etfTokenFactoryCid = await charlieWrappedSdk.tokenFactory.getOrCreate( - etfInstrumentId - ); - console.info(`✓ ETF token factory created: ${etfTokenFactoryCid}\n`); - // === PHASE 3: PORTFOLIO COMPOSITION CREATION === console.info("3. Creating portfolio composition..."); @@ -658,7 +652,6 @@ async function etfBurn() { mintRecipeCid, requester: aliceAllocatedParty.partyId, amount: 1.0, - tokenFactoryCid: etfTokenFactoryCid, inputHoldingCid: etfTokenCid, issuer: charlieAllocatedParty.partyId, }); diff --git a/packages/token-sdk/src/testScripts/etfMint.ts b/packages/token-sdk/src/testScripts/etfMint.ts index 7c3ecc0..6f1d4ce 100644 --- a/packages/token-sdk/src/testScripts/etfMint.ts +++ b/packages/token-sdk/src/testScripts/etfMint.ts @@ -120,12 +120,6 @@ async function etfMint() { await charlieWrappedSdk.transferFactory.getOrCreate(rulesCid); console.info(`✓ Transfer factory 3 created: ${transferFactory3Cid}`); - // Create ETF token factory - const etfTokenFactoryCid = await charlieWrappedSdk.tokenFactory.getOrCreate( - etfInstrumentId - ); - console.info(`✓ ETF token factory created: ${etfTokenFactoryCid}\n`); - // === PHASE 3: PORTFOLIO COMPOSITION CREATION === console.info("3. Creating portfolio composition..."); diff --git a/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts b/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts index 498fa9f..7a5f589 100644 --- a/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts +++ b/packages/token-sdk/src/wrappedSdk/etf/burnRequest.ts @@ -11,7 +11,6 @@ export interface EtfBurnRequestParams { mintRecipeCid: ContractId; requester: Party; amount: number; - tokenFactoryCid: ContractId; inputHoldingCid: ContractId; issuer: Party; } From be560da55a4ef04a8ccd749463e4bbd03407619e Mon Sep 17 00:00:00 2001 From: Oscar Baracos Date: Fri, 19 Dec 2025 15:55:04 +0100 Subject: [PATCH 13/13] Update claude docs, and etf improvements file to minimal token --- packages/minimal-token/CLAUDE.md | 10 +- packages/minimal-token/ETF_IMPROVEMENTS.md | 1180 ++++++++++++++++++ packages/token-sdk/CLAUDE.md | 1272 +++----------------- 3 files changed, 1343 insertions(+), 1119 deletions(-) create mode 100644 packages/minimal-token/ETF_IMPROVEMENTS.md diff --git a/packages/minimal-token/CLAUDE.md b/packages/minimal-token/CLAUDE.md index 4bbadec..e0ba40f 100644 --- a/packages/minimal-token/CLAUDE.md +++ b/packages/minimal-token/CLAUDE.md @@ -114,11 +114,12 @@ The implementation includes Exchange-Traded Fund (ETF) contracts that enable min **MyMintRecipe** (`daml/ETF/MyMintRecipe.daml`) - Defines how to mint ETF tokens based on a portfolio composition -- References a `PortfolioComposition` contract and `MyTokenFactory` +- 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` - Mint ETF tokens (called by MyMintRequest after validation) + - `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 @@ -153,7 +154,7 @@ The implementation includes Exchange-Traded Fund (ETF) contracts that enable min **ETF Minting Workflow:** 1. Issuer creates `PortfolioComposition` defining underlying assets and weights -2. Issuer creates `MyMintRecipe` referencing the portfolio and authorizing minters +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 @@ -161,7 +162,8 @@ The implementation includes Exchange-Traded Fund (ETF) contracts that enable min 7. Issuer accepts `MyMintRequest`, which: - Validates transfer instructions match portfolio composition - Executes all transfer instructions (custody of underlying assets to issuer) - - Mints ETF tokens to requester + - 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) 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/token-sdk/CLAUDE.md b/packages/token-sdk/CLAUDE.md index 9086abb..c5a2e09 100644 --- a/packages/token-sdk/CLAUDE.md +++ b/packages/token-sdk/CLAUDE.md @@ -1,1200 +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 - -- **[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. - -## Prerequisites - -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` - -## Development Commands - -### 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 - -### Testing -- `pnpm test` - Run tests once (CI mode) -- `pnpm test:watch` - Run tests in watch mode - -### Linting -- `pnpm lint` - Check for linting errors -- `pnpm lint:fix` - Auto-fix linting errors - -### 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) -- `tsx src/testScripts/etfMint.ts` - ETF minting demonstration following mintToOtherTokenETF pattern -- `tsx src/testScripts/etfBurn.ts` - ETF burning demonstration following burnTokenETF pattern (complete mint-burn cycle) +**Build**: `pnpm build` | `pnpm test` | `pnpm lint:fix` -### 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 +**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 ## Architecture -### 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 +### SDK Wrappers (`src/wrappedSdk/`) -**`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 +All wrappers follow CRUD pattern: `create`, `getLatest`, `getOrCreate`, `accept`/`decline`/`withdraw`. -**`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 +**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 -**`transferPreapproval.ts` / `transferPreapprovalProposal.ts`** - Transfer preapproval patterns -- Support for preapproved transfer workflows +**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 -**Bond Operations (`src/wrappedSdk/bonds/`)** -- Comprehensive bond instrument support with 8 wrapper modules -- See dedicated Bond Operations section below for complete documentation +**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) -**ETF Operations (`src/wrappedSdk/etf/`)** -- Exchange-Traded Fund token support with 3 wrapper modules (portfolioComposition, mintRecipe, mintRequest) -- See dedicated ETF Operations section below for complete documentation +### Key Patterns -**`wrappedSdk.ts`** - SDK wrapper convenience functions -- `getWrappedSdkWithKeyPair()` - Create wrapped SDK with key pair +**Request/Accept**: Sender creates request → Admin accepts → Operation completes (avoids multi-party signing complexity) -**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` +**Disclosure**: Use `getTransferInstructionDisclosure()` for receiver visibility (required for three-party transfers) -**Template ID Constants (`src/constants/templateIds.ts`)** -- Centralized template ID definitions -- Template IDs are prefixed with `#minimal-token:` (e.g., `#minimal-token:MyTokenFactory:MyTokenFactory`) -- **ETF Template IDs** (added for ETF support): - - `portfolioCompositionTemplateId`: `#minimal-token:ETF.PortfolioComposition:PortfolioComposition` - - `mintRecipeTemplateId`: `#minimal-token:ETF.MyMintRecipe:MyMintRecipe` - - `etfMintRequestTemplateId`: `#minimal-token:ETF.MyMintRequest:MyMintRequest` - - `etfBurnRequestTemplateId`: `#minimal-token:ETF.MyBurnRequest:MyBurnRequest` +**Template IDs**: Centralized in `src/constants/templateIds.ts` with `#minimal-token:` prefix -### Canton Ledger Interaction Pattern +## Token Operations -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); -``` - -**Minting Phase (Two-step: Alice proposes, Charlie accepts):** +### Basic Minting (Two-Step Pattern) ```typescript -// Step 1: Alice creates mint request -await createIssuerMintRequest(aliceLedger, aliceKeyPair, { - tokenFactoryCid, - issuer: charlie, - 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); -``` - -**Transfer Request Phase (Alice proposes transfer to Bob):** -```typescript -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], -}); - -await createTransferRequest(aliceLedger, aliceKeyPair, { - transferFactoryCid, - expectedAdmin: charlie, - transfer, - extraArgs: emptyExtraArgs(), -}); -``` - -**Approval Phase (Charlie accepts, locks tokens):** -```typescript -const requestCid = await getLatestTransferRequest(aliceLedger, charlie); -await acceptTransferRequest(charlieLedger, charlieKeyPair, requestCid); -// Creates MyTransferInstruction with locked tokens -``` - -**Disclosure Phase (Charlie provides disclosure to Bob):** -```typescript -const disclosure = await getTransferInstructionDisclosure( - charlieLedger, - transferInstructionCid -); -// Returns: { lockedTokenDisclosure, transferInstruction } -``` - -**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 - -### Bond Operations - -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 }, -}); -``` - -**Phase 2: Bond Minting (Receiver proposes, Issuer accepts)** -```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(), +// 1. Receiver creates mint request +await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid, issuer, receiver: alice, amount: 100 }); -// Issuer accepts mint request -const mintRequestCid = await aliceWrappedSdk.bonds.issuerMintRequest.getLatest(charlie.partyId); -await charlieWrappedSdk.bonds.issuerMintRequest.accept(mintRequestCid); +// 2. Issuer accepts +const requestCid = await issuerWrappedSdk.issuerMintRequest.getLatest(alice); +await issuerWrappedSdk.issuerMintRequest.accept(requestCid); ``` -**Phase 3: Coupon Payment (Issuer processes, Holder claims)** +### Transfer Pattern ```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 - } -); - -// Get the effect and new version -const { contractId: effectCid, producedVersion } = - await charlieWrappedSdk.bonds.lifecycleEffect.getLatest(charlie.partyId); +// 1. Setup infrastructure (issuer) +const rulesCid = await issuerWrappedSdk.tokenRules.getOrCreate(); +const transferFactoryCid = await issuerWrappedSdk.transferFactory.getOrCreate(rulesCid); -// Holder creates claim request -await aliceWrappedSdk.bonds.lifecycleClaimRequest.create({ - effectCid, - bondHoldingCid: aliceBondCid, - bondRulesCid, - bondFactoryCid, - currencyTransferFactoryCid, - issuerCurrencyHoldingCid: currencyHolding1, - holder: alice.partyId, - issuer: charlie.partyId, +// 2. Sender creates transfer request +const transfer = buildTransfer({ + 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 }); -// Issuer accepts claim (creates instruction + currency transfer) -const claimCid = await aliceWrappedSdk.bonds.lifecycleClaimRequest.getLatest(charlie.partyId); -await charlieWrappedSdk.bonds.lifecycleClaimRequest.accept(claimCid); - -// 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]); -} +// 3. Issuer accepts (locks tokens, creates instruction) +const requestCid = await issuerWrappedSdk.transferRequest.getLatest(alice); +await issuerWrappedSdk.transferRequest.accept(requestCid); -// Result: Alice receives coupon payment (3 bonds × $25 = $75) and has new bond version +// 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); ``` -**Phase 4: Bond Transfer (Sender proposes, Admin accepts, Receiver accepts)** -```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(), -}); +## Bond Operations -// 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); +**Architecture**: Fungible bonds (single contract holds multiple units), per-unit coupon payments, version tracking, ledger time security. -// 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]); +**Lifecycle**: Mint → Coupon payments → Transfers → Redemption -// Result: Bob has 1 bond, Alice has 2 bonds (as change) -``` +**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 5: Redemption at Maturity (Issuer processes, Holder claims)** +### Bond Workflow Summary ```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, -}); - -// Issuer accepts claim -const claimCid2 = await bobWrappedSdk.bonds.lifecycleClaimRequest.getLatest(charlie.partyId); -await charlieWrappedSdk.bonds.lifecycleClaimRequest.accept(claimCid2); - -// Bob processes instruction (no disclosure needed for redemption) -const instructionCid2 = await charlieWrappedSdk.bonds.lifecycleInstruction.getLatest(charlie.partyId); -await bobWrappedSdk.bonds.lifecycleInstruction.process(instructionCid2); - -// 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 +// 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 ``` -#### 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 +See `src/testScripts/bondLifecycleTest.ts` for complete implementation. -7. **Version Tracking**: Bonds have version strings that increment with each coupon payment, preventing double-claiming of lifecycle events +## ETF Operations -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 - -### ETF Operations - -The SDK provides comprehensive support for Exchange-Traded Fund (ETF) tokens backed by underlying assets. The ETF implementation demonstrates composite token creation with atomic asset backing validation. - -#### ETF Architecture Overview - -**Purpose**: ETFs are composite tokens backed by a basket of underlying assets held in issuer custody. - -**Three-Contract Structure**: -1. **PortfolioComposition** - Defines the basket of underlying assets with weights -2. **MyMintRecipe** - Defines minting rules and authorized minters -3. **MyMintRequest** - Request/accept pattern for ETF minting with validation - -**Validation Pattern**: ETF minting validates that all underlying assets are transferred to issuer custody before minting the ETF token, ensuring proper backing. - -#### ETF Components - -**PortfolioComposition** (`etf/portfolioComposition.ts`): -- Data-only contract defining asset basket -- Fields: `owner: Party`, `name: string`, `items: PortfolioItem[]` -- PortfolioItem: `instrumentId: { admin, id }`, `weight: number` -- Reusable across multiple ETF mint recipes -- No choices (pure data contract) - -**MyMintRecipe** (`etf/mintRecipe.ts`): -- Defines ETF minting rules and authorization -- Fields: `issuer`, `instrumentId`, `tokenFactory`, `authorizedMinters: Party[]`, `composition: ContractId` -- Choices: - - `MyMintRecipe_AddAuthorizedMinter(newMinter)` - Add minter to authorized list - - `MyMintRecipe_RemoveAuthorizedMinter(minterToRemove)` - Remove minter from authorized list - - `MyMintRecipe_UpdateComposition(newComposition)` - Update portfolio reference - - `MyMintRecipe_CreateAndUpdateComposition(newCompositionItems, compositionName, archiveOld)` - Create new portfolio and update reference - - `MyMintRecipe_Mint(receiver, amount)` - Internal mint choice (called by MintRequest_Accept) -- Observers: authorizedMinters (allows authorized parties to create mint requests) - -**MyMintRequest** (`etf/mintRequest.ts`): -- Request pattern for ETF minting -- Fields: `mintRecipeCid`, `requester`, `amount`, `transferInstructionCids: ContractId[]`, `issuer` -- Validation ensures: - - Transfer instruction count matches portfolio item count - - Each transfer sender matches requester - - Each transfer receiver matches issuer - - Each transfer instrumentId matches portfolio item - - Each transfer amount equals `portfolioItem.weight × ETF amount` -- Choices: - - `MintRequest_Accept` - Validates transfers, executes all transfer instructions, mints ETF - - `MintRequest_Decline` - Issuer declines the request - - `MintRequest_Withdraw` - Requester withdraws the request - -#### ETF Minting Workflow - -**Phase 1: Infrastructure Setup** (Issuer) -```typescript -// Create underlying token factories (one per asset in portfolio) -const tokenFactory1Cid = await issuerWrappedSdk.tokenFactory.getOrCreate(instrumentId1); -const tokenFactory2Cid = await issuerWrappedSdk.tokenFactory.getOrCreate(instrumentId2); -const tokenFactory3Cid = await issuerWrappedSdk.tokenFactory.getOrCreate(instrumentId3); - -// Create ETF token factory -const etfTokenFactoryCid = await issuerWrappedSdk.tokenFactory.getOrCreate(etfInstrumentId); - -// Create token rules and transfer factories (for transferring underlying assets) -const rulesCid = await issuerWrappedSdk.tokenRules.getOrCreate(); -const transferFactoryCid = await issuerWrappedSdk.transferFactory.getOrCreate(rulesCid); -``` +**Architecture**: ETF tokens backed by basket of underlying assets in issuer custody. No separate factory (MyMintRecipe creates tokens directly to prevent unbacked minting). -**Phase 2: Portfolio Composition Creation** (Issuer) +### ETF Minting Workflow ```typescript +// 1. Create portfolio composition (issuer) await issuerWrappedSdk.etf.portfolioComposition.create({ - owner: issuer.partyId, - name: "Three Token ETF", + owner: issuer, name: "My ETF", items: [ - { instrumentId: { admin: issuer.partyId, id: instrumentId1 }, weight: 1.0 }, - { instrumentId: { admin: issuer.partyId, id: instrumentId2 }, weight: 1.0 }, - { instrumentId: { admin: issuer.partyId, id: instrumentId3 }, weight: 1.0 }, - ], + { instrumentId: { admin: issuer, id: "Token1" }, weight: 1.0 }, + { instrumentId: { admin: issuer, id: "Token2" }, weight: 1.0 }, + { instrumentId: { admin: issuer, id: "Token3" }, weight: 1.0 } + ] }); -const portfolioCid = await issuerWrappedSdk.etf.portfolioComposition.getLatest("Three Token ETF"); -``` - -**Phase 3: Mint Recipe Creation** (Issuer) -```typescript +// 2. Create mint recipe (issuer) await issuerWrappedSdk.etf.mintRecipe.create({ - issuer: issuer.partyId, - instrumentId: etfInstrumentId, - tokenFactory: etfTokenFactoryCid, - authorizedMinters: [issuer.partyId, alice.partyId], - composition: portfolioCid, -}); - -const mintRecipeCid = await issuerWrappedSdk.etf.mintRecipe.getLatest(etfInstrumentId); -``` - -**Phase 4: Acquire Underlying Tokens** (Authorized Minter) -```typescript -// Alice creates mint requests for each underlying token -await aliceWrappedSdk.issuerMintRequest.create({ - tokenFactoryCid: tokenFactory1Cid, - issuer: issuer.partyId, - receiver: alice.partyId, - amount: 1.0, + issuer, instrumentId: etfInstrumentId, + authorizedMinters: [alice], composition: portfolioCid + // No tokenFactory - creates directly to prevent bypass }); -// ... repeat for token 2 and 3 -// Issuer accepts all mint requests -const mintRequestCid = await aliceWrappedSdk.issuerMintRequest.getLatest(issuer.partyId); -await issuerWrappedSdk.issuerMintRequest.accept(mintRequestCid); -// ... repeat for token 2 and 3 +// 3. Acquire underlying tokens (alice) +// ... mint or acquire Token1, Token2, Token3 ... -// Alice now owns 3 underlying tokens -``` +// 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 -**Phase 5: Transfer Underlying Tokens to Issuer** (Authorized Minter) -```typescript -// Alice creates transfer request 1 (alice → issuer, 1.0) -const transfer1 = buildTransfer({ - sender: alice.partyId, - receiver: issuer.partyId, - amount: 1.0, - instrumentId: { admin: issuer.partyId, id: instrumentId1 }, - requestedAt: new Date(Date.now() - 1000), - executeBefore: new Date(Date.now() + 3600000), - inputHoldingCids: [token1Cid], -}); - -await aliceWrappedSdk.transferRequest.create({ - transferFactoryCid, - expectedAdmin: issuer.partyId, - transfer: transfer1, - extraArgs: emptyExtraArgs(), -}); - -const transferRequestCid1 = await aliceWrappedSdk.transferRequest.getLatest(issuer.partyId); -await issuerWrappedSdk.transferRequest.accept(transferRequestCid1); - -// IMPORTANT: Get transfer instruction CID immediately after accepting -const transferInstructionCid1 = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); - -// Repeat pattern for token 2 -// ... create transfer2, accept, get instruction CID 2 immediately - -// Repeat pattern for token 3 -// ... create transfer3, accept, get instruction CID 3 immediately - -// Result: Three distinct transfer instruction CIDs in correct order -``` - -**⚠️ Critical Pattern**: You must capture each transfer instruction CID **immediately** after accepting each transfer request. If you wait until all transfers are accepted and then call `getLatest()` three times, you'll get the same CID three times, causing ETF minting validation to fail. - -**Phase 6: Create ETF Mint Request** (Authorized Minter) -```typescript +// 5. Create ETF mint request (alice) await aliceWrappedSdk.etf.mintRequest.create({ - mintRecipeCid, - requester: alice.partyId, - amount: 1.0, - transferInstructionCids: [transferInstructionCid1, transferInstructionCid2, transferInstructionCid3], - issuer: issuer.partyId, + mintRecipeCid, requester: alice, amount: 1.0, + transferInstructionCids: [ti1Cid, ti2Cid, ti3Cid], // MUST be in portfolio order + issuer }); -const etfMintRequestCid = await aliceWrappedSdk.etf.mintRequest.getLatest(issuer.partyId); +// 6. Accept mint request (issuer) +await issuerWrappedSdk.etf.mintRequest.accept(mintRequestCid); +// Validates transfers → Executes transfers → Mints ETF ``` -**Phase 7: Accept ETF Mint** (Issuer) +### ETF Burning Workflow ```typescript -await issuerWrappedSdk.etf.mintRequest.accept(etfMintRequestCid); - -// Result: -// - Validates all 3 transfer instructions match portfolio composition -// - Executes all 3 transfer instructions (underlying assets → issuer custody) -// - Mints 1.0 ETF tokens to Alice -``` - -#### ETF Wrapper Modules - -**`etf/portfolioComposition.ts`** - Portfolio basket management -- `createPortfolioComposition(userLedger, userKeyPair, params)` - Create portfolio -- `getLatestPortfolioComposition(userLedger, name?)` - Query latest by owner -- `getAllPortfolioCompositions(userLedger)` - Query all by owner -- `getPortfolioComposition(userLedger, contractId)` - Fetch specific portfolio details - -**`etf/mintRecipe.ts`** - Mint recipe management -- `createMintRecipe(userLedger, userKeyPair, params)` - Create recipe -- `getLatestMintRecipe(userLedger, instrumentId)` - Query by issuer and instrumentId -- `getOrCreateMintRecipe(userLedger, userKeyPair, params)` - Convenience function -- `addAuthorizedMinter(userLedger, userKeyPair, contractId, params)` - Add minter to authorized list -- `removeAuthorizedMinter(userLedger, userKeyPair, contractId, params)` - Remove minter -- `updateComposition(userLedger, userKeyPair, contractId, params)` - Update portfolio reference -- `createAndUpdateComposition(userLedger, userKeyPair, contractId, params)` - Create new portfolio and update - -**`etf/mintRequest.ts`** - ETF mint request/accept pattern -- `createEtfMintRequest(requesterLedger, requesterKeyPair, params)` - Requester creates request -- `getLatestEtfMintRequest(userLedger, issuer)` - Query latest -- `getAllEtfMintRequests(userLedger, issuer)` - Query all for issuer -- `acceptEtfMintRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer validates and mints -- `declineEtfMintRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer declines -- `withdrawEtfMintRequest(requesterLedger, requesterKeyPair, contractId)` - Requester withdraws - -**`etf/burnRequest.ts`** - ETF burn request/accept pattern -- `createEtfBurnRequest(requesterLedger, requesterKeyPair, params)` - Requester creates burn request -- `getLatestEtfBurnRequest(userLedger, issuer)` - Query latest -- `getAllEtfBurnRequests(userLedger, issuer)` - Query all for issuer -- `acceptEtfBurnRequest(issuerLedger, issuerKeyPair, contractId, transferInstructionCids)` - **CRITICAL**: Issuer validates and burns with transfer instruction CIDs -- `declineEtfBurnRequest(issuerLedger, issuerKeyPair, contractId)` - Issuer declines -- `withdrawEtfBurnRequest(requesterLedger, requesterKeyPair, contractId)` - Requester withdraws - -**Key Difference**: Unlike `acceptEtfMintRequest()`, the `acceptEtfBurnRequest()` function MUST include a `transferInstructionCids` array parameter to return underlying assets to the requester. +// 1. Create transfer requests for underlying (issuer → alice) +// ... issuer creates transfer requests for Token1, Token2, Token3 ... -#### ETF Burning Workflow +// 2. Accept transfers, capture CIDs (issuer) +const ti1Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer); +// ... accept remaining transfers, capture CIDs ... -**Phase 1: ETF Token Holder Creates Transfer Requests** (Issuer → Holder) -```typescript -// Issuer creates transfer requests for underlying assets back to holder -const returnTransfer1 = buildTransfer({ - sender: issuer.partyId, - receiver: holder.partyId, - amount: 1.0, - instrumentId: { admin: issuer.partyId, id: instrumentId1 }, - requestedAt: new Date(Date.now() - 1000), - executeBefore: new Date(Date.now() + 3600000), - inputHoldingCids: [issuerToken1Cid], -}); - -await issuerWrappedSdk.transferRequest.create({ - transferFactoryCid: transferFactory1Cid, - expectedAdmin: issuer.partyId, - transfer: returnTransfer1, - extraArgs: emptyExtraArgs(), +// 3. Create burn request (alice) +await aliceWrappedSdk.etf.burnRequest.create({ + mintRecipeCid, requester: alice, amount: 1.0, + tokenFactoryCid: etfFactoryCid, + inputHoldingCid: aliceEtfTokenCid, + issuer }); -const returnTransferRequest1Cid = await issuerWrappedSdk.transferRequest.getLatest(issuer.partyId); -// Repeat for token 2 and 3... -``` - -**Phase 2: Issuer Accepts Transfer Requests** (Creates Transfer Instructions) -```typescript -// **CRITICAL**: Capture each transfer instruction CID immediately after each accept -await issuerWrappedSdk.transferRequest.accept(returnTransferRequest1Cid); -const returnTransferInstruction1Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); - -await issuerWrappedSdk.transferRequest.accept(returnTransferRequest2Cid); -const returnTransferInstruction2Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); - -await issuerWrappedSdk.transferRequest.accept(returnTransferRequest3Cid); -const returnTransferInstruction3Cid = await issuerWrappedSdk.transferInstruction.getLatest(issuer.partyId); -``` - -**Phase 3: Holder Creates ETF Burn Request** -```typescript -await holderWrappedSdk.etf.burnRequest.create({ - mintRecipeCid, - requester: holder.partyId, - amount: 1.0, - tokenFactoryCid: etfTokenFactoryCid, - inputHoldingCid: etfTokenCid, - issuer: issuer.partyId, -}); - -const etfBurnRequestCid = await holderWrappedSdk.etf.burnRequest.getLatest(issuer.partyId); -``` - -**Phase 4: Issuer Accepts Burn Request with Transfer Instruction CIDs** -```typescript +// 4. Accept burn request (issuer) await issuerWrappedSdk.etf.burnRequest.accept( - etfBurnRequestCid, - [returnTransferInstruction1Cid, returnTransferInstruction2Cid, returnTransferInstruction3Cid] + burnRequestCid, + [ti1Cid, ti2Cid, ti3Cid] // MUST be in portfolio order ); - -// Result: -// - Validates all 3 return transfer instructions match portfolio composition -// - Executes all 3 transfer instructions (underlying assets → holder custody) -// - Burns 1.0 ETF tokens from holder +// Validates transfers → Executes transfers → Burns ETF ``` -#### Key ETF Patterns - -1. **Authorization via authorizedMinters List**: MyMintRecipe maintains a list of parties authorized to create mint requests, enabling flexible minting access control - -2. **Atomic Validation and Execution**: The `MintRequest_Accept` choice validates all transfer instructions before executing any of them, ensuring ETF tokens are only minted when properly backed - -3. **Weight-Based Amount Calculation**: For each underlying asset, the transfer amount must equal `portfolioItem.weight × ETF amount`, ensuring correct proportional backing - -4. **Issuer Custody Model**: All underlying assets are transferred to the issuer during minting, establishing clear custody and backing for the ETF - -5. **Array Ordering Requirement**: Transfer instruction CIDs must be provided in the same order as portfolio composition items for validation to succeed - -6. **Critical Timing Pattern**: When collecting transfer instruction CIDs, you must capture each CID **immediately** after accepting each transfer request. Calling `getLatest()` multiple times after all transfers are accepted will return the same CID, causing validation failure. Pattern: `accept transfer 1 → get CID 1 → accept transfer 2 → get CID 2 → accept transfer 3 → get CID 3` - -7. **No Disclosure Required**: ETF minting doesn't require additional disclosure beyond the transfer instructions (issuer can see all transfer instructions they accepted) - -#### Key Differences from Token and Bond Operations - -1. **Composite Structure**: ETFs are backed by a portfolio of underlying assets, not standalone tokens - -2. **Multi-Asset Validation**: Minting validates multiple transfer instructions atomically - -3. **Authorized Minter Pattern**: Only parties in the authorizedMinters list can create mint requests - -4. **Custody Transfer**: ETF minting requires transferring underlying assets to issuer custody - -5. **Portfolio Management**: Issuers can update portfolio compositions and authorized minters dynamically - -#### ETF Implementation Notes - -**Relationship to Daml Contracts** (`packages/minimal-token/daml/ETF/`) +**Critical**: Transfer instruction CIDs must be in same order as portfolio composition items. Capture each CID immediately after accepting each transfer request. -The TypeScript SDK wrappers map directly to Daml contracts in the minimal-token package: -- `portfolioComposition.ts` → `ETF.PortfolioComposition.daml` - Pure data contract with no choices -- `mintRecipe.ts` → `ETF.MyMintRecipe.daml` - Factory contract with 5 choices (AddAuthorizedMinter, RemoveAuthorizedMinter, UpdateComposition, CreateAndUpdateComposition, Mint) -- `mintRequest.ts` → `ETF.MyMintRequest.daml` - Request contract with 3 choices (MintRequest_Accept, MintRequest_Decline, MintRequest_Withdraw) +## Multi-Party Authorization -**Key Design Decisions:** +**Challenge**: MyToken requires `signatory issuer, owner`. High-level Wallet SDK only supports single-party signing. -1. **Request/Accept Pattern**: ETF minting uses a two-step pattern (requester creates, issuer accepts) to avoid multi-party signing complexity. This mirrors the `IssuerMintRequest` pattern used for basic token minting. +**Solution**: Use request/accept pattern (sender creates request, admin accepts) instead of direct multi-party operations. -2. **Observers on MyMintRecipe**: The `authorizedMinters` list is part of the contract's observers, allowing authorized parties to see the mint recipe and create mint requests. This is how authorization is enforced at the Daml level. +**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). -3. **Validation in Daml**: All critical validation (transfer instruction count, instrumentId matching, amount calculation) happens in the Daml `MintRequest_Accept` choice, not in TypeScript. The SDK simply prepares the data and submits the command. +## Canton Ledger Interaction -4. **Array Ordering Enforcement**: The Daml validation iterates through transfer instructions and portfolio items in parallel using `zip`, requiring exact ordering. The SDK must preserve this order when collecting CIDs. +**Pattern**: Create `WrappedCommand` → `prepareSignExecuteAndWaitFor()` with private key and UUID → Query via `activeContracts()` -**Common Pitfalls and Solutions:** +**Party Context**: Use `sdk.setPartyId()` to switch query perspective -1. **Issue**: Transfer instruction validation fails with "instrumentId does not match" - - **Cause**: Collected CIDs in wrong order or collected duplicate CIDs - - **Solution**: Capture each transfer instruction CID immediately after accepting each transfer request (see Pattern #6 above) +**InstrumentId Format**: `{admin: partyId, id: fullInstrumentId}` where fullInstrumentId is typically `partyId#TokenName` -2. **Issue**: ETF mint request creation fails with "contract not found" - - **Cause**: Transfer instructions have already been executed or withdrawn - - **Solution**: Transfer instructions are consumed when executed. Create the ETF mint request before accepting any transfer instructions on behalf of the receiver. +## Known Issues -3. **Issue**: Cannot create mint request - "party not authorized" - - **Cause**: Requester is not in the `authorizedMinters` list on MyMintRecipe - - **Solution**: Issuer must add the party using `addAuthorizedMinter()` before they can create mint requests +### 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 -4. **Issue**: Amount calculation mismatch - - **Cause**: Transfer amounts don't equal `portfolioItem.weight × ETF amount` - - **Solution**: Use the exact formula for each transfer. Example: If ETF amount is 2.0 and weight is 1.5, transfer amount must be exactly 3.0 +### Multi-Party Signing +**Issue**: High-level API doesn't support multi-party external signing +**Workaround**: Use request/accept pattern or lower-level `LedgerClient` API -**Implementation Pattern Consistency:** +## Template IDs Reference -The ETF SDK implementation follows the same patterns as Token and Bond operations: -- All functions take `(userLedger, userKeyPair, params)` for single-party perspective -- Template IDs defined in `constants/templateIds.ts` with format `#minimal-token:ETF.{Contract}:{Contract}` -- Query functions use `activeContracts()` filtered by party and template ID -- Choice execution uses `getExerciseCommand()` helper -- Two wrapper versions: `getWrappedSdk()` (userKeyPair per call) and `getWrappedSdkWithKeyPair()` (pre-bound) - -**Testing Approach:** - -The `etfMint.ts` test script follows the `mintToOtherTokenETF` Daml test pattern exactly, providing a reference implementation: -1. Uses Charlie (issuer) and Alice (authorized minter) as parties -2. Creates 3 underlying token factories and 1 ETF token factory -3. Creates portfolio composition with 3 items (weight 1.0 each) -4. Alice acquires underlying tokens via IssuerMintRequest pattern -5. Alice transfers underlying tokens to Charlie (issuer custody) -6. Alice creates ETF mint request with transfer instruction CIDs -7. Charlie accepts, validating and executing atomically -8. Verifies Alice receives ETF token and Charlie holds underlying assets - -Run `tsx src/testScripts/etfMint.ts` to see the complete workflow in action. - -### Known Issues and Multi-Party Authorization - -#### Multi-Party Signing Challenge - -The `MyToken` contract in `../minimal-token/daml/MyToken.daml` defines: -```daml -template MyToken - with - issuer : Party - owner : Party - ... - where - signatory issuer, owner -``` - -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:** -```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, -}); -``` - -**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" -}); -``` - -**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. - -#### Alternative Approaches - -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 - -## SDK Wrapper Design Principles - -When creating new SDK wrapper functions, follow these patterns: - -### 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 - -### 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 - -### 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` - -### 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 - -### Command Creation -- Use `getCreateCommand()` helper for CreateCommand -- Use `getExerciseCommand()` helper for ExerciseCommand -- Use `prepareSignExecuteAndWaitFor()` for submission -- Generate UUID with `v4()` for correlation IDs - -### 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 -- Separate param interfaces from contract field interfaces where needed - -### Two Wrapper Versions - -The SDK provides two versions of the wrapped SDK to accommodate different usage patterns: - -**`getWrappedSdk(sdk: WalletSDK)`** - Takes userKeyPair for each call -```typescript -const wrappedSdk = getWrappedSdk(sdk); - -// userKeyPair must be passed to each operation -await wrappedSdk.tokenFactory.create(userKeyPair, { issuer, instrumentId }); -await wrappedSdk.issuerMintRequest.accept(userKeyPair, contractId); -``` - -**`getWrappedSdkWithKeyPair(sdk: WalletSDK, userKeyPair: UserKeyPair)`** - Pre-bound keyPair -```typescript -const wrappedSdk = getWrappedSdkWithKeyPair(sdk, userKeyPair); - -// userKeyPair is pre-bound, omitted from method signatures -await wrappedSdk.tokenFactory.create({ issuer, instrumentId }); -await wrappedSdk.issuerMintRequest.accept(contractId); -``` - -**When to Use Each**: -- Use `getWrappedSdk` when a single SDK instance needs to perform operations with multiple key pairs -- Use `getWrappedSdkWithKeyPair` (recommended) when all operations use the same key pair - cleaner API, less repetition - -### Nested Structure - -Group related contracts in subdirectories and organize methods by operation type: - -```typescript -{ - bonds: { - factory: { create, getLatest, getOrCreate, createInstrument, getLatestInstrument }, - bondRules: { create, getLatest, getOrCreate }, - issuerMintRequest: { create, getLatest, getAll, accept, decline, withdraw }, - lifecycleRule: { create, getLatest, getOrCreate, processCouponPaymentEvent, processRedemptionEvent }, - // ... more subsections - }, - etf: { - portfolioComposition: { create, getLatest, getAll, get }, - mintRecipe: { create, getLatest, getOrCreate, addAuthorizedMinter, removeAuthorizedMinter, updateComposition, createAndUpdateComposition }, - mintRequest: { create, getLatest, getAll, accept, decline, withdraw }, - }, - tokenFactory: { create, getLatest, getOrCreate, mintToken }, - // ... more top-level sections -} -``` - -**Organization Principles**: -1. **Top-level instrument type** (e.g., `bonds`, `etf`) for complex instruments with multiple related contracts -2. **Sub-sections for component contracts** (e.g., `factory`, `rules`, `mintRequest`, `lifecycle`) -3. **Methods grouped by operation type** (create, getLatest, getOrCreate, accept, decline, withdraw) -4. **Consistent method ordering** within each subsection for predictability - -### Module Organization - -**File Structure**: -- One module per contract type (e.g., `tokenFactory.ts`, `issuerMintRequest.ts`) -- Group related contracts in subdirectories (e.g., `bonds/`, `etf/`) -- Index files (`index.ts`) for re-exporting from subdirectories -- Clear separation of concerns - each module handles a single contract type - -**Import/Export Strategy**: -```typescript -// In subdirectory: bonds/index.ts or etf/index.ts -export * from "./factory.js"; -export * from "./issuerMintRequest.js"; -export * from "./lifecycleRule.js"; -// ... more exports - -// In main wrappedSdk/index.ts -export * from "./bonds/index.js"; -export * from "./etf/index.js"; -``` - -**Integration Pattern** (`wrappedSdk.ts`): -1. Import all functions and types from subdirectory modules -2. Create nested object structure in `getWrappedSdk()` return object -3. Duplicate structure in `getWrappedSdkWithKeyPair()` with userKeyPair pre-bound -4. Ensure consistent ordering between both wrapper versions - -#### Other TODOs - -- The codebase includes TODOs around using `submitCommand` vs `prepareSignExecuteAndWaitFor` with notes about synchronizer submission errors +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. ## Testing -### 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` - -**`src/testScripts/etfMint.ts`** - ETF minting demonstration (mintToOtherTokenETF pattern) -- Demonstrates complete ETF minting workflow: Charlie (issuer) and Alice (authorized minter) -- Covers: 3 underlying token factories, portfolio composition, mint recipe, authorized minters -- Shows ETF-specific patterns: atomic multi-asset backing, weight-based validation, issuer custody -- 8-phase workflow: infrastructure → portfolio → recipe → mint underlying → transfer → ETF mint request → accept -- Run with: `tsx src/testScripts/etfMint.ts` - -**`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.