diff --git a/core/ledger-client/src/token-standard-service.ts b/core/ledger-client/src/token-standard-service.ts index b55bfb59d..cf4cdb980 100644 --- a/core/ledger-client/src/token-standard-service.ts +++ b/core/ledger-client/src/token-standard-service.ts @@ -18,6 +18,8 @@ import { allocationInstructionRegistryTypes, ExtraArgs, Metadata, + FEATURED_APP_DELEGATE_PROXY_INTERFACE_ID, + Beneficiaries, } from '@canton-network/core-token-standard' import { Logger, PartyId } from '@canton-network/core-types' import { LedgerClient } from './ledger-client.js' @@ -837,6 +839,114 @@ class TransferService { } } + async exerciseDelegateProxyTransferInstructionAccept( + proxyCid: string, + transferInstructionCid: string, + registryUrl: URL, + featuredAppRightCid: string, + beneficiaries: Beneficiaries[] + ): Promise<[ExerciseCommand, DisclosedContract[]]> { + const [acceptTransferInstructionContext, disclosedContracts] = + await this.createAcceptTransferInstruction( + transferInstructionCid, + registryUrl.href + ) + + const choiceArgs = { + cid: acceptTransferInstructionContext.contractId, + proxyArg: { + featuredAppRightCid: featuredAppRightCid, + beneficiaries: beneficiaries, + choiceArg: acceptTransferInstructionContext.choiceArgument, + }, + } + + return [ + { + templateId: FEATURED_APP_DELEGATE_PROXY_INTERFACE_ID, + contractId: proxyCid, + choice: 'DelegateProxy_TransferInstruction_Accept', + choiceArgument: choiceArgs, + }, + disclosedContracts, + ] + } + + async exerciseDelegateProxyTransferInstructionReject( + proxyCid: string, + transferInstructionCid: string, + registryUrl: URL, + featuredAppRightCid: string, + beneficiaries: Beneficiaries[] + ): Promise<[ExerciseCommand, DisclosedContract[]]> { + const [rejectTransferInstructionContext, disclosedContracts] = + await this.createRejectTransferInstruction( + transferInstructionCid, + registryUrl.href + ) + + const choiceArgs = { + cid: rejectTransferInstructionContext.contractId, + proxyArg: { + featuredAppRightCid: featuredAppRightCid, + beneficiaries, + choiceArg: rejectTransferInstructionContext.choiceArgument, + }, + } + + return [ + { + templateId: FEATURED_APP_DELEGATE_PROXY_INTERFACE_ID, + contractId: proxyCid, + choice: 'DelegateProxy_TransferInstruction_Reject', + choiceArgument: choiceArgs, + }, + disclosedContracts, + ] + } + + async exerciseDelegateProxyTransferInstructioWithdraw( + proxyCid: string, + transferInstructionCid: string, + registryUrl: URL, + featuredAppRightCid: string, + beneficiaries: Beneficiaries[] + ): Promise<[ExerciseCommand, DisclosedContract[]]> { + const [withdrawTransferInstructionContext, disclosedContracts] = + await this.createWithdrawTransferInstruction( + transferInstructionCid, + registryUrl.href + ) + + const sumOfWeights: number = beneficiaries.reduce( + (totalWeight, beneficiary) => totalWeight + beneficiary.weight, + 0 + ) + + if (sumOfWeights > 1.0) { + throw new Error('Sum of beneficiary weights is larger than 1.') + } + + const choiceArgs = { + cid: withdrawTransferInstructionContext.contractId, + proxyArg: { + featuredAppRightCid: featuredAppRightCid, + beneficiaries, + choiceArg: withdrawTransferInstructionContext.choiceArgument, + }, + } + + return [ + { + templateId: FEATURED_APP_DELEGATE_PROXY_INTERFACE_ID, + contractId: proxyCid, + choice: 'DelegateProxy_TransferInstruction_Withdraw', + choiceArgument: choiceArgs, + }, + disclosedContracts, + ] + } + async createAcceptTransferInstruction( transferInstructionCid: string, registryUrl: string, @@ -1198,13 +1308,13 @@ export class TokenStandardService { async createDelegateProxyTranfser( sender: PartyId, receiver: PartyId, - exchangeParty: PartyId, amount: string, instrumentAdmin: PartyId, // TODO (#907): replace with registry call instrumentId: string, registryUrl: string, featuredAppRightCid: string, proxyCid: string, + beneficiaries: Beneficiaries[], inputUtxos?: string[], memo?: string, expiryDate?: Date, @@ -1224,23 +1334,26 @@ export class TokenStandardService { meta ) + const sumOfWeights: number = beneficiaries.reduce( + (totalWeight, beneficiary) => totalWeight + beneficiary.weight, + 0 + ) + + if (sumOfWeights > 1.0) { + throw new Error('Sum of beneficiary weights is larger than 1.') + } + const choiceArgs = { cid: transferCommand.contractId, proxyArg: { featuredAppRightCid: featuredAppRightCid, - beneficiaries: [ - { - beneficiary: exchangeParty, - weight: 1.0, - }, - ], + beneficiaries, choiceArg: transferCommand.choiceArgument, }, } const exercise: ExerciseCommand = { - templateId: - '#splice-util-featured-app-proxies:Splice.Util.FeaturedApp.DelegateProxy:DelegateProxy', + templateId: FEATURED_APP_DELEGATE_PROXY_INTERFACE_ID, contractId: proxyCid, choice: 'DelegateProxy_TransferFactory_Transfer', choiceArgument: choiceArgs, @@ -1426,4 +1539,43 @@ export class TokenStandardService { return [exercise, disclosed] } + + async exerciseDelegateProxyTransferInstructionAccept( + exchangeParty: PartyId, + proxyCid: string, + transferInstructionCid: string, + registryUrl: string, + featuredAppRightCid: string + ): Promise<[ExerciseCommand, DisclosedContract[]]> { + const [acceptTransferInstructionContext, disclosedContracts] = + await this.transfer.createAcceptTransferInstruction( + transferInstructionCid, + registryUrl + ) + + const choiceArgs = { + cid: acceptTransferInstructionContext.contractId, + proxyArg: { + featuredAppRightCid: featuredAppRightCid, + beneficiaries: [ + { + beneficiary: exchangeParty, + weight: 1.0, + }, + ], + choiceArg: acceptTransferInstructionContext.choiceArgument, + }, + } + + return [ + { + templateId: + '#splice-util-featured-app-proxies:Splice.Util.FeaturedApp.DelegateProxy:DelegateProxy', + contractId: proxyCid, + choice: 'DelegateProxy_TransferInstruction_Accept', + choiceArgument: choiceArgs, + }, + disclosedContracts, + ] + } } diff --git a/core/token-standard/src/interface-ids.const.ts b/core/token-standard/src/interface-ids.const.ts index fbcd6c532..d9c96a621 100644 --- a/core/token-standard/src/interface-ids.const.ts +++ b/core/token-standard/src/interface-ids.const.ts @@ -17,3 +17,5 @@ export const TRANSFER_FACTORY_INTERFACE_ID = '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory' export const TRANSFER_INSTRUCTION_INTERFACE_ID = '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction' +export const FEATURED_APP_DELEGATE_PROXY_INTERFACE_ID = + '#splice-util-featured-app-proxies:Splice.Util.FeaturedApp.DelegateProxy:DelegateProxy' diff --git a/core/token-standard/src/types.ts b/core/token-standard/src/types.ts index da53e69a3..2ac2a5850 100644 --- a/core/token-standard/src/types.ts +++ b/core/token-standard/src/types.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { PartyId } from '@canton-network/core-types' + // Mirrored types from token standard codegen // With current rollup setup it's not possible to bundle types from codegen into this package /dist // TODO(#614) adjust pipeline to bundle types reexported from codegen @@ -94,3 +96,8 @@ export type Transfer = { inputHoldingCids: string[] meta: Metadata } + +export type Beneficiaries = { + beneficiary: PartyId + weight: number +} diff --git a/docs/wallet-integration-guide/examples/package.json b/docs/wallet-integration-guide/examples/package.json index db8219bd4..7fcc4e3b7 100644 --- a/docs/wallet-integration-guide/examples/package.json +++ b/docs/wallet-integration-guide/examples/package.json @@ -25,6 +25,7 @@ "run-12": "tsx ./scripts/12-integration-extensions.ts | pino-pretty", "run-13": "tsx ./scripts/13-auth-tls.ts | pino-pretty", "run-14": "tsx ./scripts/14-token-standard-preapproval-renew-cancel-localnet | pino-pretty", + "run-15": "tsx ./scripts/15-rewards-for-deposits.ts | pino-pretty", "run-exch-01": "tsx ./scripts/exchange-integration/01-one-step-deposit.ts | pino-pretty", "run-exch-02": "tsx ./scripts/exchange-integration/02-one-step-withdrawal.ts | pino-pretty", "run-exch-03": "tsx ./scripts/exchange-integration/03-multi-step-deposit.ts | pino-pretty", diff --git a/docs/wallet-integration-guide/examples/scripts/12-integration-extensions.ts b/docs/wallet-integration-guide/examples/scripts/12-integration-extensions.ts index 156e60675..189610cbc 100644 --- a/docs/wallet-integration-guide/examples/scripts/12-integration-extensions.ts +++ b/docs/wallet-integration-guide/examples/scripts/12-integration-extensions.ts @@ -220,9 +220,15 @@ logger.info(featuredAppRights, 'featured app rights') await sdk.setPartyId(treasuryParty?.partyId!) +const beneficiaries = [ + { + beneficiary: exchangeParty!, + weight: 1.0, + }, +] + const [transferCommand, disclosedContracts2] = await sdk.tokenStandard!.createTransferUsingDelegateProxy( - exchangeParty!, proxyCid!, featuredAppRights?.contract_id!, treasuryParty?.partyId!, @@ -230,6 +236,7 @@ const [transferCommand, disclosedContracts2] = '100', 'Amulet', instrumentAdminPartyId, + beneficiaries, [], 'memo-ref' ) diff --git a/docs/wallet-integration-guide/examples/scripts/15-rewards-for-deposits.ts b/docs/wallet-integration-guide/examples/scripts/15-rewards-for-deposits.ts new file mode 100644 index 000000000..b8d7df889 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-rewards-for-deposits.ts @@ -0,0 +1,281 @@ +import { + WalletSDKImpl, + localNetAuthDefault, + localNetLedgerDefault, + localNetTopologyDefault, + localNetTokenStandardDefault, + createKeyPair, + localValidatorDefault, + localNetStaticConfig, + signTransactionHash, +} from '@canton-network/wallet-sdk' +import path from 'path' +import { pino } from 'pino' +import { v4 } from 'uuid' +import { fileURLToPath } from 'url' +import fs from 'fs/promises' + +const logger = pino({ name: '15-rewards-for-deposits', level: 'info' }) + +// This example script implements https://docs.digitalasset.com/integrate/devnet/exchange-integration/extensions.html#earning-app-rewards-for-deposits +// It requires the /dars/splice-util-featured-app-proxies-1.1.0.dar which is in files of localnet, but it's not uploaded to participant, so we need to do this in the script +// Adjust if to your .localnet location +const PATH_TO_LOCALNET = '../../../../.localnet' +const PATH_TO_DAR_IN_LOCALNET = + '/dars/splice-util-featured-app-proxies-1.1.0.dar' +const SPLICE_UTIL_PROXY_PACKAGE_ID = + '81dd5a9e5c02d0de03208522a895fb85eeb12fbea4aca7c4ad0ad106f3b0bfce' + +// it is important to configure the SDK correctly else you might run into connectivity or authentication issues +const sdk = new WalletSDKImpl().configure({ + logger, + authFactory: localNetAuthDefault, + ledgerFactory: localNetLedgerDefault, + topologyFactory: localNetTopologyDefault, + tokenStandardFactory: localNetTokenStandardDefault, + validatorFactory: localValidatorDefault, +}) + +logger.info('SDK initialized') + +await sdk.connect() +logger.info('Connected to ledger') + +const treasuryKeyPair = createKeyPair() +const aliceKeyPair = createKeyPair() + +await sdk.connectAdmin() +await sdk.connectTopology(localNetStaticConfig.LOCALNET_SCAN_PROXY_API_URL) +sdk.tokenStandard?.setTransferFactoryRegistryUrl( + localNetStaticConfig.LOCALNET_REGISTRY_API_URL +) + +//set up alice with a preapproval from her validator operator +const alice = await sdk.userLedger?.signAndAllocateExternalParty( + aliceKeyPair.privateKey, + 'alice' +) + +const exchangeParty = await sdk.validator?.getValidatorUser() + +logger.info(`Created party: ${alice?.partyId}`) + +await sdk.setPartyId(alice!.partyId) + +const synchronizers = await sdk.userLedger?.listSynchronizers() + +const synchonizerId = synchronizers!.connectedSynchronizers![0].synchronizerId + +const instrumentAdminPartyId = + (await sdk.tokenStandard?.getInstrumentAdmin()) || '' + +const transferPreApprovalProposal = + await sdk.userLedger?.createTransferPreapprovalCommand( + exchangeParty!, + alice?.partyId!, + instrumentAdminPartyId + ) + +await sdk.userLedger?.prepareSignExecuteAndWaitFor( + [transferPreApprovalProposal], + aliceKeyPair.privateKey, + v4() +) + +//upload splice-util-featured-app-proxies dar +const here = path.dirname(fileURLToPath(import.meta.url)) + +const spliceUtilFeaturedAppProxyDarPath = path.join( + here, + PATH_TO_LOCALNET, + PATH_TO_DAR_IN_LOCALNET +) + +const isDarUploaded = await sdk.userLedger?.isPackageUploaded( + SPLICE_UTIL_PROXY_PACKAGE_ID +) +logger.info( + { isDarUploaded }, + 'Status of splice-util-featured-app-proxies dar upload' +) + +if (!isDarUploaded) { + try { + const darBytes = await fs.readFile(spliceUtilFeaturedAppProxyDarPath) + await sdk.adminLedger?.uploadDar(darBytes) + logger.info( + 'splice-util-featured-app-proxies DAR ensured on participant (uploaded or already present)' + ) + } catch (e) { + logger.error( + { e, spliceUtilFeaturedAppProxyDarPath }, + 'Failed to ensure splice-util-featured-app-proxies DAR uploaded' + ) + throw e + } +} + +logger.info(synchronizers, `synchronizer id`) +//featured exchange party is just the validator operator party +await sdk.setPartyId(exchangeParty!, synchonizerId) +await sdk.tokenStandard?.createAndSubmitTapInternal( + exchangeParty!, + '20000000', + { + instrumentId: 'Amulet', + instrumentAdmin: instrumentAdminPartyId, + } +) +await sdk.tokenStandard?.grantFeatureAppRightsForInternalParty() +const featuredAppRights = await sdk.tokenStandard!.lookupFeaturedApps() +logger.info(featuredAppRights, `Featured app rights`) + +//set up treasury party for exchange +const treasuryParty = await sdk.userLedger?.signAndAllocateExternalParty( + treasuryKeyPair.privateKey, + 'TreasuryParty' +) + +logger.info(`Created party: ${treasuryParty?.partyId}`) + +await sdk.setPartyId(treasuryParty?.partyId!) + +const [tapCommand1, disclosedContracts] = await sdk.tokenStandard!.createTap( + treasuryParty!.partyId, + '20000000', + { + instrumentId: 'Amulet', + instrumentAdmin: instrumentAdminPartyId, + } +) + +await sdk.userLedger?.prepareSignExecuteAndWaitFor( + tapCommand1, + treasuryKeyPair.privateKey, + v4(), + disclosedContracts +) +await sdk.userLedger?.grantRights([exchangeParty!], [exchangeParty!]) + +logger.info(`Allocated external treasury for the exchange with some funds`) + +await sdk.setPartyId(exchangeParty!) + +const delegateCommand = await sdk.userLedger?.createDelegateProxyCommand( + exchangeParty!, + treasuryParty!.partyId +) + +logger.info(delegateCommand, `delegate command:`) + +const delegationContractResult = + await sdk.userLedger?.submitCommand(delegateCommand) + +logger.info('Set up wallet provider token standard proxy') + +//analogous to the TestSetup script here: https://github.com/hyperledger-labs/splice/blob/5870d2d8b0c6b9dfcf8afe11ab0685e2ee58342f/daml/splice-util-featured-app-proxies-test/daml/Splice/Scripts/TestFeaturedDepositsAndWithdrawals.daml#L147-L161 + +logger.info('funding alice') +await sdk.setPartyId(alice?.partyId!) + +const [tapCommand2, disclosedContracts2] = await sdk.tokenStandard!.createTap( + alice!.partyId, + '20000000', + { + instrumentId: 'Amulet', + instrumentAdmin: instrumentAdminPartyId, + } +) + +await sdk.userLedger?.prepareSignExecuteAndWaitFor( + tapCommand2, + aliceKeyPair.privateKey, + v4(), + disclosedContracts2 +) + +logger.info('Creating transfer transaction') + +const [transferCommand, disclosedContracts3] = + await sdk.tokenStandard!.createTransfer( + alice!.partyId, + treasuryParty!.partyId, + '100', + { + instrumentId: 'Amulet', + instrumentAdmin: instrumentAdminPartyId, + }, + [], + 'memo-ref' + ) + +await sdk.userLedger?.prepareSignExecuteAndWaitFor( + transferCommand, + aliceKeyPair.privateKey, + v4(), + disclosedContracts3 +) +logger.info('Submitted transfer transaction') + +//Treasury can see the pending transfer + +logger.info(`Fetching proxyCid`) +const end = await sdk.userLedger?.ledgerEnd() + +await sdk.setPartyId(treasuryParty!.partyId) + +const activeContractsForDelegateProxy = await sdk.userLedger?.activeContracts({ + offset: end?.offset!, + filterByParty: true, + parties: [treasuryParty?.partyId!], + templateIds: [ + '#splice-util-featured-app-proxies:Splice.Util.FeaturedApp.DelegateProxy:DelegateProxy', + ], +}) + +const proxyCid = + activeContractsForDelegateProxy![0].contractEntry.JsActiveContract + ?.createdEvent.contractId + +logger.info(proxyCid, `Fetched proxyCid`) + +const pendingInstructions = + await sdk.tokenStandard?.fetchPendingTransferInstructionView() + +const transferCid = pendingInstructions?.[0].contractId! + +const [acceptCommand, disclosedContracts4] = + await sdk.tokenStandard?.exerciseTransferInstructionChoiceWithDelegate( + transferCid, + 'Accept', + proxyCid!, + featuredAppRights?.contract_id!, + [ + { + beneficiary: exchangeParty!, + weight: 1.0, + }, + ], + featuredAppRights! + )! + +try { + await sdk.userLedger?.prepareSignExecuteAndWaitFor( + acceptCommand, + treasuryKeyPair.privateKey, + v4(), + disclosedContracts4 + ) +} catch (error) { + logger.error({ error }, 'Failed to accept transfer') +} + +{ + await sdk.setPartyId(treasuryParty!.partyId) + const treasuryHoldings = await sdk.tokenStandard?.listHoldingTransactions() + logger.info(treasuryHoldings, '[TREASURY PARTY] holding transactions') + + await sdk.setPartyId(alice!.partyId) + const aliceHoldings = await sdk.tokenStandard?.listHoldingTransactions() + logger.info(aliceHoldings, '[ALICE] holding transactions') +} diff --git a/sdk/wallet-sdk/src/tokenStandardController.ts b/sdk/wallet-sdk/src/tokenStandardController.ts index ae51b4f61..59edf7b98 100644 --- a/sdk/wallet-sdk/src/tokenStandardController.ts +++ b/sdk/wallet-sdk/src/tokenStandardController.ts @@ -31,6 +31,7 @@ import { Metadata, transferInstructionRegistryTypes, allocationInstructionRegistryTypes, + Beneficiaries, } from '@canton-network/core-token-standard' import { PartyId } from '@canton-network/core-types' import { WrappedCommand } from './ledgerController.js' @@ -40,6 +41,14 @@ export type AllocationInstructionChoice = 'Withdraw' export type AllocationChoice = 'ExecuteTransfer' | 'Withdraw' | 'Cancel' export type AllocationRequestChoice = 'Reject' | 'Withdraw' +export type FeaturedAppRight = { + template_id: string + contract_id: string + payload: Record + created_event_blob: string + created_at: string +} + /** * TokenStandardController handles token standard management tasks. * This controller requires a userId and token. @@ -591,7 +600,10 @@ export class TokenStandardController { * Has an in built retry and delay between attempts * @returns If defined, a contract of Daml template `Splice.Amulet.FeaturedAppRight`. */ - async lookupFeaturedApps(maxRetries = 10, delayMs = 5000) { + async lookupFeaturedApps( + maxRetries = 10, + delayMs = 5000 + ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { const result = await this.service.getFeaturedAppsByParty( this.getPartyId() @@ -703,6 +715,80 @@ export class TokenStandardController { } } + async exerciseTransferInstructionChoiceWithDelegate( + transferInstructionCid: string, + instructionChoice: TransactionInstructionChoice, + proxyCid: string, + featuredAppRightCid: string, + beneficiaries: Beneficiaries[], + featuredAppRight2: FeaturedAppRight + ): Promise< + [WrappedCommand<'ExerciseCommand'>, Types['DisclosedContract'][]] + > { + let ExerciseCommand: ExerciseCommand + let disclosedContracts: DisclosedContract[] + + const featuredAppDisclosedContract = { + templateId: featuredAppRight2.template_id, + contractId: featuredAppRight2.contract_id, + createdEventBlob: featuredAppRight2.created_event_blob!, + synchronizerId: this.getSynchronizerId(), + } + + try { + switch (instructionChoice) { + case 'Accept': + ;[ExerciseCommand, disclosedContracts] = + await this.service.transfer.exerciseDelegateProxyTransferInstructionAccept( + proxyCid, + transferInstructionCid, + this.getTransferFactoryRegistryUrl(), + featuredAppRightCid, + beneficiaries + ) + return [ + { ExerciseCommand }, + [featuredAppDisclosedContract, ...disclosedContracts], + ] + case 'Reject': + ;[ExerciseCommand, disclosedContracts] = + await this.service.transfer.exerciseDelegateProxyTransferInstructionReject( + proxyCid, + transferInstructionCid, + this.getTransferFactoryRegistryUrl(), + featuredAppRightCid, + beneficiaries + ) + return [ + { ExerciseCommand }, + [featuredAppDisclosedContract, ...disclosedContracts], + ] + case 'Withdraw': + ;[ExerciseCommand, disclosedContracts] = + await this.service.transfer.exerciseDelegateProxyTransferInstructioWithdraw( + proxyCid, + transferInstructionCid, + this.getTransferFactoryRegistryUrl(), + featuredAppRightCid, + beneficiaries + ) + + return [ + { ExerciseCommand }, + [featuredAppDisclosedContract, ...disclosedContracts], + ] + default: + throw new Error('Unexpected transfer instruction choice') + } + } catch (error) { + this.logger.error( + { error }, + 'Failed to exercise transfer instruction choice' + ) + throw error + } + } + /** * Builds and fetches the registry context for a transfer factory call. * Use this to prefetch context for offline signing. @@ -770,7 +856,6 @@ export class TokenStandardController { * @returns A promise that resolves to the ExerciseCommand which creates the transfer. */ async createTransferUsingDelegateProxy( - exchangeParty: PartyId, proxyCid: string, featuredAppRightCid: string, sender: PartyId, @@ -778,6 +863,7 @@ export class TokenStandardController { amount: string, instrumentId: string, instrumentAdmin: PartyId, + beneficiaries: Beneficiaries[], inputUtxos?: string[], memo?: string, expiryDate?: Date, @@ -789,13 +875,13 @@ export class TokenStandardController { await this.service.createDelegateProxyTranfser( sender, receiver, - exchangeParty, amount, instrumentAdmin, instrumentId, this.getTransferFactoryRegistryUrl().href, featuredAppRightCid, proxyCid, + beneficiaries, inputUtxos, memo, expiryDate,