diff --git a/packages/token-app/app/api/wallet/etf/mint-recipe/route.ts b/packages/token-app/app/api/wallet/etf/mint-recipe/route.ts new file mode 100644 index 0000000..761dfa1 --- /dev/null +++ b/packages/token-app/app/api/wallet/etf/mint-recipe/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getWrappedSdkForParty, + getWrappedSdkWithKeyPairForParty, + keyPairFromSeed, + getSdkForParty, + mintRecipeTemplateId, + ActiveContractResponse, +} from "@denotecapital/token-sdk"; + +interface MintRecipeParams { + issuer: string; + instrumentId: string; + authorizedMinters: string[]; + composition: string; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const issuer = searchParams.get("issuer"); + + if (!issuer) { + return NextResponse.json( + { error: "Missing issuer" }, + { status: 400 } + ); + } + + const sdk = await getSdkForParty(issuer); + const ledger = sdk.userLedger!; + const end = await ledger.ledgerEnd(); + + const activeContracts = (await ledger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [issuer], + templateIds: [mintRecipeTemplateId], + })) as ActiveContractResponse[]; + + const recipes = activeContracts.map((contract) => { + const jsActive = contract.contractEntry.JsActiveContract; + if (!jsActive) return null; + + const createArg = jsActive.createdEvent.createArgument; + const contractId = jsActive.createdEvent.contractId; + + return { + contractId, + issuer: createArg.issuer, + instrumentId: createArg.instrumentId, + authorizedMinters: createArg.authorizedMinters, + composition: createArg.composition, + }; + }); + + const validRecipes = recipes.filter((recipe) => recipe !== null); + + return NextResponse.json({ recipes: validRecipes }); + } catch (error) { + console.error("Error getting mint recipes:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { issuer, instrumentId, authorizedMinters, composition, seed } = + await request.json(); + + if ( + !issuer || + !instrumentId || + !authorizedMinters || + !composition || + !seed + ) { + return NextResponse.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + if ( + !Array.isArray(authorizedMinters) || + authorizedMinters.length === 0 + ) { + return NextResponse.json( + { error: "authorizedMinters must be a non-empty array" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + issuer, + keyPair + ); + + await wrappedSdk.etf.mintRecipe.create({ + issuer, + instrumentId, + authorizedMinters, + composition, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error creating mint recipe:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/etf/mint-request/accept/route.ts b/packages/token-app/app/api/wallet/etf/mint-request/accept/route.ts new file mode 100644 index 0000000..85b990a --- /dev/null +++ b/packages/token-app/app/api/wallet/etf/mint-request/accept/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getWrappedSdkWithKeyPairForParty, + keyPairFromSeed, +} from "@denotecapital/token-sdk"; + +export async function POST(request: NextRequest) { + try { + const { contractId, issuer, seed } = await request.json(); + + if (!contractId || !issuer || !seed) { + return NextResponse.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + issuer, + keyPair + ); + + await wrappedSdk.etf.mintRequest.accept(contractId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error accepting ETF mint request:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/etf/mint-request/decline/route.ts b/packages/token-app/app/api/wallet/etf/mint-request/decline/route.ts new file mode 100644 index 0000000..f6c34a9 --- /dev/null +++ b/packages/token-app/app/api/wallet/etf/mint-request/decline/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getWrappedSdkWithKeyPairForParty, + keyPairFromSeed, +} from "@denotecapital/token-sdk"; + +export async function POST(request: NextRequest) { + try { + const { contractId, issuer, seed } = await request.json(); + + if (!contractId || !issuer || !seed) { + return NextResponse.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + issuer, + keyPair + ); + + await wrappedSdk.etf.mintRequest.decline(contractId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error declining ETF mint request:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/etf/mint-request/route.ts b/packages/token-app/app/api/wallet/etf/mint-request/route.ts new file mode 100644 index 0000000..e8f2b45 --- /dev/null +++ b/packages/token-app/app/api/wallet/etf/mint-request/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getWrappedSdkForParty, + getWrappedSdkWithKeyPairForParty, + keyPairFromSeed, + getSdkForParty, + etfMintRequestTemplateId, + ActiveContractResponse, +} from "@denotecapital/token-sdk"; + +interface EtfMintRequestParams { + mintRecipeCid: string; + requester: string; + amount: number; + transferInstructionCids: string[]; + issuer: string; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const partyId = searchParams.get("partyId"); + const issuer = searchParams.get("issuer"); + + if (!partyId || !issuer) { + return NextResponse.json( + { error: "Missing partyId or issuer" }, + { status: 400 } + ); + } + + const wrappedSdk = await getWrappedSdkForParty(partyId); + const contractIds = await wrappedSdk.etf.mintRequest.getAll(issuer); + + const sdk = await getSdkForParty(partyId); + const ledger = sdk.userLedger!; + const end = await ledger.ledgerEnd(); + + const activeContracts = (await ledger.activeContracts({ + offset: end.offset, + filterByParty: true, + parties: [partyId], + templateIds: [etfMintRequestTemplateId], + })) as ActiveContractResponse[]; + + const requests = contractIds.map((contractId: string) => { + const contract = activeContracts.find( + (c) => + c.contractEntry.JsActiveContract?.createdEvent + .contractId === contractId + ); + if (!contract?.contractEntry.JsActiveContract) { + return null; + } + + const jsActive = contract.contractEntry.JsActiveContract; + const createArg = jsActive.createdEvent.createArgument; + + return { + contractId, + mintRecipeCid: createArg.mintRecipeCid, + requester: createArg.requester, + amount: createArg.amount, + transferInstructionCids: createArg.transferInstructionCids, + issuer: createArg.issuer, + }; + }); + + const validRequests = requests.filter((req) => req !== null); + + return NextResponse.json({ requests: validRequests }); + } catch (error) { + console.error("Error getting ETF mint requests:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { + mintRecipeCid, + requester, + amount, + transferInstructionCids, + issuer, + seed, + } = await request.json(); + + if ( + !mintRecipeCid || + !requester || + !amount || + !transferInstructionCids || + !issuer || + !seed + ) { + return NextResponse.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + if ( + !Array.isArray(transferInstructionCids) || + transferInstructionCids.length === 0 + ) { + return NextResponse.json( + { error: "transferInstructionCids must be a non-empty array" }, + { status: 400 } + ); + } + + if (typeof amount !== "number" || amount <= 0) { + return NextResponse.json( + { error: "amount must be a positive number" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + requester, + keyPair + ); + + await wrappedSdk.etf.mintRequest.create({ + mintRecipeCid, + requester, + amount, + transferInstructionCids, + issuer, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error creating ETF mint request:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/etf/mint-request/withdraw/route.ts b/packages/token-app/app/api/wallet/etf/mint-request/withdraw/route.ts new file mode 100644 index 0000000..290f650 --- /dev/null +++ b/packages/token-app/app/api/wallet/etf/mint-request/withdraw/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getWrappedSdkWithKeyPairForParty, + keyPairFromSeed, +} from "@denotecapital/token-sdk"; + +export async function POST(request: NextRequest) { + try { + const { contractId, requester, seed } = await request.json(); + + if (!contractId || !requester || !seed) { + return NextResponse.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + requester, + keyPair + ); + + await wrappedSdk.etf.mintRequest.withdraw(contractId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error withdrawing ETF mint request:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/etf/portfolio-composition/route.ts b/packages/token-app/app/api/wallet/etf/portfolio-composition/route.ts new file mode 100644 index 0000000..d2ecca0 --- /dev/null +++ b/packages/token-app/app/api/wallet/etf/portfolio-composition/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getWrappedSdkForParty, + getWrappedSdkWithKeyPairForParty, + keyPairFromSeed, +} from "@denotecapital/token-sdk"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const owner = searchParams.get("owner"); + + if (!owner) { + return NextResponse.json( + { error: "Missing owner" }, + { status: 400 } + ); + } + + const wrappedSdk = await getWrappedSdkForParty(owner); + const compositionCids = + await wrappedSdk.etf.portfolioComposition.getAll(); + + const compositions = await Promise.all( + compositionCids.map(async (cid) => { + const details = await wrappedSdk.etf.portfolioComposition.get( + cid + ); + return { + contractId: cid, + ...details, + }; + }) + ); + + return NextResponse.json({ compositions }); + } catch (error) { + console.error("Error getting portfolio compositions:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { owner, name, items, seed } = await request.json(); + + if (!owner || !name || !items || !seed) { + return NextResponse.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + if (!Array.isArray(items) || items.length === 0) { + return NextResponse.json( + { error: "Items must be a non-empty array" }, + { status: 400 } + ); + } + + for (const item of items) { + if ( + !item.instrumentId || + !item.instrumentId.admin || + !item.instrumentId.id || + typeof item.weight !== "number" || + item.weight <= 0 + ) { + return NextResponse.json( + { error: "Invalid portfolio item structure" }, + { status: 400 } + ); + } + } + + const keyPair = keyPairFromSeed(seed); + const wrappedSdk = await getWrappedSdkWithKeyPairForParty( + owner, + keyPair + ); + + await wrappedSdk.etf.portfolioComposition.create({ + owner, + name, + items, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error creating portfolio composition:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/etf/setup/route.ts b/packages/token-app/app/api/wallet/etf/setup/route.ts new file mode 100644 index 0000000..b87a3c9 --- /dev/null +++ b/packages/token-app/app/api/wallet/etf/setup/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { etfSetup } from "@denotecapital/token-sdk"; + +export async function POST(_request: NextRequest) { + try { + const setupResult = await etfSetup(); + + return NextResponse.json(setupResult); + } catch (error) { + console.error("Error running ETF setup:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/packages/token-app/app/api/wallet/transfer-instruction/route.ts b/packages/token-app/app/api/wallet/transfer-instruction/route.ts index 0d23e6b..c06d68d 100644 --- a/packages/token-app/app/api/wallet/transfer-instruction/route.ts +++ b/packages/token-app/app/api/wallet/transfer-instruction/route.ts @@ -44,7 +44,10 @@ export async function GET(request: NextRequest) { }; const contractId = jsActive.createdEvent.contractId; - if (createArg.transfer?.receiver !== partyId) { + if ( + createArg.transfer?.sender !== partyId && + createArg.transfer?.receiver !== partyId + ) { return null; } diff --git a/packages/token-app/app/etf/page.tsx b/packages/token-app/app/etf/page.tsx new file mode 100644 index 0000000..617eec5 --- /dev/null +++ b/packages/token-app/app/etf/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { ConnectionStatus } from "@/components/ConnectionStatus"; +import { EtfSetupSection } from "@/components/EtfSetupSection"; +import { EtfCustodianView } from "@/components/EtfCustodianView"; +import { EtfUserView } from "@/components/EtfUserView"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const PARTIES = ["custodian", "alice"] as const; + +export default function EtfPage() { + const [selectedParty, setSelectedParty] = useState("custodian"); + const [partyIds, setPartyIds] = useState>({ + custodian: null, + alice: null, + }); + const [setupComplete, setSetupComplete] = useState(false); + + return ( +
+
+
+
+

+ ETF Management +

+ +
+ +
+ {PARTIES.map((party) => ( + + ))} +
+
+ + + + {!setupComplete && ( + { + setSetupComplete(true); + setPartyIds({ + custodian: result.parties.custodian, + alice: result.parties.alice, + }); + }} + /> + )} + + {setupComplete && + (selectedParty === "custodian" ? ( + + ) : ( + + ))} +
+
+ ); +} diff --git a/packages/token-app/components/BondUserView.tsx b/packages/token-app/components/BondUserView.tsx index 3d1f210..1b2ef76 100644 --- a/packages/token-app/components/BondUserView.tsx +++ b/packages/token-app/components/BondUserView.tsx @@ -8,7 +8,10 @@ import { useLifecycleClaimRequest, useLifecycleInstruction, } from "@/lib/queries/bondLifecycle"; -import type { BondLifecycleInstruction } from "@denotecapital/token-sdk"; +import type { + BondLifecycleInstruction, + TokenBalance, +} from "@denotecapital/token-sdk"; import { useBondInstruments } from "@/lib/queries/bondInstruments"; import { useQueryClient } from "@tanstack/react-query"; import { useTransferInstruction } from "@/lib/queries/transferInstruction"; @@ -359,7 +362,8 @@ export function BondUserView({

Bonds

- {selectedBalance.total || 0} + {(selectedBalance as TokenBalance) + .total || 0}

)} @@ -369,7 +373,8 @@ export function BondUserView({ Currency

- {currencyBalance.total || 0} + {(currencyBalance as TokenBalance) + .total || 0}

)} diff --git a/packages/token-app/components/EtfCustodianView.tsx b/packages/token-app/components/EtfCustodianView.tsx new file mode 100644 index 0000000..253d0b6 --- /dev/null +++ b/packages/token-app/components/EtfCustodianView.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import { useTokenFactory } from "@/lib/queries/tokenFactory"; +import { useAllBalancesForAllInstruments } from "@/lib/queries/allBalances"; +import { EtfPortfolioComposition } from "./EtfPortfolioComposition"; +import { EtfMintRecipe } from "./EtfMintRecipe"; +import { EtfMintRequestManagement } from "./EtfMintRequestManagement"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +interface EtfCustodianViewProps { + partyId: string; + partyName: string; + allPartyIds: Record; +} + +export function EtfCustodianView({ + partyId, + partyName, + allPartyIds, +}: EtfCustodianViewProps) { + const [selectedInstrumentForBalances, setSelectedInstrumentForBalances] = + useState(null); + + const tokenFactory = useTokenFactory(partyId); + const instruments = tokenFactory.getInstruments.data?.instruments || []; + + const { + allBalances, + isLoadingBalances, + hasBalanceError, + balanceQueries, + setupInstruments, + } = useAllBalancesForAllInstruments(partyId, instruments); + + const allBalancesList = selectedInstrumentForBalances + ? balanceQueries[ + setupInstruments.findIndex( + (inst) => inst.instrumentId === selectedInstrumentForBalances + ) + ]?.data?.balances ?? [] + : allBalances; + + return ( +
+ + + Minted Tokens Summary + + View token balances by instrument + + + + {setupInstruments.length > 0 && ( +
+ + +
+ )} + {isLoadingBalances ? ( +
+ +
+ ) : hasBalanceError ? ( +

+ Unable to load balances. Token factory may not be + set up yet. +

+ ) : allBalancesList.length === 0 ? ( +

+ {selectedInstrumentForBalances + ? "No tokens minted yet for this instrument" + : "No tokens minted yet"} +

+ ) : ( +
+ {allBalancesList.map((balance) => { + const instrumentName = + selectedInstrumentForBalances + ? selectedInstrumentForBalances.split( + "#" + )[1] + : null; + + return ( +
+
+

+ {balance.party} +

+ {instrumentName && ( +

+ {instrumentName} +

+ )} +
+
+

+ {balance.total} tokens +

+
+
+ ); + })} +
+ )} +
+
+ + + + + {/* TODO: Add ETF burn section */} +
+ ); +} diff --git a/packages/token-app/components/EtfMintRecipe.tsx b/packages/token-app/components/EtfMintRecipe.tsx new file mode 100644 index 0000000..a6f4ead --- /dev/null +++ b/packages/token-app/components/EtfMintRecipe.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useEtfMintRecipe, type MintRecipe } from "@/lib/queries/etfMintRecipe"; +import { useEtfPortfolioComposition } from "@/lib/queries/etfPortfolioComposition"; +import { useTokenFactory } from "@/lib/queries/tokenFactory"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface EtfMintRecipeProps { + partyId: string; + partyName: string; + allPartyIds: Record; +} + +export function EtfMintRecipe({ + partyId, + partyName, + allPartyIds, +}: EtfMintRecipeProps) { + const [selectedInstrumentId, setSelectedInstrumentId] = useState(""); + const [selectedComposition, setSelectedComposition] = useState(""); + const [selectedMinters, setSelectedMinters] = useState([partyId]); + + const { get: getRecipes, create: createRecipe } = useEtfMintRecipe(partyId); + const { get: getCompositions } = useEtfPortfolioComposition(partyId); + const { getInstruments } = useTokenFactory(partyId); + + const recipes = getRecipes.data?.recipes ?? []; + const compositions = getCompositions.data?.compositions ?? []; + const instruments = getInstruments.data?.instruments ?? []; + + const getInstrumentName = (instrumentId: string) => { + return instrumentId.split("#")[1]; + }; + + const etfInstruments = useMemo(() => { + return instruments + .filter((inst) => + getInstrumentName(inst.instrumentId).includes("ETF") + ) + .map((inst) => ({ + instrumentId: inst.instrumentId, + name: getInstrumentName(inst.instrumentId), + })); + }, [instruments]); + + const availableParties = useMemo(() => { + const parties = Object.entries(allPartyIds) + .filter(([_, id]) => id !== null && id !== partyId) + .map(([name, id]) => ({ name, id: id! })); + return parties; + }, [allPartyIds, partyId]); + + const handleToggleMinter = (partyIdToToggle: string) => { + if (partyIdToToggle === partyId) return; + + setSelectedMinters((prev) => + prev.includes(partyIdToToggle) + ? prev.filter((id) => id !== partyIdToToggle) + : [...prev, partyIdToToggle] + ); + }; + + const getPartyName = (partyId: string) => { + const entry = Object.entries(allPartyIds).find( + ([_, id]) => id === partyId + ); + return entry?.[0] ?? `${partyId.slice(0, 20)}...`; + }; + + const handleCreateRecipe = async () => { + if (!selectedInstrumentId) { + toast.error("Please select an ETF instrument"); + return; + } + if (!selectedComposition) { + toast.error("Portfolio composition is required"); + return; + } + if (selectedMinters.length === 0) { + toast.error("At least one authorized minter is required"); + return; + } + + try { + await createRecipe.mutateAsync({ + issuer: partyId, + instrumentId: selectedInstrumentId, + authorizedMinters: selectedMinters, + composition: selectedComposition, + seed: partyName, + }); + toast.success("Mint recipe created successfully!"); + setSelectedComposition(""); + setSelectedMinters([partyId]); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to create mint recipe" + ); + } + }; + + return ( +
+ + + Create Mint Recipe + + Define how ETF tokens can be minted based on a portfolio + composition + + + +
+ + {getInstruments.isLoading ? ( +
+ + + Loading instruments... + +
+ ) : etfInstruments.length > 0 ? ( + + ) : ( +
+

+ No ETF instrument found. Please create an + ETF instrument first. +

+
+ )} +
+ +
+ + + {compositions.length === 0 && ( +

+ No compositions available. Create one first. +

+ )} +
+ +
+
+ + + {selectedMinters.length} selected + +
+ +
+
+

+ {partyName} (Custodian) +

+

+ {partyId.slice(0, 30)}... +

+
+
+ {availableParties.length > 0 ? ( +
+ {availableParties.map((party) => ( +
+ + handleToggleMinter(party.id) + } + className="h-4 w-4 rounded" + /> + +
+ ))} +
+ ) : ( +

+ No additional parties available +

+ )} +
+ + +
+
+ + + + Existing Mint Recipes + + {recipes.length === 0 + ? "No mint recipes created yet" + : `${recipes.length} recipe${ + recipes.length === 1 ? "" : "s" + } found`} + + + + {getRecipes.isLoading ? ( +
+ +
+ ) : recipes.length === 0 ? ( +
+

+ No mint recipes have been created yet. +

+

+ Create one above to get started. +

+
+ ) : ( +
+ {recipes.map((recipe: MintRecipe) => { + const instrumentName = getInstrumentName( + recipe.instrumentId + ); + const compositionName = + compositions.find( + (c) => + c.contractId === recipe.composition + )?.name ?? "Unknown Composition"; + + return ( +
+
+

+ {instrumentName} +

+

+ {recipe.instrumentId} +

+
+ +
+

+ Portfolio Composition +

+

+ {compositionName} +

+

+ {recipe.composition} +

+
+ +
+

+ Authorized Minters ( + { + recipe.authorizedMinters + .length + } + ) +

+
+ {recipe.authorizedMinters.map( + (minter) => ( +
+ {getPartyName( + minter + )} +
+ ) + )} +
+
+
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/packages/token-app/components/EtfMintRequestManagement.tsx b/packages/token-app/components/EtfMintRequestManagement.tsx new file mode 100644 index 0000000..9f57a16 --- /dev/null +++ b/packages/token-app/components/EtfMintRequestManagement.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEtfMintRequest } from "@/lib/queries/etfMintRequest"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +export function EtfMintRequestManagement({ + partyId, + partyName, +}: { + partyId: string; + partyName: string; +}) { + const { + get: getEtfMintRequest, + accept: acceptEtfMintRequest, + decline: declineEtfMintRequest, + } = useEtfMintRequest(partyId, partyId); + const mintRequests = getEtfMintRequest.data?.requests ?? []; + + const handleAccept = async (contractId: string) => { + try { + await acceptEtfMintRequest.mutateAsync({ + contractId, + issuer: partyId, + seed: partyName, + }); + toast.success("ETF mint request accepted successfully!"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to accept ETF mint request" + ); + } + }; + + const handleDecline = async (contractId: string) => { + try { + await declineEtfMintRequest.mutateAsync({ + contractId, + issuer: partyId, + seed: partyName, + }); + toast.success("ETF mint request declined"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to decline ETF mint request" + ); + } + }; + + return ( + + + ETF Mint Requests + + Review and approve/decline ETF mint requests from users + + + + {getEtfMintRequest.isLoading ? ( +
+ +
+ ) : ( +
+ {mintRequests.map((mintRequest) => ( +
+
+
+

+ Amount: {mintRequest.amount} ETF + tokens +

+

+ Requester: {mintRequest.requester} +

+ + {mintRequest.contractId.slice( + 0, + 20 + )} + ... + +
+
+
+

+ Transfer Instructions:{" "} + { + mintRequest.transferInstructionCids + .length + } +

+

+ Mint Recipe:{" "} + {mintRequest.mintRecipeCid.slice(0, 20)} + ... +

+
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/token-app/components/EtfPortfolioComposition.tsx b/packages/token-app/components/EtfPortfolioComposition.tsx new file mode 100644 index 0000000..13703c2 --- /dev/null +++ b/packages/token-app/components/EtfPortfolioComposition.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useState } from "react"; +import { + useEtfPortfolioComposition, + PortfolioItem, +} from "@/lib/queries/etfPortfolioComposition"; +import { useTokenFactory } from "@/lib/queries/tokenFactory"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface EtfPortfolioCompositionProps { + partyId: string; + partyName: string; +} + +export function EtfPortfolioComposition({ + partyId, + partyName, +}: EtfPortfolioCompositionProps) { + const [compositionName, setCompositionName] = useState(""); + const [items, setItems] = useState([]); + + const { get: getCompositions, create: createComposition } = + useEtfPortfolioComposition(partyId); + const { getInstruments } = useTokenFactory(partyId); + const instruments = getInstruments.data?.instruments ?? []; + + const compositions = getCompositions.data?.compositions ?? []; + + const parseInstrumentId = (instrumentId: string) => { + const [admin, id] = instrumentId.split("#"); + return { admin, id }; + }; + + const handleAddItem = () => { + if (instruments.length === 0) { + toast.error( + "No instruments available. Please create tokens first." + ); + return; + } + + setItems([ + ...items, + { + instrumentId: parseInstrumentId(instruments[0].instrumentId), + weight: 1.0, + }, + ]); + }; + + const handleRemoveItem = (index: number) => { + setItems(items.filter((_, i) => i !== index)); + }; + + const handleInstrumentChange = (index: number, instrumentId: string) => { + setItems( + items.map((item, i) => + i === index + ? { ...item, instrumentId: parseInstrumentId(instrumentId) } + : item + ) + ); + }; + + const handleWeightChange = (index: number, weight: number) => { + setItems( + items.map((item, i) => (i === index ? { ...item, weight } : item)) + ); + }; + + const handleCreateComposition = async () => { + if (!compositionName.trim()) { + toast.error("Composition name is required"); + return; + } + + if (items.length === 0) { + toast.error("At least one portfolio item is required"); + return; + } + + try { + await createComposition.mutateAsync({ + owner: partyId, + name: compositionName.trim(), + items, + seed: partyName, + }); + toast.success( + `Portfolio composition "${compositionName.trim()}" created successfully!` + ); + setCompositionName(""); + setItems([]); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to create portfolio composition" + ); + } + }; + + return ( +
+ + + Portfolio Compositions + + Create and manage portfolio compositions for ETF tokens + + + +
+ + setCompositionName(e.target.value)} + placeholder="e.g., Three Token ETF" + /> +
+ +
+
+ + +
+ + {items.map((item, index) => ( +
+
+ + +
+
+ + + handleWeightChange( + index, + e.target.valueAsNumber || 0 + ) + } + /> +
+ +
+ ))} +
+ + +
+
+ + + + Existing Compositions + + {compositions.length === 0 + ? "No portfolio compositions created yet" + : `${compositions.length} composition(s) found`} + + + + {getCompositions.isLoading ? ( +
+ +
+ ) : ( +
+ {compositions.map((composition) => ( +
+
+

+ {composition.name} +

+ + {composition.contractId.slice( + 0, + 20 + )} + ... + +
+
+

+ Items ({composition.items.length}): +

+
    + {composition.items.map( + (item, idx) => ( +
  • + •{" "} + + { + item + .instrumentId + .id + } + {" "} + (weight: {item.weight}) +
  • + ) + )} +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/token-app/components/EtfSetupSection.tsx b/packages/token-app/components/EtfSetupSection.tsx new file mode 100644 index 0000000..f5f9b58 --- /dev/null +++ b/packages/token-app/components/EtfSetupSection.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +interface EtfSetupSectionProps { + onSetupComplete?: (result: any) => void; +} + +export function EtfSetupSection({ onSetupComplete }: EtfSetupSectionProps) { + const setupMutation = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/wallet/etf/setup", { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to run ETF setup"); + } + + return response.json(); + }, + onSuccess: (data) => { + onSetupComplete?.(data); + toast.success("ETF setup completed successfully!"); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to run ETF setup" + ); + }, + }); + + return ( + + + ETF Setup + + + + + + ); +} diff --git a/packages/token-app/components/EtfUserView.tsx b/packages/token-app/components/EtfUserView.tsx new file mode 100644 index 0000000..2b08792 --- /dev/null +++ b/packages/token-app/components/EtfUserView.tsx @@ -0,0 +1,656 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useEtfMintRequest } from "@/lib/queries/etfMintRequest"; +import { useEtfMintRecipe } from "@/lib/queries/etfMintRecipe"; +import { useTransferInstruction } from "@/lib/queries/transferInstruction"; +import { useTransferRequest } from "@/lib/queries/transferRequest"; +import { useTokenFactory } from "@/lib/queries/tokenFactory"; +import { useBalance, TokenBalance } from "@/lib/queries/balance"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; + +interface EtfUserViewProps { + partyId: string; + partyName: string; + custodianPartyId: string; +} + +export function EtfUserView({ + partyId, + partyName, + custodianPartyId, +}: EtfUserViewProps) { + const [selectedMintRecipe, setSelectedMintRecipe] = useState(""); + const [selectedTransferInstructions, setSelectedTransferInstructions] = + useState([]); + const [amount, setAmount] = useState(0); + const [transferAmounts, setTransferAmounts] = useState< + Record + >({ + Token1: 0, + Token2: 0, + Token3: 0, + }); + + const { + get: getEtfMintRequest, + create: createEtfMintRequest, + withdraw: withdrawEtfMintRequest, + } = useEtfMintRequest(partyId, custodianPartyId); + const { get: getMintRecipe } = useEtfMintRecipe(custodianPartyId); + const { get: getTransferInstruction } = useTransferInstruction(partyId); + const { get: getTransferRequest, create: createTransferRequest } = + useTransferRequest(partyId, custodianPartyId); + const { getInstruments, getTransferFactory } = + useTokenFactory(custodianPartyId); + + const requests = getEtfMintRequest.data?.requests || []; + const recipes = getMintRecipe.data?.recipes ?? []; + const instructions = getTransferInstruction.data?.instructions ?? []; + const transferRequests = getTransferRequest.data?.requests ?? []; + const instruments = getInstruments.data?.instruments ?? []; + const transferFactory = getTransferFactory.data; + + const { data: allBalances, isLoading: isLoadingBalances } = + useBalance(partyId); + const balancesRecord = + (allBalances as Record) || undefined; + + const availableInstructions = instructions.filter( + (inst) => inst.transfer.receiver === custodianPartyId + ); + + const pendingTransferRequests = transferRequests.filter( + (req) => req.transfer.receiver === custodianPartyId + ); + + const handleCreateRequest = async () => { + if ( + !selectedMintRecipe || + selectedTransferInstructions.length === 0 || + amount <= 0 + ) { + toast.error("Please fill in all fields"); + return; + } + + try { + await createEtfMintRequest.mutateAsync({ + mintRecipeCid: selectedMintRecipe, + requester: partyId, + amount, + transferInstructionCids: selectedTransferInstructions, + issuer: custodianPartyId, + seed: partyName, + }); + toast.success("ETF mint request created successfully!"); + setSelectedMintRecipe(""); + setSelectedTransferInstructions([]); + setAmount(0); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to create ETF mint request" + ); + } + }; + + const handleWithdrawRequest = async (contractId: string) => { + try { + await withdrawEtfMintRequest.mutateAsync({ + contractId, + requester: partyId, + seed: partyName, + }); + toast.success("ETF mint request withdrawn successfully!"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to withdraw ETF mint request" + ); + } + }; + + const toggleTransferInstruction = (instructionId: string) => { + setSelectedTransferInstructions((prev) => + prev.includes(instructionId) + ? prev.filter((id) => id !== instructionId) + : [...prev, instructionId] + ); + }; + + const handleCreateTransferRequest = async (instrumentId: string) => { + const token = instruments.find( + (inst) => inst.instrumentId === instrumentId + ); + if (!token) return; + + const tokenName = instrumentId.split("#")[1]; + const tokenAmount = transferAmounts[tokenName]; + const balance = balancesRecord?.[instrumentId]; + + if (!balance || !transferFactory?.transferFactoryCid) { + toast.error( + `Missing information for ${tokenName}. Ensure infrastructure is set up.` + ); + return; + } + + if (tokenAmount <= 0 || tokenAmount > (balance.total || 0)) { + toast.error(`Invalid amount for ${tokenName}`); + return; + } + + if (!balance.utxos || balance.utxos.length === 0) { + toast.error(`No tokens available for ${tokenName}`); + return; + } + + try { + const [, tokenName] = instrumentId.split("#"); + + await createTransferRequest.mutateAsync({ + transferFactoryCid: transferFactory.transferFactoryCid, + expectedAdmin: custodianPartyId, + sender: partyId, + receiver: custodianPartyId, + amount: tokenAmount, + instrumentId: { + admin: custodianPartyId, + id: tokenName, + }, + inputHoldingCids: [balance.utxos[0].contractId], + seed: partyName, + }); + toast.success(`Transfer request created for ${tokenName}`); + setTransferAmounts((prev) => ({ ...prev, [tokenName]: 0 })); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : `Failed to create transfer request for ${tokenName}` + ); + } + }; + + return ( +
+ + + Token Balances + + Your current balances for underlying tokens and ETF + + + + {isLoadingBalances ? ( +
+ +
+ ) : ( +
+ {instruments.map((token) => { + const tokenName = token.instrumentId.includes( + "#" + ) + ? token.instrumentId.split("#")[1] + : token.instrumentId; + const balance = + balancesRecord?.[token.instrumentId]; + const isEtf = tokenName.includes("ETF"); + + return ( +
+
+

+ {tokenName} +

+

+ {isEtf + ? "ETF Token" + : token.instrumentId} +

+
+

+ {balance?.total || 0} +

+
+ ); + })} + {instruments.length === 0 && ( +

+ No token balances found +

+ )} +
+ )} +
+
+ + + + Create Transfer Requests + + Create transfer requests for underlying tokens to the + custodian. Once accepted, they become transfer + instructions you can use for ETF minting. + + + + {instruments.filter((inst) => { + const tokenName = inst.instrumentId.includes("#") + ? inst.instrumentId.split("#")[1] + : inst.instrumentId; + return !tokenName.includes("ETF"); + }).length === 0 ? ( +

+ No underlying tokens found. Ensure setup is + complete. +

+ ) : ( + instruments + .filter((inst) => { + const tokenName = inst.instrumentId.includes( + "#" + ) + ? inst.instrumentId.split("#")[1] + : inst.instrumentId; + return !tokenName.includes("ETF"); + }) + .map((token) => { + const tokenName = token.instrumentId.includes( + "#" + ) + ? token.instrumentId.split("#")[1] + : token.instrumentId; + const balance = + balancesRecord?.[token.instrumentId]; + const tokenAmount = + transferAmounts[tokenName] || 0; + const hasBalance = balance && balance.total > 0; + const isPending = + createTransferRequest.isPending && + createTransferRequest.variables + ?.instrumentId.id === tokenName; + const pendingRequests = + pendingTransferRequests.filter((req) => { + const reqTokenName = + req.transfer.instrumentId.id; + const tokenTokenName = + token.instrumentId.includes("#") + ? token.instrumentId.split( + "#" + )[1] + : token.instrumentId; + return reqTokenName === tokenTokenName; + }); + + return ( +
+
+

+ {tokenName} +

+

+ Balance: {balance?.total || 0} +

+
+ + {hasBalance && ( +
+
+ + setTransferAmounts( + (prev) => ({ + ...prev, + [tokenName]: + e.target + .valueAsNumber || + 0, + }) + ) + } + placeholder="Amount" + className="flex-1" + /> + +
+ {pendingRequests.length > 0 && ( +
+ {pendingRequests.length}{" "} + pending request(s) - + waiting for custodian + acceptance +
+ )} +
+ )} +
+ ); + }) + )} +
+
+ + + + ETF Mint Requests + + Create requests to mint ETF tokens using underlying + assets + + + +
+ + + {recipes.length === 0 && ( +

+ No mint recipes available. Ask custodian to + create one. +

+ )} +
+ +
+ +

+ Select transfer instructions for underlying assets + to be used in minting +

+ {availableInstructions.length === 0 ? ( +
+

No transfer instructions available.

+

+ Create transfer requests in the "Create + Transfer Requests" section above. Once the + custodian accepts them, they will appear + here as transfer instructions. +

+ {pendingTransferRequests.length > 0 && ( +

+ {pendingTransferRequests.length}{" "} + transfer request(s) pending acceptance +

+ )} +
+ ) : ( +
+ {availableInstructions.map((instruction) => { + const instrumentId = `${instruction.transfer.instrumentId.admin}#${instruction.transfer.instrumentId.id}`; + const displayName = instrumentId.includes( + "#" + ) + ? instrumentId.split("#")[1] + : instrumentId; + const isSelected = + selectedTransferInstructions.includes( + instruction.contractId + ); + return ( + + ); + })} +
+ )} + {selectedTransferInstructions.length > 0 && ( +

+ {selectedTransferInstructions.length}{" "} + instruction(s) selected +

+ )} +
+ +
+ + + setAmount(e.target.valueAsNumber || 0) + } + placeholder="e.g., 1.0" + /> +
+ + +
+
+ + + + Pending Mint Requests + + {requests.length === 0 + ? "No mint requests created yet" + : `${requests.length} request(s) found`} + + + + {getEtfMintRequest.isLoading ? ( +
+ +
+ ) : ( +
+ {requests.map((request) => ( +
+
+
+

+ Amount: {request.amount} ETF + tokens +

+ + {request.contractId.slice( + 0, + 20 + )} + ... + +
+
+ + Pending + + +
+
+
+

+ Transfer Instructions:{" "} + { + request.transferInstructionCids + .length + } +

+

+ Mint Recipe:{" "} + {request.mintRecipeCid.slice(0, 20)} + ... +

+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/token-app/components/UserView.tsx b/packages/token-app/components/UserView.tsx index 7443f39..23338f9 100644 --- a/packages/token-app/components/UserView.tsx +++ b/packages/token-app/components/UserView.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useBalance } from "@/lib/queries/balance"; +import { useBalance, TokenBalance } from "@/lib/queries/balance"; import { useIssuerMintRequest } from "@/lib/queries/issuerMintRequest"; import { useIssuerBurnRequest } from "@/lib/queries/issuerBurnRequest"; import { useTokenFactory } from "@/lib/queries/tokenFactory"; @@ -51,13 +51,15 @@ export function UserView({ (i) => i.instrumentId === selectedInstrumentId ); - const { data: balance } = useBalance( + const { data: balanceData } = useBalance( partyId, custodianPartyId && selectedInstrumentId ? { admin: custodianPartyId, id: selectedInstrumentId } : null ); + const balance = balanceData as TokenBalance | undefined; + const transferFactory = tokenFactoryQuery.getTransferFactory.data; const issuerMintRequest = useIssuerMintRequest(partyId, custodianPartyId); diff --git a/packages/token-app/lib/queries/allBalances.ts b/packages/token-app/lib/queries/allBalances.ts index 67f33d1..b773938 100644 --- a/packages/token-app/lib/queries/allBalances.ts +++ b/packages/token-app/lib/queries/allBalances.ts @@ -95,5 +95,6 @@ export function useAllBalancesForAllInstruments( isLoadingBalances, hasBalanceError, setupInstruments, + balanceQueries, }; } diff --git a/packages/token-app/lib/queries/balance.ts b/packages/token-app/lib/queries/balance.ts index 437aa63..cab5623 100644 --- a/packages/token-app/lib/queries/balance.ts +++ b/packages/token-app/lib/queries/balance.ts @@ -9,7 +9,7 @@ export function useBalance( owner: string | null, instrumentId?: { admin: string; id: string } | null ) { - return useQuery({ + return useQuery | TokenBalance>({ queryKey: ["balances", owner, instrumentId], queryFn: async () => { if (!owner) throw new Error("Owner required"); @@ -28,7 +28,7 @@ export function useBalance( throw new Error(error.error || "Failed to get balance"); } - return response.json() as Promise; + return response.json(); }, enabled: !!owner, refetchInterval: 5000, diff --git a/packages/token-app/lib/queries/etfMintRecipe.ts b/packages/token-app/lib/queries/etfMintRecipe.ts new file mode 100644 index 0000000..53f9a9e --- /dev/null +++ b/packages/token-app/lib/queries/etfMintRecipe.ts @@ -0,0 +1,74 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +export interface MintRecipe { + contractId: string; + issuer: string; + instrumentId: string; + authorizedMinters: string[]; + composition: string; +} + +export function useEtfMintRecipe(issuer: string | null) { + const queryClient = useQueryClient(); + + const get = useQuery({ + queryKey: ["etfMintRecipes", issuer], + queryFn: async () => { + if (!issuer) { + throw new Error("Issuer required"); + } + + const params = new URLSearchParams({ + issuer, + }); + + const response = await fetch( + `/api/wallet/etf/mint-recipe?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to get mint recipes"); + } + + return response.json() as Promise<{ + recipes: MintRecipe[]; + }>; + }, + enabled: !!issuer, + refetchInterval: 5000, + }); + + const create = useMutation({ + mutationFn: async (params: { + issuer: string; + instrumentId: string; + authorizedMinters: string[]; + composition: string; + seed: string; + }) => { + const response = await fetch("/api/wallet/etf/mint-recipe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to create mint recipe"); + } + + return response.json(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["etfMintRecipes", variables.issuer], + }); + }, + }); + + return { + get, + create, + }; +} diff --git a/packages/token-app/lib/queries/etfMintRequest.ts b/packages/token-app/lib/queries/etfMintRequest.ts new file mode 100644 index 0000000..b8778cc --- /dev/null +++ b/packages/token-app/lib/queries/etfMintRequest.ts @@ -0,0 +1,184 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +export interface EtfMintRequest { + contractId: string; + mintRecipeCid: string; + requester: string; + amount: number; + transferInstructionCids: string[]; + issuer: string; +} + +export function useEtfMintRequest( + partyId: string | null, + issuer: string | null +) { + const queryClient = useQueryClient(); + + const get = useQuery({ + queryKey: ["etfMintRequests", partyId, issuer], + queryFn: async () => { + if (!partyId || !issuer) { + throw new Error("Party ID and issuer required"); + } + + const params = new URLSearchParams({ + partyId, + issuer, + }); + + const response = await fetch( + `/api/wallet/etf/mint-request?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to get ETF mint requests" + ); + } + + return response.json() as Promise<{ + requests: EtfMintRequest[]; + }>; + }, + enabled: !!partyId && !!issuer, + refetchInterval: 5000, + }); + + const create = useMutation({ + mutationFn: async (params: { + mintRecipeCid: string; + requester: string; + amount: number; + transferInstructionCids: string[]; + issuer: string; + seed: string; + }) => { + const response = await fetch("/api/wallet/etf/mint-request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to create ETF mint request" + ); + } + + return response.json(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: [ + "etfMintRequests", + variables.requester, + variables.issuer, + ], + }); + }, + }); + + const accept = useMutation({ + mutationFn: async (params: { + contractId: string; + issuer: string; + seed: string; + }) => { + const response = await fetch( + "/api/wallet/etf/mint-request/accept", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to accept ETF mint request" + ); + } + + return response.json(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["etfMintRequests"], + }); + }, + }); + + const decline = useMutation({ + mutationFn: async (params: { + contractId: string; + issuer: string; + seed: string; + }) => { + const response = await fetch( + "/api/wallet/etf/mint-request/decline", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to decline ETF mint request" + ); + } + + return response.json(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["etfMintRequests"], + }); + }, + }); + + const withdraw = useMutation({ + mutationFn: async (params: { + contractId: string; + requester: string; + seed: string; + }) => { + const response = await fetch( + "/api/wallet/etf/mint-request/withdraw", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to withdraw ETF mint request" + ); + } + + return response.json(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["etfMintRequests"], + }); + }, + }); + + return { + get, + create, + accept, + decline, + withdraw, + }; +} diff --git a/packages/token-app/lib/queries/etfPortfolioComposition.ts b/packages/token-app/lib/queries/etfPortfolioComposition.ts new file mode 100644 index 0000000..ac45d3a --- /dev/null +++ b/packages/token-app/lib/queries/etfPortfolioComposition.ts @@ -0,0 +1,87 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +export interface PortfolioItem { + instrumentId: { + admin: string; + id: string; + }; + weight: number; +} + +export interface PortfolioComposition { + contractId: string; + owner: string; + name: string; + items: PortfolioItem[]; +} + +export function useEtfPortfolioComposition(owner: string | null) { + const queryClient = useQueryClient(); + + const get = useQuery({ + queryKey: ["etfPortfolioCompositions", owner], + queryFn: async () => { + if (!owner) { + throw new Error("Owner required"); + } + + const params = new URLSearchParams({ + owner, + }); + + const response = await fetch( + `/api/wallet/etf/portfolio-composition?${params}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to get portfolio compositions" + ); + } + + return response.json() as Promise<{ + compositions: PortfolioComposition[]; + }>; + }, + enabled: !!owner, + refetchInterval: 5000, + }); + + const create = useMutation({ + mutationFn: async (params: { + owner: string; + name: string; + items: PortfolioItem[]; + seed: string; + }) => { + const response = await fetch( + "/api/wallet/etf/portfolio-composition", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "Failed to create portfolio composition" + ); + } + + return response.json(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["etfPortfolioCompositions", variables.owner], + }); + }, + }); + + return { + get, + create, + }; +} diff --git a/packages/token-app/next-env.d.ts b/packages/token-app/next-env.d.ts index c4b7818..0c7fad7 100644 --- a/packages/token-app/next-env.d.ts +++ b/packages/token-app/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited diff --git a/packages/token-sdk/src/index.ts b/packages/token-sdk/src/index.ts index 7315b3e..2462539 100644 --- a/packages/token-sdk/src/index.ts +++ b/packages/token-sdk/src/index.ts @@ -3,3 +3,4 @@ export * from "./helpers/index.js"; export * from "./types/index.js"; export * from "./wrappedSdk/index.js"; export * from "./sdkHelpers.js"; +export * from "./testScripts/etfSetup.js"; diff --git a/packages/token-sdk/src/testScripts/etfSetup.ts b/packages/token-sdk/src/testScripts/etfSetup.ts new file mode 100644 index 0000000..60f8eaf --- /dev/null +++ b/packages/token-sdk/src/testScripts/etfSetup.ts @@ -0,0 +1,205 @@ +import { signTransactionHash } from "@canton-network/wallet-sdk"; +import { getDefaultSdkAndConnect } from "../sdkHelpers.js"; +import { keyPairFromSeed } from "../helpers/keyPairFromSeed.js"; +import { getWrappedSdkWithKeyPair } from "../wrappedSdk/wrappedSdk.js"; + +/** + * ETF Setup Script + * + * Automates initial setup for ETF functionality: + * 1. Allocates parties (custodian, alice) + * 2. Creates infrastructure for 3 underlying tokens + * 3. Mints underlying tokens to Alice (100 each) + * + * Note: Does NOT create portfolio composition or mint recipe (done in UI) + */ +export async function etfSetup() { + console.info("=== ETF Setup Script ===\n"); + + const custodianSdk = await getDefaultSdkAndConnect(); + const aliceSdk = await getDefaultSdkAndConnect(); + + const custodianKeyPair = keyPairFromSeed("custodian"); + const aliceKeyPair = keyPairFromSeed("alice"); + + const custodianLedger = custodianSdk.userLedger!; + const aliceLedger = aliceSdk.userLedger!; + + const custodianWrappedSdk = getWrappedSdkWithKeyPair( + custodianSdk, + custodianKeyPair + ); + const aliceWrappedSdk = getWrappedSdkWithKeyPair(aliceSdk, aliceKeyPair); + + // === PHASE 1: PARTY ALLOCATION === + console.info("1. Allocating parties..."); + + // Allocate Custodian (issuer/admin) + const custodianParty = await custodianLedger.generateExternalParty( + custodianKeyPair.publicKey, + "custodian" + ); + if (!custodianParty) throw new Error("Error creating Custodian party"); + + const custodianSignedHash = signTransactionHash( + custodianParty.multiHash, + custodianKeyPair.privateKey + ); + const custodianAllocatedParty = await custodianLedger.allocateExternalParty( + custodianSignedHash, + custodianParty + ); + + const aliceParty = await aliceLedger.generateExternalParty( + aliceKeyPair.publicKey, + "alice" + ); + if (!aliceParty) throw new Error("Error creating Alice party"); + + const aliceSignedHash = signTransactionHash( + aliceParty.multiHash, + aliceKeyPair.privateKey + ); + const aliceAllocatedParty = await aliceLedger.allocateExternalParty( + aliceSignedHash, + aliceParty + ); + + await custodianSdk.setPartyId(custodianAllocatedParty.partyId); + await aliceSdk.setPartyId(aliceAllocatedParty.partyId); + + console.info("✓ Parties allocated:"); + console.info(` Custodian (issuer): ${custodianAllocatedParty.partyId}`); + console.info(` Alice (minter): ${aliceAllocatedParty.partyId}\n`); + + // === PHASE 2: INFRASTRUCTURE SETUP === + console.info("2. Setting up infrastructure (underlying tokens)..."); + + // Instrument IDs for 3 underlying tokens + const instrumentId1 = custodianAllocatedParty.partyId + "#Token1"; + const instrumentId2 = custodianAllocatedParty.partyId + "#Token2"; + const instrumentId3 = custodianAllocatedParty.partyId + "#Token3"; + const etfInstrumentId = custodianAllocatedParty.partyId + "#ThreeTokenETF"; + + // Create token rules (shared for all transfers) + const rulesCid = await custodianWrappedSdk.tokenRules.getOrCreate(); + console.info(`✓ MyTokenRules created: ${rulesCid}`); + + // Create token factories for underlying assets + const tokenFactory1Cid = await custodianWrappedSdk.tokenFactory.getOrCreate( + instrumentId1 + ); + console.info(`✓ Token1 factory created: ${tokenFactory1Cid}`); + + const tokenFactory2Cid = await custodianWrappedSdk.tokenFactory.getOrCreate( + instrumentId2 + ); + console.info(`✓ Token2 factory created: ${tokenFactory2Cid}`); + + const tokenFactory3Cid = await custodianWrappedSdk.tokenFactory.getOrCreate( + instrumentId3 + ); + console.info(`✓ Token3 factory created: ${tokenFactory3Cid}`); + + // Create transfer factories for underlying assets + const transferFactory1Cid = + await custodianWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 1 created: ${transferFactory1Cid}`); + + const transferFactory2Cid = + await custodianWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 2 created: ${transferFactory2Cid}`); + + const transferFactory3Cid = + await custodianWrappedSdk.transferFactory.getOrCreate(rulesCid); + console.info(`✓ Transfer factory 3 created: ${transferFactory3Cid}\n`); + + // Note: ETF tokens are created directly via MyMintRecipe (no factory needed) + + // === PHASE 3: MINT UNDERLYING TOKENS TO ALICE === + console.info("3. Minting underlying tokens to Alice (100 each)..."); + + // Token 1 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory1Cid, + issuer: custodianAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 100.0, + }); + const mintRequest1Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + custodianAllocatedParty.partyId + ); + if (!mintRequest1Cid) { + throw new Error("Mint request 1 not found"); + } + await custodianWrappedSdk.issuerMintRequest.accept(mintRequest1Cid); + console.info(" ✓ Token1 minted to Alice (100.0)"); + + // Token 2 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory2Cid, + issuer: custodianAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 100.0, + }); + const mintRequest2Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + custodianAllocatedParty.partyId + ); + if (!mintRequest2Cid) { + throw new Error("Mint request 2 not found"); + } + await custodianWrappedSdk.issuerMintRequest.accept(mintRequest2Cid); + console.info(" ✓ Token2 minted to Alice (100.0)"); + + // Token 3 + await aliceWrappedSdk.issuerMintRequest.create({ + tokenFactoryCid: tokenFactory3Cid, + issuer: custodianAllocatedParty.partyId, + receiver: aliceAllocatedParty.partyId, + amount: 100.0, + }); + const mintRequest3Cid = await aliceWrappedSdk.issuerMintRequest.getLatest( + custodianAllocatedParty.partyId + ); + if (!mintRequest3Cid) { + throw new Error("Mint request 3 not found"); + } + await custodianWrappedSdk.issuerMintRequest.accept(mintRequest3Cid); + console.info(" ✓ Token3 minted to Alice (100.0)"); + + console.info("✓ All 3 underlying tokens minted to Alice\n"); + + // === RETURN SETUP RESULT === + const result = { + parties: { + custodian: custodianAllocatedParty.partyId, + alice: aliceAllocatedParty.partyId, + }, + tokens: { + token1: { + instrumentId: instrumentId1, + tokenFactoryCid: tokenFactory1Cid, + transferFactoryCid: transferFactory1Cid, + }, + token2: { + instrumentId: instrumentId2, + tokenFactoryCid: tokenFactory2Cid, + transferFactoryCid: transferFactory2Cid, + }, + token3: { + instrumentId: instrumentId3, + tokenFactoryCid: tokenFactory3Cid, + transferFactoryCid: transferFactory3Cid, + }, + }, + etf: { + instrumentId: etfInstrumentId, + }, + rulesCid: rulesCid, + }; + + console.info("=== Setup Complete ==="); + console.info(JSON.stringify(result, null, 2)); + + return result; +} diff --git a/packages/token-sdk/src/wrappedSdk/balances.ts b/packages/token-sdk/src/wrappedSdk/balances.ts index f407276..b5b7378 100644 --- a/packages/token-sdk/src/wrappedSdk/balances.ts +++ b/packages/token-sdk/src/wrappedSdk/balances.ts @@ -42,15 +42,16 @@ export async function getBalances( > = {}; utxos.forEach((utxo) => { - const instrumentId = instrumentIdToString(utxo.instrumentId); - if (!balances[instrumentId]) { - balances[instrumentId] = { total: 0, utxos: [] }; + const { id } = utxo.instrumentId; + + if (!balances[id]) { + balances[id] = { total: 0, utxos: [] }; } const amount = Number(utxo.amount); - balances[instrumentId].total += amount; - balances[instrumentId].utxos.push({ + balances[id].total += amount; + balances[id].utxos.push({ amount, contractId: utxo.contractId, }); diff --git a/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts b/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts index d5865a5..d2c87ad 100644 --- a/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts +++ b/packages/token-sdk/src/wrappedSdk/bonds/lifecycleInstruction.ts @@ -15,18 +15,18 @@ import { CreatedEvent } from "../../types/CreatedEvent.js"; export type LifecycleEventType = "CouponPayment" | "Redemption"; -export interface BondLifecycleEffect { - issuer: Party; - depository: Party; - eventType: LifecycleEventType; - targetInstrumentId: string; - targetVersion: string; - producedVersion?: string; - eventDate: number; - settlementTime?: number; - amount: number; - currencyInstrumentId: InstrumentId; -} +// export interface BondLifecycleEffect { +// issuer: Party; +// depository: Party; +// eventType: LifecycleEventType; +// targetInstrumentId: string; +// targetVersion: string; +// producedVersion?: string; +// eventDate: number; +// settlementTime?: number; +// amount: number; +// currencyInstrumentId: InstrumentId; +// } export interface BondLifecycleInstructionParams { eventType: LifecycleEventType;