diff --git a/examples/test-token-v1-registry/src/api/allocation-instruction/allocationInstruction.test.ts b/examples/test-token-v1-registry/src/api/allocation-instruction/allocationInstruction.test.ts index 169110a27..2b4e34757 100644 --- a/examples/test-token-v1-registry/src/api/allocation-instruction/allocationInstruction.test.ts +++ b/examples/test-token-v1-registry/src/api/allocation-instruction/allocationInstruction.test.ts @@ -5,6 +5,7 @@ import { describe, vi, it, expect, beforeEach } from 'vitest' import { expressContext, mock, RequestType } from '../../__test__/mocks' import { APIError, emptyChoiceContext } from '../common' import { getAllocationFactory } from './getAllocationFactory' +import { synchronizerId } from '../../common/synchronizer' const { res, next } = expressContext @@ -48,6 +49,8 @@ vi.mock('@canton-network/core-splice-codegen', () => ({ describe('Allocation Instruction', () => { beforeEach(() => { vi.clearAllMocks() + synchronizerId.transferInstruction = '' + synchronizerId.allocationInstruction = '' }) it('should successfully return factory contract from acs reader', async () => { @@ -105,4 +108,48 @@ describe('Allocation Instruction', () => { choiceContext: emptyChoiceContext, }) }) + + it('should return factory matching allocation synchronizer id', async () => { + const request = {} as RequestType + + synchronizerId.allocationInstruction = 'allocation-sync-id' + mock.sdk.ledger.acsReader.readJsContracts.mockResolvedValueOnce([ + { + contractId: 'cid-1', + synchronizerId: 'some-other-sync-id', + }, + { + contractId: 'cid-2', + synchronizerId: 'allocation-sync-id', + }, + ]) + + await getAllocationFactory(request, res, next) + + expect(res.json).toHaveBeenCalledWith({ + factoryId: 'cid-2', + choiceContext: emptyChoiceContext, + }) + }) + + it('should pass allocation synchronizer id when creating factory contract', async () => { + const request = {} as RequestType + + synchronizerId.allocationInstruction = 'allocation-sync-id' + mock.sdk.ledger.acsReader.readJsContracts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + contractId: 'cid', + }, + ]) + + await getAllocationFactory(request, res, next) + + expect(mock.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + synchronizerId: 'allocation-sync-id', + }) + ) + }) }) diff --git a/examples/test-token-v1-registry/src/api/allocation-instruction/getAllocationFactory.ts b/examples/test-token-v1-registry/src/api/allocation-instruction/getAllocationFactory.ts index cc8f85dbb..dda8be8c5 100644 --- a/examples/test-token-v1-registry/src/api/allocation-instruction/getAllocationFactory.ts +++ b/examples/test-token-v1-registry/src/api/allocation-instruction/getAllocationFactory.ts @@ -7,6 +7,7 @@ import { TestToken } from '@canton-network/core-splice-codegen' import { APIError, emptyChoiceContext } from '../common' import { OffLedger } from '@canton-network/core-token-standard' import { TExpressOpenApiRequestHandler } from 'openapi-ts-router/express' +import { synchronizerId } from '../../common/synchronizer' /** * Resolves or creates an allocation factory for initiating allocation workflows. @@ -18,17 +19,30 @@ export const getAllocationFactory: TExpressOpenApiRequestHandler< OffLedger.AllocationInstructionV1.paths['/registry/allocation-instruction/v1/allocation-factory']['post'] > = async (_req, res, next) => { // fetch factory contract (if existing)... - const fetchedFactory = ( - await sdk.ledger.acsReader.readJsContracts({ - filterByParty: true, - parties: [operator.party], - templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId], - }) - )[0] + const fetchedFactories = await sdk.ledger.acsReader.readJsContracts({ + filterByParty: true, + parties: [operator.party], + templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId], + }) + + // multi-sync mode + if (synchronizerId.allocationInstruction) { + const syncFactory = fetchedFactories.find( + (factory) => + factory.synchronizerId === synchronizerId.allocationInstruction + ) + if (syncFactory) { + res.json({ + factoryId: syncFactory.contractId, + choiceContext: emptyChoiceContext, + }) + return + } + } - if (fetchedFactory) { + if (fetchedFactories[0]) { res.json({ - factoryId: fetchedFactory.contractId, + factoryId: fetchedFactories[0].contractId, choiceContext: emptyChoiceContext, }) return @@ -41,6 +55,9 @@ export const getAllocationFactory: TExpressOpenApiRequestHandler< commands: TestToken.commands.create.rules({ admin: operator.party, }), + ...(synchronizerId.allocationInstruction + ? { synchronizerId: synchronizerId.allocationInstruction } + : {}), }) .sign(operator.keys.privateKey) .execute({ diff --git a/examples/test-token-v1-registry/src/api/transfer-instruction/getTransferFactory.ts b/examples/test-token-v1-registry/src/api/transfer-instruction/getTransferFactory.ts index 5497656b2..ef2577ff6 100644 --- a/examples/test-token-v1-registry/src/api/transfer-instruction/getTransferFactory.ts +++ b/examples/test-token-v1-registry/src/api/transfer-instruction/getTransferFactory.ts @@ -8,13 +8,14 @@ import z from 'zod' import { APIError, emptyChoiceContext } from '../common' import { OffLedger } from '@canton-network/core-token-standard' import { TExpressOpenApiRequestHandler } from 'openapi-ts-router/express' +import { synchronizerId } from '../../common/synchronizer' export const getTransferFactoryChoiceArgumentsSchema = z.object({ sender: z.string(), receiver: z.string(), - transferKind: z.optional( - z.union([z.literal('self'), z.literal('offer'), z.literal('direct')]) - ), + transferKind: z + .union([z.literal('self'), z.literal('offer'), z.literal('direct')]) + .optional(), }) /** @@ -51,17 +52,31 @@ export const getTransferFactory: TExpressOpenApiRequestHandler< parsedChoiceArguments.data.transferKind ?? (isToSelf ? 'self' : 'offer') // fetch the factory contract (if existing)... - const fetchedFactory = ( - await sdk.ledger.acsReader.readJsContracts({ - filterByParty: true, - parties: [operator.party], - templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId], - }) - )[0] + const fetchedFactories = await sdk.ledger.acsReader.readJsContracts({ + filterByParty: true, + parties: [operator.party], + templateIds: [TestToken.DAR.TestTokenV1.TokenRules.templateId], + }) + + // multi-sync mode + if (synchronizerId.transferInstruction) { + const syncFactory = fetchedFactories.find( + (factory) => + factory.synchronizerId === synchronizerId.transferInstruction + ) + if (syncFactory) { + res.json({ + factoryId: syncFactory.contractId, + transferKind, + choiceContext: emptyChoiceContext, + }) + return + } + } - if (fetchedFactory) { + if (fetchedFactories[0]) { res.json({ - factoryId: fetchedFactory.contractId, + factoryId: fetchedFactories[0].contractId, transferKind, choiceContext: emptyChoiceContext, }) @@ -75,6 +90,9 @@ export const getTransferFactory: TExpressOpenApiRequestHandler< commands: TestToken.commands.create.rules({ admin: operator.party, }), + ...(synchronizerId.transferInstruction + ? { synchronizerId: synchronizerId.transferInstruction } + : {}), }) .sign(operator.keys.privateKey) .execute({ diff --git a/examples/test-token-v1-registry/src/api/transfer-instruction/transferInstruction.test.ts b/examples/test-token-v1-registry/src/api/transfer-instruction/transferInstruction.test.ts index 9620fc11a..f3c4ea86f 100644 --- a/examples/test-token-v1-registry/src/api/transfer-instruction/transferInstruction.test.ts +++ b/examples/test-token-v1-registry/src/api/transfer-instruction/transferInstruction.test.ts @@ -8,6 +8,7 @@ import { getTransferInstructionWithdrawContext } from './getTransferInstructionW import { getTransferFactory } from './getTransferFactory' import { APIError, emptyChoiceContext } from '../common' import { expressContext, mock, RequestType } from '../../__test__/mocks' +import { synchronizerId } from '../../common/synchronizer' const { res, next } = expressContext @@ -51,6 +52,8 @@ vi.mock('@canton-network/core-splice-codegen', () => ({ describe('Transfer Instruction', () => { beforeEach(() => { vi.clearAllMocks() + synchronizerId.transferInstruction = '' + synchronizerId.allocationInstruction = '' }) it('should get accept choice context', () => { @@ -250,5 +253,56 @@ describe('Transfer Instruction', () => { choiceContext: emptyChoiceContext, }) }) + + it('should return factory matching transfer synchronizer id', async () => { + const request = getTransferFactoryRequest({ + sender: 's', + receiver: 'r', + }) + + synchronizerId.transferInstruction = 'transfer-sync-id' + mock.sdk.ledger.acsReader.readJsContracts.mockResolvedValueOnce([ + { + contractId: 'cid-1', + synchronizerId: 'some-other-sync-id', + }, + { + contractId: 'cid-2', + synchronizerId: 'transfer-sync-id', + }, + ]) + + await getTransferFactory(request, res, next) + + expect(res.json).toHaveBeenCalledWith({ + factoryId: 'cid-2', + transferKind: 'offer', + choiceContext: emptyChoiceContext, + }) + }) + + it('should pass transfer synchronizer id when creating factory contract', async () => { + const request = getTransferFactoryRequest({ + sender: 's', + receiver: 'r', + }) + + synchronizerId.transferInstruction = 'transfer-sync-id' + mock.sdk.ledger.acsReader.readJsContracts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + contractId: 'cid', + }, + ]) + + await getTransferFactory(request, res, next) + + expect(mock.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + synchronizerId: 'transfer-sync-id', + }) + ) + }) }) }) diff --git a/examples/test-token-v1-registry/src/common/operator.ts b/examples/test-token-v1-registry/src/common/operator.ts index 4b58f51f4..64b9cdf3d 100644 --- a/examples/test-token-v1-registry/src/common/operator.ts +++ b/examples/test-token-v1-registry/src/common/operator.ts @@ -8,7 +8,11 @@ export const operator = { keys: sdk.keys.generate(), } -export const initOperatorParty = async () => { +export const initOperatorParty = async (admin?: typeof operator) => { + if (admin) { + Object.assign(operator, admin) + return + } const createdParty = await sdk.party.external .create(operator.keys.publicKey, { partyHint: 'operator', diff --git a/examples/test-token-v1-registry/src/common/synchronizer.test.ts b/examples/test-token-v1-registry/src/common/synchronizer.test.ts new file mode 100644 index 000000000..573200e7c --- /dev/null +++ b/examples/test-token-v1-registry/src/common/synchronizer.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, afterEach } from 'vitest' +import { assignSynchronizerIds, synchronizerId } from './synchronizer' + +describe('synchronizer', () => { + afterEach(() => { + Object.assign(synchronizerId, { + transferInstruction: '', + allocationInstruction: '', + }) + }) + it('should be set to empty strings by default', () => { + expect(synchronizerId).toStrictEqual({ + transferInstruction: '', + allocationInstruction: '', + }) + }) + it('should properly assign syncrhonizers', () => { + const expectedResult = { + transferInstruction: 'transfer-sync-id', + allocationInstruction: 'allocation-sync-id', + } + + assignSynchronizerIds(expectedResult) + + expect(synchronizerId).toStrictEqual(expectedResult) + }) +}) diff --git a/examples/test-token-v1-registry/src/common/synchronizer.ts b/examples/test-token-v1-registry/src/common/synchronizer.ts new file mode 100644 index 000000000..efe560faf --- /dev/null +++ b/examples/test-token-v1-registry/src/common/synchronizer.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const synchronizerId = { + transferInstruction: '', + allocationInstruction: '', +} + +export const assignSynchronizerIds = (sync: typeof synchronizerId) => { + Object.assign(synchronizerId, sync) +} diff --git a/examples/test-token-v1-registry/src/index.test.ts b/examples/test-token-v1-registry/src/index.test.ts index e6dbf504a..ede07c556 100644 --- a/examples/test-token-v1-registry/src/index.test.ts +++ b/examples/test-token-v1-registry/src/index.test.ts @@ -3,28 +3,49 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -const use = vi.fn().mockReturnThis() -const listen = vi.fn() -const json = vi.fn() +const mocks = vi.hoisted(() => { + const use = vi.fn().mockReturnThis() + const close = vi.fn() + const listen = vi.fn().mockReturnValue({ + close, + }) + const json = vi.fn() -const expressFactory = vi.fn(() => ({ - use, - listen, -})) + const expressFactory = vi.fn(() => ({ + use, + listen, + })) + + const initOperatorParty = vi.fn() + const vetDar = vi.fn() -const initOperatorParty = vi.fn() -const vetDar = vi.fn() + return { + use, + close, + listen, + json, + expressFactory, + initOperatorParty, + vetDar, + } +}) vi.mock('express', () => { - const defaultFn = () => expressFactory() - defaultFn.json = json + const defaultFn = () => mocks.expressFactory() + defaultFn.json = mocks.json return { default: defaultFn, } }) vi.mock('./common/operator', () => ({ - initOperatorParty, + initOperatorParty: mocks.initOperatorParty, + operator: { + party: 'operator-party', + keys: { + privateKey: 'operator-private-key', + }, + }, })) vi.mock('./common/sdk', () => ({ @@ -50,7 +71,7 @@ vi.mock('./api/allocation-instruction/index.js', () => ({ vi.mock('@canton-network/core-splice-codegen', () => ({ TestToken: { utils: { - vetDar, + vetDar: mocks.vetDar, }, }, })) @@ -60,14 +81,35 @@ describe('entry file', () => { vi.clearAllMocks() }) + it("shouldn't do anything", async () => { + const { stopRegistry } = await import('.') + + stopRegistry() + expect(mocks.close).not.toHaveBeenCalled() + }) + it('should initialize the app and start listening', async () => { - await import('./index') - - expect(initOperatorParty).toHaveBeenCalledOnce() - expect(json).toHaveBeenCalledOnce() - expect(use).toHaveBeenCalledTimes(6) - expect(listen).toHaveBeenCalledOnce() - expect(listen).toHaveBeenCalledWith(5634, expect.any(Function)) - expect(vetDar).not.toHaveBeenCalled() + const { startRegistry } = await import('.') + + await startRegistry() + + expect(mocks.initOperatorParty).toHaveBeenCalledOnce() + expect(mocks.json).toHaveBeenCalledOnce() + expect(mocks.use).toHaveBeenCalledTimes(6) + expect(mocks.listen).toHaveBeenCalledOnce() + expect(mocks.listen).toHaveBeenCalledWith(5634, expect.any(Function)) + expect(mocks.vetDar).not.toHaveBeenCalled() + }) + + it('should properly close the server', async () => { + const { startRegistry, stopRegistry } = await import('.') + + await startRegistry() + + expect(mocks.close).not.toHaveBeenCalled() + + stopRegistry() + + expect(mocks.close).toHaveBeenCalledOnce() }) }) diff --git a/examples/test-token-v1-registry/src/index.ts b/examples/test-token-v1-registry/src/index.ts index 036010c98..176380857 100644 --- a/examples/test-token-v1-registry/src/index.ts +++ b/examples/test-token-v1-registry/src/index.ts @@ -6,38 +6,64 @@ import allocationAPIRouter from './api/allocation/index.js' import { APIError } from './api/common' import metadataAPIRouter from './api/metadata/index.js' import transferInstructionAPIRouter from './api/transfer-instruction/index.js' -import { initOperatorParty } from './common/operator' +import { initOperatorParty, operator } from './common/operator' import express, { ErrorRequestHandler, Request, Response } from 'express' import { TestToken } from '@canton-network/core-splice-codegen' import sdk from './common/sdk' +import { Server } from 'http' +import { assignSynchronizerIds } from './common/synchronizer.js' -const app = express() +let server: Server -await initOperatorParty() +export const startRegistry = async ( + options?: Partial<{ + operator: typeof operator + synchronizerIds: { + transferInstruction: string + allocationInstruction: string + } + }> +) => { + const app = express() -/** - * @customize The registry shouldn't be responsible for vetting daml files. We're doing this for development purposes only. Feel free to remove this when constructing your own token. - */ -if (process.env.NODE_ENV === 'development') await TestToken.utils.vetDar(sdk) + await initOperatorParty(options?.operator) + if (options?.synchronizerIds) { + assignSynchronizerIds(options.synchronizerIds) + } -const errorMiddleware: ErrorRequestHandler = ( - error: Error, - _req: Request, - res: Response -) => { - if (error instanceof APIError) { - res.status(error.status).send({ - error: error.message, - }) - return + /** + * @customize The registry shouldn't be responsible for vetting daml files. We're doing this for development purposes only. Feel free to remove this when constructing your own token. + */ + if (process.env.NODE_ENV === 'development') + await TestToken.utils.vetDar(sdk) + + const errorMiddleware: ErrorRequestHandler = ( + error: Error, + _req: Request, + res: Response + ) => { + if (error instanceof APIError) { + res.status(error.status).send({ + error: error.message, + }) + return + } + res.status(500).send({ error: error.message }) } - res.status(500).send({ error: error.message }) + + server = app + .use(express.json()) + .use(metadataAPIRouter) + .use(transferInstructionAPIRouter) + .use(allocationAPIRouter) + .use(allocationInstructionAPIRouter) + .use(errorMiddleware) + .listen(5634, () => + console.info('api listening on http://localhost:5634') + ) } -app.use(express.json()) - .use(metadataAPIRouter) - .use(transferInstructionAPIRouter) - .use(allocationAPIRouter) - .use(allocationInstructionAPIRouter) - .use(errorMiddleware) - .listen(5634, () => console.info('api listening on http://localhost:5634')) +export const stopRegistry = () => { + if (!server) return + server.close() +}