diff --git a/README.md b/README.md index 700383f0f..6e4b84fa8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Shared libraries used by the Wallet Gateway, SDKs, and signing providers: | `core-signing-fireblocks` | [`core/signing-fireblocks`](core/signing-fireblocks) | Fireblocks signing driver integration | | `core-signing-blockdaemon` | [`core/signing-blockdaemon`](core/signing-blockdaemon) | Blockdaemon signing driver integration | | `core-signing-securosys` | [`core/signing-securosys`](core/signing-securosys) | Securosys TSB signing driver integration | +| `core-signing-taurus-protect` | [`core/signing-taurus-protect`](core/signing-taurus-protect) | Taurus-PROTECT signing driver integration | | **RPC & Transport** | | | | `core-types` | [`core/types`](core/types) | Shared types and transport-agnostic parsers | | `core-rpc-transport` | [`core/rpc-transport`](core/rpc-transport) | RPC transport implementations | diff --git a/core/signing-lib/src/config/schema.ts b/core/signing-lib/src/config/schema.ts index 0f1205954..12bfe2f6d 100644 --- a/core/signing-lib/src/config/schema.ts +++ b/core/signing-lib/src/config/schema.ts @@ -10,6 +10,7 @@ export enum SigningProvider { BLOCKDAEMON = 'blockdaemon', DFNS = 'dfns', SECUROSYS = 'securosys', + TAURUS_PROTECT = 'taurus-protect', } // Generic signing driver configuration schema diff --git a/core/signing-taurus-protect/README.md b/core/signing-taurus-protect/README.md new file mode 100644 index 000000000..eab7500cd --- /dev/null +++ b/core/signing-taurus-protect/README.md @@ -0,0 +1,152 @@ +# @canton-network/core-signing-taurus-protect + +This package provides a signing driver for integrating the Wallet Gateway with [Taurus-PROTECT](https://www.taurushq.com/). It implements the `SigningDriverInterface` defined in `@canton-network/core-signing-lib`, allowing the Wallet Gateway to submit Canton commands through a Taurus-PROTECT Canton gateway. + +## Tenancy model + +This driver is **single-tenant by design**. One machine bearer token serves every +Wallet Gateway user — `controller(userId)` ignores its `userId`, and the gateway's +account list is tenant-global. Consequences to be aware of before deploying: + +- Any authenticated Wallet Gateway user can list every party in the Taurus-PROTECT + tenant and import any of them as their own wallet. +- Signing requests carry no per-user identity, so the gateway cannot attribute a + submission to the Wallet Gateway user who triggered it. + +Deploy this driver only where all Wallet Gateway users are equally trusted with +every party in the tenant. Per-user scoping would require per-user credentials at +the gateway, which the current API does not offer. + +## Installation + +This package is part of the Wallet Gateway monorepo and is typically installed as a workspace dependency. + +```bash +pnpm add @canton-network/core-signing-taurus-protect +``` + +## Usage + +The `TaurusProtectSigningDriver` requires the gateway's JSON-RPC base URL and a bearer api-key. + +### Initialization + +```typescript +import TaurusProtectSigningDriver, { + TaurusProtectConfig, +} from '@canton-network/core-signing-taurus-protect' + +const config: TaurusProtectConfig = { + baseUrl: '', + token: '', +} + +const driver = new TaurusProtectSigningDriver(config) +``` + +### Features + +Unlike the sign-only providers, Taurus-PROTECT is a **submit** provider: the gateway prepares, signs (ECDSA P-256) and submits each CIP-103 command against its own validator under Taurus governance. This driver never signs a hash itself — it forwards commands and tracks status. + +- **Key Management**: + - `getKeys`: Lists Canton parties already provisioned in Taurus-PROTECT, filtered to `allocated` parties that carry a `publicKey`. Parties are named by their prefix (falling back to the partyId). + - `createKey`: Not supported — parties are provisioned in Taurus-PROTECT, and this driver only imports existing ones. +- **Signing**: + - `signTransaction`: Expects `tx` to be a JSON-encoded CIP-103 command (`{ commands, actAs?, commandId?, preparedTransaction? }`) and forwards it to the gateway. Always returns `pending` — signing and execution happen asynchronously under governance. Only those four fields reach the gateway; see [Known gateway limitations](#known-gateway-limitations) for `disclosedContracts` / `readAs` / `packageIdSelectionPreference`. + - `signMessage`: Not supported yet. +- **Status**: + - `getTransaction`: Polls the gateway for command status. An optional `requestId` re-seeds the RPC fallback after a restart, when the client-side cache is cold. + - `getTransactions`: Batch variant keyed by `txIds` (required — Taurus-PROTECT does not enumerate by public key). It takes no `requestId`, so commands whose cached mapping is gone are omitted from the result. +- **Configuration**: + - `getConfiguration` / `setConfiguration`. `getConfiguration` masks the token. + +### Status mapping + +`core-signing-lib` has no `executed` state, so the real gateway state is carried in `metadata.gatewayStatus` alongside the mapped `SigningStatus`: + +| Gateway status | `SigningStatus` | `metadata.gatewayStatus` | +| :------------- | :-------------- | :----------------------- | +| `pending` | `pending` | `pending` | +| `signed` | `signed` | `signed` | +| `executed` | `signed` | `executed` | +| `failed` | `failed` | `failed` | +| anything else | `failed` | the raw value | + +`metadata` also carries `requestId` and `commandId` on submission, and `updateId` / `contractId` once the gateway reports the ledger result. `TransactionService` never posts to the ledger for this provider, and treats the command as complete only on `failed`, or on `gatewayStatus === 'executed'` once the `updateId` is present — the ledger `updateId` then stands in for the signature. + +## Configuration + +The driver accepts a `TaurusProtectConfig` object: + +| Property | Type | Required | Description | +| :-------- | :------- | :------- | :----------------------------------------------------------------------------- | +| `baseUrl` | `string` | Yes | Base URL of the Taurus-PROTECT Canton gateway JSON-RPC endpoint. | +| `token` | `string` | Yes | Bearer api-key (HMAC-JWT); mint one via the gateway's `api-key issue` command. | + +### Wallet Gateway Configuration + +When running the Wallet Gateway (Remote), the driver is registered only when both variables are set; otherwise it logs a warning and stays unavailable. + +- `TAURUS_PROTECT_GATEWAY_URL`: Base URL of the Taurus-PROTECT Canton gateway. +- `TAURUS_PROTECT_GATEWAY_TOKEN`: Bearer api-key for that gateway. + +Example usage: + +```bash +TAURUS_PROTECT_GATEWAY_URL="" \ +TAURUS_PROTECT_GATEWAY_TOKEN="" \ +pnpm start +``` + +## Known gateway limitations + +Behaviours of the Taurus-PROTECT Canton gateway that this driver works around or cannot work +around. Each is a gateway-side issue; none is a wallet bug. + +### The command's arguments are validated and then discarded (worked around) + +On the encoded path — which is the only path this driver uses, because the Wallet Gateway prepares +every transaction itself — the gateway converts the command's entire argument tree, and _then_ +replaces it with the `preparedTransaction` and forwards only that. Nothing it validated is ever +used, but the validation still rejects: + +| Gateway rule | What a real Splice command carries | +| :------------------------------- | :--------------------------------------------------------------------- | +| `templateId` must begin with `#` | a registry package-id, e.g. `6c5802f8…:Splice.AmuletRules:AmuletRules` | +| bare JSON arrays unsupported | `inputs: […]`, `issuingMiningRounds: []` | +| bare `null` ambiguous | `featuredAppRight: null` | + +So `GatewayClient.prepareExecute` normalises the command down to the routing fields the gateway +actually reads: it prefixes `#` on a templateId that lacks one, blanks `createArguments` / +`choiceArgument`, and drops everything else. This applies **only** when a non-empty +`preparedTransaction` is present — with an empty one the gateway takes the structured path, where +those arguments _are_ the submission, and blanking them would put an argument-less contract in +front of an approver. + +**Delete `routingOnlyCommands` once the gateway stops converting a tree it discards** (taking the +create-vs-exercise discriminator straight off the wire when a `preparedTransaction` is set). + +### `disclosedContracts`, `readAs` and `packageIdSelectionPreference` are inert + +The gateway has no `prepareExecute` field for `disclosedContracts` or +`packageIdSelectionPreference`, and does not reject unknown JSON — they are silently dropped. It +parses `readAs` and never reads it, deliberately: validatord's governance rules are the sole +authority on read access. All three were already baked into the `preparedTransaction` at prepare +time, so nothing is lost, and this driver does not send them. + +### Cannot be worked around from here + +| Limitation | Effect | +| :------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The gateway reads the request body through a 1 MiB `io.LimitReader`, which truncates _silently_ | An oversized request comes back as a bare `-32700` parse error. `GatewayClient` pre-checks the body size so the real reason is reported, but the ceiling itself (~750 KB of prepared transaction after base64) stands, and the gateway's own advertised 10 MiB limit is unreachable. | +| A validatord 404 maps to `-32603` Internal error | An unknown `requestId` is indistinguishable from a gateway fault. | +| CIP-103's lifecycle has no `rejected` state | A governance decline and an HSM fault both arrive as `failed`, even though `core-signing-lib`'s `SigningStatus` can express `rejected`. | +| `prepareExecute` submits to validatord _before_ registering its status poller, and reports only the poller's failure | A poller-capacity rejection can arrive with the request already live and its `requestId` discarded. This driver always sends a `commandId`, which the gateway passes to validatord as an idempotency key, so re-submitting the same `commandId` returns the original request instead of a duplicate — that is the recovery path, and the error message says so. Note the per-user poller cap (10) applies to the whole driver, since one machine token means one gateway user. | + +## Canton Network Support + +Parties are provisioned and hosted in Taurus-PROTECT, so the Wallet Gateway imports them rather than allocating them: `TaurusProtectWalletAllocator` records the party with no topology transaction and no hash signing. A single machine token serves all users, so `controller(userId)` ignores its argument. + +## License + +Apache-2.0 diff --git a/core/signing-taurus-protect/package.json b/core/signing-taurus-protect/package.json new file mode 100644 index 000000000..e6c3084e7 --- /dev/null +++ b/core/signing-taurus-protect/package.json @@ -0,0 +1,53 @@ +{ + "name": "@canton-network/core-signing-taurus-protect", + "version": "0.1.0", + "type": "module", + "description": "Wallet Gateway signing driver for Taurus-PROTECT", + "license": "Apache-2.0", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsdown && tsc -p tsconfig.types.json", + "dev": "tsdown --watch --onSuccess \"tsc -p tsconfig.types.json\"", + "clean": "tsc -b --clean; rm -rf dist", + "flatpack": "pnpm pack --pack-destination \"$FLATPACK_OUTDIR\"", + "test": "vitest run --project node", + "test:coverage": "vitest run --project node --coverage" + }, + "dependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/core-wallet-auth": "workspace:^", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^25.9.4", + "@vitest/coverage-v8": "^4.1.10", + "tsdown": "^0.22.9", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/canton-network/wallet.git", + "directory": "core/signing-taurus-protect" + }, + "homepage": "https://github.com/canton-network/wallet/tree/main/core/signing-taurus-protect#readme", + "bugs": { + "url": "https://github.com/canton-network/wallet/issues" + } +} diff --git a/core/signing-taurus-protect/src/gateway-client.test.ts b/core/signing-taurus-protect/src/gateway-client.test.ts new file mode 100644 index 000000000..f78aa2584 --- /dev/null +++ b/core/signing-taurus-protect/src/gateway-client.test.ts @@ -0,0 +1,530 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + GatewayClient, + GatewayError, + type GatewayAccount, +} from './gateway-client.js' + +const BASE = 'http://gateway.test' + +type RpcHandler = (method: string, params: unknown) => unknown // {result} | {error} + +function rpcResponse(body: unknown, id: unknown) { + return { + ok: true, + status: 200, + json: async () => ({ jsonrpc: '2.0', id, ...(body as object) }), + text: async () => '', + } +} + +/** Mock global fetch: routes /jsonrpc to `rpc`. */ +function stubFetch(opts: { rpc?: RpcHandler }) { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (url.endsWith('/jsonrpc')) { + const req = JSON.parse(String(init?.body)) as { + method: string + params: unknown + id: unknown + } + return rpcResponse(opts.rpc!(req.method, req.params), req.id) + } + throw new Error(`unexpected url ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +const account: GatewayAccount = { + partyId: 'alice::ns1', + status: 'allocated', + prefix: 'alice', + publicKey: 'pub-alice', + namespace: 'ns1', + networkId: 'canton:test', + signingProviderId: 'taurus-protect', +} + +describe('GatewayClient JSON-RPC', () => { + it('connects lazily and lists accounts', async () => { + const fetchMock = stubFetch({ + rpc: (method) => { + if (method === 'connect') + return { result: { isConnected: true } } + if (method === 'listAccounts') return { result: [account] } + throw new Error(`unexpected ${method}`) + }, + }) + const client = new GatewayClient({ baseUrl: BASE + '/', token: 't' }) + const accounts = await client.listAccounts() + expect(accounts).toEqual([account]) + // connect (lazy) + listAccounts + expect(fetchMock).toHaveBeenCalledTimes(2) + // a second call reuses the session (no extra connect) + await client.listAccounts() + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('prepareExecute returns requestId and enables the RPC fallback', async () => { + const fetchMock = stubFetch({ + rpc: (method) => { + if (method === 'connect') + return { result: { isConnected: true } } + if (method === 'prepareExecute') + return { result: { userUrl: 'u', requestId: '99' } } + if (method === 'getTransactionStatus') + return { result: { status: 'pending' } } + throw new Error(`unexpected ${method}`) + }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + const res = await client.prepareExecute({ + commands: [{ CreateCommand: {} }], + actAs: ['alice::ns1'], + commandId: 'cmd1', + }) + expect(res.requestId).toBe('99') + + // getStatus has no cache → RPC fallback using the remembered requestId. + const info = await client.getStatus('cmd1') + expect(info).toEqual({ status: 'pending' }) + const statusCall = fetchMock.mock.calls.find( + (c) => + String( + ( + JSON.parse(String((c[1] as RequestInit).body)) as { + method: string + } + ).method + ) === 'getTransactionStatus' + ) + expect(statusCall).toBeDefined() + }) + + it('getStatus returns undefined when neither cache nor requestId is known', async () => { + stubFetch({ rpc: () => ({ result: {} }) }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + expect(await client.getStatus('unknown')).toBeUndefined() + }) + + it('reconnects once on a 4900 (disconnected) and retries', async () => { + let listCalls = 0 + const fetchMock = stubFetch({ + rpc: (method) => { + if (method === 'connect') + return { result: { isConnected: true } } + if (method === 'listAccounts') { + listCalls++ + if (listCalls === 1) + return { + error: { code: 4900, message: 'disconnected' }, + } + return { result: [account] } + } + throw new Error(`unexpected ${method}`) + }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + const accounts = await client.listAccounts() + expect(accounts).toEqual([account]) + expect(listCalls).toBe(2) + // connect, listAccounts(4900), connect(reconnect), listAccounts(ok) + expect(fetchMock).toHaveBeenCalledTimes(4) + }) + + it('throws GatewayError carrying the JSON-RPC code (e.g. 4100 unauthorized)', async () => { + stubFetch({ + rpc: (method) => + method === 'connect' + ? { error: { code: 4100, message: 'unauthorized' } } + : { result: {} }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect(client.listAccounts()).rejects.toMatchObject({ + name: 'GatewayError', + code: 4100, + }) + }) + + it('throws GatewayError on a non-2xx HTTP response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + text: async () => 'upstream down', + })) + ) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect(client.connect()).rejects.toBeInstanceOf(GatewayError) + }) + + it('rejects a connect that succeeds with isConnected:false', async () => { + // Refusal arrives as a successful result, not an error. + stubFetch({ + rpc: () => ({ + result: { + isConnected: false, + reason: 'authentication required', + }, + }), + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect(client.connect()).rejects.toMatchObject({ + name: 'GatewayError', + message: expect.stringContaining('authentication required'), + }) + }) + + it('keeps polling an executed status until the updateId lands', async () => { + let statusCalls = 0 + stubFetch({ + rpc: (method) => { + if (method === 'connect') + return { result: { isConnected: true } } + if (method === 'prepareExecute') + return { result: { userUrl: 'u', requestId: '99' } } + if (method === 'getTransactionStatus') { + statusCalls++ + // updateId lags: the first executed read has only contractId. + return statusCalls === 1 + ? { result: { status: 'executed', contractId: 'c1' } } + : { + result: { + status: 'executed', + contractId: 'c1', + updateId: 'u1', + }, + } + } + throw new Error(`unexpected ${method}`) + }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await client.prepareExecute({ commands: [], commandId: 'cmd1' }) + + const first = await client.getStatus('cmd1') + expect(first).toEqual({ status: 'executed', contractId: 'c1' }) + + // Not complete without an updateId, so this must hit the network again. + const second = await client.getStatus('cmd1') + expect(second).toEqual({ + status: 'executed', + contractId: 'c1', + updateId: 'u1', + }) + expect(statusCalls).toBe(2) + + // Now complete — further reads are served from cache. + expect(await client.getStatus('cmd1')).toEqual(second) + expect(statusCalls).toBe(2) + }) + + it('stops polling a failed status', async () => { + let statusCalls = 0 + stubFetch({ + rpc: (method) => { + if (method === 'connect') + return { result: { isConnected: true } } + if (method === 'prepareExecute') + return { result: { userUrl: 'u', requestId: '99' } } + if (method === 'getTransactionStatus') { + statusCalls++ + return { result: { status: 'failed' } } + } + throw new Error(`unexpected ${method}`) + }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await client.prepareExecute({ commands: [], commandId: 'cmd1' }) + await client.getStatus('cmd1') + await client.getStatus('cmd1') + expect(statusCalls).toBe(1) + }) + + /** Captures the params of the single prepareExecute call. */ + function stubPrepareExecute(requestId = '7') { + const seen: { params?: Record } = {} + stubFetch({ + rpc: (method, params) => { + if (method === 'connect') + return { result: { isConnected: true } } + if (method === 'prepareExecute') { + seen.params = params as Record + return { result: { userUrl: 'u', requestId } } + } + if (method === 'getTransactionStatus') + return { result: { status: 'pending' } } + throw new Error(`unexpected ${method}`) + }, + }) + return seen + } + + it('sends a commandId even when the caller omits one, and reports it back', async () => { + // The gateway forwards commandId as validatord's externalRequestId idempotency key; + // without one it mints a fresh uuid per attempt and a retry double-submits. + const seen = stubPrepareExecute() + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + const res = await client.prepareExecute({ commands: [] }) + + expect(res.commandId).toEqual(expect.any(String)) + expect(res.commandId).not.toHaveLength(0) + expect(seen.params?.commandId).toBe(res.commandId) + // Keyed off the effective id, so the status fallback works without the caller's help. + expect(await client.getStatus(res.commandId)).toBeDefined() + }) + + it('keeps the caller-supplied commandId', async () => { + const seen = stubPrepareExecute() + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + const res = await client.prepareExecute({ + commands: [], + commandId: 'cmd1', + }) + expect(res.commandId).toBe('cmd1') + expect(seen.params?.commandId).toBe('cmd1') + }) + + it('normalises a real token-standard command on the encoded path', async () => { + // A registry templateId with no '#', plus a choiceArgument carrying + // bare arrays and a bare null — untouched, the gateway rejects all + // three before it ever looks at preparedTransaction. + const seen = stubPrepareExecute() + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await client.prepareExecute({ + preparedTransaction: 'CgVoZWxsbw==', + commands: [ + { + ExerciseCommand: { + templateId: + '6c5802f86709a0ad4784af81f0bab40f3070b2f58128d8843da1e1784c147802:Splice.AmuletRules:TransferPreapproval', + contractId: '00ab', + choice: 'TransferPreapproval_Renew', + choiceArgument: { + context: { + issuingMiningRounds: [], + featuredAppRight: null, + }, + inputs: [{ tag: 'InputAmulet', value: 'cid1' }], + }, + }, + }, + ], + }) + + expect(seen.params?.commands).toEqual([ + { + ExerciseCommand: { + templateId: + '#6c5802f86709a0ad4784af81f0bab40f3070b2f58128d8843da1e1784c147802:Splice.AmuletRules:TransferPreapproval', + contractId: '00ab', + choice: 'TransferPreapproval_Renew', + choiceArgument: {}, + }, + }, + ]) + // The PTX carries the substance and must survive verbatim. + expect(seen.params?.preparedTransaction).toBe('CgVoZWxsbw==') + }) + + it('leaves an already-#-prefixed templateId alone', async () => { + const seen = stubPrepareExecute() + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await client.prepareExecute({ + preparedTransaction: 'CgVoZWxsbw==', + commands: [ + { + CreateCommand: { + templateId: '#splice-amulet:Splice.AmuletRules:Amulet', + createArguments: { owner: 'alice::ns1' }, + }, + }, + ], + }) + expect(seen.params?.commands).toEqual([ + { + CreateCommand: { + templateId: '#splice-amulet:Splice.AmuletRules:Amulet', + createArguments: {}, + }, + }, + ]) + }) + + // The guard that stops this being dangerous: with no PTX the gateway takes the structured + // path, where the arguments ARE the submission. Blanking them there would put an + // argument-less contract in front of an approver. + it('does NOT touch the command when there is no preparedTransaction', async () => { + const original = { + CreateCommand: { + templateId: 'pkg:M:E', + createArguments: { owner: 'alice::ns1' }, + }, + } + for (const ptx of [undefined, '']) { + const seen = stubPrepareExecute() + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await client.prepareExecute({ + commands: [original], + ...(ptx === undefined ? {} : { preparedTransaction: ptx }), + }) + expect(seen.params?.commands).toEqual([original]) + } + }) + + it('passes a non-array commands through so the gateway can reject it', async () => { + const seen = stubPrepareExecute() + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await client.prepareExecute({ + commands: { CreateCommand: {} }, + preparedTransaction: 'CgVoZWxsbw==', + }) + expect(seen.params?.commands).toEqual({ CreateCommand: {} }) + }) + + it('names the commandId as retryable when prepareExecute fails', async () => { + // The gateway submits before registering its poller, so a capacity rejection can arrive + // with the request already live and its requestId discarded. + stubFetch({ + rpc: (method) => + method === 'connect' + ? { result: { isConnected: true } } + : { + error: { + code: -32005, + message: 'max concurrent polls exceeded', + }, + }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect( + client.prepareExecute({ commands: [], commandId: 'cmd1' }) + ).rejects.toMatchObject({ + name: 'GatewayError', + code: -32005, + message: expect.stringContaining('cmd1 may already be submitted'), + }) + }) + + it('refuses a request over the gateway body limit instead of letting it truncate', async () => { + // The gateway's io.LimitReader silently truncates past 1 MiB and the reply is a bare + // -32700, so the size has to be caught here. + const fetchMock = stubFetch({ + rpc: (method) => + method === 'connect' + ? { result: { isConnected: true } } + : { result: { userUrl: 'u', requestId: '7' } }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect( + client.prepareExecute({ + commands: [], + commandId: 'cmd1', + preparedTransaction: 'A'.repeat((1 << 20) + 1), + }) + ).rejects.toMatchObject({ + name: 'GatewayError', + message: expect.stringContaining('over the gateway'), + }) + // connect only — the oversized call never left the process. + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects a prepareExecute reply without a numeric requestId', async () => { + // Persisting a non-numeric id gets every later poll rejected. + stubFetch({ + rpc: (method) => + method === 'connect' + ? { result: { isConnected: true } } + : { result: { userUrl: 'u' } }, + }) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect( + client.prepareExecute({ commands: [], commandId: 'cmd1' }) + ).rejects.toMatchObject({ name: 'GatewayError' }) + }) + + it('throws GatewayError (not SyntaxError) on a non-JSON 2xx body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => { + throw new SyntaxError("Unexpected token '<'") + }, + text: async () => 'proxy', + })) + ) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect(client.connect()).rejects.toBeInstanceOf(GatewayError) + }) + + it('surfaces a timed-out request as a GatewayError', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + const err = new Error('The operation was aborted') + err.name = 'TimeoutError' + throw err + }) + ) + const client = new GatewayClient( + { baseUrl: BASE, token: 't' }, + { timeoutMs: 5 } + ) + await expect(client.connect()).rejects.toMatchObject({ + name: 'GatewayError', + message: expect.stringContaining('no response within 5ms'), + }) + }) + + it('reconnects when the session is lost via HTTP 401', async () => { + let calls = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const method = ( + JSON.parse(String(init?.body)) as { method: string } + ).method + if (method === 'connect') { + return { + ok: true, + status: 200, + json: async () => ({ result: { isConnected: true } }), + text: async () => '', + } + } + calls++ + if (calls === 1) { + return { + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'session expired', + } + } + return { + ok: true, + status: 200, + json: async () => ({ result: [account] }), + text: async () => '', + } + }) + ) + const client = new GatewayClient({ baseUrl: BASE, token: 't' }) + await expect(client.listAccounts()).resolves.toEqual([account]) + expect(calls).toBe(2) + }) +}) diff --git a/core/signing-taurus-protect/src/gateway-client.ts b/core/signing-taurus-protect/src/gateway-client.ts new file mode 100644 index 000000000..6d5f9debf --- /dev/null +++ b/core/signing-taurus-protect/src/gateway-client.ts @@ -0,0 +1,391 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Thin client for the gateway's CIP-103 JSON-RPC API. The gateway signs and submits, + * so this never handles a raw signature. Status resolves via the getTransactionStatus + * RPC, cached until complete. + */ + +export type GatewayTxStatus = 'pending' | 'signed' | 'executed' | 'failed' + +/** A Canton party as surfaced by the gateway's listAccounts. */ +export interface GatewayAccount { + partyId: string + status: string // "allocated" | "initializing" + prefix: string + publicKey: string + namespace: string + networkId: string + signingProviderId: string +} + +/** The gateway's prepareExecute reply, verbatim. */ +export interface GatewayPrepareExecuteResult { + userUrl: string + requestId: string +} + +/** + * The gateway's reply plus the commandId actually sent — the client defaults + * a missing one, and status lookups and retries need it. + */ +export interface PrepareExecuteOutcome extends GatewayPrepareExecuteResult { + commandId: string +} + +/** Reply to `connect`; a refusal arrives here rather than as a JSON-RPC error. */ +export interface GatewayConnectResult { + isConnected: boolean + reason?: string +} + +/** + * The gateway's prepareExecute surface, and nothing more. `disclosedContracts`, + * `readAs` and `packageIdSelectionPreference` are deliberately absent: inert at + * the gateway and already baked into `preparedTransaction`. + */ +export interface PrepareExecuteParams { + commands: unknown + actAs?: string[] + commandId?: string + preparedTransaction?: string +} + +export interface GatewayTxStatusInfo { + status: GatewayTxStatus + updateId?: string + contractId?: string +} + +export interface TaurusProtectGatewayConfig { + baseUrl: string + token: string +} + +export interface GatewayClientOptions { + /** Cap on commands tracked in the status/requestId caches; oldest evicted past this (default 10000). */ + maxTrackedCommands?: number + /** + * Per-RPC timeout in ms (default 35000), kept above the gateway's own 30s + * handler timeout so its JSON-RPC timeout reply wins over a bare abort. + */ + timeoutMs?: number +} + +/** Carries the JSON-RPC error code so callers can branch (e.g. 4900 → reconnect). */ +export class GatewayError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly data?: unknown + ) { + super(message) + this.name = 'GatewayError' + } +} + +// EIP-1193 range codes the gateway returns on session loss. +const ERR_DISCONNECTED = 4900 +const ERR_UNAUTHORIZED = 4100 +// An edge proxy signals session loss as HTTP, not JSON-RPC. +const SESSION_LOST_HTTP = new Set([401, 403]) + +// Client-side failures, kept clear of the gateway's -32000..-32005 range. +const ERR_TRANSPORT = -32090 +const ERR_INVALID_RESPONSE = -32091 +const ERR_REQUEST_TOO_LARGE = -32092 + +// Past the gateway's 1 MiB body cap the JSON is silently truncated and the +// reply is a bare -32700; fail here while the reason is still known. +const MAX_REQUEST_BYTES = 1 << 20 + +/** Final only when nothing is left to fetch: updateId can lag `executed`. */ +const isComplete = (info: GatewayTxStatusInfo): boolean => + info.status === 'failed' || (info.status === 'executed' && !!info.updateId) + +const isSessionLost = (err: unknown): boolean => + err instanceof GatewayError && + (err.code === ERR_DISCONNECTED || + err.code === ERR_UNAUTHORIZED || + SESSION_LOST_HTTP.has(err.code)) + +// The routing fields the gateway reads off a command; `contractId` and `choice` are +// exercise-only. Everything else is dropped by routingOnlyCommands. +const ROUTING_KEYS = ['templateId', 'contractId', 'choice'] as const +// Argument trees, blanked rather than dropped: the gateway requires the key to classify the +// command, and `{}` converts to an empty record without error. +const BLANK_ARG_KEYS = ['createArguments', 'choiceArgument'] as const + +// The gateway's templateId grammar demands a leading '#'; a bare package-id +// passes once prefixed, already-prefixed ids are left alone. +const hashPrefixed = (templateId: unknown): unknown => + typeof templateId === 'string' && !templateId.startsWith('#') + ? `#${templateId}` + : templateId + +function routingOnlyCommand(command: unknown): unknown { + if (typeof command !== 'object' || command === null) return command + const out: Record = {} + // One entry, keyed by kind: {CreateCommand: {…}} | {ExerciseCommand: {…}}. + for (const [kind, payload] of Object.entries(command)) { + if (typeof payload !== 'object' || payload === null) { + out[kind] = payload + continue + } + const body = payload as Record + const trimmed: Record = {} + for (const key of ROUTING_KEYS) { + if (key in body) { + trimmed[key] = + key === 'templateId' ? hashPrefixed(body[key]) : body[key] + } + } + for (const key of BLANK_ARG_KEYS) { + if (key in body) trimmed[key] = {} + } + out[kind] = trimmed + } + return out +} + +/** + * Reduce each command to the routing fields the gateway reads on the encoded + * path: it validates the full argument tree only to discard it for the PTX, + * and real token-standard arguments fail that validation. Only safe alongside + * a non-empty preparedTransaction — structured-path arguments ARE the + * submission — and prepareExecute enforces that. A non-array passes through + * untouched so the gateway's own error still surfaces. + */ +export function routingOnlyCommands(commands: unknown): unknown { + return Array.isArray(commands) ? commands.map(routingOnlyCommand) : commands +} + +export class GatewayClient { + private readonly baseUrl: string + private readonly token: string + private readonly maxTrackedCommands: number + private readonly timeoutMs: number + + private connected = false + private nextId = 1 + + // commandId → latest status (monotonic: a complete state never regresses). + private readonly statusByCommand = new Map() + // commandId → requestId from prepareExecute (RPC fallback). + private readonly requestIdByCommand = new Map() + + constructor( + config: TaurusProtectGatewayConfig, + opts: GatewayClientOptions = {} + ) { + this.baseUrl = config.baseUrl.endsWith('/') + ? config.baseUrl.slice(0, -1) + : config.baseUrl + this.token = config.token + this.maxTrackedCommands = opts.maxTrackedCommands ?? 10_000 + this.timeoutMs = opts.timeoutMs ?? 35_000 + } + + // --- JSON-RPC --- + + private async rpc(method: string, params: unknown): Promise { + const payload = JSON.stringify({ + jsonrpc: '2.0', + method, + params, + id: this.nextId++, + }) + const bytes = new TextEncoder().encode(payload).byteLength + if (bytes > MAX_REQUEST_BYTES) { + throw new GatewayError( + ERR_REQUEST_TOO_LARGE, + `gateway ${method}: request body is ${bytes} bytes, over the gateway's ${MAX_REQUEST_BYTES}-byte limit` + ) + } + let response: Response + try { + response = await fetch(`${this.baseUrl}/jsonrpc`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.token}`, + }, + body: payload, + // A hung call stalls the shared SigningWorker for every provider. + signal: AbortSignal.timeout(this.timeoutMs), + }) + } catch (err) { + const timedOut = (err as Error).name === 'TimeoutError' + throw new GatewayError( + ERR_TRANSPORT, + timedOut + ? `gateway ${method} failed: no response within ${this.timeoutMs}ms` + : `gateway ${method} failed: ${(err as Error).message}` + ) + } + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new GatewayError( + response.status, + `gateway ${method} failed: HTTP ${response.status} ${text || response.statusText}` + ) + } + // A 2xx HTML/empty body would escape as SyntaxError and skip reconnect. + let body: { + result?: T + error?: { code: number; message: string; data?: unknown } + } + try { + body = await response.json() + } catch { + throw new GatewayError( + ERR_INVALID_RESPONSE, + `gateway ${method}: response was not valid JSON` + ) + } + if (body.error) { + throw new GatewayError( + body.error.code, + `gateway ${method}: ${body.error.message}`, + body.error.data + ) + } + return body.result as T + } + + private async ensureConnected(): Promise { + if (this.connected) return + // A refusal arrives as a successful result with isConnected:false. + const result = await this.rpc('connect', {}) + if (!result?.isConnected) { + throw new GatewayError( + ERR_UNAUTHORIZED, + `gateway connect refused: ${result?.reason || 'isConnected was false'}` + ) + } + this.connected = true + } + + /** Session-bound call: lazily connects, and reconnects once when the session is lost. */ + private async callAuthed(method: string, params: unknown): Promise { + await this.ensureConnected() + try { + return await this.rpc(method, params) + } catch (err) { + if (isSessionLost(err)) { + // Cleared first so a failed retry still reconnects next call. + this.connected = false + await this.ensureConnected() + return this.rpc(method, params) + } + throw err + } + } + + async connect(): Promise { + await this.ensureConnected() + } + + async listAccounts(): Promise { + return this.callAuthed('listAccounts', {}) + } + + async prepareExecute( + params: PrepareExecuteParams + ): Promise { + // Always send one: commandId is the end-to-end idempotency key. Left + // unset the gateway mints a fresh uuid per attempt and a retry would + // submit twice. + const commandId = params.commandId ?? crypto.randomUUID() + const encoded = + typeof params.preparedTransaction === 'string' && + params.preparedTransaction.length > 0 + const request: PrepareExecuteParams = { + ...params, + commandId, + // Only on the encoded path — see routingOnlyCommands. + ...(encoded + ? { commands: routingOnlyCommands(params.commands) } + : {}), + } + + let result: GatewayPrepareExecuteResult + try { + result = await this.callAuthed( + 'prepareExecute', + request + ) + } catch (err) { + // The command may already be live (the gateway submits before it + // registers its poller); re-submitting the same commandId is the + // recovery path. + if (err instanceof GatewayError) { + throw new GatewayError( + err.code, + `${err.message} — commandId ${commandId} may already be submitted; retrying with the same commandId is safe and will return the original request`, + err.data + ) + } + throw err + } + // getTransactionStatus parses this with ParseUint; non-numeric is unpollable. + if (!/^\d+$/.test(result?.requestId ?? '')) { + throw new GatewayError( + ERR_INVALID_RESPONSE, + 'gateway prepareExecute returned no numeric requestId' + ) + } + this.requestIdByCommand.set(commandId, result.requestId) + this.capMap(this.requestIdByCommand) + return { ...result, commandId } + } + + async getTransactionStatus( + requestId: string + ): Promise { + return this.callAuthed('getTransactionStatus', { + requestId, + }) + } + + // --- status resolution (RPC + cache) --- + + /** Re-seed commandId→requestId so getStatus's RPC fallback works on a cold cache. */ + rememberRequestId(commandId: string, requestId: string): void { + this.requestIdByCommand.set(commandId, requestId) + this.capMap(this.requestIdByCommand) + } + + /** Cached complete state, else poll getTransactionStatus; undefined when nothing cached and no known requestId. */ + async getStatus( + commandId: string + ): Promise { + const cached = this.statusByCommand.get(commandId) + if (cached && isComplete(cached)) { + return cached + } + const requestId = this.requestIdByCommand.get(commandId) + if (!requestId) return cached + const info = await this.getTransactionStatus(requestId) + this.cacheStatus(commandId, info) + return info + } + + /** Monotonic cache write — a complete state is final; `executed` without ids is not. */ + private cacheStatus(commandId: string, info: GatewayTxStatusInfo): void { + const prev = this.statusByCommand.get(commandId) + if (prev && isComplete(prev)) return + this.statusByCommand.set(commandId, info) + this.capMap(this.statusByCommand) + } + + // Evict oldest (insertion-order) entries past the cap; evicted commands are recoverable via the requestId RPC fallback. + private capMap(map: Map): void { + while (map.size > this.maxTrackedCommands) { + const oldest = map.keys().next().value + if (oldest === undefined) break + map.delete(oldest) + } + } +} diff --git a/core/signing-taurus-protect/src/index.test.ts b/core/signing-taurus-protect/src/index.test.ts new file mode 100644 index 000000000..b44c34c3a --- /dev/null +++ b/core/signing-taurus-protect/src/index.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { isRpcError, SigningProvider } from '@canton-network/core-signing-lib' +import type { GatewayAccount } from './gateway-client.js' + +const clientMock = vi.hoisted(() => ({ + listAccounts: vi.fn(), + prepareExecute: vi.fn(), + getStatus: vi.fn(), + getTransactionStatus: vi.fn(), + rememberRequestId: vi.fn(), +})) + +vi.mock('./gateway-client.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + GatewayClient: vi.fn(function () { + return clientMock + }), + } +}) + +// Imported after vi.mock (hoisted) so the driver picks up the mocked client. +const { default: TaurusProtectSigningDriver } = await import('./index.js') + +const account: GatewayAccount = { + partyId: 'alice::ns1', + status: 'allocated', + prefix: 'alice', + publicKey: 'pub-alice', + namespace: 'ns1', + networkId: 'canton:test', + signingProviderId: 'taurus-protect', +} + +let driver: InstanceType +let ctl: ReturnType< + InstanceType['controller'] +> + +beforeEach(() => { + vi.clearAllMocks() + driver = new TaurusProtectSigningDriver({ + baseUrl: 'http://gw', + token: 'tok', + }) + ctl = driver.controller(undefined) +}) + +describe('TaurusProtectSigningDriver', () => { + it('reports the taurus-protect provider', () => { + expect(driver.signingProvider).toBe(SigningProvider.TAURUS_PROTECT) + }) + + it('getKeys maps allocated parties and skips non-allocated ones', async () => { + const initializing: GatewayAccount = { + ...account, + partyId: 'bob::ns2', + prefix: 'bob', + publicKey: 'pub-bob', + status: 'initializing', + } + clientMock.listAccounts.mockResolvedValue([account, initializing]) + const res = await ctl.getKeys() + expect(res).toEqual({ + keys: [{ id: 'alice::ns1', name: 'alice', publicKey: 'pub-alice' }], + }) + }) + + // The gateway can return an account without a publicKey; Wallet.publicKey + // is required, so an empty one would persist a keyless wallet. + it('getKeys skips an allocated party the gateway returned without a publicKey', async () => { + clientMock.listAccounts.mockResolvedValue([ + account, + { + ...account, + partyId: 'carol::ns3', + prefix: 'carol', + publicKey: '', + }, + ]) + expect(await ctl.getKeys()).toEqual({ + keys: [{ id: 'alice::ns1', name: 'alice', publicKey: 'pub-alice' }], + }) + }) + + it('getKeys surfaces fetch errors', async () => { + clientMock.listAccounts.mockRejectedValue(new Error('boom')) + expect(isRpcError(await ctl.getKeys())).toBe(true) + }) + + it('signMessage is not allowed (gated to wallet-kernel, like the other external drivers)', async () => { + const res = await ctl.signMessage({ message: 'hi' }) + expect(isRpcError(res)).toBe(true) + expect((res as { error: string }).error).toBe('not_allowed') + }) + + it('signTransaction forwards the command to prepareExecute and returns pending', async () => { + clientMock.prepareExecute.mockResolvedValue({ + userUrl: 'u', + requestId: '42', + commandId: 'cmd1', + }) + const tx = JSON.stringify({ + commands: [{ CreateCommand: {} }], + actAs: ['alice::ns1'], + commandId: 'cmd1', + }) + const res = await ctl.signTransaction({ + tx, + txHash: '', + keyIdentifier: { id: 'alice::ns1' }, + }) + expect(res).toEqual({ + txId: 'cmd1', + status: 'pending', + metadata: { + gatewayStatus: 'pending', + requestId: '42', + commandId: 'cmd1', + }, + }) + expect(clientMock.prepareExecute).toHaveBeenCalledWith({ + commands: [{ CreateCommand: {} }], + actAs: ['alice::ns1'], + commandId: 'cmd1', + }) + }) + + // The client defaults a missing commandId, and getStatus is keyed off that value — so the + // txId has to be the id the client actually used, not the requestId. + it('signTransaction reports the commandId the client resolved', async () => { + clientMock.prepareExecute.mockResolvedValue({ + userUrl: 'u', + requestId: '42', + commandId: 'generated-uuid', + }) + const res = await ctl.signTransaction({ + tx: JSON.stringify({ commands: [{ CreateCommand: {} }] }), + txHash: '', + keyIdentifier: { id: 'alice::ns1' }, + }) + expect(res).toMatchObject({ txId: 'generated-uuid' }) + }) + + it('signTransaction rejects a non-JSON tx without calling the gateway', async () => { + const res = await ctl.signTransaction({ + tx: 'not-json', + txHash: '', + keyIdentifier: { id: 'x' }, + }) + expect(isRpcError(res)).toBe(true) + expect(clientMock.prepareExecute).not.toHaveBeenCalled() + }) + + // JSON.parse succeeds on these; a parse-only guard leaves a TypeError on + // the property read, outside the try/catch. + it.each(['null', '"a string"', '42'])( + 'signTransaction rejects tx %s that parses but is not an object', + async (tx) => { + const res = await ctl.signTransaction({ + tx, + txHash: '', + keyIdentifier: { id: 'x' }, + }) + expect(isRpcError(res)).toBe(true) + expect(clientMock.prepareExecute).not.toHaveBeenCalled() + } + ) + + // disclosedContracts, readAs and packageIdSelectionPreference are inert at + // the gateway and already inside the PTX, so they stay off the wire. + it('signTransaction sends only the fields the gateway consumes', async () => { + clientMock.prepareExecute.mockResolvedValue({ + userUrl: 'u', + requestId: '42', + commandId: 'cmd1', + }) + const tx = JSON.stringify({ + commands: [{ ExerciseCommand: {} }], + actAs: ['alice::ns1'], + readAs: ['registry::ns'], + disclosedContracts: [{ contractId: 'c1' }], + packageIdSelectionPreference: ['pkg1'], + commandId: 'cmd1', + preparedTransaction: 'CgVoZWxsbw==', + }) + await ctl.signTransaction({ + tx, + txHash: '', + keyIdentifier: { id: 'alice::ns1' }, + }) + expect(clientMock.prepareExecute).toHaveBeenCalledWith({ + commands: [{ ExerciseCommand: {} }], + actAs: ['alice::ns1'], + commandId: 'cmd1', + preparedTransaction: 'CgVoZWxsbw==', + }) + }) + + it('signTransaction surfaces gateway errors', async () => { + clientMock.prepareExecute.mockRejectedValue(new Error('rule reject')) + const tx = JSON.stringify({ commands: [{}], commandId: 'c' }) + expect( + isRpcError( + await ctl.signTransaction({ + tx, + txHash: '', + keyIdentifier: { id: 'x' }, + }) + ) + ).toBe(true) + }) + + it('getTransaction maps gateway status and re-seeds the requestId', async () => { + clientMock.getStatus.mockResolvedValue({ + status: 'executed', + updateId: 'u1', + contractId: '00ab', + }) + const res = await ctl.getTransaction({ txId: 'cmd1', requestId: '42' }) + expect(clientMock.rememberRequestId).toHaveBeenCalledWith('cmd1', '42') + expect(res).toEqual({ + txId: 'cmd1', + status: 'signed', + metadata: { + gatewayStatus: 'executed', + updateId: 'u1', + contractId: '00ab', + }, + }) + }) + + it('getTransaction returns not_found when status is unavailable', async () => { + clientMock.getStatus.mockResolvedValue(undefined) + expect(isRpcError(await ctl.getTransaction({ txId: 'cmd1' }))).toBe( + true + ) + }) + + it('getTransactions resolves each txId', async () => { + clientMock.getStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'failed' }) + const res = await ctl.getTransactions({ txIds: ['a', 'b'] }) + expect(res).toEqual({ + transactions: [ + { + txId: 'a', + status: 'pending', + metadata: { gatewayStatus: 'pending' }, + }, + { + txId: 'b', + status: 'failed', + metadata: { gatewayStatus: 'failed' }, + }, + ], + }) + }) + + it('getTransactions requires txIds', async () => { + expect(isRpcError(await ctl.getTransactions({}))).toBe(true) + }) + + it('createKey is not allowed (import-only)', async () => { + const res = await ctl.createKey({ name: 'x' }) + expect(isRpcError(res)).toBe(true) + expect((res as { error: string }).error).toBe('not_allowed') + }) + + it('getConfiguration masks the token', async () => { + expect(await ctl.getConfiguration()).toEqual({ + baseUrl: 'http://gw', + token: '***HIDDEN***', + }) + }) + + it('setConfiguration validates input and accepts a valid change', async () => { + expect( + isRpcError(await ctl.setConfiguration({ baseUrl: 'http://y' })) + ).toBe(true) + expect( + await ctl.setConfiguration({ baseUrl: 'http://y', token: 'newtok' }) + ).toEqual({ baseUrl: 'http://y', token: '***HIDDEN***' }) + }) + + it('subscribeTransactions is a no-op', async () => { + expect(await ctl.subscribeTransactions({ txIds: ['a'] })).toEqual({}) + }) +}) diff --git a/core/signing-taurus-protect/src/index.ts b/core/signing-taurus-protect/src/index.ts new file mode 100644 index 000000000..3565b2568 --- /dev/null +++ b/core/signing-taurus-protect/src/index.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + buildController, + type CreateKeyResult, + type GetConfigurationResult, + type GetKeysResult, + type GetTransactionParams, + type GetTransactionResult, + type GetTransactionsParams, + type GetTransactionsResult, + PartyMode, + type SetConfigurationParams, + type SetConfigurationResult, + type SigningDriverInterface, + SigningProvider, + type SigningStatus, + type SignMessageResult, + type SignTransactionParams, + type SignTransactionResult, + type SubscribeTransactionsResult, + type Transaction, +} from '@canton-network/core-signing-lib' +import { AuthContext } from '@canton-network/core-wallet-auth' +import { z } from 'zod' +import { + GatewayClient, + type GatewayTxStatus, + type GatewayTxStatusInfo, +} from './gateway-client.js' + +export { + GatewayClient, + GatewayError, + routingOnlyCommands, + type GatewayAccount, + type GatewayConnectResult, + type GatewayPrepareExecuteResult, + type GatewayTxStatus, + type GatewayTxStatusInfo, + type PrepareExecuteOutcome, + type PrepareExecuteParams, + type TaurusProtectGatewayConfig, +} from './gateway-client.js' + +export interface TaurusProtectConfig { + /** Base URL of the gateway JSON-RPC endpoint. */ + baseUrl: string + /** Bearer api-key (HMAC-JWT); mint via the gateway's `api-key issue` command. */ + token: string +} + +// Not .url(): the gateway is routinely reached on a bare host:port inside the cluster. +const TaurusProtectConfigSchema = z.object({ + baseUrl: z.string().min(1), + token: z.string().min(1), +}) + +// signing-lib has no 'executed' state; map it to 'signed' and carry the real state in metadata.gatewayStatus. +function toSigningStatus(status: GatewayTxStatus): SigningStatus { + switch (status) { + case 'pending': + return 'pending' + case 'signed': + case 'executed': + return 'signed' + case 'failed': + default: + return 'failed' + } +} + +function toTransaction( + commandId: string, + info: GatewayTxStatusInfo +): Transaction { + return { + txId: commandId, + status: toSigningStatus(info.status), + metadata: { + gatewayStatus: info.status, + ...(info.updateId ? { updateId: info.updateId } : {}), + ...(info.contractId ? { contractId: info.contractId } : {}), + }, + } +} + +/** + * Custodies Canton parties via the gateway, which prepares, signs (ECDSA P-256), and submits + * each CIP-103 command. This driver never signs a hash — it forwards commands and tracks status. + */ +export default class TaurusProtectSigningDriver implements SigningDriverInterface { + private config: TaurusProtectConfig + private client: GatewayClient + + public partyMode = PartyMode.EXTERNAL + public signingProvider = SigningProvider.TAURUS_PROTECT + + constructor(config: TaurusProtectConfig) { + this.config = config + this.client = new GatewayClient(config) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- a single machine token serves all users; userId is unused + public controller = (_userId: AuthContext['userId'] | undefined) => + buildController({ + signTransaction: async ( + params: SignTransactionParams + ): Promise => { + let parsed: unknown + try { + parsed = JSON.parse(params.tx) + } catch { + parsed = undefined + } + // JSON.parse('null') and '"str"' succeed; guard the shape too. + if (typeof parsed !== 'object' || parsed === null) { + return { + error: 'bad_arguments', + error_description: + 'tx must be a JSON-encoded CIP-103 command { commands, actAs?, commandId? }', + } + } + const command = parsed as { + commands?: unknown + actAs?: string[] + commandId?: string + preparedTransaction?: string + } + if (command.commands === undefined) { + return { + error: 'bad_arguments', + error_description: 'tx.commands is required', + } + } + try { + // Only the four fields the gateway reads. disclosedContracts, readAs and + // packageIdSelectionPreference are inert there and already inside the PTX. + const result = await this.client.prepareExecute({ + commands: command.commands, + ...(command.actAs ? { actAs: command.actAs } : {}), + ...(command.commandId + ? { commandId: command.commandId } + : {}), + ...(command.preparedTransaction + ? { + preparedTransaction: + command.preparedTransaction, + } + : {}), + }) + // Gateway only submitted; signing/execution are async under governance, so surface 'pending'. + return { + // The client's effective commandId — it defaults one when tx omitted it, + // and getStatus is keyed off exactly that. + txId: result.commandId, + status: 'pending', + metadata: { + gatewayStatus: 'pending', + requestId: result.requestId, + commandId: result.commandId, + }, + } + } catch (error) { + return { + error: 'signing_error', + error_description: (error as Error).message, + } + } + }, + + signMessage: async (): Promise => ({ + error: 'not_allowed', + error_description: + 'Signing messages is not yet supported with Taurus-PROTECT.', + }), + + getTransaction: async ( + params: GetTransactionParams + ): Promise => { + try { + // Re-seed requestId for the RPC fallback (cache is cold after restart). + if ( + typeof params.requestId === 'string' && + params.requestId + ) { + this.client.rememberRequestId( + params.txId, + params.requestId + ) + } + const info = await this.client.getStatus(params.txId) + if (!info) { + return { + error: 'transaction_not_found', + error_description: `no status available for ${params.txId}`, + } + } + return toTransaction(params.txId, info) + } catch (error) { + return { + error: 'fetch_error', + error_description: (error as Error).message, + } + } + }, + + getTransactions: async ( + params: GetTransactionsParams + ): Promise => { + if (!params.txIds || params.txIds.length === 0) { + return { + error: 'bad_arguments', + error_description: + 'txIds must be supplied (Taurus-PROTECT does not enumerate by public key)', + } + } + try { + const resolved = await Promise.all( + params.txIds.map(async (txId) => { + const info = await this.client.getStatus(txId) + return info ? toTransaction(txId, info) : undefined + }) + ) + const transactions: Transaction[] = resolved.filter( + (tx): tx is Transaction => tx !== undefined + ) + return { transactions } + } catch (error) { + return { + error: 'fetch_error', + error_description: (error as Error).message, + } + } + }, + + getKeys: async (): Promise => { + try { + const accounts = await this.client.listAccounts() + return { + keys: accounts + // Skip parties not yet ready ('initializing') and + // any returned without a publicKey — an empty one + // would persist a keyless wallet. + .filter( + (account) => + account.status === 'allocated' && + !!account.publicKey + ) + .map((account) => ({ + id: account.partyId, + name: account.prefix || account.partyId, + publicKey: account.publicKey, + })), + } + } catch (error) { + return { + error: 'fetch_error', + error_description: (error as Error).message, + } + } + }, + + createKey: async (): Promise => ({ + error: 'not_allowed', + error_description: + 'Parties are provisioned in Taurus-PROTECT; this driver imports existing parties and cannot create new ones.', + }), + + getConfiguration: async (): Promise => ({ + baseUrl: this.config.baseUrl, + token: this.config.token ? '***HIDDEN***' : undefined, + }), + + setConfiguration: async ( + params: SetConfigurationParams + ): Promise => { + const validated = TaurusProtectConfigSchema.safeParse(params) + if (!validated.success) { + return { + error: 'bad_arguments', + error_description: validated.error.message, + } + } + const newConfig: TaurusProtectConfig = { + baseUrl: validated.data.baseUrl, + token: validated.data.token, + } + if ( + newConfig.baseUrl !== this.config.baseUrl || + newConfig.token !== this.config.token + ) { + this.config = newConfig + this.client = new GatewayClient(this.config) + } + // Never echo the bearer token back. + return { baseUrl: newConfig.baseUrl, token: '***HIDDEN***' } + }, + + subscribeTransactions: + async (): Promise => + Promise.resolve({} as SubscribeTransactionsResult), + }) +} diff --git a/core/signing-taurus-protect/tsconfig.json b/core/signing-taurus-protect/tsconfig.json new file mode 100644 index 000000000..2bdd85a2d --- /dev/null +++ b/core/signing-taurus-protect/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "nodenext", + "strict": true, + "exactOptionalPropertyTypes": false + }, + "include": ["src"] +} diff --git a/core/signing-taurus-protect/tsconfig.types.json b/core/signing-taurus-protect/tsconfig.types.json new file mode 100644 index 000000000..b2af05b78 --- /dev/null +++ b/core/signing-taurus-protect/tsconfig.types.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.web.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "rootDir": "./src", + "outDir": "./dist", + "moduleResolution": "bundler", + "noEmit": false, + "composite": false, + "exactOptionalPropertyTypes": false + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["**/*.test.*", "**/*.spec.*", "**/__tests__/**"] +} diff --git a/core/signing-taurus-protect/tsdown.config.ts b/core/signing-taurus-protect/tsdown.config.ts new file mode 100644 index 000000000..122f47978 --- /dev/null +++ b/core/signing-taurus-protect/tsdown.config.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'tsdown' +import { base } from '../../tsdown.base.ts' + +export default defineConfig({ + ...base, + entry: ['src/index.ts'], + outputOptions: { + exports: 'named', + }, +}) diff --git a/core/signing-taurus-protect/vitest.config.ts b/core/signing-taurus-protect/vitest.config.ts new file mode 100644 index 000000000..e8485a934 --- /dev/null +++ b/core/signing-taurus-protect/vitest.config.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig, defineProject } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**/*.ts'], + provider: 'v8', + reporter: ['text', 'html', 'lcov', 'json-summary'], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + projects: [ + defineProject({ + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }), + ], + }, +}) diff --git a/docs/wallet-gateway/signing-providers/index.md b/docs/wallet-gateway/signing-providers/index.md index b31e576c1..2b305a9a0 100644 --- a/docs/wallet-gateway/signing-providers/index.md +++ b/docs/wallet-gateway/signing-providers/index.md @@ -40,7 +40,7 @@ This provider is always available and requires no additional configuration. You **Security Considerations:** > [!IMPORTANT] -> Participant-based signing is **not recommended** in production setups where the User API is accessible. Any user who can reach the User API can create parties that sign via your participant node, which may grant broader signing authority than intended. Reserve participant-based signing for deployments where wallet creation is restricted to trusted operators, or use an external signing provider (Fireblocks, Dfns, Blockdaemon, Securosys) when the User API is exposed in production. +> Participant-based signing is **not recommended** in production setups where the User API is accessible. Any user who can reach the User API can create parties that sign via your participant node, which may grant broader signing authority than intended. Reserve participant-based signing for deployments where wallet creation is restricted to trusted operators, or use an external signing provider (Fireblocks, Dfns, Blockdaemon, Securosys, Taurus-PROTECT) when the User API is exposed in production. **How it Works:** @@ -136,6 +136,32 @@ Set the following environment variables: - Environments already using Securosys TSB / CloudHSM - High-security production environments +## Taurus-PROTECT + +Taurus-PROTECT provides custody-grade key management and governance for Canton parties. Unlike the sign-only providers, it is a **submit** provider: its Canton gateway prepares, signs (ECDSA P-256) and submits each CIP-103 command against its own validator under Taurus governance, while the Wallet Gateway forwards commands and tracks their status. Parties are provisioned in Taurus-PROTECT and imported by the Wallet Gateway rather than allocated. + +**Setup:** + +See the [Taurus-PROTECT signing documentation](https://github.com/canton-network/wallet/tree/main/core/signing-taurus-protect) for driver details, the submission model, status mapping, and known gateway limitations. + +**Configuration:** + +Set the following environment variables: + +- `TAURUS_PROTECT_GATEWAY_URL` - Base URL of the Taurus-PROTECT Canton gateway JSON-RPC endpoint +- `TAURUS_PROTECT_GATEWAY_TOKEN` - Bearer api-key for that gateway + +**Use Cases:** + +- Enterprise deployments where parties are custodied in Taurus-PROTECT +- Governance-controlled signing with approval workflows +- High-security production environments + +**Security Considerations:** + +> [!IMPORTANT] +> The driver is single-tenant by design: one machine bearer token serves every Wallet Gateway user, so any authenticated user can list and import every party in the Taurus-PROTECT tenant, and submissions are not attributed to individual Wallet Gateway users. Deploy only where all Wallet Gateway users are equally trusted with every party in the tenant. + ## Selecting a Provider When creating a new party through the User API or web UI, you can select which signing provider to use. The choice depends on your security requirements, infrastructure setup, and compliance needs. @@ -143,7 +169,7 @@ When creating a new party through the User API or web UI, you can select which s **Recommendations:** - **Development/Testing**: Use Wallet Gateway (internal) or Participant-based signing -- **Production (User API accessible)**: Use Fireblocks, Dfns, Blockdaemon, or Securosys +- **Production (User API accessible)**: Use Fireblocks, Dfns, Blockdaemon, Securosys, or Taurus-PROTECT - **Production (operator-controlled, User API restricted)**: Participant-based signing may be appropriate when wallet creation is limited to trusted operators The signing provider is selected per-party, so you can have different parties using different providers within the same Gateway instance. @@ -158,6 +184,7 @@ Each provider handles key management differently: - **Blockdaemon**: Keys are managed by Blockdaemon's infrastructure - **Dfns**: Keys are managed by Dfns' secure infrastructure - **Securosys**: Keys are managed by Securosys TSB (HSM-backed) +- **Taurus-PROTECT**: Keys are managed by Taurus-PROTECT; parties are hosted there and the platform signs and submits on their behalf When migrating between providers, keys cannot be directly transferred. You'll need to: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f349b101c..48f62631c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1040,6 +1040,34 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.9.4)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + core/signing-taurus-protect: + dependencies: + '@canton-network/core-signing-lib': + specifier: workspace:^ + version: link:../signing-lib + '@canton-network/core-wallet-auth': + specifier: workspace:^ + version: link:../wallet-auth + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^25.9.4 + version: 25.9.4 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + tsdown: + specifier: ^0.22.9 + version: 0.22.14(@volar/typescript@2.4.28(typescript@5.9.3))(oxc-resolver@11.24.2)(tsx@4.23.5)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.9.4)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + core/splice-client: dependencies: '@canton-network/core-types': @@ -2588,6 +2616,9 @@ importers: '@canton-network/core-signing-store-sql': specifier: workspace:^ version: link:../../core/signing-store-sql + '@canton-network/core-signing-taurus-protect': + specifier: workspace:^ + version: link:../../core/signing-taurus-protect '@canton-network/core-tx-visualizer': specifier: workspace:^ version: link:../../core/tx-visualizer diff --git a/wallet-gateway/remote/README.md b/wallet-gateway/remote/README.md index d5d5a5741..598603c1e 100644 --- a/wallet-gateway/remote/README.md +++ b/wallet-gateway/remote/README.md @@ -88,6 +88,17 @@ the Gateway: See [`@canton-network/core-signing-securosys`](../../core/signing-securosys/README.md) for key creation, public-key, and signature format details. +## Taurus-PROTECT + +The Taurus-PROTECT signing driver is registered at startup as `taurus-protect` when +both of these environment variables are set before starting the Gateway: + +- `TAURUS_PROTECT_GATEWAY_URL` — Base URL of the Taurus-PROTECT Canton gateway JSON-RPC endpoint +- `TAURUS_PROTECT_GATEWAY_TOKEN` — Bearer api-key for that gateway + +See [`@canton-network/core-signing-taurus-protect`](../../core/signing-taurus-protect/README.md) +for the submission model, status mapping, and tenancy caveats. + ## Postgres connection To create a Postgres database you need to: diff --git a/wallet-gateway/remote/package.json b/wallet-gateway/remote/package.json index cf8549899..e42e9226c 100644 --- a/wallet-gateway/remote/package.json +++ b/wallet-gateway/remote/package.json @@ -49,6 +49,7 @@ "@canton-network/core-signing-participant": "workspace:^", "@canton-network/core-signing-securosys": "workspace:^", "@canton-network/core-signing-store-sql": "workspace:^", + "@canton-network/core-signing-taurus-protect": "workspace:^", "@canton-network/core-tx-visualizer": "workspace:^", "@canton-network/core-types": "workspace:^", "@canton-network/core-wallet-auth": "workspace:^", diff --git a/wallet-gateway/remote/src/env.ts b/wallet-gateway/remote/src/env.ts index e3224a4d9..e688df606 100644 --- a/wallet-gateway/remote/src/env.ts +++ b/wallet-gateway/remote/src/env.ts @@ -33,6 +33,10 @@ export class Env { static DFNS_CRED_ID = () => Env.get('DFNS_CRED_ID') static DFNS_PRIVATE_KEY = () => Env.get('DFNS_PRIVATE_KEY') static DFNS_AUTH_TOKEN = () => Env.get('DFNS_AUTH_TOKEN') + static TAURUS_PROTECT_GATEWAY_URL = () => + Env.get('TAURUS_PROTECT_GATEWAY_URL') + static TAURUS_PROTECT_GATEWAY_TOKEN = () => + Env.get('TAURUS_PROTECT_GATEWAY_TOKEN') static get( key: string, diff --git a/wallet-gateway/remote/src/init.ts b/wallet-gateway/remote/src/init.ts index ff57ead87..30a27a1a0 100644 --- a/wallet-gateway/remote/src/init.ts +++ b/wallet-gateway/remote/src/init.ts @@ -30,6 +30,7 @@ import BlockdaemonSigningProvider, { import SecurosysSigningProvider, { type TsbSignatureAlgorithm, } from '@canton-network/core-signing-securosys' +import TaurusProtectSigningProvider from '@canton-network/core-signing-taurus-protect' import { jwtAuthService } from './auth/jwt-auth-service.js' import express from 'express' import { CliOptions } from './index.js' @@ -364,6 +365,21 @@ export async function initialize(opts: CliOptions, logger: Logger) { ) } + if ( + Env.TAURUS_PROTECT_GATEWAY_URL() && + Env.TAURUS_PROTECT_GATEWAY_TOKEN() + ) { + drivers[SigningProvider.TAURUS_PROTECT] = + new TaurusProtectSigningProvider({ + baseUrl: Env.TAURUS_PROTECT_GATEWAY_URL()!, + token: Env.TAURUS_PROTECT_GATEWAY_TOKEN()!, + }) + } else { + logger.warn( + 'Taurus-PROTECT env vars not fully set — Taurus-PROTECT signing provider will be unavailable' + ) + } + const allowedPaths = { [config.server.dappPath]: ['*'], [config.server.userPath]: [ diff --git a/wallet-gateway/remote/src/ledger/transaction-service.test.ts b/wallet-gateway/remote/src/ledger/transaction-service.test.ts index dc7e762e6..4a545e2a7 100644 --- a/wallet-gateway/remote/src/ledger/transaction-service.test.ts +++ b/wallet-gateway/remote/src/ledger/transaction-service.test.ts @@ -63,6 +63,20 @@ const executedTransaction: Transaction = { status: 'executed', } +const taurusCommands = [{ CreateCommand: { templateId: 'pkg:Mod:Ent' } }] + +// Taurus-PROTECT forwards the stored payload as a CIP-103 command, so it needs one. +const taurusPendingTransaction: Transaction = { + ...pendingTransaction, + payload: { commands: taurusCommands }, +} + +// Same row after the gateway accepted the submission and handed back a requestId. +const taurusInFlightTransaction: Transaction = { + ...taurusPendingTransaction, + externalTxId: 'tp-request-1', +} + const signParams = { transactionId: pendingTransaction.id, partyId: wallet.partyId, @@ -94,6 +108,8 @@ function walletWithProvider(signingProviderId: SigningProvider): Wallet { return { ...wallet, signingProviderId } } +const taurusWallet = walletWithProvider(SigningProvider.TAURUS_PROTECT) + function createDriver(options: { signTransaction?: ReturnType getTransaction?: ReturnType @@ -529,6 +545,238 @@ describe('TransactionService', () => { }) }) + describe('taurus-protect', () => { + it('forwards the CIP-103 command and persists pending on first submission', async () => { + const signTransaction = vi.fn().mockResolvedValue({ + txId: taurusPendingTransaction.commandId, + status: 'pending', + metadata: { + gatewayStatus: 'pending', + requestId: 'tp-request-1', + }, + }) + const getTransaction = vi.fn() + const store = createStore(taurusPendingTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + signTransaction, + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.sign( + authContext, + taurusWallet, + signParams + ) + + expect(getTransaction).not.toHaveBeenCalled() + const [signArgs] = signTransaction.mock.calls[0] + expect(JSON.parse(signArgs.tx)).toEqual({ + commands: taurusCommands, + actAs: [wallet.partyId], + commandId: taurusPendingTransaction.commandId, + preparedTransaction: + taurusPendingTransaction.preparedTransaction, + }) + expect(signArgs.keyIdentifier).toEqual({ + id: wallet.partyId, + publicKey: wallet.publicKey, + }) + expect(store.setTransactionStatus).toHaveBeenCalledWith( + taurusPendingTransaction.id, + 'pending', + { externalTxId: 'tp-request-1' } + ) + expect(result).toEqual({ + status: 'pending', + partyId: wallet.partyId, + externalTxId: 'tp-request-1', + }) + }) + + it('re-polls instead of resubmitting once an externalTxId is stored', async () => { + const signTransaction = vi.fn() + const getTransaction = vi.fn().mockResolvedValue({ + txId: taurusPendingTransaction.commandId, + status: 'pending', + metadata: { gatewayStatus: 'pending' }, + }) + const store = createStore(taurusInFlightTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + signTransaction, + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.sign( + authContext, + taurusWallet, + signParams + ) + + expect(signTransaction).not.toHaveBeenCalled() + expect(getTransaction).toHaveBeenCalledWith({ + txId: taurusPendingTransaction.commandId, + requestId: 'tp-request-1', + }) + expect(result).toEqual({ + status: 'pending', + partyId: wallet.partyId, + externalTxId: 'tp-request-1', + }) + }) + + it('marks the transaction signed once the gateway reports executed', async () => { + const getTransaction = vi.fn().mockResolvedValue({ + txId: taurusPendingTransaction.commandId, + status: 'signed', + metadata: { + gatewayStatus: 'executed', + updateId: 'tp-update-1', + }, + }) + const store = createStore(taurusInFlightTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.sign( + authContext, + taurusWallet, + signParams + ) + + expect(store.setTransactionSigned).toHaveBeenCalledWith( + taurusPendingTransaction.id, + expect.any(Date), + 'tp-request-1' + ) + // The gateway submits, so the ledger updateId stands in for the signature. + expect(result).toEqual({ + status: 'signed', + signature: 'tp-update-1', + signedBy: wallet.namespace, + partyId: wallet.partyId, + externalTxId: 'tp-request-1', + }) + }) + + it('keeps the transaction pending when the gateway reports executed before the updateId', async () => { + const getTransaction = vi.fn().mockResolvedValue({ + txId: taurusPendingTransaction.commandId, + status: 'signed', + metadata: { gatewayStatus: 'executed' }, + }) + const store = createStore(taurusInFlightTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.sign( + authContext, + taurusWallet, + signParams + ) + + expect(store.setTransactionSigned).not.toHaveBeenCalled() + expect(store.setTransactionStatus).toHaveBeenCalledWith( + taurusPendingTransaction.id, + 'pending', + { externalTxId: 'tp-request-1' } + ) + expect(emit).toHaveBeenCalledWith( + 'txChanged', + expect.objectContaining({ + id: taurusPendingTransaction.id, + status: 'pending', + }) + ) + expect(result).toEqual({ + status: 'pending', + partyId: wallet.partyId, + externalTxId: 'tp-request-1', + }) + }) + + it('marks the transaction failed when the gateway reports failed', async () => { + const getTransaction = vi.fn().mockResolvedValue({ + txId: taurusPendingTransaction.commandId, + status: 'failed', + metadata: { gatewayStatus: 'failed' }, + }) + const store = createStore(taurusInFlightTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.sign( + authContext, + taurusWallet, + signParams + ) + + expect(store.setTransactionSigned).not.toHaveBeenCalled() + expect(store.setTransactionStatus).toHaveBeenCalledWith( + taurusPendingTransaction.id, + 'failed', + { externalTxId: 'tp-request-1' } + ) + expect(result).toEqual({ + status: 'failed', + partyId: wallet.partyId, + externalTxId: 'tp-request-1', + }) + }) + + it('throws when the Taurus-PROTECT driver is not registered', async () => { + const service = createService( + createStore(taurusPendingTransaction), + {}, + notifier, + logger + ) + + await expect( + service.sign(authContext, taurusWallet, signParams) + ).rejects.toThrow( + `No driver found for ${SigningProvider.TAURUS_PROTECT}` + ) + }) + }) + it.each([ { name: 'participant', @@ -560,6 +808,11 @@ describe('TransactionService', () => { provider: SigningProvider.SECUROSYS, auth: authContext, }, + { + name: 'taurus-protect', + provider: SigningProvider.TAURUS_PROTECT, + auth: authContext, + }, ])( 'rejects signing an already executed transaction for $name', async ({ provider, auth }) => { @@ -716,6 +969,220 @@ describe('TransactionService', () => { } ) }) + + describe('taurus-protect', () => { + it('reconciles the gateway status without posting to the ledger', async () => { + const signedTaurusTransaction = { + ...taurusInFlightTransaction, + status: 'signed' as const, + } + const getTransaction = vi.fn().mockResolvedValue({ + txId: signedTaurusTransaction.commandId, + status: 'signed', + metadata: { + gatewayStatus: 'executed', + updateId: 'tp-update-1', + contractId: 'tp-contract-1', + }, + }) + const store = createStore(signedTaurusTransaction) + const postWithRetry = vi.fn() + const ledgerClient = { + postWithRetry, + } as unknown as LedgerClient + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.execute( + authContext.userId, + taurusWallet, + signedTaurusTransaction, + executeParams, + ledgerClient, + network + ) + + // The gateway already submitted — executing must never re-post the command. + expect(postWithRetry).not.toHaveBeenCalled() + expect(store.setTransactionStatus).toHaveBeenCalledWith( + signedTaurusTransaction.id, + 'executed', + { + payload: { + updateId: 'tp-update-1', + completionOffset: 0, + }, + externalTxId: 'tp-request-1', + } + ) + expect(emit).toHaveBeenCalledWith( + 'txChanged', + expect.objectContaining({ + id: signedTaurusTransaction.id, + status: 'executed', + }) + ) + expect(result).toEqual({ + status: 'executed', + updateId: 'tp-update-1', + contractId: 'tp-contract-1', + }) + }) + + it('leaves the signed row untouched while the gateway is still processing', async () => { + const signedTaurusTransaction = { + ...taurusInFlightTransaction, + status: 'signed' as const, + } + const getTransaction = vi.fn().mockResolvedValue({ + txId: signedTaurusTransaction.commandId, + status: 'pending', + metadata: { gatewayStatus: 'pending' }, + }) + const store = createStore(signedTaurusTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.execute( + authContext.userId, + taurusWallet, + signedTaurusTransaction, + executeParams + ) + + // Demoting 'signed' would fail the guard on the next execute poll. + expect(store.setTransactionStatus).not.toHaveBeenCalled() + // The requestId is not an updateId, so none is reported. + expect(result).toEqual({ status: 'pending' }) + }) + + it('re-polls to completion after an executed status with no updateId yet', async () => { + const signedTaurusTransaction = { + ...taurusInFlightTransaction, + status: 'signed' as const, + } + const getTransaction = vi + .fn() + .mockResolvedValueOnce({ + txId: signedTaurusTransaction.commandId, + status: 'signed', + metadata: { + gatewayStatus: 'executed', + contractId: 'c1', + }, + }) + .mockResolvedValueOnce({ + txId: signedTaurusTransaction.commandId, + status: 'signed', + metadata: { + gatewayStatus: 'executed', + contractId: 'c1', + updateId: 'u1', + }, + }) + const store = createStore(signedTaurusTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + const first = await service.execute( + authContext.userId, + taurusWallet, + signedTaurusTransaction, + executeParams + ) + // Not complete yet: the row must stay 'signed' and pollable. + expect(first).toEqual({ status: 'pending' }) + expect(store.setTransactionStatus).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + + const second = await service.execute( + authContext.userId, + taurusWallet, + signedTaurusTransaction, + executeParams + ) + expect(second).toEqual({ + status: 'executed', + updateId: 'u1', + contractId: 'c1', + }) + expect(store.setTransactionStatus).toHaveBeenCalledWith( + signedTaurusTransaction.id, + 'executed', + { + payload: { updateId: 'u1', completionOffset: 0 }, + externalTxId: 'tp-request-1', + } + ) + }) + + it('emits an executed txChanged carrying a payload', async () => { + const signedTaurusTransaction = { + ...taurusInFlightTransaction, + status: 'signed' as const, + } + const getTransaction = vi.fn().mockResolvedValue({ + txId: signedTaurusTransaction.commandId, + status: 'signed', + metadata: { + gatewayStatus: 'executed', + updateId: 'u1', + contractId: 'c1', + }, + }) + const store = createStore(signedTaurusTransaction) + const service = createService( + store, + { + [SigningProvider.TAURUS_PROTECT]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + await service.execute( + authContext.userId, + taurusWallet, + signedTaurusTransaction, + executeParams + ) + + // TxChangedExecutedEvent requires payload. + expect(notifier.emit).toHaveBeenCalledWith( + 'txChanged', + expect.objectContaining({ + status: 'executed', + payload: { updateId: 'u1', completionOffset: 0 }, + }) + ) + }) + }) }) describe('signAndExecute', () => { diff --git a/wallet-gateway/remote/src/ledger/transaction-service.ts b/wallet-gateway/remote/src/ledger/transaction-service.ts index c5714fb41..1e301dd37 100644 --- a/wallet-gateway/remote/src/ledger/transaction-service.ts +++ b/wallet-gateway/remote/src/ledger/transaction-service.ts @@ -108,6 +108,13 @@ export class TransactionService { signParams ) } + case SigningProvider.TAURUS_PROTECT: { + return this.signWithTaurusProtect( + authContext.userId, + wallet, + signParams + ) + } default: throw new Error( `Unsupported signing provider: ${wallet.signingProviderId}` @@ -181,6 +188,9 @@ export class TransactionService { ledgerClient ) } + case SigningProvider.TAURUS_PROTECT: { + return this.executeWithSubmitProvider(userId, transaction) + } default: throw new Error( `Unsupported signing provider: ${wallet.signingProviderId}` @@ -809,6 +819,139 @@ export class TransactionService { } } + /** Gateway signs and submits the CIP-103 command; forward on first call, re-poll after, terminal only once `executed` carries the updateId. */ + private async signWithTaurusProtect( + userId: UserId, + wallet: Wallet, + signParams: SignParams + ): Promise { + const signingProvider = + this.signingDrivers[SigningProvider.TAURUS_PROTECT] + if (!signingProvider) { + throw new Error('Taurus-PROTECT signing driver not available') + } + const driver = signingProvider.controller(userId) + + const tx = await this.loadPreparedTransactionForSigning( + signParams.transactionId + ) + + let signingResult: Exclude< + GetTransactionResult | SignTransactionResult, + SigningError + > + let requestId: string + if (tx.externalTxId) { + // Already submitted — re-poll (requestId lets the RPC fallback work after restart). + signingResult = await driver + .getTransaction({ + txId: tx.commandId, + requestId: tx.externalTxId, + }) + .then(handleSigningError) + requestId = tx.externalTxId + } else { + // Only what the gateway consumes; disclosedContracts, readAs and + // packageIdSelectionPreference are inert there and already ride + // inside the prepared transaction. + const payload = (tx.payload ?? {}) as PrepareParams + const command = JSON.stringify({ + commands: payload.commands, + actAs: payload.actAs?.length ? payload.actAs : [wallet.partyId], + commandId: tx.commandId, + preparedTransaction: tx.preparedTransaction, + }) + signingResult = await driver + .signTransaction({ + tx: command, + txHash: tx.preparedTransactionHash, + keyIdentifier: { + id: wallet.partyId, + publicKey: wallet.publicKey, + }, + }) + .then(handleSigningError) + // txId is the commandId here; persisting it would strand the row. + const returned = signingResult.metadata?.requestId as + string | undefined + if (!returned) { + throw new Error( + 'Taurus-PROTECT gateway accepted the command without returning a requestId' + ) + } + requestId = returned + } + + const gatewayStatus = + (signingResult.metadata?.gatewayStatus as string | undefined) ?? + signingResult.status + // Only a real ledger updateId; the requestId is not one, and the + // gateway can report `executed` before the updateId is observable. + const updateId = signingResult.metadata?.updateId as string | undefined + const now = new Date() + + logDynamically(this.logger, 'Taurus-PROTECT signing result', { + info: { transactionId: tx.id, status: gatewayStatus }, + debug: { signingResult, tx }, + }) + + if (gatewayStatus === 'executed' && updateId) { + const signedTx: Transaction = { + id: tx.id, + commandId: tx.commandId, + status: 'signed', + preparedTransaction: tx.preparedTransaction, + preparedTransactionHash: tx.preparedTransactionHash, + origin: tx?.origin ?? null, + ...(tx?.createdAt && { createdAt: tx.createdAt }), + signedAt: now, + externalTxId: requestId, + } + await this.store.setTransactionSigned(tx.id, now, requestId) + this.notifier.emit('txChanged', signedTx) + + return { + status: 'signed', + signature: updateId, + signedBy: wallet.namespace, + partyId: wallet.partyId, + externalTxId: requestId, + } + } + + // pending / signed (in-flight under governance), executed before the + // updateId is observable, or failed. + const status: 'pending' | 'failed' = + gatewayStatus === 'failed' ? 'failed' : 'pending' + const pendingTx: Transaction = { + id: tx.id, + commandId: tx.commandId, + status, + preparedTransaction: tx.preparedTransaction, + preparedTransactionHash: tx.preparedTransactionHash, + origin: tx?.origin ?? null, + ...(tx?.createdAt && { createdAt: tx.createdAt }), + externalTxId: requestId, + } + await this.store.setTransactionStatus(tx.id, status, { + externalTxId: requestId, + }) + this.notifier.emit('txChanged', pendingTx) + + if (status === 'failed') { + return { + status: 'failed', + partyId: wallet.partyId, + externalTxId: requestId, + } + } + return { + status: 'pending', + partyId: wallet.partyId, + externalTxId: requestId, + } + } + private async executeWithParticipant( userId: UserId, executeParams: ExecuteParams, @@ -926,4 +1069,92 @@ export class TransactionService { return result } + + /** + * Reconcile the stored tx against the provider's status; the provider + * already submitted, so this never posts to the ledger. Callers poll + * repeatedly: the row stays 'signed' and pollable until `executed` + * carries the updateId, then persists terminal. + */ + private async executeWithSubmitProvider( + userId: UserId, + transaction: Transaction + ): Promise { + const signingProvider = + this.signingDrivers[SigningProvider.TAURUS_PROTECT] + if (!signingProvider) { + throw new Error('Taurus-PROTECT signing driver not available') + } + const driver = signingProvider.controller(userId) + + const result = await driver + .getTransaction({ + txId: transaction.commandId, + ...(transaction.externalTxId && { + requestId: transaction.externalTxId, + }), + }) + .then(handleSigningError) + + const gatewayStatus = + (result.metadata?.gatewayStatus as string | undefined) ?? + result.status + // Only a real ledger id: callers resolve this against the ledger. + const updateId = result.metadata?.updateId as string | undefined + const contractId = result.metadata?.contractId as string | undefined + + const status = + gatewayStatus === 'executed' && updateId + ? 'executed' + : gatewayStatus === 'failed' + ? 'failed' + : 'pending' + + logDynamically(this.logger, 'Taurus-PROTECT execution result', { + info: { transactionId: transaction.id, status }, + debug: { result, transaction, userId }, + }) + + // Leave the row 'signed': demoting it fails the guard on the next + // poll, and `executed` without the updateId is not yet complete. + if (status === 'pending') { + return { status } + } + + // The gateway never reports a real offset, hence 0; omitted on + // failure so the stored payload survives. + const resultPayload = + status === 'executed' + ? { updateId, completionOffset: 0 } + : undefined + const reconciledTx: Transaction = { + id: transaction.id, + commandId: transaction.commandId, + status, + preparedTransaction: transaction.preparedTransaction, + preparedTransactionHash: transaction.preparedTransactionHash, + // TxChangedExecutedEvent requires payload. + ...(resultPayload && { payload: resultPayload }), + origin: transaction.origin ?? null, + ...(transaction.createdAt && { createdAt: transaction.createdAt }), + ...(transaction.signedAt && { signedAt: transaction.signedAt }), + ...(transaction.externalTxId && { + externalTxId: transaction.externalTxId, + }), + } + // Persisted so a history reload needn't re-poll the gateway. + await this.store.setTransactionStatus(transaction.id, status, { + ...(resultPayload && { payload: resultPayload }), + ...(transaction.externalTxId && { + externalTxId: transaction.externalTxId, + }), + }) + this.notifier.emit('txChanged', reconciledTx) + + return { + status, + ...(updateId && { updateId }), + ...(contractId && { contractId }), + } + } } diff --git a/wallet-gateway/remote/src/ledger/wallet-allocation/signing-providers/taurus-protect-wallet-allocator.ts b/wallet-gateway/remote/src/ledger/wallet-allocation/signing-providers/taurus-protect-wallet-allocator.ts new file mode 100644 index 000000000..1aec123c6 --- /dev/null +++ b/wallet-gateway/remote/src/ledger/wallet-allocation/signing-providers/taurus-protect-wallet-allocator.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { UserId } from '@canton-network/core-wallet-auth' +import { Store, Wallet } from '@canton-network/core-wallet-store' +import { + isRpcError, + SigningDriverInterface, + SigningProvider, +} from '@canton-network/core-signing-lib' +import { Logger } from 'pino' +import { + PartyHint, + Primary, + VaultName, +} from '../../../user-api/rpc-gen/typings.js' +import type { WalletAllocator } from '../wallet-allocation-service.js' + +/** + * Imports Canton parties already provisioned in Taurus-PROTECT (via the gateway's listAccounts). + * The party is hosted externally — no topology transaction, no hash signing; createWallet just records it. + */ +export class TaurusProtectWalletAllocator implements WalletAllocator { + constructor( + private store: Store, + private logger: Logger, + private signingDriver: SigningDriverInterface + ) {} + + async createWallet( + userId: UserId, + email: string | undefined, + partyHint: PartyHint, + primary: Primary = false, + vaultName?: VaultName | undefined + ): Promise { + const keys = await this.listParties(userId) + + // Prefixes are not unique (Canton uniqueness is prefix::fingerprint), + // so a silent first-match could custody the wrong party. + const selector = vaultName ?? partyHint + const label = vaultName === undefined ? 'hint' : 'vault' + const matches = keys.filter((k) => k.name === selector) + if (matches.length > 1) { + throw new Error( + `Ambiguous Taurus-PROTECT ${label} "${selector}": ${matches + .map((k) => k.id) + .join(', ')}` + ) + } + const key = matches[0] + if (!key) { + throw new Error( + `No Taurus-PROTECT party found for ${label} "${selector}"` + ) + } + + const partyId = key.id + const namespace = partyId.includes('::') + ? partyId.slice(partyId.indexOf('::') + 2) + : partyId + const network = await this.store.getCurrentNetwork() + const wallet: Wallet = { + partyId, + hint: partyHint, + namespace, + signingProviderId: SigningProvider.TAURUS_PROTECT, + networkId: network.id, + status: 'allocated', + primary, + publicKey: key.publicKey, + externalTxId: '', + topologyTransactions: '', + rights: [], + } + this.logger.info( + { partyId, hint: partyHint }, + 'Imported Taurus-PROTECT party' + ) + await this.store.addWallet(wallet) + return wallet + } + + async allocateParty( + _userId: UserId, + _email: string | undefined, + existingWallet: Wallet + ): Promise { + // Taurus-PROTECT parties are provisioned externally; nothing to allocate, just confirm the wallet is active. + const network = await this.store.getCurrentNetwork() + await this.store.updateWallet({ + partyId: existingWallet.partyId, + networkId: network.id, + status: 'allocated', + }) + } + + // Taurus-PROTECT has no vaults; the selectable unit is the provisioned party, named by its prefix. + async getVaults(userId: UserId): Promise<{ vaults: string[] }> { + const keys = await this.listParties(userId) + return { vaults: keys.map((key) => key.name) } + } + + private async listParties(userId: UserId) { + const driver = this.signingDriver.controller(userId) + const keysResult = await driver.getKeys() + if (isRpcError(keysResult)) { + throw new Error( + `Failed to list Taurus-PROTECT parties: ${keysResult.error_description}` + ) + } + return keysResult.keys + } +} diff --git a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts index 68a83da4e..635cc60be 100644 --- a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts +++ b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts @@ -258,6 +258,42 @@ function createSecurosysDriver(options: { } as unknown as SigningDriverInterface } +function createTaurusProtectDriver(options: { + getKeysResult?: + | { + keys: Array<{ + id: string + name: string + publicKey: string + }> + } + | { error: string; error_description: string } +}): SigningDriverInterface { + const getKeysResult = options.getKeysResult ?? { + keys: [ + { id: 'alice::tp-namespace', name: 'alice', publicKey: 'tp-pk' }, + ], + } + return { + controller: vi.fn().mockReturnValue({ + getKeys: vi + .fn< + () => Promise< + | { + keys: Array<{ + id: string + name: string + publicKey: string + }> + } + | { error: string; error_description: string } + > + >() + .mockResolvedValue(getKeysResult), + }), + } as unknown as SigningDriverInterface +} + describe('WalletAllocationService', () => { let mockLogger: Logger let mockStore: { @@ -1656,4 +1692,241 @@ describe('WalletAllocationService', () => { }) }) }) + + describe('Taurus-PROTECT', () => { + it('throws when Taurus-PROTECT signing driver not available', async () => { + const serviceWithoutTaurus = createService({}) + + await expect( + serviceWithoutTaurus.createWallet( + authContext, + 'alice', + false, + SigningProvider.TAURUS_PROTECT + ) + ).rejects.toThrow('Taurus-PROTECT signing driver not available') + }) + + it('createWallet imports the party matching the hint', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({ + getKeysResult: { + keys: [ + { + id: 'alice::tp-namespace', + name: 'alice', + publicKey: 'tp-pk-a', + }, + { + id: 'bob::tp-namespace', + name: 'bob', + publicKey: 'tp-pk-b', + }, + ], + }, + }), + }) + + const result = await serviceWithTaurus.createWallet( + authContext, + 'bob', + false, + SigningProvider.TAURUS_PROTECT + ) + + // Externally hosted: allocated straight away, no topology transaction. + expect(result.status).toBe('allocated') + expect(result.partyId).toBe('bob::tp-namespace') + expect(result.namespace).toBe('tp-namespace') + expect(result.publicKey).toBe('tp-pk-b') + expect(result.topologyTransactions).toBe('') + expect(mockStore.addWallet).toHaveBeenCalled() + }) + + it('createWallet selects by vaultName over partyHint', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({ + getKeysResult: { + keys: [ + { + id: 'alice::tp-namespace', + name: 'alice', + publicKey: 'tp-pk-a', + }, + { + id: 'bob::tp-namespace', + name: 'bob', + publicKey: 'tp-pk-b', + }, + ], + }, + }), + }) + + const result = await serviceWithTaurus.createWallet( + authContext, + 'alice', + false, + SigningProvider.TAURUS_PROTECT, + 'bob' + ) + + expect(result.partyId).toBe('bob::tp-namespace') + // hint is still what the wallet records, vaultName only drives selection + expect(result.hint).toBe('alice') + }) + + it('createWallet throws rather than bind the sole party to a hint it does not match', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({ + getKeysResult: { + keys: [ + { + id: 'alice::tp-namespace', + name: 'some-other-prefix', + publicKey: 'tp-pk-a', + }, + ], + }, + }), + }) + + await expect( + serviceWithTaurus.createWallet( + authContext, + 'alice', + false, + SigningProvider.TAURUS_PROTECT + ) + ).rejects.toThrow(/No Taurus-PROTECT party found for hint "alice"/) + }) + + it('createWallet throws when two parties share the selected prefix', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({ + getKeysResult: { + keys: [ + { + id: 'alice::ns-a', + name: 'alice', + publicKey: 'tp-pk-a', + }, + { + id: 'alice::ns-b', + name: 'alice', + publicKey: 'tp-pk-b', + }, + ], + }, + }), + }) + + await expect( + serviceWithTaurus.createWallet( + authContext, + 'alice', + false, + SigningProvider.TAURUS_PROTECT + ) + ).rejects.toThrow(/Ambiguous.*alice::ns-a, alice::ns-b/) + }) + + it('createWallet throws when an explicit vaultName matches nothing', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({ + getKeysResult: { + keys: [ + { + id: 'alice::tp-namespace', + name: 'alice', + publicKey: 'tp-pk-a', + }, + ], + }, + }), + }) + + await expect( + serviceWithTaurus.createWallet( + authContext, + 'alice', + false, + SigningProvider.TAURUS_PROTECT, + 'missing-party' + ) + ).rejects.toThrow( + 'No Taurus-PROTECT party found for vault "missing-party"' + ) + }) + + it('createWallet surfaces a driver error when listing parties fails', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({ + getKeysResult: { + error: 'fetch_error', + error_description: 'gateway unreachable', + }, + }), + }) + + await expect( + serviceWithTaurus.createWallet( + authContext, + 'alice', + false, + SigningProvider.TAURUS_PROTECT + ) + ).rejects.toThrow( + 'Failed to list Taurus-PROTECT parties: gateway unreachable' + ) + }) + + it('getVaults returns the provisioned party names', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({ + getKeysResult: { + keys: [ + { + id: 'alice::tp-namespace', + name: 'alice', + publicKey: 'tp-pk-a', + }, + { + id: 'bob::tp-namespace', + name: 'bob', + publicKey: 'tp-pk-b', + }, + ], + }, + }), + }) + + const result = await serviceWithTaurus.getVaults( + authContext, + SigningProvider.TAURUS_PROTECT + ) + + expect(result).toEqual({ vaults: ['alice', 'bob'] }) + }) + + it('allocateParty confirms the externally hosted wallet', async () => { + const serviceWithTaurus = createService({ + [SigningProvider.TAURUS_PROTECT]: createTaurusProtectDriver({}), + }) + + await serviceWithTaurus.allocateParty( + authContext, + createWallet('alice::tp-namespace', { + signingProviderId: SigningProvider.TAURUS_PROTECT, + }), + SigningProvider.TAURUS_PROTECT + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::tp-namespace', + networkId: 'network1', + status: 'allocated', + }) + }) + }) }) diff --git a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts index ac966bef9..5d521b7d1 100644 --- a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts +++ b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts @@ -20,6 +20,7 @@ import { FireblocksWalletAllocator } from './signing-providers/fireblocks-wallet import { BlockdaemonWalletAllocator } from './signing-providers/blockdaemon-wallet-allocator.js' import { DfnsWalletAllocator } from './signing-providers/dfns-wallet-allocator.js' import { SecurosysWalletAllocator } from './signing-providers/securosys-wallet-allocator.js' +import { TaurusProtectWalletAllocator } from './signing-providers/taurus-protect-wallet-allocator.js' export interface WalletAllocator { createWallet( @@ -44,6 +45,7 @@ export class WalletAllocationService { private readonly blockdaemonAllocator?: BlockdaemonWalletAllocator private readonly dfnsAllocator?: DfnsWalletAllocator private readonly securosysAllocator?: SecurosysWalletAllocator + private readonly taurusProtectAllocator?: TaurusProtectWalletAllocator constructor( store: Store, @@ -108,6 +110,16 @@ export class WalletAllocationService { securosysDriver ) } + + const taurusProtectDriver = + signingDrivers[SigningProvider.TAURUS_PROTECT] + if (taurusProtectDriver) { + this.taurusProtectAllocator = new TaurusProtectWalletAllocator( + store, + logger, + taurusProtectDriver + ) + } } public async createWallet( @@ -188,6 +200,20 @@ export class WalletAllocationService { partyHint, primary ) + case SigningProvider.TAURUS_PROTECT: + if (!this.taurusProtectAllocator) { + throw new Error( + 'Taurus-PROTECT signing driver not available' + ) + } + // vaultName is optional here: the M2M path has no picker and selects by partyHint. + return this.taurusProtectAllocator.createWallet( + authContext.userId, + authContext.email, + partyHint, + primary, + vaultName + ) default: throw new Error( `Unsupported signing provider: ${signingProviderId}` @@ -259,6 +285,17 @@ export class WalletAllocationService { authContext.email, existingWallet ) + case SigningProvider.TAURUS_PROTECT: + if (!this.taurusProtectAllocator) { + throw new Error( + 'Taurus-PROTECT signing driver not available' + ) + } + return this.taurusProtectAllocator.allocateParty( + authContext.userId, + authContext.email, + existingWallet + ) default: throw new Error( `Unsupported signing provider: ${signingProviderId}` @@ -276,6 +313,13 @@ export class WalletAllocationService { throw new Error('Fireblocks signing driver not available') } return this.fireblocksAllocator.getVaults(authContext.userId) + case SigningProvider.TAURUS_PROTECT: + if (!this.taurusProtectAllocator) { + throw new Error( + 'Taurus-PROTECT signing driver not available' + ) + } + return this.taurusProtectAllocator.getVaults(authContext.userId) default: throw new Error( `Signing provider ${signingProviderId} does not support listing vaults` diff --git a/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts b/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts index 1938ec70c..6f12fb56b 100644 --- a/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts +++ b/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts @@ -717,6 +717,42 @@ describe('WalletSyncService - multi-network features', () => { expect(syncNeeded).toBe(false) }) + it('isWalletSyncNeeded should return false when a taurus-protect wallet has no party', async () => { + const network1 = createNetwork('network1') + await store.addNetwork(network1) + await setSession('network1') + const taurusWallet = createWallet('party1::namespace', 'network1') + taurusWallet.signingProviderId = SigningProvider.TAURUS_PROTECT + await store.addWallet(taurusWallet) + await store.addWallet( + createWallet( + 'party2::namespace', + 'network1', + undefined, + 'allocated' + ) + ) + + mockLedgerGet.mockResolvedValueOnce({ + rights: [ + { + kind: { + CanActAs: { + value: { + party: 'party2::namespace', + }, + }, + }, + }, + ], + }) + + const syncNeeded = await service.isWalletSyncNeeded() + + // Hosted remotely: absence from local rights must not latch sync-needed. + expect(syncNeeded).toBe(false) + }) + it('syncWallets marks allocated wallet as initialized when party not on ledger', async () => { const network1 = createNetwork('network1') await store.addNetwork(network1) @@ -905,6 +941,46 @@ describe('WalletSyncService - multi-network features', () => { expect(result.disabled[0].partyId).toBe('party1::namespace') }) + it('syncWallets leaves taurus-protect wallet allocated when party not on ledger', async () => { + const network1 = createNetwork('network1') + await store.addNetwork(network1) + await setSession('network1') + const taurusWallet = createWallet('party1::namespace', 'network1') + taurusWallet.signingProviderId = SigningProvider.TAURUS_PROTECT + await store.addWallet(taurusWallet) + + mockLedgerGet + .mockResolvedValueOnce({ + participantId: 'participant1::namespace', + }) + .mockResolvedValueOnce({ + rights: [ + { + kind: { + CanActAs: { + value: { party: 'party2::namespace' }, + }, + }, + }, + ], + }) + + const updateWalletSpy = vi.spyOn(store, 'updateWallet') + + const result = await service.syncWallets() + + expect(updateWalletSpy).not.toHaveBeenCalled() + const wallets = await store.getWallets() + const party1Wallet = wallets.find( + (w) => w.partyId === 'party1::namespace' + ) + expect(party1Wallet?.status).toBe('allocated') + expect(party1Wallet?.disabled).toBe(false) + expect(result.added.length).toBe(1) + expect(result.updated.length).toBe(0) + expect(result.disabled.length).toBe(0) + }) + it('syncWallets reports proper changes while adding a wallet when there are disabled wallets', async () => { const network1 = createNetwork('network1') await store.addNetwork(network1) diff --git a/wallet-gateway/remote/src/ledger/wallet-sync-service.ts b/wallet-gateway/remote/src/ledger/wallet-sync-service.ts index a52e233e2..63d9caa6c 100644 --- a/wallet-gateway/remote/src/ledger/wallet-sync-service.ts +++ b/wallet-gateway/remote/src/ledger/wallet-sync-service.ts @@ -281,6 +281,10 @@ export class WalletSyncService { const hasWalletsWithoutParty = enabledWallets.some( (wallet) => wallet.status === 'allocated' && + // Hosted remotely: absence from local rights says nothing + // and would latch sync-needed forever. + wallet.signingProviderId !== + SigningProvider.TAURUS_PROTECT && !partiesWithRights.includes(wallet.partyId) ) @@ -332,6 +336,11 @@ export class WalletSyncService { for (const wallet of walletsWithoutParty) { if (wallet.status !== 'allocated' || wallet.disabled) continue + // Hosted remotely: absence from local rights says nothing, and + // demoting fights createWallet every tick. + if (wallet.signingProviderId === SigningProvider.TAURUS_PROTECT) { + continue + } try { if (wallet.signingProviderId === SigningProvider.PARTICIPANT) { diff --git a/wallet-gateway/remote/src/web/frontend/parties/add/index.ts b/wallet-gateway/remote/src/web/frontend/parties/add/index.ts index ea4866807..5cb9a4db3 100644 --- a/wallet-gateway/remote/src/web/frontend/parties/add/index.ts +++ b/wallet-gateway/remote/src/web/frontend/parties/add/index.ts @@ -24,7 +24,10 @@ import { detectCurrentOrigin } from '../../listeners.js' @customElement('user-ui-add-party') export class UserUiAddParty extends BaseElement { - private static readonly vaultSigningProviders = [SigningProvider.FIREBLOCKS] + private static readonly vaultSigningProviders = [ + SigningProvider.FIREBLOCKS, + SigningProvider.TAURUS_PROTECT, + ] @state() accessor signingProviders: string[] = Object.values(SigningProvider)