From d593fa54454b165ab42ffdab83ebb2fe5295841c Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Thu, 30 Apr 2026 10:41:10 +0300 Subject: [PATCH 01/13] feat(user-api+sdk): add signMessage on signing driver and SIWX over WalletConnect Signed-off-by: Gancho Radkov --- api-specs/openrpc-user-api.json | 52 ++++++++ core/signing-blockdaemon/src/index.ts | 14 +++ core/signing-fireblocks/src/index.ts | 14 +++ core/signing-internal/src/controller.ts | 29 +++++ core/signing-lib/src/index.ts | 6 + core/signing-lib/src/rpc-gen/index.ts | 3 + core/signing-lib/src/rpc-gen/typings.ts | 8 ++ core/wallet-user-rpc-client/src/index.ts | 33 +++++ core/wallet-user-rpc-client/src/openrpc.json | 52 ++++++++ examples/ping/src/hooks/useConnect.ts | 13 +- .../walletconnect/src/walletkit/handler.ts | 4 +- .../src/adapter/walletconnect-adapter.ts | 113 +++++++++++++++++- sdk/dapp-sdk/src/util.ts | 37 ++++++ .../remote/src/user-api/controller.ts | 59 +++++++++ .../remote/src/user-api/rpc-gen/index.ts | 3 + .../remote/src/user-api/rpc-gen/typings.ts | 27 +++++ 16 files changed, 461 insertions(+), 6 deletions(-) diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index 866139881..5cd645d29 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -528,6 +528,58 @@ }, "description": "Signs the provided data with the private key of the specified or active party." }, + { + "name": "signMessage", + "params": [ + { + "name": "params", + "schema": { + "title": "SignMessageParams", + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "title": "message", + "type": "string", + "description": "Arbitrary UTF-8 message to sign." + }, + "partyId": { + "title": "partyId", + "type": "string", + "description": "Party that should sign the message. If omitted, the primary wallet is used." + } + }, + "required": ["message"] + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "SignMessageResult", + "type": "object", + "additionalProperties": false, + "properties": { + "signature": { + "title": "signature", + "type": "string", + "description": "Base64-encoded Ed25519 signature over the prefixed message." + }, + "signedBy": { + "title": "signedBy", + "type": "string", + "description": "Namespace fingerprint that signed the message." + }, + "partyId": { + "title": "partyId", + "type": "string" + } + }, + "required": ["signature", "signedBy", "partyId"] + } + }, + "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message is domain-separated with a Canton-specific prefix to prevent collisions with prepared transaction hashes." + }, { "name": "execute", "params": [ diff --git a/core/signing-blockdaemon/src/index.ts b/core/signing-blockdaemon/src/index.ts index 46887cda3..35d08440b 100644 --- a/core/signing-blockdaemon/src/index.ts +++ b/core/signing-blockdaemon/src/index.ts @@ -16,6 +16,8 @@ import { type SetConfigurationResult, type SigningDriverInterface, SigningProvider, + SignMessageParams, + SignMessageResult, type SignTransactionParams, type SignTransactionResult, type SubscribeTransactionsParams, @@ -89,6 +91,18 @@ export default class BlockdaemonSigningDriver implements SigningDriverInterface } }, + signMessage: async ( + // Disabled unused vars rule to allow for future implementations + // eslint-disable-next-line @typescript-eslint/no-unused-vars + params: SignMessageParams + ): Promise => { + return { + error: 'not_allowed', + error_description: + 'Signing messages is not yet supported with Blockdaemon.', + } + }, + getTransaction: async ( params: GetTransactionParams ): Promise => { diff --git a/core/signing-fireblocks/src/index.ts b/core/signing-fireblocks/src/index.ts index 1b7889cb2..06fc6d410 100644 --- a/core/signing-fireblocks/src/index.ts +++ b/core/signing-fireblocks/src/index.ts @@ -8,6 +8,8 @@ import { PartyMode, SigningDriverInterface, SigningProvider, + SignMessageParams, + SignMessageResult, } from '@canton-network/core-signing-lib' import { @@ -102,6 +104,18 @@ export default class FireblocksSigningDriver implements SigningDriverInterface { } }, + signMessage: async ( + // Disabled unused vars rule to allow for future implementations + + params: SignMessageParams + ): Promise => { + return { + error: 'not_allowed', + error_description: + 'Signing messages is not yet supported with Fireblocks.', + } + }, + getTransaction: async ( params: GetTransactionParams ): Promise => { diff --git a/core/signing-internal/src/controller.ts b/core/signing-internal/src/controller.ts index 443ce042c..c6083c70e 100644 --- a/core/signing-internal/src/controller.ts +++ b/core/signing-internal/src/controller.ts @@ -9,6 +9,7 @@ import { SigningDriverInterface, SigningProvider, signTransactionHash, + signMessage, createKeyPair, SigningDriverStore, SigningTransaction, @@ -31,6 +32,8 @@ import { SubscribeTransactionsResult, SetConfigurationResult, Transaction, + SignMessageParams, + SignMessageResult, } from '@canton-network/core-signing-lib' import { randomUUID } from 'node:crypto' import { AuthContext } from '@canton-network/core-wallet-auth' @@ -132,6 +135,32 @@ export class InternalSigningDriver implements SigningDriverInterface { } }, + signMessage: async ( + params: SignMessageParams + ): Promise => { + if (!params.keyIdentifier.publicKey) { + return Promise.resolve({ + error: 'key_not_found', + error_description: + 'The provided key identifier must include a publicKey.', + }) + } + + const key = await this.store.getSigningKeyByPublicKey( + params.keyIdentifier.publicKey + ) + if (!key?.privateKey) { + return Promise.resolve({ + error: 'key_not_found', + error_description: + 'The provided key identifier must include a privateKey.', + }) + } + return Promise.resolve({ + signature: signMessage(params.message, key.privateKey), + }) + }, + getTransaction: async ( params: GetTransactionParams ): Promise => { diff --git a/core/signing-lib/src/index.ts b/core/signing-lib/src/index.ts index 9d4fd5954..c8ee9a6df 100644 --- a/core/signing-lib/src/index.ts +++ b/core/signing-lib/src/index.ts @@ -64,6 +64,12 @@ export const signTransactionHash = ( ) } +export const signMessage = (message: string, privateKey: string): string => { + const msgBytes = new TextEncoder().encode(message) + const decodedKey = naclUtil.decodeBase64(privateKey) + return naclUtil.encodeBase64(nacl.sign.detached(msgBytes, decodedKey)) +} + export const getPublicKeyFromPrivate = (privateKeyBase64: string): string => { const secretKey = naclUtil.decodeBase64(privateKeyBase64) const keyPair = nacl.sign.keyPair.fromSecretKey(secretKey) diff --git a/core/signing-lib/src/rpc-gen/index.ts b/core/signing-lib/src/rpc-gen/index.ts index 2fa6bd53b..fc1e53a0a 100644 --- a/core/signing-lib/src/rpc-gen/index.ts +++ b/core/signing-lib/src/rpc-gen/index.ts @@ -9,6 +9,7 @@ import { CreateKey } from './typings.js' import { GetConfiguration } from './typings.js' import { SetConfiguration } from './typings.js' import { SubscribeTransactions } from './typings.js' +import { SignMessage } from './typings.js' export type Methods = { signTransaction: SignTransaction @@ -19,11 +20,13 @@ export type Methods = { getConfiguration: GetConfiguration setConfiguration: SetConfiguration subscribeTransactions: SubscribeTransactions + signMessage: SignMessage } function buildController(methods: Methods) { return { signTransaction: methods.signTransaction, + signMessage: methods.signMessage, getTransaction: methods.getTransaction, getTransactions: methods.getTransactions, getKeys: methods.getKeys, diff --git a/core/signing-lib/src/rpc-gen/typings.ts b/core/signing-lib/src/rpc-gen/typings.ts index b78c1ffcc..72362e705 100644 --- a/core/signing-lib/src/rpc-gen/typings.ts +++ b/core/signing-lib/src/rpc-gen/typings.ts @@ -156,6 +156,10 @@ export interface SignTransactionParams { internalTxId?: InternalTxId [k: string]: any } +export interface SignMessageParams { + message: string + keyIdentifier: KeyIdentifier +} export interface GetTransactionParams { txId: TxId [k: string]: any @@ -181,6 +185,7 @@ export interface SubscribeTransactionsParams { txIds: TxIds } export type SignTransactionResult = Error | Transaction +export type SignMessageResult = Error | { signature: Signature } export type GetTransactionResult = Error | Transaction export type GetTransactionsResult = Error | TransactionsResult export type GetKeysResult = Error | Keys @@ -207,6 +212,9 @@ export interface SubscribeTransactionsResult { export type SignTransaction = ( params: SignTransactionParams ) => Promise +export type SignMessage = ( + params: SignMessageParams +) => Promise export type GetTransaction = ( params: GetTransactionParams ) => Promise diff --git a/core/wallet-user-rpc-client/src/index.ts b/core/wallet-user-rpc-client/src/index.ts index 6fb1b1018..b776b8deb 100644 --- a/core/wallet-user-rpc-client/src/index.ts +++ b/core/wallet-user-rpc-client/src/index.ts @@ -161,7 +161,23 @@ export interface WalletFilter { * */ export type TransactionId = string +/** + * + * Arbitrary UTF-8 message to sign. + * + */ +export type Message = string +/** + * + * Base64-encoded Ed25519 signature over the prefixed message. + * + */ export type Signature = string +/** + * + * Namespace fingerprint that signed the message. + * + */ export type SignedBy = string export type Networks = Network[] export type Idps = Idp[] @@ -419,6 +435,10 @@ export interface SignParams { transactionId: TransactionId partyId: PartyId } +export interface SignMessageParams { + message: Message + partyId?: PartyId +} export interface ExecuteParams { signature: Signature partyId: PartyId @@ -479,6 +499,11 @@ export type SignResult = | SignResultPending | SignResultRejected | SignResultFailed +export interface SignMessageResult { + signature: Signature + signedBy: SignedBy + partyId: PartyId +} export interface ExecuteResult { [key: string]: any } @@ -546,6 +571,9 @@ export type ListWallets = ( export type SyncWallets = () => Promise export type IsWalletSyncNeeded = () => Promise export type Sign = (params: SignParams) => Promise +export type SignMessage = ( + params: SignMessageParams +) => Promise export type Execute = (params: ExecuteParams) => Promise export type AddSession = (params: AddSessionParams) => Promise export type RemoveSession = () => Promise @@ -638,6 +666,11 @@ export type RpcTypes = { result: Result } + signMessage: { + params: Params + result: Result + } + execute: { params: Params result: Result diff --git a/core/wallet-user-rpc-client/src/openrpc.json b/core/wallet-user-rpc-client/src/openrpc.json index 866139881..5cd645d29 100644 --- a/core/wallet-user-rpc-client/src/openrpc.json +++ b/core/wallet-user-rpc-client/src/openrpc.json @@ -528,6 +528,58 @@ }, "description": "Signs the provided data with the private key of the specified or active party." }, + { + "name": "signMessage", + "params": [ + { + "name": "params", + "schema": { + "title": "SignMessageParams", + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "title": "message", + "type": "string", + "description": "Arbitrary UTF-8 message to sign." + }, + "partyId": { + "title": "partyId", + "type": "string", + "description": "Party that should sign the message. If omitted, the primary wallet is used." + } + }, + "required": ["message"] + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "SignMessageResult", + "type": "object", + "additionalProperties": false, + "properties": { + "signature": { + "title": "signature", + "type": "string", + "description": "Base64-encoded Ed25519 signature over the prefixed message." + }, + "signedBy": { + "title": "signedBy", + "type": "string", + "description": "Namespace fingerprint that signed the message." + }, + "partyId": { + "title": "partyId", + "type": "string" + } + }, + "required": ["signature", "signedBy", "partyId"] + } + }, + "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message is domain-separated with a Canton-specific prefix to prevent collisions with prepared transaction hashes." + }, { "name": "execute", "params": [ diff --git a/examples/ping/src/hooks/useConnect.ts b/examples/ping/src/hooks/useConnect.ts index 949762584..434cafaa4 100644 --- a/examples/ping/src/hooks/useConnect.ts +++ b/examples/ping/src/hooks/useConnect.ts @@ -14,7 +14,18 @@ const loopAdapter = new LoopAdapter({ const wcProjectId = import.meta.env.VITE_WC_PROJECT_ID as string const wcAdapter = wcProjectId - ? WalletConnectAdapter.create({ projectId: wcProjectId }) + ? WalletConnectAdapter.create({ + projectId: wcProjectId, + signInWithCanton: { + domain: 'http://localhost:3000', + uri: 'http://localhost:3000/login', + version: '1.0.0', + nonce: '1234567890', + }, + onSignInWithCanton: (result) => { + console.log('onSignInWithCanton:', result) + }, + }) : undefined const additionalAdapters = wcAdapter ? [loopAdapter, wcAdapter] : [loopAdapter] diff --git a/examples/walletconnect/src/walletkit/handler.ts b/examples/walletconnect/src/walletkit/handler.ts index 88893aa8c..fbc46fc8a 100644 --- a/examples/walletconnect/src/walletkit/handler.ts +++ b/examples/walletconnect/src/walletkit/handler.ts @@ -6,6 +6,7 @@ import type { SessionTypes } from '@walletconnect/types' import { initWalletKit, getWalletKit } from './client' import { callDappApi, + callUserApi, bootstrapSession, getPrimaryPartyId, prepareSignExecute, @@ -407,8 +408,9 @@ export const walletHandler: WalletHandler = { result = await prepareSignExecute( params as Record ) + } else if (method === 'canton_signMessage') { + result = await callUserApi('signMessage', params) } else { - // For other approval-required methods (signMessage, etc.) const controllerMethod = method.startsWith(CANTON_PREFIX) ? method.slice(CANTON_PREFIX.length) : method diff --git a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts index 6cad8bbfd..f41fb8907 100644 --- a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts +++ b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts @@ -22,6 +22,7 @@ import type { StatusEvent, } from '@canton-network/core-wallet-dapp-rpc-client' import { WALLETCONNECT_ICON } from '../assets' +import { composeSIWXMessage } from '../util' const CANTON_WC_METHODS = [ 'canton_prepareSignExecute', @@ -40,6 +41,58 @@ const PROVIDER_INFO: ProviderInfo = { providerType: 'mobile', } +/** + * The timestamp is a UTC string representing the time in ISO 8601 format. + */ +export type Timestamp = string + +/** + * This interface represents the SIWX message metadata. + * Here must contain the main data related to the app. + */ +export interface Metadata { + requestId?: string + domain: string + uri: string + version: string + nonce: string + notBefore?: Timestamp + statement?: string + resources?: string[] +} + +/** + * This interface represents the SIWX message identifier. + * Here must contain the request id and the timestamps. + */ +export interface Identifier { + requestId?: string + issuedAt?: Timestamp + expirationTime?: Timestamp +} + +export interface SIWXMessageParams extends Metadata, Identifier {} + +export interface RequestSIWXParams extends SIWXMessageParams { + account: string +} + +export interface SignInWithCantonResult { + requestId: string | undefined + nonce: string + account: string + chainId: string + message: string + publicKey: string + signature: string + error?: SignInWithCantonError +} + +export interface SignInWithCantonError { + message: string + code: number +} + export interface WalletConnectAdapterConfig { projectId: string chainId?: string @@ -49,8 +102,11 @@ export interface WalletConnectAdapterConfig { url: string icons: string[] } + /** Whether to trigger a canton_signMessage request after the session is established. */ + signInWithCanton?: SIWXMessageParams /** Called with the pairing URI so the dApp can display or forward it. */ onUri?: (uri: string) => void + onSignInWithCanton?: (result: SignInWithCantonResult) => void } /** @@ -74,6 +130,10 @@ export class WalletConnectAdapter | WalletConnectAdapterConfig['metadata'] | undefined private readonly onUri: ((uri: string) => void) | undefined + private readonly onSignInWithCanton: + | ((result: SignInWithCantonResult) => void) + | undefined + private readonly signInWithCanton: WalletConnectAdapterConfig['signInWithCanton'] private signClient: SignClient | null = null private session: SessionTypes.Struct | null = null @@ -89,9 +149,11 @@ export class WalletConnectAdapter constructor(config: WalletConnectAdapterConfig) { this.projectId = config.projectId + this.signInWithCanton = config.signInWithCanton this.chainId = config.chainId ?? 'canton:devnet' this.metadata = config.metadata this.onUri = config.onUri + this.onSignInWithCanton = config.onSignInWithCanton } // ── ProviderAdapter ───────────────────────────────────────────── @@ -246,22 +308,22 @@ export class WalletConnectAdapter // ── Private: WC request ───────────────────────────────────────── /** Send a request through signClient, prefixing the method with `canton_`. */ - private async walletConnectRequest( + private async walletConnectRequest( method: string, params?: unknown - ): Promise { + ): Promise { if (!this.signClient || !this.session) { throw new Error('WalletConnect session not established') } try { - return await this.signClient.request({ + return (await this.signClient.request({ topic: this.session.topic, chainId: this.chainId, request: { method: `canton_${method}`, params: params ?? {}, }, - }) + })) as T } catch (err: unknown) { const errObj = typeof err === 'object' && err !== null ? err : {} const message = @@ -363,6 +425,49 @@ export class WalletConnectAdapter this.session = await approval() this.setupSessionEvents() + if (this.signInWithCanton) { + try { + const account = this.session?.namespaces?.canton?.accounts?.[0] + const address = decodeURIComponent(account?.split(':')[2]) + const chainId = account?.split(':')[1] ?? this.chainId + const message = composeSIWXMessage({ + ...this.signInWithCanton, + accountAddress: address, + chainId: chainId, + }) + + const result = await this.walletConnectRequest<{ + signature: string + publicKey: string + }>('signMessage', { + message: message, + }) + this.onSignInWithCanton?.({ + requestId: this.signInWithCanton.requestId, + nonce: this.signInWithCanton.nonce, + account: account, + chainId: chainId, + message: message, + signature: result.signature, + publicKey: result.publicKey, + }) + } catch (error) { + const err = error as Error + this.onSignInWithCanton?.({ + requestId: this.signInWithCanton.requestId, + nonce: this.signInWithCanton.nonce, + account: '', + chainId: '', + message: '', + publicKey: '', + signature: '', + error: { + message: err.message, + code: -32603, + }, + }) + } + } } private async showUriInPopup(uri: string): Promise { diff --git a/sdk/dapp-sdk/src/util.ts b/sdk/dapp-sdk/src/util.ts index ac3940f0a..3ae421fec 100644 --- a/sdk/dapp-sdk/src/util.ts +++ b/sdk/dapp-sdk/src/util.ts @@ -3,6 +3,7 @@ import { popup } from '@canton-network/core-wallet-ui-components' import { removeKernelDiscovery, removeKernelSession } from './storage' +import { SIWXMessageParams } from './adapter/walletconnect-adapter' export const clearAllLocalState = ({ closePopup, @@ -13,3 +14,39 @@ export const clearAllLocalState = ({ popup.close() } } + +interface ComposeSIWXMessageParams extends SIWXMessageParams { + chainId: string + accountAddress: string +} + +export const composeSIWXMessage = ( + params: ComposeSIWXMessageParams +): string => { + { + const networkName = 'Canton' + + return [ + `${params.domain} wants you to sign in with your ${networkName} account:`, + params.accountAddress, + params.statement ? `\n${params.statement}\n` : '', + `URI: ${params.uri}`, + `Version: ${params.version}`, + `Chain ID: ${params.chainId}`, + `Nonce: ${params.nonce}`, + params.issuedAt && `Issued At: ${params.issuedAt}`, + params.expirationTime && + `Expiration Time: ${params.expirationTime}`, + params.notBefore && `Not Before: ${params.notBefore}`, + params.requestId && `Request ID: ${params.requestId}`, + params.resources?.length && + params.resources.reduce( + (acc: string, resource: string) => `${acc}\n- ${resource}`, + 'Resources:' + ), + ] + .filter((line) => typeof line === 'string') + .join('\n') + .trim() + } +} diff --git a/wallet-gateway/remote/src/user-api/controller.ts b/wallet-gateway/remote/src/user-api/controller.ts index 9a1c1fcf4..8306a1276 100644 --- a/wallet-gateway/remote/src/user-api/controller.ts +++ b/wallet-gateway/remote/src/user-api/controller.ts @@ -10,6 +10,8 @@ import { RemoveNetworkParams, ExecuteParams, SignParams, + SignMessageParams, + SignMessageResult, AddSessionParams, AddSessionResult, ListSessionsResult, @@ -40,6 +42,7 @@ import { } from '@canton-network/core-wallet-auth' import { KernelInfo } from '../config/Config.js' import { + isRpcError, SigningDriverInterface, SigningProvider, } from '@canton-network/core-signing-lib' @@ -55,6 +58,14 @@ type AvailableSigningDrivers = Partial< Record > +/** + * Domain-separator prefix for arbitrary message signing. + * Prevents collisions between user-supplied messages and prepared transaction + * hashes, so a malicious dApp cannot trick the wallet into authorizing an + * on-chain action via signMessage. + */ +const CANTON_SIGNED_MESSAGE_PREFIX = '\x19Canton Signed Message:\n' + export const userController = ( kernelInfo: KernelInfo, userUrl: string, @@ -459,6 +470,54 @@ export const userController = ( ) } }, + signMessage: async ( + params: SignMessageParams + ): Promise => { + const wallet = params.partyId + ? (await store.getWallets()).find( + (w) => w.partyId === params.partyId + ) + : await store.getPrimaryWallet() + + if (!wallet) { + throw new Error( + params.partyId + ? `No wallet found for partyId ${params.partyId}` + : 'No primary wallet configured' + ) + } + // TODO: support other signing providers + if (wallet.signingProviderId !== SigningProvider.WALLET_KERNEL) { + throw new Error( + `signMessage is only supported for ${SigningProvider.WALLET_KERNEL} wallets, got ${wallet.signingProviderId}` + ) + } + + const userId = assertConnected(authContext).userId + const driver = + drivers[SigningProvider.WALLET_KERNEL]?.controller(userId) + if (!driver) { + throw new Error('Wallet Kernel signing driver not available') + } + + const result = await driver.signMessage({ + message: params.message, + keyIdentifier: { publicKey: wallet.publicKey }, + }) + + if (isRpcError(result)) { + throw new Error(result.error_description) + } + + if (!result?.signature) { + throw new Error(`signMessage failed`) + } + + return { + signature: result.signature, + publicKey: wallet.publicKey, + } + }, execute: async (executeParams: ExecuteParams) => { const wallets = await store.getWallets() const network = await store.getCurrentNetwork() diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/index.ts b/wallet-gateway/remote/src/user-api/rpc-gen/index.ts index b28a2549b..244164736 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/index.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/index.ts @@ -15,6 +15,7 @@ import { ListWallets } from './typings.js' import { SyncWallets } from './typings.js' import { IsWalletSyncNeeded } from './typings.js' import { Sign } from './typings.js' +import { SignMessage } from './typings.js' import { Execute } from './typings.js' import { AddSession } from './typings.js' import { RemoveSession } from './typings.js' @@ -39,6 +40,7 @@ export type Methods = { syncWallets: SyncWallets isWalletSyncNeeded: IsWalletSyncNeeded sign: Sign + signMessage: SignMessage execute: Execute addSession: AddSession removeSession: RemoveSession @@ -65,6 +67,7 @@ function buildController(methods: Methods) { syncWallets: methods.syncWallets, isWalletSyncNeeded: methods.isWalletSyncNeeded, sign: methods.sign, + signMessage: methods.signMessage, execute: methods.execute, addSession: methods.addSession, removeSession: methods.removeSession, diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts index 7e1e63424..82d21bd4a 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts @@ -160,7 +160,23 @@ export interface WalletFilter { * */ export type TransactionId = string +/** + * + * Arbitrary UTF-8 message to sign. + * + */ +export type Message = string +/** + * + * Base64-encoded Ed25519 signature over the prefixed message. + * + */ export type Signature = string +/** + * + * Namespace fingerprint that signed the message. + * + */ export type SignedBy = string export type Networks = Network[] export type Idps = Idp[] @@ -418,6 +434,10 @@ export interface SignParams { transactionId: TransactionId partyId: PartyId } +export interface SignMessageParams { + message: Message + partyId?: PartyId +} export interface ExecuteParams { signature: Signature partyId: PartyId @@ -478,6 +498,10 @@ export type SignResult = | SignResultPending | SignResultRejected | SignResultFailed +export interface SignMessageResult { + signature: Signature + publicKey: PublicKey +} export interface ExecuteResult { [key: string]: any } @@ -545,6 +569,9 @@ export type ListWallets = ( export type SyncWallets = () => Promise export type IsWalletSyncNeeded = () => Promise export type Sign = (params: SignParams) => Promise +export type SignMessage = ( + params: SignMessageParams +) => Promise export type Execute = (params: ExecuteParams) => Promise export type AddSession = (params: AddSessionParams) => Promise export type RemoveSession = () => Promise From cec69818b21ee12971620e9ddc6b62a6c71fea5d Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Thu, 30 Apr 2026 10:58:27 +0300 Subject: [PATCH 02/13] feat(user-api+sdk): aligns SIWX specs Signed-off-by: Gancho Radkov --- api-specs/openrpc-user-api.json | 14 +++++--------- core/wallet-user-rpc-client/src/index.ts | 10 ++-------- core/wallet-user-rpc-client/src/openrpc.json | 12 ++++-------- wallet-gateway/remote/src/user-api/controller.ts | 8 -------- .../remote/src/user-api/rpc-gen/typings.ts | 7 +------ 5 files changed, 12 insertions(+), 39 deletions(-) diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index 5cd645d29..cf9cb237a 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -565,20 +565,16 @@ "type": "string", "description": "Base64-encoded Ed25519 signature over the prefixed message." }, - "signedBy": { - "title": "signedBy", + "publicKey": { + "title": "publicKey", "type": "string", - "description": "Namespace fingerprint that signed the message." - }, - "partyId": { - "title": "partyId", - "type": "string" + "description": "Base64-encoded Ed25519 public key of the wallet that produced the signature." } }, - "required": ["signature", "signedBy", "partyId"] + "required": ["signature", "publicKey"] } }, - "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message is domain-separated with a Canton-specific prefix to prevent collisions with prepared transaction hashes." + "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message bytes are signed as-is; callers are expected to embed any application-level domain separation (e.g. EIP-4361 / SIWX text) in the message itself. Only supported for WALLET_KERNEL wallets." }, { "name": "execute", diff --git a/core/wallet-user-rpc-client/src/index.ts b/core/wallet-user-rpc-client/src/index.ts index b776b8deb..141ac1445 100644 --- a/core/wallet-user-rpc-client/src/index.ts +++ b/core/wallet-user-rpc-client/src/index.ts @@ -173,11 +173,6 @@ export type Message = string * */ export type Signature = string -/** - * - * Namespace fingerprint that signed the message. - * - */ export type SignedBy = string export type Networks = Network[] export type Idps = Idp[] @@ -195,7 +190,7 @@ export type WalletStatus = 'initialized' | 'allocated' | 'removed' export type Hint = string /** * - * The public key of the party. + * Base64-encoded Ed25519 public key of the wallet that produced the signature. * */ export type PublicKey = string @@ -501,8 +496,7 @@ export type SignResult = | SignResultFailed export interface SignMessageResult { signature: Signature - signedBy: SignedBy - partyId: PartyId + publicKey: PublicKey } export interface ExecuteResult { [key: string]: any diff --git a/core/wallet-user-rpc-client/src/openrpc.json b/core/wallet-user-rpc-client/src/openrpc.json index 5cd645d29..14746c885 100644 --- a/core/wallet-user-rpc-client/src/openrpc.json +++ b/core/wallet-user-rpc-client/src/openrpc.json @@ -565,17 +565,13 @@ "type": "string", "description": "Base64-encoded Ed25519 signature over the prefixed message." }, - "signedBy": { - "title": "signedBy", + "publicKey": { + "title": "publicKey", "type": "string", - "description": "Namespace fingerprint that signed the message." - }, - "partyId": { - "title": "partyId", - "type": "string" + "description": "Base64-encoded Ed25519 public key of the wallet that produced the signature." } }, - "required": ["signature", "signedBy", "partyId"] + "required": ["signature", "publicKey"] } }, "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message is domain-separated with a Canton-specific prefix to prevent collisions with prepared transaction hashes." diff --git a/wallet-gateway/remote/src/user-api/controller.ts b/wallet-gateway/remote/src/user-api/controller.ts index 8306a1276..932d76e8b 100644 --- a/wallet-gateway/remote/src/user-api/controller.ts +++ b/wallet-gateway/remote/src/user-api/controller.ts @@ -58,14 +58,6 @@ type AvailableSigningDrivers = Partial< Record > -/** - * Domain-separator prefix for arbitrary message signing. - * Prevents collisions between user-supplied messages and prepared transaction - * hashes, so a malicious dApp cannot trick the wallet into authorizing an - * on-chain action via signMessage. - */ -const CANTON_SIGNED_MESSAGE_PREFIX = '\x19Canton Signed Message:\n' - export const userController = ( kernelInfo: KernelInfo, userUrl: string, diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts index 82d21bd4a..5ab76c82c 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts @@ -172,11 +172,6 @@ export type Message = string * */ export type Signature = string -/** - * - * Namespace fingerprint that signed the message. - * - */ export type SignedBy = string export type Networks = Network[] export type Idps = Idp[] @@ -194,7 +189,7 @@ export type WalletStatus = 'initialized' | 'allocated' | 'removed' export type Hint = string /** * - * The public key of the party. + * Base64-encoded Ed25519 public key of the wallet that produced the signature. * */ export type PublicKey = string From a1afcd625be9ade1010300105e88aefca0b5732a Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Thu, 30 Apr 2026 15:40:07 +0200 Subject: [PATCH 03/13] add types to signing spec Signed-off-by: Marc Juchli --- api-specs/openrpc-signing-api.json | 49 ++++++++++++++++++++ core/signing-blockdaemon/src/index.ts | 7 +-- core/signing-fireblocks/src/index.ts | 6 +-- core/signing-internal/src/controller.ts | 2 +- core/signing-lib/src/rpc-gen/index.ts | 4 +- core/signing-lib/src/rpc-gen/typings.ts | 15 ++++-- core/signing-participant/src/controller.ts | 8 ++++ core/wallet-user-rpc-client/src/openrpc.json | 2 +- 8 files changed, 75 insertions(+), 18 deletions(-) diff --git a/api-specs/openrpc-signing-api.json b/api-specs/openrpc-signing-api.json index 29e391bdb..0ac475235 100644 --- a/api-specs/openrpc-signing-api.json +++ b/api-specs/openrpc-signing-api.json @@ -57,6 +57,55 @@ }, "description": "Uses the Wallet Provider to sign a transaction. This will likely be an asynchronous operation." }, + { + "name": "signMessage", + "params": [ + { + "name": "params", + "schema": { + "title": "SignMessageParams", + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "title": "message", + "type": "string", + "description": "Arbitrary UTF-8 message to sign." + }, + "keyIdentifier": { + "$ref": "#/components/schemas/KeyIdentifier", + "description": "Identifier for the key to use for signing. At least one of publicKey or id must be provided." + } + }, + "required": ["message"] + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "SignMessageResult", + "oneOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "signature": { + "title": "signature", + "type": "string", + "description": "Base64-encoded Ed25519 signature over the prefixed message." + } + }, + "required": ["signature"] + } + ] + } + }, + "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message bytes are signed as-is; callers are expected to embed any application-level domain separation (e.g. EIP-4361 / SIWX text) in the message itself." + }, { "name": "getTransaction", "description": "Get the status of a single transaction by its ID.", diff --git a/core/signing-blockdaemon/src/index.ts b/core/signing-blockdaemon/src/index.ts index 35d08440b..d53304f80 100644 --- a/core/signing-blockdaemon/src/index.ts +++ b/core/signing-blockdaemon/src/index.ts @@ -16,7 +16,6 @@ import { type SetConfigurationResult, type SigningDriverInterface, SigningProvider, - SignMessageParams, SignMessageResult, type SignTransactionParams, type SignTransactionResult, @@ -91,11 +90,7 @@ export default class BlockdaemonSigningDriver implements SigningDriverInterface } }, - signMessage: async ( - // Disabled unused vars rule to allow for future implementations - // eslint-disable-next-line @typescript-eslint/no-unused-vars - params: SignMessageParams - ): Promise => { + signMessage: async (): Promise => { return { error: 'not_allowed', error_description: diff --git a/core/signing-fireblocks/src/index.ts b/core/signing-fireblocks/src/index.ts index 06fc6d410..4b531789c 100644 --- a/core/signing-fireblocks/src/index.ts +++ b/core/signing-fireblocks/src/index.ts @@ -104,11 +104,7 @@ export default class FireblocksSigningDriver implements SigningDriverInterface { } }, - signMessage: async ( - // Disabled unused vars rule to allow for future implementations - - params: SignMessageParams - ): Promise => { + signMessage: async (): Promise => { return { error: 'not_allowed', error_description: diff --git a/core/signing-internal/src/controller.ts b/core/signing-internal/src/controller.ts index c6083c70e..cc4648149 100644 --- a/core/signing-internal/src/controller.ts +++ b/core/signing-internal/src/controller.ts @@ -138,7 +138,7 @@ export class InternalSigningDriver implements SigningDriverInterface { signMessage: async ( params: SignMessageParams ): Promise => { - if (!params.keyIdentifier.publicKey) { + if (!params.keyIdentifier?.publicKey) { return Promise.resolve({ error: 'key_not_found', error_description: diff --git a/core/signing-lib/src/rpc-gen/index.ts b/core/signing-lib/src/rpc-gen/index.ts index fc1e53a0a..f1a6c55c9 100644 --- a/core/signing-lib/src/rpc-gen/index.ts +++ b/core/signing-lib/src/rpc-gen/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { SignTransaction } from './typings.js' +import { SignMessage } from './typings.js' import { GetTransaction } from './typings.js' import { GetTransactions } from './typings.js' import { GetKeys } from './typings.js' @@ -9,10 +10,10 @@ import { CreateKey } from './typings.js' import { GetConfiguration } from './typings.js' import { SetConfiguration } from './typings.js' import { SubscribeTransactions } from './typings.js' -import { SignMessage } from './typings.js' export type Methods = { signTransaction: SignTransaction + signMessage: SignMessage getTransaction: GetTransaction getTransactions: GetTransactions getKeys: GetKeys @@ -20,7 +21,6 @@ export type Methods = { getConfiguration: GetConfiguration setConfiguration: SetConfiguration subscribeTransactions: SubscribeTransactions - signMessage: SignMessage } function buildController(methods: Methods) { diff --git a/core/signing-lib/src/rpc-gen/typings.ts b/core/signing-lib/src/rpc-gen/typings.ts index 72362e705..270e7bac3 100644 --- a/core/signing-lib/src/rpc-gen/typings.ts +++ b/core/signing-lib/src/rpc-gen/typings.ts @@ -59,6 +59,12 @@ export type KeyIdentifier = KeyIdentifierWithPublicKey | KeyIdentifierWithId */ export type InternalTxId = string type AlwaysTrue = any +/** + * + * Arbitrary UTF-8 message to sign. + * + */ +export type Message = string /** * * Unique identifier of the signed transaction given by the Signing Provider. This may not be the same as the internal txId given by the Wallet Gateway. @@ -126,6 +132,9 @@ export interface Transaction { publicKey?: PublicKey metadata?: Metadata } +export interface ObjectOfSignature5UsXNp3D { + signature: Signature +} /** * * List of transactions matching the provided filters @@ -157,8 +166,8 @@ export interface SignTransactionParams { [k: string]: any } export interface SignMessageParams { - message: string - keyIdentifier: KeyIdentifier + message: Message + keyIdentifier?: KeyIdentifier } export interface GetTransactionParams { txId: TxId @@ -185,7 +194,7 @@ export interface SubscribeTransactionsParams { txIds: TxIds } export type SignTransactionResult = Error | Transaction -export type SignMessageResult = Error | { signature: Signature } +export type SignMessageResult = Error | ObjectOfSignature5UsXNp3D export type GetTransactionResult = Error | Transaction export type GetTransactionsResult = Error | TransactionsResult export type GetKeysResult = Error | Keys diff --git a/core/signing-participant/src/controller.ts b/core/signing-participant/src/controller.ts index bdf90e73b..07eeebe27 100644 --- a/core/signing-participant/src/controller.ts +++ b/core/signing-participant/src/controller.ts @@ -12,6 +12,7 @@ import { SetConfigurationResult, SigningDriverInterface, SigningProvider, + SignMessageResult, SignTransactionParams, SignTransactionResult, SubscribeTransactionsResult, @@ -35,6 +36,13 @@ export class ParticipantSigningDriver implements SigningDriverInterface { status: 'signed', }) }, + signMessage: async (): Promise => { + return Promise.resolve({ + error: 'not_allowed', + error_description: + 'Signing messages is not supported with Participant.', + }) + }, getTransaction: function (): Promise { throw new Error('Function not implemented.') }, diff --git a/core/wallet-user-rpc-client/src/openrpc.json b/core/wallet-user-rpc-client/src/openrpc.json index 14746c885..cf9cb237a 100644 --- a/core/wallet-user-rpc-client/src/openrpc.json +++ b/core/wallet-user-rpc-client/src/openrpc.json @@ -574,7 +574,7 @@ "required": ["signature", "publicKey"] } }, - "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message is domain-separated with a Canton-specific prefix to prevent collisions with prepared transaction hashes." + "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message bytes are signed as-is; callers are expected to embed any application-level domain separation (e.g. EIP-4361 / SIWX text) in the message itself. Only supported for WALLET_KERNEL wallets." }, { "name": "execute", From 5720511c659cccf52ed78170951be4a37cde9443 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Thu, 30 Apr 2026 16:02:02 +0200 Subject: [PATCH 04/13] naming Signed-off-by: Marc Juchli --- api-specs/openrpc-signing-api.json | 13 ++++++++----- core/signing-lib/src/rpc-gen/typings.ts | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/api-specs/openrpc-signing-api.json b/api-specs/openrpc-signing-api.json index 0ac475235..5bc4e2b34 100644 --- a/api-specs/openrpc-signing-api.json +++ b/api-specs/openrpc-signing-api.json @@ -91,12 +91,11 @@ }, { "type": "object", + "title": "SignatureResult", "additionalProperties": false, "properties": { "signature": { - "title": "signature", - "type": "string", - "description": "Base64-encoded Ed25519 signature over the prefixed message." + "$ref": "#/components/schemas/Signature" } }, "required": ["signature"] @@ -462,8 +461,7 @@ "description": "Status of the transaction signing process." }, "signature": { - "title": "signature", - "type": "string", + "$ref": "#/components/schemas/Signature", "description": "Signature of the transaction if it was signed." }, "publicKey": { @@ -479,6 +477,11 @@ } }, "required": ["txId", "status"] + }, + "Signature": { + "title": "signature", + "type": "string", + "description": "Base64-encoded Ed25519 signature." } } } diff --git a/core/signing-lib/src/rpc-gen/typings.ts b/core/signing-lib/src/rpc-gen/typings.ts index 270e7bac3..9eb4a55ef 100644 --- a/core/signing-lib/src/rpc-gen/typings.ts +++ b/core/signing-lib/src/rpc-gen/typings.ts @@ -132,7 +132,7 @@ export interface Transaction { publicKey?: PublicKey metadata?: Metadata } -export interface ObjectOfSignature5UsXNp3D { +export interface SignatureResult { signature: Signature } /** @@ -194,7 +194,7 @@ export interface SubscribeTransactionsParams { txIds: TxIds } export type SignTransactionResult = Error | Transaction -export type SignMessageResult = Error | ObjectOfSignature5UsXNp3D +export type SignMessageResult = Error | SignatureResult export type GetTransactionResult = Error | Transaction export type GetTransactionsResult = Error | TransactionsResult export type GetKeysResult = Error | Keys From f173f4f9c1309567db31f9833664abd6806dcb61 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Mon, 4 May 2026 10:40:04 +0200 Subject: [PATCH 05/13] remove inconsistent wording Signed-off-by: Marc Juchli --- api-specs/openrpc-user-api.json | 2 +- core/signing-fireblocks/src/index.ts | 1 - core/wallet-user-rpc-client/src/index.ts | 2 +- core/wallet-user-rpc-client/src/openrpc.json | 2 +- wallet-gateway/remote/src/user-api/rpc-gen/typings.ts | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index cf9cb237a..ca8476f8b 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -563,7 +563,7 @@ "signature": { "title": "signature", "type": "string", - "description": "Base64-encoded Ed25519 signature over the prefixed message." + "description": "Base64-encoded Ed25519 signature over the message." }, "publicKey": { "title": "publicKey", diff --git a/core/signing-fireblocks/src/index.ts b/core/signing-fireblocks/src/index.ts index 4b531789c..684cee655 100644 --- a/core/signing-fireblocks/src/index.ts +++ b/core/signing-fireblocks/src/index.ts @@ -8,7 +8,6 @@ import { PartyMode, SigningDriverInterface, SigningProvider, - SignMessageParams, SignMessageResult, } from '@canton-network/core-signing-lib' diff --git a/core/wallet-user-rpc-client/src/index.ts b/core/wallet-user-rpc-client/src/index.ts index 141ac1445..dccafd7f0 100644 --- a/core/wallet-user-rpc-client/src/index.ts +++ b/core/wallet-user-rpc-client/src/index.ts @@ -169,7 +169,7 @@ export type TransactionId = string export type Message = string /** * - * Base64-encoded Ed25519 signature over the prefixed message. + * Base64-encoded Ed25519 signature over the message. * */ export type Signature = string diff --git a/core/wallet-user-rpc-client/src/openrpc.json b/core/wallet-user-rpc-client/src/openrpc.json index cf9cb237a..ca8476f8b 100644 --- a/core/wallet-user-rpc-client/src/openrpc.json +++ b/core/wallet-user-rpc-client/src/openrpc.json @@ -563,7 +563,7 @@ "signature": { "title": "signature", "type": "string", - "description": "Base64-encoded Ed25519 signature over the prefixed message." + "description": "Base64-encoded Ed25519 signature over the message." }, "publicKey": { "title": "publicKey", diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts index 5ab76c82c..c786dd76b 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts @@ -168,7 +168,7 @@ export type TransactionId = string export type Message = string /** * - * Base64-encoded Ed25519 signature over the prefixed message. + * Base64-encoded Ed25519 signature over the message. * */ export type Signature = string From e9cba296c035be4facdd0d8c5ebc7eba53ebe69d Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Tue, 5 May 2026 10:56:49 +0200 Subject: [PATCH 06/13] message signing flow with dapp and user api Signed-off-by: Marc Juchli --- api-specs/openrpc-dapp-api.json | 1 + api-specs/openrpc-dapp-remote-api.json | 12 +- api-specs/openrpc-user-api.json | 29 +++ core/types/src/index.ts | 9 + .../src/index.ts | 19 +- .../src/openrpc.json | 12 +- core/wallet-dapp-rpc-client/src/openrpc.json | 1 + .../src/StoreInternal.ts | 72 ++++++ .../migrations/012-add-messages-to-sign.ts | 32 +++ core/wallet-store-sql/src/schema.ts | 65 +++++ core/wallet-store-sql/src/store-sql.ts | 116 +++++++++ core/wallet-store/src/Store.ts | 29 +++ core/wallet-ui-components/src/routing.ts | 1 + core/wallet-user-rpc-client/src/index.ts | 51 +++- sdk/dapp-sdk/src/sdk-controller.ts | 79 +++++- .../remote/src/dapp-api/controller.ts | 33 ++- .../remote/src/dapp-api/rpc-gen/typings.ts | 19 +- .../remote/src/user-api/controller.ts | 120 ++++++++- .../remote/src/user-api/rpc-gen/index.ts | 9 + .../remote/src/user-api/rpc-gen/typings.ts | 44 +++- .../src/web/frontend/sign-message/index.html | 15 ++ .../src/web/frontend/sign-message/index.ts | 234 ++++++++++++++++++ 22 files changed, 956 insertions(+), 46 deletions(-) create mode 100644 core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts create mode 100644 wallet-gateway/remote/src/web/frontend/sign-message/index.html create mode 100644 wallet-gateway/remote/src/web/frontend/sign-message/index.ts diff --git a/api-specs/openrpc-dapp-api.json b/api-specs/openrpc-dapp-api.json index ea99511e6..a4e8c3ebc 100644 --- a/api-specs/openrpc-dapp-api.json +++ b/api-specs/openrpc-dapp-api.json @@ -112,6 +112,7 @@ "params": [ { "name": "params", + "required": true, "schema": { "title": "signMessageParams", "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" diff --git a/api-specs/openrpc-dapp-remote-api.json b/api-specs/openrpc-dapp-remote-api.json index 44df6bb7a..d7a18864b 100644 --- a/api-specs/openrpc-dapp-remote-api.json +++ b/api-specs/openrpc-dapp-remote-api.json @@ -102,10 +102,18 @@ "result": { "name": "result", "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" + "title": "signMessageResult", + "type": "object", + "additionalProperties": false, + "properties": { + "userUrl": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" + } + }, + "required": ["userUrl"] } }, - "description": "Signs a message." + "description": "Requests a message signature. The wallet will prompt the user for confirmation and return the signature via the UI." }, { "name": "ledgerApi", diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index ca8476f8b..95686d713 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -576,6 +576,35 @@ }, "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message bytes are signed as-is; callers are expected to embed any application-level domain separation (e.g. EIP-4361 / SIWX text) in the message itself. Only supported for WALLET_KERNEL wallets." }, + { + "name": "deleteMessageToSign", + "params": [ + { + "name": "params", + "schema": { + "title": "DeleteMessageToSignParams", + "type": "object", + "additionalProperties": false, + "properties": { + "messageId": { + "title": "messageId", + "type": "string", + "format": "uuid", + "description": "The internal identifier of the pending message-signing request." + } + }, + "required": ["messageId"] + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Null" + } + }, + "description": "Rejects a pending message signing request." + }, { "name": "execute", "params": [ diff --git a/core/types/src/index.ts b/core/types/src/index.ts index ce749000f..c6d3c7766 100644 --- a/core/types/src/index.ts +++ b/core/types/src/index.ts @@ -78,6 +78,8 @@ export enum WalletEvent { // Auth events SPLICE_WALLET_IDP_AUTH_SUCCESS = 'SPLICE_WALLET_IDP_AUTH_SUCCESS', SPLICE_WALLET_LOGOUT = 'SPLICE_WALLET_LOGOUT', + // Message signing events (remote gateway UI -> dApp) + SPLICE_WALLET_SIGN_MESSAGE_RESULT = 'SPLICE_WALLET_SIGN_MESSAGE_RESULT', } export type SpliceMessageEvent = MessageEvent @@ -117,6 +119,13 @@ export const SpliceMessage = z.discriminatedUnion('type', [ token: z.string(), sessionId: z.string(), }), + z.object({ + type: z.literal(WalletEvent.SPLICE_WALLET_SIGN_MESSAGE_RESULT), + messageId: z.string().min(1), + status: z.enum(['signed', 'rejected', 'failed']), + signature: z.string().optional(), + publicKey: z.string().optional(), + }), ]) export type SpliceMessage = z.infer diff --git a/core/wallet-dapp-remote-rpc-client/src/index.ts b/core/wallet-dapp-remote-rpc-client/src/index.ts index 9188e7e0d..a1c16d45c 100644 --- a/core/wallet-dapp-remote-rpc-client/src/index.ts +++ b/core/wallet-dapp-remote-rpc-client/src/index.ts @@ -229,12 +229,6 @@ export interface Session { accessToken: AccessToken userId: UserId } -/** - * - * The signature of the transaction. - * - */ -export type Signature = string /** * * Set as primary wallet for dApp usage. @@ -343,6 +337,12 @@ export interface TxChangedPendingEvent { * */ export type StatusSigned = 'signed' +/** + * + * The signature of the transaction. + * + */ +export type Signature = string /** * * The identifier of the provider that signed the transaction. @@ -465,13 +465,8 @@ export type Null = null export interface PrepareExecuteResult { userUrl: UserUrl } -/** - * - * Result of signing a message. - * - */ export interface SignMessageResult { - signature: Signature + userUrl: UserUrl } /** * diff --git a/core/wallet-dapp-remote-rpc-client/src/openrpc.json b/core/wallet-dapp-remote-rpc-client/src/openrpc.json index 44df6bb7a..d7a18864b 100644 --- a/core/wallet-dapp-remote-rpc-client/src/openrpc.json +++ b/core/wallet-dapp-remote-rpc-client/src/openrpc.json @@ -102,10 +102,18 @@ "result": { "name": "result", "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" + "title": "signMessageResult", + "type": "object", + "additionalProperties": false, + "properties": { + "userUrl": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" + } + }, + "required": ["userUrl"] } }, - "description": "Signs a message." + "description": "Requests a message signature. The wallet will prompt the user for confirmation and return the signature via the UI." }, { "name": "ledgerApi", diff --git a/core/wallet-dapp-rpc-client/src/openrpc.json b/core/wallet-dapp-rpc-client/src/openrpc.json index ea99511e6..a4e8c3ebc 100644 --- a/core/wallet-dapp-rpc-client/src/openrpc.json +++ b/core/wallet-dapp-rpc-client/src/openrpc.json @@ -112,6 +112,7 @@ "params": [ { "name": "params", + "required": true, "schema": { "title": "signMessageParams", "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" diff --git a/core/wallet-store-inmemory/src/StoreInternal.ts b/core/wallet-store-inmemory/src/StoreInternal.ts index c1bfa40a1..7c2d84c85 100644 --- a/core/wallet-store-inmemory/src/StoreInternal.ts +++ b/core/wallet-store-inmemory/src/StoreInternal.ts @@ -22,6 +22,8 @@ import { PartyLevelRight, TransactionStatusUpdate, UserLevelRight, + MessageRaw, + MessageRawStatusUpdate, } from '@canton-network/core-wallet-store' import { LedgerClient, @@ -32,6 +34,7 @@ import { CurrentNetworkWalletFilter } from '@canton-network/core-wallet-store' interface UserStorage { wallets: Array transactions: Map + messageRaws: Map session: Session | undefined userRightsByNetwork: Map> } @@ -78,6 +81,7 @@ export class StoreInternal implements Store, AuthAware { return { wallets: [], transactions: new Map(), + messageRaws: new Map(), session: undefined, userRightsByNetwork: new Map>(), } @@ -569,4 +573,72 @@ export class StoreInternal implements Store, AuthAware { storage.transactions.delete(transactionId) this.updateStorage(storage) } + + private mergeMessageRawStatusUpdate( + existing: MessageRaw, + status: MessageRaw['status'], + updates: MessageRawStatusUpdate = {} + ): MessageRaw { + const signedAt = updates.signedAt ?? existing.signedAt + const signature = updates.signature ?? existing.signature + + return { + ...existing, + status, + ...(signedAt !== undefined && { signedAt }), + ...(signature !== undefined && { signature }), + } + } + + // Message signing request methods + async setMessageRaw(message: MessageRaw): Promise { + const userId = this.assertConnected() + if (message.userId !== userId) { + throw new Error( + `MessageRaw userId mismatch: expected ${userId}, got ${message.userId}` + ) + } + const storage = this.getStorage() + storage.messageRaws.set(message.id, message) + this.updateStorage(storage) + } + + async setMessageRawStatus( + messageId: string, + status: MessageRaw['status'], + updates: MessageRawStatusUpdate = {} + ): Promise { + this.assertConnected() + const storage = this.getStorage() + const existing = storage.messageRaws.get(messageId) + if (!existing) { + throw new Error(`MessageRaw not found with id: ${messageId}`) + } + const updated = this.mergeMessageRawStatusUpdate( + existing, + status, + updates + ) + storage.messageRaws.set(messageId, updated) + this.updateStorage(storage) + } + + async getMessageRaw(messageId: string): Promise { + this.assertConnected() + const storage = this.getStorage() + return storage.messageRaws.get(messageId) + } + + async listMessageRaws(): Promise> { + this.assertConnected() + const storage = this.getStorage() + return Array.from(storage.messageRaws.values()) + } + + async removeMessageRaw(messageId: string): Promise { + this.assertConnected() + const storage = this.getStorage() + storage.messageRaws.delete(messageId) + this.updateStorage(storage) + } } diff --git a/core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts b/core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts new file mode 100644 index 000000000..115010d38 --- /dev/null +++ b/core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('messagesRaw') + .addColumn('id', 'text', (col) => col.notNull().primaryKey()) + .addColumn('status', 'text', (col) => col.notNull()) + .addColumn('partyId', 'text', (col) => col.notNull()) + .addColumn('publicKey', 'text', (col) => col.notNull()) + .addColumn('message', 'text', (col) => col.notNull()) + .addColumn('origin', 'text') + .addColumn('userId', 'text', (col) => col.notNull()) + .addColumn('networkId', 'text', (col) => col.notNull()) + .addColumn('createdAt', 'text') + .addColumn('signedAt', 'text') + .addColumn('signature', 'text') + .execute() + + await db.schema + .createIndex('idx_messagesRaw_user_network') + .on('messagesRaw') + .columns(['userId', 'networkId']) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('idx_messagesRaw_user_network').execute() + await db.schema.dropTable('messagesRaw').execute() +} diff --git a/core/wallet-store-sql/src/schema.ts b/core/wallet-store-sql/src/schema.ts index ff46aab33..e29867425 100644 --- a/core/wallet-store-sql/src/schema.ts +++ b/core/wallet-store-sql/src/schema.ts @@ -11,6 +11,7 @@ import { UpdateWallet, PartyLevelRight, UserLevelRight, + MessageRaw, } from '@canton-network/core-wallet-store' interface MigrationTable { @@ -95,6 +96,20 @@ interface TransactionTable { externalTxId: string | null } +interface MessageRawTable { + id: string + status: string + partyId: string + publicKey: string + message: string + origin: string | null + userId: UserId + networkId: string + createdAt: string | null + signedAt: string | null + signature: string | null +} + interface SessionTable extends Session { id: string userId: UserId @@ -108,6 +123,7 @@ export interface DB { userPartyRights: UserPartyRightTable userRights: UserRightTable transactions: TransactionTable + messagesRaw: MessageRawTable sessions: SessionTable } @@ -322,3 +338,52 @@ export const toTransaction = (table: TransactionTable): Transaction => { return result } + +export const fromMessageRaw = ( + message: MessageRaw, + userId: UserId, + networkId: string +): MessageRawTable => { + if (message.userId !== userId) { + throw new Error( + `MessageRaw userId mismatch: expected ${userId}, got ${message.userId}` + ) + } + return { + id: message.id, + status: message.status, + userId: message.userId, + partyId: message.partyId, + publicKey: message.publicKey, + message: message.message, + origin: message.origin || null, + networkId, + createdAt: message.createdAt?.toISOString() || null, + signedAt: message.signedAt?.toISOString() || null, + signature: message.signature ?? null, + } +} + +export const toMessageRaw = (table: MessageRawTable): MessageRaw => { + const result: MessageRaw = { + id: table.id, + status: table.status as MessageRaw['status'], + userId: table.userId, + partyId: table.partyId, + publicKey: table.publicKey, + message: table.message, + origin: table.origin || null, + } + + if (table.createdAt) { + result.createdAt = new Date(table.createdAt) + } + if (table.signedAt) { + result.signedAt = new Date(table.signedAt) + } + if (table.signature) { + result.signature = table.signature + } + + return result +} diff --git a/core/wallet-store-sql/src/store-sql.ts b/core/wallet-store-sql/src/store-sql.ts index a799a5654..b34854c5e 100644 --- a/core/wallet-store-sql/src/store-sql.ts +++ b/core/wallet-store-sql/src/store-sql.ts @@ -16,6 +16,8 @@ import { Session, WalletFilter, Transaction, + MessageRaw, + MessageRawStatusUpdate, Network, StoreConfig, UpdateWallet, @@ -31,6 +33,7 @@ import { fromIdp, fromNetwork, fromTransaction, + fromMessageRaw, fromWallet, fromPartyRight, fromUserRight, @@ -38,6 +41,7 @@ import { toIdp, toNetwork, toTransaction, + toMessageRaw, toWallet, } from './schema.js' import pg from 'pg' @@ -733,6 +737,118 @@ export class StoreSql implements BaseStore, AuthAware { ) .execute() } + + private mergeMessageRawStatusUpdate( + existing: MessageRaw, + status: MessageRaw['status'], + updates: MessageRawStatusUpdate = {} + ): MessageRaw { + const signedAt = updates.signedAt ?? existing.signedAt + const signature = updates.signature ?? existing.signature + + return { + ...existing, + status, + ...(signedAt !== undefined && { signedAt }), + ...(signature !== undefined && { signature }), + } + } + + // Message signing request methods + async setMessageRaw(message: MessageRaw): Promise { + const userId = this.assertConnected() + if (message.userId !== userId) { + throw new Error( + `MessageRaw userId mismatch: expected ${userId}, got ${message.userId}` + ) + } + const network = await this.getCurrentNetwork() + await this.db + .insertInto('messagesRaw') + .values(fromMessageRaw(message, userId, network.id)) + .execute() + } + + async setMessageRawStatus( + messageId: string, + status: MessageRaw['status'], + updates: MessageRawStatusUpdate = {} + ): Promise { + const userId = this.assertConnected() + const network = await this.getCurrentNetwork() + const existing = await this.getMessageRaw(messageId) + if (!existing) { + throw new Error(`MessageRaw not found with id: ${messageId}`) + } + + const updated = this.mergeMessageRawStatusUpdate( + existing, + status, + updates + ) + + await this.db + .updateTable('messagesRaw') + .set(fromMessageRaw(updated, userId, network.id)) + .where((eb) => + eb.and([ + eb('id', '=', messageId), + eb('userId', '=', userId), + eb('networkId', '=', network.id), + ]) + ) + .execute() + } + + async getMessageRaw(messageId: string): Promise { + const userId = this.assertConnected() + const network = await this.getCurrentNetwork() + const message = await this.db + .selectFrom('messagesRaw') + .selectAll() + .where((eb) => + eb.and([ + eb('id', '=', messageId), + eb('userId', '=', userId), + eb('networkId', '=', network.id), + ]) + ) + .executeTakeFirst() + return message ? toMessageRaw(message) : undefined + } + + async listMessageRaws(): Promise> { + const userId = this.assertConnected() + const network = await this.getCurrentNetwork() + const messages = await this.db + .selectFrom('messagesRaw') + .selectAll() + .where((eb) => + eb.and([ + eb('userId', '=', userId), + eb('networkId', '=', network.id), + ]) + ) + .orderBy('createdAt', 'desc') + .orderBy('id', 'desc') + .execute() + return messages.map((m) => toMessageRaw(m)) + } + + async removeMessageRaw(messageId: string): Promise { + const userId = this.assertConnected() + const network = await this.getCurrentNetwork() + await this.db + .deleteFrom('messagesRaw') + .where((eb) => + eb.and([ + eb('id', '=', messageId), + eb('userId', '=', userId), + eb('networkId', '=', network.id), + ]) + ) + .execute() + } } export const connection = (config: StoreConfig) => { diff --git a/core/wallet-store/src/Store.ts b/core/wallet-store/src/Store.ts index 8b1a81eab..b286e0d1b 100644 --- a/core/wallet-store/src/Store.ts +++ b/core/wallet-store/src/Store.ts @@ -97,6 +97,24 @@ export interface TransactionStatusUpdate { externalTxId?: string } +export interface MessageRaw { + id: string + status: 'pending' | 'signed' | 'failed' + userId: string + partyId: PartyId + publicKey: string + message: string + origin: string | null + createdAt?: Date + signedAt?: Date + signature?: string +} + +export interface MessageRawStatusUpdate { + signedAt?: Date + signature?: string +} + // Store interface for managing wallets, sessions, networks, and transactions export interface Store { @@ -152,4 +170,15 @@ export interface Store { ): Promise listTransactions(): Promise> removeTransaction(transactionId: string): Promise + + // Message signing request methods + setMessageRaw(message: MessageRaw): Promise + setMessageRawStatus( + messageId: string, + status: MessageRaw['status'], + updates?: MessageRawStatusUpdate + ): Promise + getMessageRaw(messageId: string): Promise + listMessageRaws(): Promise> + removeMessageRaw(messageId: string): Promise } diff --git a/core/wallet-ui-components/src/routing.ts b/core/wallet-ui-components/src/routing.ts index 41010f4bd..f7d0b57cc 100644 --- a/core/wallet-ui-components/src/routing.ts +++ b/core/wallet-ui-components/src/routing.ts @@ -15,6 +15,7 @@ export const ALLOWED_ROUTES = [ '/identity-providers', '/activities', '/approve', + '/sign-message', '/', '/404', '/callback', diff --git a/core/wallet-user-rpc-client/src/index.ts b/core/wallet-user-rpc-client/src/index.ts index dccafd7f0..f0a0bfb69 100644 --- a/core/wallet-user-rpc-client/src/index.ts +++ b/core/wallet-user-rpc-client/src/index.ts @@ -431,8 +431,35 @@ export interface SignParams { partyId: PartyId } export interface SignMessageParams { + messageId: string +} + +export interface GetMessageToSignParams { + messageId: string +} + +export interface DeleteMessageToSignParams { + messageId: string +} + +export interface MessageToSign { + id: string + status: Status + partyId: PartyId + publicKey: PublicKey message: Message - partyId?: PartyId + createdAt?: CreatedAt + signedAt?: SignedAt + origin?: Origin + signature?: Signature +} + +export interface GetMessageToSignResult { + message: MessageToSign +} + +export interface ListMessagesToSignResult { + messages: MessageToSign[] } export interface ExecuteParams { signature: Signature @@ -568,6 +595,13 @@ export type Sign = (params: SignParams) => Promise export type SignMessage = ( params: SignMessageParams ) => Promise +export type GetMessageToSign = ( + params: GetMessageToSignParams +) => Promise +export type ListMessagesToSign = () => Promise +export type DeleteMessageToSign = ( + params: DeleteMessageToSignParams +) => Promise export type Execute = (params: ExecuteParams) => Promise export type AddSession = (params: AddSessionParams) => Promise export type RemoveSession = () => Promise @@ -665,6 +699,21 @@ export type RpcTypes = { result: Result } + getMessageToSign: { + params: Params + result: Result + } + + listMessagesToSign: { + params: Params + result: Result + } + + deleteMessageToSign: { + params: Params + result: Result + } + execute: { params: Params result: Result diff --git a/sdk/dapp-sdk/src/sdk-controller.ts b/sdk/dapp-sdk/src/sdk-controller.ts index 6f091e474..763de7d7a 100644 --- a/sdk/dapp-sdk/src/sdk-controller.ts +++ b/sdk/dapp-sdk/src/sdk-controller.ts @@ -16,6 +16,7 @@ import { import { ErrorCode } from './error' import { popup } from '@canton-network/core-wallet-ui-components' import * as dappAsyncAPI from '@canton-network/core-wallet-dapp-remote-rpc-client' +import { WalletEvent } from '@canton-network/core-types' const withTimeout = ( reject: (reason?: unknown) => void, @@ -154,11 +155,83 @@ export const dappSDKController = (provider: DappAsyncProvider) => }), signMessage: async ( params: SignMessageParams - ): Promise => - provider.request({ + ): Promise => { + const response = await provider.request({ method: 'signMessage', params, - }), + }) + + // Remote gateways return a userUrl for interactive confirmation. + // Non-remote providers may return the signature directly. + if ( + typeof (response as unknown as { userUrl?: unknown }) + .userUrl === 'string' + ) { + const { userUrl } = response as unknown as { userUrl: string } + popup.open(userUrl) + + const messageId = new URL(userUrl).searchParams.get('messageId') + if (!messageId) { + throw new Error( + 'Remote signMessage userUrl is missing messageId query param' + ) + } + + return await new Promise( + (resolve, reject) => { + const timeout = withTimeout( + reject, + 'Timed out waiting for message signing approval' + ) + + const listener = (event: MessageEvent) => { + if ( + event.data?.type !== + WalletEvent.SPLICE_WALLET_SIGN_MESSAGE_RESULT + ) { + return + } + if (event.data?.messageId !== messageId) { + return + } + + window.removeEventListener('message', listener) + clearTimeout(timeout) + + if (event.data.status !== 'signed') { + reject({ + status: 'error', + error: ErrorCode.TransactionFailed, + details: + event.data.status === 'rejected' + ? 'Message signing was rejected.' + : 'Message signing failed.', + }) + return + } + + if (!event.data.signature) { + reject({ + status: 'error', + error: ErrorCode.TransactionFailed, + details: + 'Missing signature in signMessage result.', + }) + return + } + + resolve({ + signature: event.data.signature, + }) + } + + window.addEventListener('message', listener) + } + ) + } + + return response as unknown as SignMessageResult + }, getPrimaryAccount: async (): Promise => provider.request({ method: 'getPrimaryAccount', diff --git a/wallet-gateway/remote/src/dapp-api/controller.ts b/wallet-gateway/remote/src/dapp-api/controller.ts index f72e7a2fe..8dc768ce7 100644 --- a/wallet-gateway/remote/src/dapp-api/controller.ts +++ b/wallet-gateway/remote/src/dapp-api/controller.ts @@ -13,6 +13,7 @@ import { LedgerApiResult, Network, PrepareExecuteParams, + SignMessageParams, SignMessageResult, StatusEvent, Wallet, @@ -335,8 +336,36 @@ export const dappController = ( : {}), } }, - signMessage: function (): Promise { - throw new Error('Function not implemented.') + signMessage: async ( + params: SignMessageParams + ): Promise => { + if (!params?.message) throw new Error('Message is required') + + const wallet = await store.getPrimaryWallet() + + if (context === undefined) { + throw new Error('Unauthenticated context') + } + + if (wallet === undefined) { + throw new Error('No primary wallet found') + } + + const messageId = v4() + await store.setMessageRaw({ + id: messageId, + status: 'pending', + userId: context.userId, + partyId: wallet.partyId, + publicKey: wallet.publicKey, + message: params.message, + origin: origin || null, + createdAt: new Date(), + }) + + return { + userUrl: `${userUrl}/sign-message/index.html?messageId=${messageId}&closeafteraction`, + } }, getPrimaryAccount: async function (): Promise { const wallet = await store.getPrimaryWallet() diff --git a/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts index 5e77ce966..90c2ea3e8 100644 --- a/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts @@ -228,12 +228,6 @@ export interface Session { accessToken: AccessToken userId: UserId } -/** - * - * The signature of the transaction. - * - */ -export type Signature = string /** * * Set as primary wallet for dApp usage. @@ -342,6 +336,12 @@ export interface TxChangedPendingEvent { * */ export type StatusSigned = 'signed' +/** + * + * The signature of the transaction. + * + */ +export type Signature = string /** * * The identifier of the provider that signed the transaction. @@ -464,13 +464,8 @@ export type Null = null export interface PrepareExecuteResult { userUrl: UserUrl } -/** - * - * Result of signing a message. - * - */ export interface SignMessageResult { - signature: Signature + userUrl: UserUrl } /** * diff --git a/wallet-gateway/remote/src/user-api/controller.ts b/wallet-gateway/remote/src/user-api/controller.ts index 932d76e8b..da37b97ed 100644 --- a/wallet-gateway/remote/src/user-api/controller.ts +++ b/wallet-gateway/remote/src/user-api/controller.ts @@ -12,6 +12,10 @@ import { SignParams, SignMessageParams, SignMessageResult, + GetMessageToSignParams, + GetMessageToSignResult, + ListMessagesToSignResult, + DeleteMessageToSignParams, AddSessionParams, AddSessionResult, ListSessionsResult, @@ -465,19 +469,39 @@ export const userController = ( signMessage: async ( params: SignMessageParams ): Promise => { - const wallet = params.partyId - ? (await store.getWallets()).find( - (w) => w.partyId === params.partyId - ) - : await store.getPrimaryWallet() + const pending = await store.getMessageRaw(params.messageId) + if (!pending) { + throw new Error( + `Message signing request not found with id: ${params.messageId}` + ) + } + if (pending.status !== 'pending') { + throw new Error( + `Cannot sign message with status '${pending.status}'. Only pending messages can be signed.` + ) + } + + const userId = assertConnected(authContext).userId + if (pending.userId !== userId) { + throw new Error( + `Message signing request ${pending.id} is not owned by user ${userId}` + ) + } + const wallet = (await store.getWallets()).find( + (w) => w.partyId === pending.partyId + ) if (!wallet) { throw new Error( - params.partyId - ? `No wallet found for partyId ${params.partyId}` - : 'No primary wallet configured' + `No wallet found for partyId ${pending.partyId} (from message request ${pending.id})` + ) + } + if (wallet.publicKey !== pending.publicKey) { + throw new Error( + `Wallet public key changed for partyId ${pending.partyId}; refusing to sign message request ${pending.id}` ) } + // TODO: support other signing providers if (wallet.signingProviderId !== SigningProvider.WALLET_KERNEL) { throw new Error( @@ -485,7 +509,6 @@ export const userController = ( ) } - const userId = assertConnected(authContext).userId const driver = drivers[SigningProvider.WALLET_KERNEL]?.controller(userId) if (!driver) { @@ -493,23 +516,100 @@ export const userController = ( } const result = await driver.signMessage({ - message: params.message, + message: pending.message, keyIdentifier: { publicKey: wallet.publicKey }, }) if (isRpcError(result)) { + await store.setMessageRawStatus(pending.id, 'failed') throw new Error(result.error_description) } if (!result?.signature) { + await store.setMessageRawStatus(pending.id, 'failed') throw new Error(`signMessage failed`) } + await store.setMessageRawStatus(pending.id, 'signed', { + signedAt: new Date(), + signature: result.signature, + }) + return { signature: result.signature, publicKey: wallet.publicKey, } }, + getMessageToSign: async ( + params: GetMessageToSignParams + ): Promise => { + const message = await store.getMessageRaw(params.messageId) + if (!message) { + throw new Error( + `Message signing request not found with id: ${params.messageId}` + ) + } + return { + message: { + id: message.id, + status: message.status, + partyId: message.partyId, + publicKey: message.publicKey, + message: message.message, + ...(message.origin !== null && { origin: message.origin }), + ...(message.createdAt && { + createdAt: message.createdAt.toISOString(), + }), + ...(message.signedAt && { + signedAt: message.signedAt.toISOString(), + }), + ...(message.signature && { signature: message.signature }), + }, + } + }, + listMessagesToSign: async (): Promise => { + const messages = await store.listMessageRaws() + return { + messages: messages.map((message) => ({ + id: message.id, + status: message.status, + partyId: message.partyId, + publicKey: message.publicKey, + message: message.message, + ...(message.origin !== null && { origin: message.origin }), + ...(message.createdAt && { + createdAt: message.createdAt.toISOString(), + }), + ...(message.signedAt && { + signedAt: message.signedAt.toISOString(), + }), + ...(message.signature && { signature: message.signature }), + })), + } + }, + deleteMessageToSign: async ( + params: DeleteMessageToSignParams + ): Promise => { + const message = await store.getMessageRaw(params.messageId) + if (!message) { + throw new Error( + `Message signing request not found with id: ${params.messageId}` + ) + } + if (message.status !== 'pending') { + throw new Error( + `Cannot delete message with status '${message.status}'. Only pending messages can be deleted.` + ) + } + const userId = assertConnected(authContext).userId + if (message.userId !== userId) { + throw new Error( + `Message signing request ${message.id} is not owned by user ${userId}` + ) + } + await store.removeMessageRaw(message.id) + return null + }, execute: async (executeParams: ExecuteParams) => { const wallets = await store.getWallets() const network = await store.getCurrentNetwork() diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/index.ts b/wallet-gateway/remote/src/user-api/rpc-gen/index.ts index 244164736..4b0c3e318 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/index.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/index.ts @@ -16,6 +16,9 @@ import { SyncWallets } from './typings.js' import { IsWalletSyncNeeded } from './typings.js' import { Sign } from './typings.js' import { SignMessage } from './typings.js' +import { GetMessageToSign } from './typings.js' +import { ListMessagesToSign } from './typings.js' +import { DeleteMessageToSign } from './typings.js' import { Execute } from './typings.js' import { AddSession } from './typings.js' import { RemoveSession } from './typings.js' @@ -41,6 +44,9 @@ export type Methods = { isWalletSyncNeeded: IsWalletSyncNeeded sign: Sign signMessage: SignMessage + getMessageToSign: GetMessageToSign + listMessagesToSign: ListMessagesToSign + deleteMessageToSign: DeleteMessageToSign execute: Execute addSession: AddSession removeSession: RemoveSession @@ -68,6 +74,9 @@ function buildController(methods: Methods) { isWalletSyncNeeded: methods.isWalletSyncNeeded, sign: methods.sign, signMessage: methods.signMessage, + getMessageToSign: methods.getMessageToSign, + listMessagesToSign: methods.listMessagesToSign, + deleteMessageToSign: methods.deleteMessageToSign, execute: methods.execute, addSession: methods.addSession, removeSession: methods.removeSession, diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts index c786dd76b..a2aa1bb9b 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts @@ -160,6 +160,12 @@ export interface WalletFilter { * */ export type TransactionId = string +/** + * + * The internal message identifier. + * + */ +export type MessageId = string /** * * Arbitrary UTF-8 message to sign. @@ -430,8 +436,15 @@ export interface SignParams { partyId: PartyId } export interface SignMessageParams { - message: Message - partyId?: PartyId + messageId: MessageId +} + +export interface GetMessageToSignParams { + messageId: MessageId +} + +export interface DeleteMessageToSignParams { + messageId: MessageId } export interface ExecuteParams { signature: Signature @@ -497,6 +510,26 @@ export interface SignMessageResult { signature: Signature publicKey: PublicKey } + +export interface MessageToSign { + id: MessageId + status: Status + partyId: PartyId + publicKey: PublicKey + message: Message + createdAt?: CreatedAt + signedAt?: SignedAt + origin?: Origin + signature?: Signature +} + +export interface GetMessageToSignResult { + message: MessageToSign +} + +export interface ListMessagesToSignResult { + messages: MessageToSign[] +} export interface ExecuteResult { [key: string]: any } @@ -567,6 +600,13 @@ export type Sign = (params: SignParams) => Promise export type SignMessage = ( params: SignMessageParams ) => Promise +export type GetMessageToSign = ( + params: GetMessageToSignParams +) => Promise +export type ListMessagesToSign = () => Promise +export type DeleteMessageToSign = ( + params: DeleteMessageToSignParams +) => Promise export type Execute = (params: ExecuteParams) => Promise export type AddSession = (params: AddSessionParams) => Promise export type RemoveSession = () => Promise diff --git a/wallet-gateway/remote/src/web/frontend/sign-message/index.html b/wallet-gateway/remote/src/web/frontend/sign-message/index.html new file mode 100644 index 000000000..c2f4595a8 --- /dev/null +++ b/wallet-gateway/remote/src/web/frontend/sign-message/index.html @@ -0,0 +1,15 @@ + + + + + + Wallet Gateway - Sign message + + + + + + + + + diff --git a/wallet-gateway/remote/src/web/frontend/sign-message/index.ts b/wallet-gateway/remote/src/web/frontend/sign-message/index.ts new file mode 100644 index 000000000..df3c460cd --- /dev/null +++ b/wallet-gateway/remote/src/web/frontend/sign-message/index.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { css, html } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { + BaseElement, + handleErrorToast, + toRelHref, +} from '@canton-network/core-wallet-ui-components' +import { createUserClient } from '../rpc-client' +import { stateManager } from '../state-manager' +import '../index' +import { WalletEvent } from '@canton-network/core-types' + +@customElement('user-ui-sign-message') +export class UserUiSignMessage extends BaseElement { + @state() accessor messageId = '' + @state() accessor message = '' + @state() accessor origin: string | null = null + @state() accessor status = '' + @state() accessor isApproving = false + @state() accessor isDeleting = false + @state() accessor disabled = false + @state() accessor loadError: string | null = null + @state() accessor isLoading = true + + static styles = [ + BaseElement.styles, + css` + :host { + display: block; + max-width: 900px; + margin: 0 auto; + } + .card { + border: 1px solid var(--wg-border-color, #e5e7eb); + border-radius: 12px; + padding: 16px; + background: var(--wg-bg-color, #fff); + } + .message { + white-space: pre-wrap; + word-break: break-word; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + font-size: 13px; + line-height: 1.4; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px; + margin-top: 8px; + } + .actions { + margin-top: 16px; + display: flex; + gap: 12px; + } + `, + ] + + connectedCallback(): void { + super.connectedCallback() + const url = new URL(window.location.href) + this.messageId = url.searchParams.get('messageId') || '' + void this.updateState() + } + + private closeOrGoToActivities() { + this.disabled = true + const params = new URLSearchParams(window.location.search) + const shouldClose = params.has('closeafteraction') + setTimeout(() => { + if (shouldClose && window.opener) { + window.close() + } else { + window.location.href = toRelHref('/activities') + } + }, 500) + } + + private postResult(payload: { + status: 'signed' | 'rejected' | 'failed' + signature?: string + publicKey?: string + }) { + if (window.opener && !window.opener.closed) { + window.opener.postMessage( + { + type: WalletEvent.SPLICE_WALLET_SIGN_MESSAGE_RESULT, + messageId: this.messageId, + ...payload, + }, + '*' + ) + } + } + + private async updateState() { + this.isLoading = true + this.loadError = null + try { + if (!this.messageId) { + this.loadError = 'Message not found.' + return + } + const userClient = await createUserClient( + stateManager.accessToken.get() + ) + const result = await userClient.request({ + method: 'getMessageToSign', + params: { messageId: this.messageId }, + }) + this.message = result.message.message + this.origin = result.message.origin ?? null + this.status = result.message.status + } catch (err) { + console.error(err) + // Most common case: messageId doesn't exist anymore / was deleted + this.loadError = 'Message not found.' + } finally { + this.isLoading = false + } + } + + private async handleReject() { + if (!confirm('Reject message signing request?')) return + this.isDeleting = true + try { + const userClient = await createUserClient( + stateManager.accessToken.get() + ) + await userClient.request({ + method: 'deleteMessageToSign', + params: { messageId: this.messageId }, + }) + this.postResult({ status: 'rejected' }) + this.closeOrGoToActivities() + } catch (err) { + console.error(err) + handleErrorToast(err, { message: 'Error rejecting message' }) + } finally { + this.isDeleting = false + } + } + + private async handleApprove() { + this.isApproving = true + try { + const userClient = await createUserClient( + stateManager.accessToken.get() + ) + const result = await userClient.request({ + method: 'signMessage', + params: { messageId: this.messageId }, + }) + this.postResult({ + status: 'signed', + signature: result.signature, + publicKey: result.publicKey, + }) + this.closeOrGoToActivities() + } catch (err) { + console.error(err) + this.postResult({ status: 'failed' }) + handleErrorToast(err, { message: 'Error signing message' }) + } finally { + this.isApproving = false + } + } + + protected render() { + if (this.isLoading) { + return html` +
+

Sign message

+

Loading...

+
+ ` + } + + if (this.loadError) { + return html` +
+

Sign message

+ + Back to activities +
+ ` + } + + return html` +
+

Sign message

+ ${this.origin + ? html`

+ Requested by: ${this.origin} +

` + : ''} + +

+ Please confirm you want to sign this message with your + wallet. +

+
${this.message}
+ +
+ + +
+
+ ` + } +} From 0d69328ba5c06836c219e23f498d99f74f71f87b Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Tue, 5 May 2026 11:49:17 +0200 Subject: [PATCH 07/13] naming Signed-off-by: Marc Juchli --- api-specs/openrpc-dapp-remote-api.json | 3 ++- core/wallet-dapp-remote-rpc-client/src/openrpc.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api-specs/openrpc-dapp-remote-api.json b/api-specs/openrpc-dapp-remote-api.json index d7a18864b..9f637ce53 100644 --- a/api-specs/openrpc-dapp-remote-api.json +++ b/api-specs/openrpc-dapp-remote-api.json @@ -93,6 +93,7 @@ "params": [ { "name": "params", + "required": true, "schema": { "title": "signMessageParams", "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" @@ -113,7 +114,7 @@ "required": ["userUrl"] } }, - "description": "Requests a message signature. The wallet will prompt the user for confirmation and return the signature via the UI." + "description": "Requests a message signature. The wallet will prompt the user for confirmation." }, { "name": "ledgerApi", diff --git a/core/wallet-dapp-remote-rpc-client/src/openrpc.json b/core/wallet-dapp-remote-rpc-client/src/openrpc.json index d7a18864b..305e427e3 100644 --- a/core/wallet-dapp-remote-rpc-client/src/openrpc.json +++ b/core/wallet-dapp-remote-rpc-client/src/openrpc.json @@ -93,6 +93,7 @@ "params": [ { "name": "params", + "required": true, "schema": { "title": "signMessageParams", "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageRequest" From ea63ffcbe5182310fc2d323a7b2c0da89bcd3aeb Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 6 May 2026 10:31:26 +0200 Subject: [PATCH 08/13] use dapp api in wc ui Signed-off-by: Marc Juchli --- .../walletconnect/src/walletkit/gateway.ts | 63 +++++++++++++++++++ .../walletconnect/src/walletkit/handler.ts | 10 ++- .../src/web/frontend/sign-message/index.ts | 40 +++++++++++- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/examples/walletconnect/src/walletkit/gateway.ts b/examples/walletconnect/src/walletkit/gateway.ts index 6f87b6e69..f8a7fa170 100644 --- a/examples/walletconnect/src/walletkit/gateway.ts +++ b/examples/walletconnect/src/walletkit/gateway.ts @@ -161,3 +161,66 @@ export async function prepareSignExecute( }, } } + +export interface SignMessageFlowResult { + signature: string + publicKey?: string +} + +export async function signMessageFlow( + message: string +): Promise { + const response = await callDappApi<{ userUrl: string }>('signMessage', { + message, + }) + + const url = new URL(response.userUrl, window.location.origin) + const messageId = url.searchParams.get('messageId') + if (!messageId) { + throw new Error('No messageId in signMessage response') + } + + // Use a popup so the user can approve in the wallet web UI. + // We rely on the gateway to postMessage the result to the opener. + const popup = window.open(response.userUrl, 'splice_wallet_sign_message') + if (!popup) { + throw new Error('Failed to open wallet popup') + } + + return await new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => { + window.removeEventListener('message', listener) + reject(new Error('Timed out waiting for message signature')) + }, + 5 * 60 * 1000 + ) + + const listener = (event: MessageEvent) => { + if (event.data?.type !== 'SPLICE_WALLET_SIGN_MESSAGE_RESULT') { + return + } + if (event.data?.messageId !== messageId) { + return + } + + window.clearTimeout(timeout) + window.removeEventListener('message', listener) + + if (event.data.status !== 'signed') { + reject(new Error(`Message signing ${event.data.status}`)) + return + } + + const signature = event.data.signature + if (!signature) { + reject(new Error('Missing signature in message signing result')) + return + } + + resolve({ signature, publicKey: event.data.publicKey }) + } + + window.addEventListener('message', listener) + }) +} diff --git a/examples/walletconnect/src/walletkit/handler.ts b/examples/walletconnect/src/walletkit/handler.ts index fbc46fc8a..747345acb 100644 --- a/examples/walletconnect/src/walletkit/handler.ts +++ b/examples/walletconnect/src/walletkit/handler.ts @@ -6,10 +6,10 @@ import type { SessionTypes } from '@walletconnect/types' import { initWalletKit, getWalletKit } from './client' import { callDappApi, - callUserApi, bootstrapSession, getPrimaryPartyId, prepareSignExecute, + signMessageFlow, } from './gateway' import type { PendingProposal, @@ -409,7 +409,13 @@ export const walletHandler: WalletHandler = { params as Record ) } else if (method === 'canton_signMessage') { - result = await callUserApi('signMessage', params) + const message = (params as { message?: unknown })?.message + if (typeof message !== 'string') { + throw new Error( + 'Invalid canton_signMessage params: expected { message: string }' + ) + } + result = await signMessageFlow(message) } else { const controllerMethod = method.startsWith(CANTON_PREFIX) ? method.slice(CANTON_PREFIX.length) diff --git a/wallet-gateway/remote/src/web/frontend/sign-message/index.ts b/wallet-gateway/remote/src/web/frontend/sign-message/index.ts index df3c460cd..88a29d33c 100644 --- a/wallet-gateway/remote/src/web/frontend/sign-message/index.ts +++ b/wallet-gateway/remote/src/web/frontend/sign-message/index.ts @@ -25,6 +25,42 @@ export class UserUiSignMessage extends BaseElement { @state() accessor loadError: string | null = null @state() accessor isLoading = true + private extractRpcErrorMessage(e: unknown): string | null { + // HttpTransport throws { error: { code, message, data } } on non-2xx. + // For JSON-RPC handlers, error.data often contains the JSON-RPC error response as a string. + try { + if (typeof e !== 'object' || e === null) return null + if (!('error' in e)) return null + const errObj = (e as { error?: unknown }).error + if (typeof errObj !== 'object' || errObj === null) return null + const data = (errObj as { data?: unknown }).data + if (typeof data !== 'string') return null + const parsed = JSON.parse(data) as unknown + if ( + typeof parsed === 'object' && + parsed !== null && + 'error' in parsed && + typeof (parsed as unknown as { error?: { message?: string } }) + .error?.message === 'string' + ) { + return (parsed as unknown as { error?: { message?: string } }) + .error?.message as string + } + return null + } catch { + return null + } + } + + private toastRpcError(e: unknown, fallbackMessage: string) { + const extracted = this.extractRpcErrorMessage(e) + if (extracted) { + handleErrorToast(new Error(extracted), { message: extracted }) + return + } + handleErrorToast(e, { message: fallbackMessage }) + } + static styles = [ BaseElement.styles, css` @@ -140,7 +176,7 @@ export class UserUiSignMessage extends BaseElement { this.closeOrGoToActivities() } catch (err) { console.error(err) - handleErrorToast(err, { message: 'Error rejecting message' }) + this.toastRpcError(err, 'Error rejecting message') } finally { this.isDeleting = false } @@ -165,7 +201,7 @@ export class UserUiSignMessage extends BaseElement { } catch (err) { console.error(err) this.postResult({ status: 'failed' }) - handleErrorToast(err, { message: 'Error signing message' }) + this.toastRpcError(err, 'Error signing message') } finally { this.isApproving = false } From 539aeab786538be48e8015032db8d173ece58671 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Wed, 6 May 2026 17:37:20 +0200 Subject: [PATCH 09/13] async signmessage flow Signed-off-by: Marc Juchli --- api-specs/openrpc-dapp-api.json | 89 +++++++++++++++ api-specs/openrpc-dapp-remote-api.json | 15 ++- core/provider-dapp/src/DappAsyncProvider.ts | 4 + core/types/src/index.ts | 9 -- .../src/index.ts | 58 +++++++++- .../src/openrpc.json | 17 ++- core/wallet-dapp-rpc-client/src/index.ts | 57 +++++++++- core/wallet-dapp-rpc-client/src/openrpc.json | 89 +++++++++++++++ .../walletconnect/src/walletkit/gateway.ts | 75 +++++++++---- sdk/dapp-sdk/src/client.ts | 20 ++++ sdk/dapp-sdk/src/dapp-api/rpc-gen/index.ts | 3 + sdk/dapp-sdk/src/dapp-api/rpc-gen/typings.ts | 52 ++++++++- sdk/dapp-sdk/src/sdk-controller.ts | 106 +++++++----------- sdk/dapp-sdk/src/sdk.ts | 27 +++++ .../extension/src/dapp-api/rpc-gen/index.ts | 3 + .../extension/src/dapp-api/rpc-gen/typings.ts | 52 ++++++++- .../remote/src/dapp-api/controller.ts | 11 ++ .../remote/src/dapp-api/rpc-gen/index.ts | 3 + .../remote/src/dapp-api/rpc-gen/typings.ts | 53 ++++++++- wallet-gateway/remote/src/dapp-api/server.ts | 5 + .../remote/src/user-api/controller.ts | 44 +++++++- .../src/web/frontend/sign-message/index.ts | 27 +---- 22 files changed, 668 insertions(+), 151 deletions(-) diff --git a/api-specs/openrpc-dapp-api.json b/api-specs/openrpc-dapp-api.json index a4e8c3ebc..a91622f13 100644 --- a/api-specs/openrpc-dapp-api.json +++ b/api-specs/openrpc-dapp-api.json @@ -187,6 +187,16 @@ "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" } } + }, + { + "name": "messageSignature", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureEvent" + } + } } ], "components": { @@ -405,6 +415,11 @@ "type": "string", "description": "The unique identifier of the command associated with the transaction." }, + "MessageId": { + "title": "MessageId", + "type": "string", + "description": "The unique identifier of the message associated with the message to be signed." + }, "TxChangedPendingEvent": { "title": "TxChangedPendingEvent", "description": "Event emitted when a transaction is pending.", @@ -543,6 +558,80 @@ } ] }, + "MessageSignaturePendingEvent": { + "title": "MessageSignaturePendingEvent", + "description": "Event emitted when a message signature is requested.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusPending", + "type": "string", + "enum": ["pending"], + "description": "The status of the message signature." + }, + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + } + }, + "required": ["status", "messageId"] + }, + "MessageSignatureSignedEvent": { + "title": "MessageSignatureSignedEvent", + "description": "Event emitted when a message signature is completed.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusSigned", + "type": "string", + "enum": ["signed"], + "description": "The status of the message signature." + }, + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + }, + "signature": { + "title": "signature", + "type": "string", + "description": "The signature of the message." + } + }, + "required": ["status", "messageId", "signature"] + }, + "MessageSignatureFailedEvent": { + "title": "MessageSignatureFailedEvent", + "description": "Event emitted when a message signature has failed.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusFailed", + "type": "string", + "enum": ["failed"], + "description": "The status of the message signature." + }, + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + } + }, + "required": ["status", "messageId"] + }, + "MessageSignatureEvent": { + "title": "MessageSignatureEvent", + "description": "Event emitted when a message signature is requested or completed.", + "oneOf": [ + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignaturePendingEvent" + }, + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureSignedEvent" + }, + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureFailedEvent" + } + ] + }, "ConnectResult": { "title": "ConnectResult", "type": "object", diff --git a/api-specs/openrpc-dapp-remote-api.json b/api-specs/openrpc-dapp-remote-api.json index 9f637ce53..c7078020d 100644 --- a/api-specs/openrpc-dapp-remote-api.json +++ b/api-specs/openrpc-dapp-remote-api.json @@ -107,11 +107,14 @@ "type": "object", "additionalProperties": false, "properties": { + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + }, "userUrl": { "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" } }, - "required": ["userUrl"] + "required": ["messageId", "userUrl"] } }, "description": "Requests a message signature. The wallet will prompt the user for confirmation." @@ -196,6 +199,16 @@ "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" } } + }, + { + "name": "messageSignature", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureEvent" + } + } } ], "components": { diff --git a/core/provider-dapp/src/DappAsyncProvider.ts b/core/provider-dapp/src/DappAsyncProvider.ts index 2d4051ea7..f9a6f5eb3 100644 --- a/core/provider-dapp/src/DappAsyncProvider.ts +++ b/core/provider-dapp/src/DappAsyncProvider.ts @@ -90,6 +90,10 @@ export class DappAsyncProvider extends AbstractProvider { 'txChanged', dispatchToProviders('txChanged') ) + eventSource.addEventListener( + 'messageSignature', + dispatchToProviders('messageSignature') + ) eventSource.onerror = () => { if (connection?.url === sseUrlString) { diff --git a/core/types/src/index.ts b/core/types/src/index.ts index c6d3c7766..ce749000f 100644 --- a/core/types/src/index.ts +++ b/core/types/src/index.ts @@ -78,8 +78,6 @@ export enum WalletEvent { // Auth events SPLICE_WALLET_IDP_AUTH_SUCCESS = 'SPLICE_WALLET_IDP_AUTH_SUCCESS', SPLICE_WALLET_LOGOUT = 'SPLICE_WALLET_LOGOUT', - // Message signing events (remote gateway UI -> dApp) - SPLICE_WALLET_SIGN_MESSAGE_RESULT = 'SPLICE_WALLET_SIGN_MESSAGE_RESULT', } export type SpliceMessageEvent = MessageEvent @@ -119,13 +117,6 @@ export const SpliceMessage = z.discriminatedUnion('type', [ token: z.string(), sessionId: z.string(), }), - z.object({ - type: z.literal(WalletEvent.SPLICE_WALLET_SIGN_MESSAGE_RESULT), - messageId: z.string().min(1), - status: z.enum(['signed', 'rejected', 'failed']), - signature: z.string().optional(), - publicKey: z.string().optional(), - }), ]) export type SpliceMessage = z.infer diff --git a/core/wallet-dapp-remote-rpc-client/src/index.ts b/core/wallet-dapp-remote-rpc-client/src/index.ts index a1c16d45c..17f87ba79 100644 --- a/core/wallet-dapp-remote-rpc-client/src/index.ts +++ b/core/wallet-dapp-remote-rpc-client/src/index.ts @@ -229,6 +229,12 @@ export interface Session { accessToken: AccessToken userId: UserId } +/** + * + * The unique identifier of the message associated with the message to be signed. + * + */ +export type MessageId = string /** * * Set as primary wallet for dApp usage. @@ -318,7 +324,7 @@ export type PartyLevelRight = any export type Rights = PartyLevelRight[] /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusPending = 'pending' @@ -333,13 +339,13 @@ export interface TxChangedPendingEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusSigned = 'signed' /** * - * The signature of the transaction. + * The signature of the message. * */ export type Signature = string @@ -403,7 +409,7 @@ export interface TxChangedExecutedEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusFailed = 'failed' @@ -416,6 +422,34 @@ export interface TxChangedFailedEvent { status: StatusFailed commandId: CommandId } +/** + * + * Event emitted when a message signature is requested. + * + */ +export interface MessageSignaturePendingEvent { + status: StatusPending + messageId: MessageId +} +/** + * + * Event emitted when a message signature is completed. + * + */ +export interface MessageSignatureSignedEvent { + status: StatusSigned + messageId: MessageId + signature: Signature +} +/** + * + * Event emitted when a message signature has failed. + * + */ +export interface MessageSignatureFailedEvent { + status: StatusFailed + messageId: MessageId +} /** * * Structure representing the request for prepare and execute calls @@ -466,6 +500,7 @@ export interface PrepareExecuteResult { userUrl: UserUrl } export interface SignMessageResult { + messageId: MessageId userUrl: UserUrl } /** @@ -498,6 +533,15 @@ export type TxChangedEvent = | TxChangedSignedEvent | TxChangedExecutedEvent | TxChangedFailedEvent +/** + * + * Event emitted when a message signature is requested or completed. + * + */ +export type MessageSignatureEvent = + | MessageSignaturePendingEvent + | MessageSignatureSignedEvent + | MessageSignatureFailedEvent /** * * Generated! Represents an alias to any of the provided schemas @@ -522,6 +566,7 @@ export type AccountsChanged = () => Promise export type GetPrimaryAccount = () => Promise export type ListAccounts = () => Promise export type TxChanged = () => Promise +export type MessageSignature = () => Promise /* eslint-enable @typescript-eslint/no-unused-vars */ type Params = T extends (...args: infer A) => any @@ -601,6 +646,11 @@ export type RpcTypes = { params: Params result: Result } + + messageSignature: { + params: Params + result: Result + } } export class SpliceWalletJSONRPCRemoteDAppAPI { diff --git a/core/wallet-dapp-remote-rpc-client/src/openrpc.json b/core/wallet-dapp-remote-rpc-client/src/openrpc.json index 305e427e3..c7078020d 100644 --- a/core/wallet-dapp-remote-rpc-client/src/openrpc.json +++ b/core/wallet-dapp-remote-rpc-client/src/openrpc.json @@ -107,14 +107,17 @@ "type": "object", "additionalProperties": false, "properties": { + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + }, "userUrl": { "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/UserUrl" } }, - "required": ["userUrl"] + "required": ["messageId", "userUrl"] } }, - "description": "Requests a message signature. The wallet will prompt the user for confirmation and return the signature via the UI." + "description": "Requests a message signature. The wallet will prompt the user for confirmation." }, { "name": "ledgerApi", @@ -196,6 +199,16 @@ "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" } } + }, + { + "name": "messageSignature", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureEvent" + } + } } ], "components": { diff --git a/core/wallet-dapp-rpc-client/src/index.ts b/core/wallet-dapp-rpc-client/src/index.ts index c41bd3567..4455e00f7 100644 --- a/core/wallet-dapp-rpc-client/src/index.ts +++ b/core/wallet-dapp-rpc-client/src/index.ts @@ -263,7 +263,7 @@ export interface TxChangedExecutedEvent { } /** * - * The signature of the transaction. + * The signature of the message. * */ export type Signature = string @@ -348,7 +348,7 @@ export interface Wallet { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusPending = 'pending' @@ -363,7 +363,7 @@ export interface TxChangedPendingEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusSigned = 'signed' @@ -395,7 +395,7 @@ export interface TxChangedSignedEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusFailed = 'failed' @@ -408,6 +408,40 @@ export interface TxChangedFailedEvent { status: StatusFailed commandId: CommandId } +/** + * + * The unique identifier of the message associated with the message to be signed. + * + */ +export type MessageId = string +/** + * + * Event emitted when a message signature is requested. + * + */ +export interface MessageSignaturePendingEvent { + status: StatusPending + messageId: MessageId +} +/** + * + * Event emitted when a message signature is completed. + * + */ +export interface MessageSignatureSignedEvent { + status: StatusSigned + messageId: MessageId + signature: Signature +} +/** + * + * Event emitted when a message signature has failed. + * + */ +export interface MessageSignatureFailedEvent { + status: StatusFailed + messageId: MessageId +} /** * * Structure representing the request for prepare and execute calls @@ -495,6 +529,15 @@ export type TxChangedEvent = | TxChangedSignedEvent | TxChangedExecutedEvent | TxChangedFailedEvent +/** + * + * Event emitted when a message signature is requested or completed. + * + */ +export type MessageSignatureEvent = + | MessageSignaturePendingEvent + | MessageSignatureSignedEvent + | MessageSignatureFailedEvent /** * * Generated! Represents an alias to any of the provided schemas @@ -518,6 +561,7 @@ export type AccountsChanged = () => Promise export type GetPrimaryAccount = () => Promise export type ListAccounts = () => Promise export type TxChanged = () => Promise +export type MessageSignature = () => Promise /* eslint-enable @typescript-eslint/no-unused-vars */ type Params = T extends (...args: infer A) => any @@ -592,6 +636,11 @@ export type RpcTypes = { params: Params result: Result } + + messageSignature: { + params: Params + result: Result + } } export class SpliceWalletJSONRPCDAppAPI { diff --git a/core/wallet-dapp-rpc-client/src/openrpc.json b/core/wallet-dapp-rpc-client/src/openrpc.json index a4e8c3ebc..a91622f13 100644 --- a/core/wallet-dapp-rpc-client/src/openrpc.json +++ b/core/wallet-dapp-rpc-client/src/openrpc.json @@ -187,6 +187,16 @@ "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/TxChangedEvent" } } + }, + { + "name": "messageSignature", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureEvent" + } + } } ], "components": { @@ -405,6 +415,11 @@ "type": "string", "description": "The unique identifier of the command associated with the transaction." }, + "MessageId": { + "title": "MessageId", + "type": "string", + "description": "The unique identifier of the message associated with the message to be signed." + }, "TxChangedPendingEvent": { "title": "TxChangedPendingEvent", "description": "Event emitted when a transaction is pending.", @@ -543,6 +558,80 @@ } ] }, + "MessageSignaturePendingEvent": { + "title": "MessageSignaturePendingEvent", + "description": "Event emitted when a message signature is requested.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusPending", + "type": "string", + "enum": ["pending"], + "description": "The status of the message signature." + }, + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + } + }, + "required": ["status", "messageId"] + }, + "MessageSignatureSignedEvent": { + "title": "MessageSignatureSignedEvent", + "description": "Event emitted when a message signature is completed.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusSigned", + "type": "string", + "enum": ["signed"], + "description": "The status of the message signature." + }, + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + }, + "signature": { + "title": "signature", + "type": "string", + "description": "The signature of the message." + } + }, + "required": ["status", "messageId", "signature"] + }, + "MessageSignatureFailedEvent": { + "title": "MessageSignatureFailedEvent", + "description": "Event emitted when a message signature has failed.", + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "title": "statusFailed", + "type": "string", + "enum": ["failed"], + "description": "The status of the message signature." + }, + "messageId": { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageId" + } + }, + "required": ["status", "messageId"] + }, + "MessageSignatureEvent": { + "title": "MessageSignatureEvent", + "description": "Event emitted when a message signature is requested or completed.", + "oneOf": [ + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignaturePendingEvent" + }, + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureSignedEvent" + }, + { + "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/MessageSignatureFailedEvent" + } + ] + }, "ConnectResult": { "title": "ConnectResult", "type": "object", diff --git a/examples/walletconnect/src/walletkit/gateway.ts b/examples/walletconnect/src/walletkit/gateway.ts index f8a7fa170..ed58eb39c 100644 --- a/examples/walletconnect/src/walletkit/gateway.ts +++ b/examples/walletconnect/src/walletkit/gateway.ts @@ -170,6 +170,7 @@ export interface SignMessageFlowResult { export async function signMessageFlow( message: string ): Promise { + const token = await getAccessToken() const response = await callDappApi<{ userUrl: string }>('signMessage', { message, }) @@ -181,46 +182,72 @@ export async function signMessageFlow( } // Use a popup so the user can approve in the wallet web UI. - // We rely on the gateway to postMessage the result to the opener. const popup = window.open(response.userUrl, 'splice_wallet_sign_message') if (!popup) { throw new Error('Failed to open wallet popup') } return await new Promise((resolve, reject) => { + const eventsUrl = new URL(`${DAPP_PATH}/events`, window.location.origin) + // gateway supports token query parameter for SSE + eventsUrl.searchParams.set('token', token) + const es = new EventSource(eventsUrl.toString()) + const timeout = window.setTimeout( () => { - window.removeEventListener('message', listener) + es.close() reject(new Error('Timed out waiting for message signature')) }, 5 * 60 * 1000 ) - const listener = (event: MessageEvent) => { - if (event.data?.type !== 'SPLICE_WALLET_SIGN_MESSAGE_RESULT') { - return - } - if (event.data?.messageId !== messageId) { - return - } - + const cleanup = () => { window.clearTimeout(timeout) - window.removeEventListener('message', listener) - - if (event.data.status !== 'signed') { - reject(new Error(`Message signing ${event.data.status}`)) - return - } + es.close() + } - const signature = event.data.signature - if (!signature) { - reject(new Error('Missing signature in message signing result')) - return + es.addEventListener('messageSignature', (e) => { + try { + // Gateway SSE uses the dApp API event format: + // `data:` is a JSON array of event args, where the first element is the event payload. + const args = JSON.parse( + (e as MessageEvent).data as string + ) as Array<{ + status: 'pending' | 'signed' | 'failed' + messageId: string + signature?: string + }> + if (!Array.isArray(args) || args.length < 1) { + throw new Error('Invalid messageSignature SSE payload') + } + const data = args[0] + if (data.messageId !== messageId) return + if (data.status === 'pending') return + + cleanup() + + if (data.status === 'failed') { + reject(new Error('Message signing failed')) + return + } + + if (!data.signature) { + reject( + new Error('Missing signature in messageSignature event') + ) + return + } + + resolve({ signature: data.signature }) + } catch (err) { + cleanup() + reject(err instanceof Error ? err : new Error(String(err))) } + }) - resolve({ signature, publicKey: event.data.publicKey }) - } - - window.addEventListener('message', listener) + es.addEventListener('error', () => { + cleanup() + reject(new Error('Disconnected from wallet event stream')) + }) }) } diff --git a/sdk/dapp-sdk/src/client.ts b/sdk/dapp-sdk/src/client.ts index f03853c1e..4d67ee43b 100644 --- a/sdk/dapp-sdk/src/client.ts +++ b/sdk/dapp-sdk/src/client.ts @@ -14,9 +14,12 @@ import type { LedgerApiParams, LedgerApiResult, ListAccountsResult, + MessageSignatureEvent, PrepareExecuteAndWaitResult, PrepareExecuteParams, ProviderType, + SignMessageParams, + SignMessageResult, StatusEvent, TxChangedEvent, } from '@canton-network/core-wallet-dapp-rpc-client' @@ -92,6 +95,10 @@ export class DappClient { }) } + async signMessage(params: SignMessageParams): Promise { + return this.provider.request({ method: 'signMessage', params }) + } + async ledgerApi(params: LedgerApiParams): Promise { return this.provider.request({ method: 'ledgerApi', params }) } @@ -114,6 +121,10 @@ export class DappClient { this.provider.on('txChanged', listener) } + onMessageSignature(listener: EventListener): void { + this.provider.on('messageSignature', listener) + } + removeOnStatusChanged(listener: EventListener): void { this.provider.removeListener('statusChanged', listener) } @@ -135,6 +146,15 @@ export class DappClient { this.provider.removeListener('txChanged', listener) } + removeOnMessageSignature( + listener: EventListener + ): void { + this.provider.removeListener( + 'messageSignature', + listener + ) + } + // ── Open wallet UI ───────────────────────────────────── async open(): Promise { diff --git a/sdk/dapp-sdk/src/dapp-api/rpc-gen/index.ts b/sdk/dapp-sdk/src/dapp-api/rpc-gen/index.ts index 9e5ff86fb..a34d5dbf0 100644 --- a/sdk/dapp-sdk/src/dapp-api/rpc-gen/index.ts +++ b/sdk/dapp-sdk/src/dapp-api/rpc-gen/index.ts @@ -14,6 +14,7 @@ import { AccountsChanged } from './typings.js' import { GetPrimaryAccount } from './typings.js' import { ListAccounts } from './typings.js' import { TxChanged } from './typings.js' +import { MessageSignature } from './typings.js' export type Methods = { status: Status @@ -29,6 +30,7 @@ export type Methods = { getPrimaryAccount: GetPrimaryAccount listAccounts: ListAccounts txChanged: TxChanged + messageSignature: MessageSignature } function buildController(methods: Methods) { @@ -46,6 +48,7 @@ function buildController(methods: Methods) { getPrimaryAccount: methods.getPrimaryAccount, listAccounts: methods.listAccounts, txChanged: methods.txChanged, + messageSignature: methods.messageSignature, } } diff --git a/sdk/dapp-sdk/src/dapp-api/rpc-gen/typings.ts b/sdk/dapp-sdk/src/dapp-api/rpc-gen/typings.ts index ed9dbc910..52c61fb05 100644 --- a/sdk/dapp-sdk/src/dapp-api/rpc-gen/typings.ts +++ b/sdk/dapp-sdk/src/dapp-api/rpc-gen/typings.ts @@ -262,7 +262,7 @@ export interface TxChangedExecutedEvent { } /** * - * The signature of the transaction. + * The signature of the message. * */ export type Signature = string @@ -347,7 +347,7 @@ export interface Wallet { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusPending = 'pending' @@ -362,7 +362,7 @@ export interface TxChangedPendingEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusSigned = 'signed' @@ -394,7 +394,7 @@ export interface TxChangedSignedEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusFailed = 'failed' @@ -407,6 +407,40 @@ export interface TxChangedFailedEvent { status: StatusFailed commandId: CommandId } +/** + * + * The unique identifier of the message associated with the message to be signed. + * + */ +export type MessageId = string +/** + * + * Event emitted when a message signature is requested. + * + */ +export interface MessageSignaturePendingEvent { + status: StatusPending + messageId: MessageId +} +/** + * + * Event emitted when a message signature is completed. + * + */ +export interface MessageSignatureSignedEvent { + status: StatusSigned + messageId: MessageId + signature: Signature +} +/** + * + * Event emitted when a message signature has failed. + * + */ +export interface MessageSignatureFailedEvent { + status: StatusFailed + messageId: MessageId +} /** * * Structure representing the request for prepare and execute calls @@ -494,6 +528,15 @@ export type TxChangedEvent = | TxChangedSignedEvent | TxChangedExecutedEvent | TxChangedFailedEvent +/** + * + * Event emitted when a message signature is requested or completed. + * + */ +export type MessageSignatureEvent = + | MessageSignaturePendingEvent + | MessageSignatureSignedEvent + | MessageSignatureFailedEvent /** * * Generated! Represents an alias to any of the provided schemas @@ -517,3 +560,4 @@ export type AccountsChanged = () => Promise export type GetPrimaryAccount = () => Promise export type ListAccounts = () => Promise export type TxChanged = () => Promise +export type MessageSignature = () => Promise diff --git a/sdk/dapp-sdk/src/sdk-controller.ts b/sdk/dapp-sdk/src/sdk-controller.ts index 763de7d7a..611eca84f 100644 --- a/sdk/dapp-sdk/src/sdk-controller.ts +++ b/sdk/dapp-sdk/src/sdk-controller.ts @@ -6,6 +6,7 @@ import buildController from './dapp-api/rpc-gen' import { ConnectResult, LedgerApiParams, + MessageSignatureEvent, Network, PrepareExecuteAndWaitResult, PrepareExecuteParams, @@ -16,7 +17,6 @@ import { import { ErrorCode } from './error' import { popup } from '@canton-network/core-wallet-ui-components' import * as dappAsyncAPI from '@canton-network/core-wallet-dapp-remote-rpc-client' -import { WalletEvent } from '@canton-network/core-types' const withTimeout = ( reject: (reason?: unknown) => void, @@ -160,80 +160,56 @@ export const dappSDKController = (provider: DappAsyncProvider) => method: 'signMessage', params, }) + const { userUrl } = response + popup.open(userUrl) - // Remote gateways return a userUrl for interactive confirmation. - // Non-remote providers may return the signature directly. - if ( - typeof (response as unknown as { userUrl?: unknown }) - .userUrl === 'string' - ) { - const { userUrl } = response as unknown as { userUrl: string } - popup.open(userUrl) - - const messageId = new URL(userUrl).searchParams.get('messageId') - if (!messageId) { - throw new Error( - 'Remote signMessage userUrl is missing messageId query param' - ) - } + const messageId = new URL(userUrl).searchParams.get('messageId') + if (!messageId) { + throw new Error( + 'Remote signMessage userUrl is missing messageId query param' + ) + } - return await new Promise( - (resolve, reject) => { - const timeout = withTimeout( - reject, - 'Timed out waiting for message signing approval' - ) - - const listener = (event: MessageEvent) => { - if ( - event.data?.type !== - WalletEvent.SPLICE_WALLET_SIGN_MESSAGE_RESULT - ) { - return - } - if (event.data?.messageId !== messageId) { - return - } - - window.removeEventListener('message', listener) - clearTimeout(timeout) + return await new Promise((resolve, reject) => { + const timeout = withTimeout( + reject, + 'Timed out waiting for message signing approval' + ) - if (event.data.status !== 'signed') { - reject({ - status: 'error', - error: ErrorCode.TransactionFailed, - details: - event.data.status === 'rejected' - ? 'Message signing was rejected.' - : 'Message signing failed.', - }) - return - } - - if (!event.data.signature) { - reject({ - status: 'error', - error: ErrorCode.TransactionFailed, - details: - 'Missing signature in signMessage result.', - }) - return - } + const listener = ( + event: dappAsyncAPI.MessageSignatureEvent + ) => { + if (event.messageId !== messageId) return - resolve({ - signature: event.data.signature, - }) - } + // pending is informational; continue waiting + if (event.status === 'pending') return + + provider.removeListener('messageSignature', listener) + clearTimeout(timeout) - window.addEventListener('message', listener) + if (event.status === 'failed') { + reject({ + status: 'error', + error: ErrorCode.TransactionFailed, + details: `Message signing failed for messageId ${event.messageId}.`, + }) + return } - ) - } - return response as unknown as SignMessageResult + resolve({ signature: event.signature }) + } + + provider.on( + 'messageSignature', + listener + ) + }) }, getPrimaryAccount: async (): Promise => provider.request({ method: 'getPrimaryAccount', }), + messageSignature: function (): Promise { + throw new Error('Only for events.') + }, }) diff --git a/sdk/dapp-sdk/src/sdk.ts b/sdk/dapp-sdk/src/sdk.ts index cbf55dbc2..49aa0c502 100644 --- a/sdk/dapp-sdk/src/sdk.ts +++ b/sdk/dapp-sdk/src/sdk.ts @@ -37,6 +37,9 @@ import type { AccountsChangedEvent, TxChangedEvent, RpcTypes as DappRpcTypes, + MessageSignatureEvent, + SignMessageParams, + SignMessageResult, } from '@canton-network/core-wallet-dapp-rpc-client' import { DappClient } from './client' import { ExtensionAdapter } from './adapter/extension-adapter' @@ -528,6 +531,10 @@ export class DappSDK { return this.requireClient().prepareExecuteAndWait(params) } + async signMessage(params: SignMessageParams): Promise { + return this.requireClient().signMessage(params) + } + async ledgerApi(params: LedgerApiParams): Promise { return this.requireClient().ledgerApi(params) } @@ -554,6 +561,12 @@ export class DappSDK { this.requireClient().onTxChanged(listener) } + async onMessageSignature( + listener: EventListener + ): Promise { + this.requireClient().onMessageSignature(listener) + } + async removeOnStatusChanged( listener: EventListener ): Promise { @@ -581,6 +594,13 @@ export class DappSDK { if (!this.client) return this.client.removeOnTxChanged(listener) } + + async removeOnMessageSignature( + listener: EventListener + ): Promise { + if (!this.client) return + this.client.removeOnMessageSignature(listener) + } } export const sdk = new DappSDK() @@ -645,6 +665,9 @@ export const onTxChanged = ( listener: EventListener ): Promise => sdk.onTxChanged(listener) +export const onMessageSignature = ( + listener: EventListener +): Promise => sdk.onMessageSignature(listener) export const removeOnStatusChanged = ( listener: EventListener ): Promise => sdk.removeOnStatusChanged(listener) @@ -661,6 +684,10 @@ export const removeOnTxChanged = ( listener: EventListener ): Promise => sdk.removeOnTxChanged(listener) +export const removeOnMessageSignature = ( + listener: EventListener +): Promise => sdk.removeOnMessageSignature(listener) + function createDefaultAdapters( defaultGatewayConfigs: RemoteAdapterConfig[] ): ProviderAdapter[] { diff --git a/wallet-gateway/extension/src/dapp-api/rpc-gen/index.ts b/wallet-gateway/extension/src/dapp-api/rpc-gen/index.ts index 9e5ff86fb..a34d5dbf0 100644 --- a/wallet-gateway/extension/src/dapp-api/rpc-gen/index.ts +++ b/wallet-gateway/extension/src/dapp-api/rpc-gen/index.ts @@ -14,6 +14,7 @@ import { AccountsChanged } from './typings.js' import { GetPrimaryAccount } from './typings.js' import { ListAccounts } from './typings.js' import { TxChanged } from './typings.js' +import { MessageSignature } from './typings.js' export type Methods = { status: Status @@ -29,6 +30,7 @@ export type Methods = { getPrimaryAccount: GetPrimaryAccount listAccounts: ListAccounts txChanged: TxChanged + messageSignature: MessageSignature } function buildController(methods: Methods) { @@ -46,6 +48,7 @@ function buildController(methods: Methods) { getPrimaryAccount: methods.getPrimaryAccount, listAccounts: methods.listAccounts, txChanged: methods.txChanged, + messageSignature: methods.messageSignature, } } diff --git a/wallet-gateway/extension/src/dapp-api/rpc-gen/typings.ts b/wallet-gateway/extension/src/dapp-api/rpc-gen/typings.ts index ed9dbc910..52c61fb05 100644 --- a/wallet-gateway/extension/src/dapp-api/rpc-gen/typings.ts +++ b/wallet-gateway/extension/src/dapp-api/rpc-gen/typings.ts @@ -262,7 +262,7 @@ export interface TxChangedExecutedEvent { } /** * - * The signature of the transaction. + * The signature of the message. * */ export type Signature = string @@ -347,7 +347,7 @@ export interface Wallet { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusPending = 'pending' @@ -362,7 +362,7 @@ export interface TxChangedPendingEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusSigned = 'signed' @@ -394,7 +394,7 @@ export interface TxChangedSignedEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusFailed = 'failed' @@ -407,6 +407,40 @@ export interface TxChangedFailedEvent { status: StatusFailed commandId: CommandId } +/** + * + * The unique identifier of the message associated with the message to be signed. + * + */ +export type MessageId = string +/** + * + * Event emitted when a message signature is requested. + * + */ +export interface MessageSignaturePendingEvent { + status: StatusPending + messageId: MessageId +} +/** + * + * Event emitted when a message signature is completed. + * + */ +export interface MessageSignatureSignedEvent { + status: StatusSigned + messageId: MessageId + signature: Signature +} +/** + * + * Event emitted when a message signature has failed. + * + */ +export interface MessageSignatureFailedEvent { + status: StatusFailed + messageId: MessageId +} /** * * Structure representing the request for prepare and execute calls @@ -494,6 +528,15 @@ export type TxChangedEvent = | TxChangedSignedEvent | TxChangedExecutedEvent | TxChangedFailedEvent +/** + * + * Event emitted when a message signature is requested or completed. + * + */ +export type MessageSignatureEvent = + | MessageSignaturePendingEvent + | MessageSignatureSignedEvent + | MessageSignatureFailedEvent /** * * Generated! Represents an alias to any of the provided schemas @@ -517,3 +560,4 @@ export type AccountsChanged = () => Promise export type GetPrimaryAccount = () => Promise export type ListAccounts = () => Promise export type TxChanged = () => Promise +export type MessageSignature = () => Promise diff --git a/wallet-gateway/remote/src/dapp-api/controller.ts b/wallet-gateway/remote/src/dapp-api/controller.ts index 8dc768ce7..9b2a04043 100644 --- a/wallet-gateway/remote/src/dapp-api/controller.ts +++ b/wallet-gateway/remote/src/dapp-api/controller.ts @@ -11,6 +11,7 @@ import { ConnectResult, LedgerApiParams, LedgerApiResult, + MessageSignatureEvent, Network, PrepareExecuteParams, SignMessageParams, @@ -351,6 +352,7 @@ export const dappController = ( throw new Error('No primary wallet found') } + const notifier = notificationService.getNotifier(context.userId) const messageId = v4() await store.setMessageRaw({ id: messageId, @@ -363,7 +365,13 @@ export const dappController = ( createdAt: new Date(), }) + notifier.emit('messageSignature', { + status: 'pending', + messageId, + } satisfies MessageSignatureEvent) + return { + messageId, userUrl: `${userUrl}/sign-message/index.html?messageId=${messageId}&closeafteraction`, } }, @@ -374,6 +382,9 @@ export const dappController = ( } return wallet }, + messageSignature: function (): Promise { + throw new Error('Only for events.') + }, }) } diff --git a/wallet-gateway/remote/src/dapp-api/rpc-gen/index.ts b/wallet-gateway/remote/src/dapp-api/rpc-gen/index.ts index 04ad6f21e..87af8cca5 100644 --- a/wallet-gateway/remote/src/dapp-api/rpc-gen/index.ts +++ b/wallet-gateway/remote/src/dapp-api/rpc-gen/index.ts @@ -15,6 +15,7 @@ import { AccountsChanged } from './typings.js' import { GetPrimaryAccount } from './typings.js' import { ListAccounts } from './typings.js' import { TxChanged } from './typings.js' +import { MessageSignature } from './typings.js' export type Methods = { status: Status @@ -31,6 +32,7 @@ export type Methods = { getPrimaryAccount: GetPrimaryAccount listAccounts: ListAccounts txChanged: TxChanged + messageSignature: MessageSignature } function buildController(methods: Methods) { @@ -49,6 +51,7 @@ function buildController(methods: Methods) { getPrimaryAccount: methods.getPrimaryAccount, listAccounts: methods.listAccounts, txChanged: methods.txChanged, + messageSignature: methods.messageSignature, } } diff --git a/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts index 90c2ea3e8..c48789526 100644 --- a/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts @@ -228,6 +228,12 @@ export interface Session { accessToken: AccessToken userId: UserId } +/** + * + * The unique identifier of the message associated with the message to be signed. + * + */ +export type MessageId = string /** * * Set as primary wallet for dApp usage. @@ -317,7 +323,7 @@ export type PartyLevelRight = any export type Rights = PartyLevelRight[] /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusPending = 'pending' @@ -332,13 +338,13 @@ export interface TxChangedPendingEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusSigned = 'signed' /** * - * The signature of the transaction. + * The signature of the message. * */ export type Signature = string @@ -402,7 +408,7 @@ export interface TxChangedExecutedEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusFailed = 'failed' @@ -415,6 +421,34 @@ export interface TxChangedFailedEvent { status: StatusFailed commandId: CommandId } +/** + * + * Event emitted when a message signature is requested. + * + */ +export interface MessageSignaturePendingEvent { + status: StatusPending + messageId: MessageId +} +/** + * + * Event emitted when a message signature is completed. + * + */ +export interface MessageSignatureSignedEvent { + status: StatusSigned + messageId: MessageId + signature: Signature +} +/** + * + * Event emitted when a message signature has failed. + * + */ +export interface MessageSignatureFailedEvent { + status: StatusFailed + messageId: MessageId +} /** * * Structure representing the request for prepare and execute calls @@ -465,6 +499,7 @@ export interface PrepareExecuteResult { userUrl: UserUrl } export interface SignMessageResult { + messageId: MessageId userUrl: UserUrl } /** @@ -497,6 +532,15 @@ export type TxChangedEvent = | TxChangedSignedEvent | TxChangedExecutedEvent | TxChangedFailedEvent +/** + * + * Event emitted when a message signature is requested or completed. + * + */ +export type MessageSignatureEvent = + | MessageSignaturePendingEvent + | MessageSignatureSignedEvent + | MessageSignatureFailedEvent /** * * Generated! Represents an alias to any of the provided schemas @@ -521,3 +565,4 @@ export type AccountsChanged = () => Promise export type GetPrimaryAccount = () => Promise export type ListAccounts = () => Promise export type TxChanged = () => Promise +export type MessageSignature = () => Promise diff --git a/wallet-gateway/remote/src/dapp-api/server.ts b/wallet-gateway/remote/src/dapp-api/server.ts index 1a933ea71..07fd0fd80 100644 --- a/wallet-gateway/remote/src/dapp-api/server.ts +++ b/wallet-gateway/remote/src/dapp-api/server.ts @@ -79,11 +79,15 @@ export const dapp = ( const onTxChanged = (...event: unknown[]) => { writeSSE(res, 'txChanged', event) } + const onMessageSignature = (...event: unknown[]) => { + writeSSE(res, 'messageSignature', event) + } notifier.on('accountsChanged', onAccountsChanged) notifier.on('connected', onConnected) notifier.on('statusChanged', onStatusChanged) notifier.on('txChanged', onTxChanged) + notifier.on('messageSignature', onMessageSignature) const cleanup = () => { logger.debug('SSE client disconnected') @@ -91,6 +95,7 @@ export const dapp = ( notifier.removeListener('connected', onConnected) notifier.removeListener('statusChanged', onStatusChanged) notifier.removeListener('txChanged', onTxChanged) + notifier.removeListener('messageSignature', onMessageSignature) } req.on('close', cleanup) diff --git a/wallet-gateway/remote/src/user-api/controller.ts b/wallet-gateway/remote/src/user-api/controller.ts index da37b97ed..4d547484b 100644 --- a/wallet-gateway/remote/src/user-api/controller.ts +++ b/wallet-gateway/remote/src/user-api/controller.ts @@ -57,6 +57,7 @@ import { networkStatus } from '../utils.js' import { v4 } from 'uuid' import { TransactionService } from '../ledger/transaction-service.js' import { StatusEvent } from '../dapp-api/rpc-gen/typings.js' +import type { MessageSignatureEvent } from '../dapp-api/rpc-gen/typings.js' type AvailableSigningDrivers = Partial< Record @@ -488,23 +489,42 @@ export const userController = ( ) } + const notifier = notificationService.getNotifier(userId) + + const emitFailedAndPersist = async ( + details: string + ): Promise => { + // Best-effort: make sure listeners see a terminal state. + try { + await store.setMessageRawStatus(pending.id, 'failed') + } catch { + // ignore (e.g. record removed concurrently) + } + notifier.emit('messageSignature', { + status: 'failed', + messageId: pending.id, + } satisfies MessageSignatureEvent) + // Preserve the original error message for the caller/UI. + throw new Error(details) + } + const wallet = (await store.getWallets()).find( (w) => w.partyId === pending.partyId ) if (!wallet) { - throw new Error( + return await emitFailedAndPersist( `No wallet found for partyId ${pending.partyId} (from message request ${pending.id})` ) } if (wallet.publicKey !== pending.publicKey) { - throw new Error( + return await emitFailedAndPersist( `Wallet public key changed for partyId ${pending.partyId}; refusing to sign message request ${pending.id}` ) } // TODO: support other signing providers if (wallet.signingProviderId !== SigningProvider.WALLET_KERNEL) { - throw new Error( + return await emitFailedAndPersist( `signMessage is only supported for ${SigningProvider.WALLET_KERNEL} wallets, got ${wallet.signingProviderId}` ) } @@ -512,7 +532,9 @@ export const userController = ( const driver = drivers[SigningProvider.WALLET_KERNEL]?.controller(userId) if (!driver) { - throw new Error('Wallet Kernel signing driver not available') + return await emitFailedAndPersist( + 'Wallet Kernel signing driver not available' + ) } const result = await driver.signMessage({ @@ -522,11 +544,19 @@ export const userController = ( if (isRpcError(result)) { await store.setMessageRawStatus(pending.id, 'failed') + notifier.emit('messageSignature', { + status: 'failed', + messageId: pending.id, + } satisfies MessageSignatureEvent) throw new Error(result.error_description) } if (!result?.signature) { await store.setMessageRawStatus(pending.id, 'failed') + notifier.emit('messageSignature', { + status: 'failed', + messageId: pending.id, + } satisfies MessageSignatureEvent) throw new Error(`signMessage failed`) } @@ -535,6 +565,12 @@ export const userController = ( signature: result.signature, }) + notifier.emit('messageSignature', { + status: 'signed', + messageId: pending.id, + signature: result.signature, + } satisfies MessageSignatureEvent) + return { signature: result.signature, publicKey: wallet.publicKey, diff --git a/wallet-gateway/remote/src/web/frontend/sign-message/index.ts b/wallet-gateway/remote/src/web/frontend/sign-message/index.ts index 88a29d33c..d5d7234ca 100644 --- a/wallet-gateway/remote/src/web/frontend/sign-message/index.ts +++ b/wallet-gateway/remote/src/web/frontend/sign-message/index.ts @@ -11,7 +11,6 @@ import { import { createUserClient } from '../rpc-client' import { stateManager } from '../state-manager' import '../index' -import { WalletEvent } from '@canton-network/core-types' @customElement('user-ui-sign-message') export class UserUiSignMessage extends BaseElement { @@ -117,23 +116,6 @@ export class UserUiSignMessage extends BaseElement { }, 500) } - private postResult(payload: { - status: 'signed' | 'rejected' | 'failed' - signature?: string - publicKey?: string - }) { - if (window.opener && !window.opener.closed) { - window.opener.postMessage( - { - type: WalletEvent.SPLICE_WALLET_SIGN_MESSAGE_RESULT, - messageId: this.messageId, - ...payload, - }, - '*' - ) - } - } - private async updateState() { this.isLoading = true this.loadError = null @@ -172,7 +154,6 @@ export class UserUiSignMessage extends BaseElement { method: 'deleteMessageToSign', params: { messageId: this.messageId }, }) - this.postResult({ status: 'rejected' }) this.closeOrGoToActivities() } catch (err) { console.error(err) @@ -188,19 +169,13 @@ export class UserUiSignMessage extends BaseElement { const userClient = await createUserClient( stateManager.accessToken.get() ) - const result = await userClient.request({ + await userClient.request({ method: 'signMessage', params: { messageId: this.messageId }, }) - this.postResult({ - status: 'signed', - signature: result.signature, - publicKey: result.publicKey, - }) this.closeOrGoToActivities() } catch (err) { console.error(err) - this.postResult({ status: 'failed' }) this.toastRpcError(err, 'Error signing message') } finally { this.isApproving = false From c6f87fb7503623631be656ed79ddf70da1db8a98 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Thu, 7 May 2026 09:52:29 +0200 Subject: [PATCH 10/13] integrate dfns signing driver Signed-off-by: Marc Juchli --- core/signing-dfns/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/signing-dfns/src/index.ts b/core/signing-dfns/src/index.ts index 03cf957c1..669ae5ed1 100644 --- a/core/signing-dfns/src/index.ts +++ b/core/signing-dfns/src/index.ts @@ -6,6 +6,7 @@ import { PartyMode, SigningDriverInterface, SigningProvider, + SignMessageResult, } from '@canton-network/core-signing-lib' import { @@ -268,5 +269,8 @@ export default class DfnsSigningDriver implements SigningDriverInterface { ): Promise => { return Promise.resolve({} as SubscribeTransactionsResult) }, + signMessage: function (): Promise { + throw new Error('Function not implemented.') + }, }) } From f1307059e5b01b15f4b8496f95b7fc8564984e03 Mon Sep 17 00:00:00 2001 From: Marc Juchli Date: Thu, 7 May 2026 10:36:08 +0200 Subject: [PATCH 11/13] user api and various small fixes Signed-off-by: Marc Juchli --- api-specs/openrpc-user-api.json | 136 ++++++++++++++- .../migrations/012-add-messages-to-sign.ts | 2 +- core/wallet-store-sql/src/schema.ts | 8 +- core/wallet-store/src/Store.ts | 2 +- core/wallet-user-rpc-client/src/index.ts | 114 +++++++------ core/wallet-user-rpc-client/src/openrpc.json | 159 +++++++++++++++++- .../remote/src/user-api/rpc-gen/typings.ts | 100 +++++------ 7 files changed, 397 insertions(+), 124 deletions(-) diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index 442fa51d1..130c84f91 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -538,10 +538,8 @@ "type": "object", "additionalProperties": false, "properties": { - "message": { - "title": "message", - "type": "string", - "description": "Arbitrary UTF-8 message to sign." + "messageId": { + "$ref": "#/components/schemas/MessageId" }, "partyId": { "title": "partyId", @@ -549,7 +547,7 @@ "description": "Party that should sign the message. If omitted, the primary wallet is used." } }, - "required": ["message"] + "required": ["messageId"] } } ], @@ -576,6 +574,62 @@ }, "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message bytes are signed as-is; callers are expected to embed any application-level domain separation (e.g. EIP-4361 / SIWX text) in the message itself. Only supported for WALLET_KERNEL wallets." }, + { + "name": "getMessageToSign", + "params": [ + { + "name": "params", + "schema": { + "title": "GetMessageToSignParams", + "type": "object", + "additionalProperties": false, + "properties": { + "messageId": { + "$ref": "#/components/schemas/MessageId" + } + }, + "required": ["messageId"] + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "GetMessageToSignResult", + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "$ref": "#/components/schemas/MessageRaw" + } + }, + "required": ["message"] + } + }, + "description": "Gets a pending message signing request." + }, + { + "name": "listMessagesToSign", + "params": [], + "result": { + "name": "result", + "schema": { + "title": "ListMessagesToSignResult", + "type": "object", + "additionalProperties": false, + "properties": { + "messages": { + "title": "messages", + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageRaw" + } + } + }, + "required": ["messages"] + } + } + }, { "name": "deleteMessageToSign", "params": [ @@ -587,10 +641,7 @@ "additionalProperties": false, "properties": { "messageId": { - "title": "messageId", - "type": "string", - "format": "uuid", - "description": "The internal identifier of the pending message-signing request." + "$ref": "#/components/schemas/MessageId" } }, "required": ["messageId"] @@ -1204,6 +1255,73 @@ "preparedTransaction", "preparedTransactionHash" ] + }, + "MessageRaw": { + "title": "MessageRaw", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/components/schemas/MessageId" + }, + "status": { + "title": "status", + "type": "string", + "enum": ["pending", "signed", "failed"] + }, + "partyId": { + "title": "partyId", + "type": "string", + "description": "The party id of the wallet to be removed." + }, + "publicKey": { + "title": "publicKey", + "type": "string", + "description": "The public key of the party." + }, + "message": { + "title": "message", + "type": "string", + "description": "The message to sign." + }, + "origin": { + "title": "origin", + "type": "string", + "description": "The origin of the message." + }, + "createdAt": { + "title": "createdAt", + "type": "string", + "format": "date-time", + "description": "The timestamp when the message was created." + }, + "signedAt": { + "title": "signedAt", + "type": "string", + "format": "date-time", + "description": "The timestamp when the message was signed." + }, + "signature": { + "title": "signature", + "type": "string", + "description": "The signature of the message." + } + }, + "required": [ + "id", + "status", + "userId", + "partyId", + "publicKey", + "message", + "createdAt" + ] + }, + "MessageId": { + "title": "MessageId", + "type": "string", + "format": "uuid", + "description": "The internal identifier of the pending message-signing request." } } } diff --git a/core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts b/core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts index 115010d38..2578f8662 100644 --- a/core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts +++ b/core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts @@ -14,7 +14,7 @@ export async function up(db: Kysely): Promise { .addColumn('origin', 'text') .addColumn('userId', 'text', (col) => col.notNull()) .addColumn('networkId', 'text', (col) => col.notNull()) - .addColumn('createdAt', 'text') + .addColumn('createdAt', 'text', (col) => col.notNull()) .addColumn('signedAt', 'text') .addColumn('signature', 'text') .execute() diff --git a/core/wallet-store-sql/src/schema.ts b/core/wallet-store-sql/src/schema.ts index e29867425..529da7761 100644 --- a/core/wallet-store-sql/src/schema.ts +++ b/core/wallet-store-sql/src/schema.ts @@ -105,7 +105,7 @@ interface MessageRawTable { origin: string | null userId: UserId networkId: string - createdAt: string | null + createdAt: string signedAt: string | null signature: string | null } @@ -358,7 +358,7 @@ export const fromMessageRaw = ( message: message.message, origin: message.origin || null, networkId, - createdAt: message.createdAt?.toISOString() || null, + createdAt: message.createdAt.toISOString(), signedAt: message.signedAt?.toISOString() || null, signature: message.signature ?? null, } @@ -373,11 +373,9 @@ export const toMessageRaw = (table: MessageRawTable): MessageRaw => { publicKey: table.publicKey, message: table.message, origin: table.origin || null, + createdAt: new Date(table.createdAt), } - if (table.createdAt) { - result.createdAt = new Date(table.createdAt) - } if (table.signedAt) { result.signedAt = new Date(table.signedAt) } diff --git a/core/wallet-store/src/Store.ts b/core/wallet-store/src/Store.ts index b286e0d1b..b25153ffa 100644 --- a/core/wallet-store/src/Store.ts +++ b/core/wallet-store/src/Store.ts @@ -105,7 +105,7 @@ export interface MessageRaw { publicKey: string message: string origin: string | null - createdAt?: Date + createdAt: Date signedAt?: Date signature?: string } diff --git a/core/wallet-user-rpc-client/src/index.ts b/core/wallet-user-rpc-client/src/index.ts index f0a0bfb69..26b0afa60 100644 --- a/core/wallet-user-rpc-client/src/index.ts +++ b/core/wallet-user-rpc-client/src/index.ts @@ -133,6 +133,11 @@ export type PartyHint = string * */ export type SigningProviderId = string +/** + * + * The party id of the wallet to be removed. + * + */ export type PartyId = string /** * @@ -163,13 +168,13 @@ export interface WalletFilter { export type TransactionId = string /** * - * Arbitrary UTF-8 message to sign. + * The internal identifier of the pending message-signing request. * */ -export type Message = string +export type MessageId = string /** * - * Base64-encoded Ed25519 signature over the message. + * The signature of the message. * */ export type Signature = string @@ -190,7 +195,7 @@ export type WalletStatus = 'initialized' | 'allocated' | 'removed' export type Hint = string /** * - * Base64-encoded Ed25519 public key of the wallet that produced the signature. + * The public key of the party. * */ export type PublicKey = string @@ -304,16 +309,52 @@ export interface SignResultFailed { } /** * - * The access token for the session. + * The status of the transaction. * */ -export type AccessToken = string +export type Status = string /** * - * The status of the transaction. + * The message to sign. * */ -export type Status = string +export type Message = string +/** + * + * The origin (dApp URL) that initiated this transaction request. + * + */ +export type Origin = string +/** + * + * The timestamp when the transaction was created. + * + */ +export type CreatedAt = string +/** + * + * The timestamp when the transaction was signed. + * + */ +export type SignedAt = string +export interface MessageRaw { + id: MessageId + status: Status + partyId: PartyId + publicKey: PublicKey + message: Message + origin?: Origin + createdAt: CreatedAt + signedAt?: SignedAt + signature?: Signature +} +export type Messages = MessageRaw[] +/** + * + * The access token for the session. + * + */ +export type AccessToken = string export type UserLevelRight = any /** * @@ -336,18 +377,6 @@ export type Sessions = Session[] * */ export type CommandId = string -/** - * - * The timestamp when the transaction was created. - * - */ -export type CreatedAt = string -/** - * - * The timestamp when the transaction was signed. - * - */ -export type SignedAt = string /** * * The transaction data corresponding to the command ID. @@ -366,12 +395,6 @@ export type PreparedTransactionHash = string * */ export type Payload = string -/** - * - * The origin (dApp URL) that initiated this transaction request. - * - */ -export type Origin = string export interface Transaction { id: TransactionId commandId: CommandId @@ -431,35 +454,14 @@ export interface SignParams { partyId: PartyId } export interface SignMessageParams { - messageId: string + messageId: MessageId + partyId?: PartyId } - export interface GetMessageToSignParams { - messageId: string + messageId: MessageId } - export interface DeleteMessageToSignParams { - messageId: string -} - -export interface MessageToSign { - id: string - status: Status - partyId: PartyId - publicKey: PublicKey - message: Message - createdAt?: CreatedAt - signedAt?: SignedAt - origin?: Origin - signature?: Signature -} - -export interface GetMessageToSignResult { - message: MessageToSign -} - -export interface ListMessagesToSignResult { - messages: MessageToSign[] + messageId: MessageId } export interface ExecuteParams { signature: Signature @@ -525,6 +527,12 @@ export interface SignMessageResult { signature: Signature publicKey: PublicKey } +export interface GetMessageToSignResult { + message: MessageRaw +} +export interface ListMessagesToSignResult { + messages: Messages +} export interface ExecuteResult { [key: string]: any } @@ -755,7 +763,7 @@ export type RpcTypes = { } } -export class SpliceWalletJSONRPCUserAPI { +export class WalletJSONRPCUserAPI { public transport: RpcTransport constructor(transport: RpcTransport) { @@ -779,4 +787,4 @@ export class SpliceWalletJSONRPCUserAPI { } } } -export default SpliceWalletJSONRPCUserAPI +export default WalletJSONRPCUserAPI diff --git a/core/wallet-user-rpc-client/src/openrpc.json b/core/wallet-user-rpc-client/src/openrpc.json index ca8476f8b..130c84f91 100644 --- a/core/wallet-user-rpc-client/src/openrpc.json +++ b/core/wallet-user-rpc-client/src/openrpc.json @@ -1,7 +1,7 @@ { "openrpc": "1.2.6", "info": { - "title": "Splice Wallet JSON-RPC User API", + "title": "Wallet JSON-RPC User API", "version": "0.1.0", "description": "An OpenRPC specification for the user to interact with the Wallet Gateway." }, @@ -538,10 +538,8 @@ "type": "object", "additionalProperties": false, "properties": { - "message": { - "title": "message", - "type": "string", - "description": "Arbitrary UTF-8 message to sign." + "messageId": { + "$ref": "#/components/schemas/MessageId" }, "partyId": { "title": "partyId", @@ -549,7 +547,7 @@ "description": "Party that should sign the message. If omitted, the primary wallet is used." } }, - "required": ["message"] + "required": ["messageId"] } } ], @@ -576,6 +574,88 @@ }, "description": "Signs an arbitrary UTF-8 message with the wallet's private key (Ed25519). The message bytes are signed as-is; callers are expected to embed any application-level domain separation (e.g. EIP-4361 / SIWX text) in the message itself. Only supported for WALLET_KERNEL wallets." }, + { + "name": "getMessageToSign", + "params": [ + { + "name": "params", + "schema": { + "title": "GetMessageToSignParams", + "type": "object", + "additionalProperties": false, + "properties": { + "messageId": { + "$ref": "#/components/schemas/MessageId" + } + }, + "required": ["messageId"] + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "GetMessageToSignResult", + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "$ref": "#/components/schemas/MessageRaw" + } + }, + "required": ["message"] + } + }, + "description": "Gets a pending message signing request." + }, + { + "name": "listMessagesToSign", + "params": [], + "result": { + "name": "result", + "schema": { + "title": "ListMessagesToSignResult", + "type": "object", + "additionalProperties": false, + "properties": { + "messages": { + "title": "messages", + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageRaw" + } + } + }, + "required": ["messages"] + } + } + }, + { + "name": "deleteMessageToSign", + "params": [ + { + "name": "params", + "schema": { + "title": "DeleteMessageToSignParams", + "type": "object", + "additionalProperties": false, + "properties": { + "messageId": { + "$ref": "#/components/schemas/MessageId" + } + }, + "required": ["messageId"] + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Null" + } + }, + "description": "Rejects a pending message signing request." + }, { "name": "execute", "params": [ @@ -1175,6 +1255,73 @@ "preparedTransaction", "preparedTransactionHash" ] + }, + "MessageRaw": { + "title": "MessageRaw", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/components/schemas/MessageId" + }, + "status": { + "title": "status", + "type": "string", + "enum": ["pending", "signed", "failed"] + }, + "partyId": { + "title": "partyId", + "type": "string", + "description": "The party id of the wallet to be removed." + }, + "publicKey": { + "title": "publicKey", + "type": "string", + "description": "The public key of the party." + }, + "message": { + "title": "message", + "type": "string", + "description": "The message to sign." + }, + "origin": { + "title": "origin", + "type": "string", + "description": "The origin of the message." + }, + "createdAt": { + "title": "createdAt", + "type": "string", + "format": "date-time", + "description": "The timestamp when the message was created." + }, + "signedAt": { + "title": "signedAt", + "type": "string", + "format": "date-time", + "description": "The timestamp when the message was signed." + }, + "signature": { + "title": "signature", + "type": "string", + "description": "The signature of the message." + } + }, + "required": [ + "id", + "status", + "userId", + "partyId", + "publicKey", + "message", + "createdAt" + ] + }, + "MessageId": { + "title": "MessageId", + "type": "string", + "format": "uuid", + "description": "The internal identifier of the pending message-signing request." } } } diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts index a2aa1bb9b..d93b98e69 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts @@ -132,6 +132,11 @@ export type PartyHint = string * */ export type SigningProviderId = string +/** + * + * The party id of the wallet to be removed. + * + */ export type PartyId = string /** * @@ -162,19 +167,13 @@ export interface WalletFilter { export type TransactionId = string /** * - * The internal message identifier. + * The internal identifier of the pending message-signing request. * */ export type MessageId = string /** * - * Arbitrary UTF-8 message to sign. - * - */ -export type Message = string -/** - * - * Base64-encoded Ed25519 signature over the message. + * The signature of the message. * */ export type Signature = string @@ -195,7 +194,7 @@ export type WalletStatus = 'initialized' | 'allocated' | 'removed' export type Hint = string /** * - * Base64-encoded Ed25519 public key of the wallet that produced the signature. + * The public key of the party. * */ export type PublicKey = string @@ -309,16 +308,52 @@ export interface SignResultFailed { } /** * - * The access token for the session. + * The status of the transaction. * */ -export type AccessToken = string +export type Status = string /** * - * The status of the transaction. + * The message to sign. * */ -export type Status = string +export type Message = string +/** + * + * The origin (dApp URL) that initiated this transaction request. + * + */ +export type Origin = string +/** + * + * The timestamp when the transaction was created. + * + */ +export type CreatedAt = string +/** + * + * The timestamp when the transaction was signed. + * + */ +export type SignedAt = string +export interface MessageRaw { + id: MessageId + status: Status + partyId: PartyId + publicKey: PublicKey + message: Message + origin?: Origin + createdAt: CreatedAt + signedAt?: SignedAt + signature?: Signature +} +export type Messages = MessageRaw[] +/** + * + * The access token for the session. + * + */ +export type AccessToken = string export type UserLevelRight = any /** * @@ -341,18 +376,6 @@ export type Sessions = Session[] * */ export type CommandId = string -/** - * - * The timestamp when the transaction was created. - * - */ -export type CreatedAt = string -/** - * - * The timestamp when the transaction was signed. - * - */ -export type SignedAt = string /** * * The transaction data corresponding to the command ID. @@ -371,12 +394,6 @@ export type PreparedTransactionHash = string * */ export type Payload = string -/** - * - * The origin (dApp URL) that initiated this transaction request. - * - */ -export type Origin = string export interface Transaction { id: TransactionId commandId: CommandId @@ -437,12 +454,11 @@ export interface SignParams { } export interface SignMessageParams { messageId: MessageId + partyId?: PartyId } - export interface GetMessageToSignParams { messageId: MessageId } - export interface DeleteMessageToSignParams { messageId: MessageId } @@ -510,25 +526,11 @@ export interface SignMessageResult { signature: Signature publicKey: PublicKey } - -export interface MessageToSign { - id: MessageId - status: Status - partyId: PartyId - publicKey: PublicKey - message: Message - createdAt?: CreatedAt - signedAt?: SignedAt - origin?: Origin - signature?: Signature -} - export interface GetMessageToSignResult { - message: MessageToSign + message: MessageRaw } - export interface ListMessagesToSignResult { - messages: MessageToSign[] + messages: Messages } export interface ExecuteResult { [key: string]: any From 667c5a95fd6eb87f1adf78d41115275c265b663e Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Thu, 7 May 2026 12:31:07 +0200 Subject: [PATCH 12/13] sign message tests with async api Signed-off-by: Pawel Stepien --- .../src/integration-test/async.test.ts | 51 ++++++++++++------- .../mock-remote/json-rpc-handlers.ts | 5 +- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/sdk/dapp-sdk/src/integration-test/async.test.ts b/sdk/dapp-sdk/src/integration-test/async.test.ts index f69b593ef..f7837637a 100644 --- a/sdk/dapp-sdk/src/integration-test/async.test.ts +++ b/sdk/dapp-sdk/src/integration-test/async.test.ts @@ -22,6 +22,7 @@ import { connectResultConnected, MOCK_DAPP_API_PATH, MOCK_SSE_PUSH_PATH, + SIGN_MESSAGE_ID, statusConnected, USER_URL, } from './mock-remote/json-rpc-handlers' @@ -563,37 +564,49 @@ describe('dApp SDK - async', () => { message: 'integration-sign-payload', } - // TODO make it sdk.signMessage once it's added to SDK - it.skip('delegates to provider.request with params', async () => { - const { sdk } = await createIntegrationSdk() - await sdk.connect() - const provider = sdk.getConnectedProvider()! - const requestSpy = vi.spyOn(provider, 'request') - - await provider.request({ method: 'signMessage', params }) - - expect(requestSpy).toHaveBeenCalledWith({ - method: 'signMessage', - params, - }) - - await sdk.disconnect() - }) + async function waitForMessageSignatureListener( + provider: Provider, + baseline: number + ): Promise { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (listenerCount(provider, 'messageSignature') > baseline) { + return + } + await new Promise((r) => setTimeout(r, 5)) + } + throw new Error( + 'timeout waiting for signMessage messageSignature listener' + ) + } - it('sends http request with params', async () => { + it('sends http request with params and waits for messageSignature event', async () => { const { sdk } = await createIntegrationSdk() await sdk.connect() const provider = sdk.getConnectedProvider()! const fetchSpy = spyOnFetch() - await provider.request({ method: 'signMessage', params }) + const baseline = listenerCount(provider, 'messageSignature') + + const waitPromise = sdk.signMessage(params) + await waitForMessageSignatureListener(provider, baseline) - const call = findRpcCallFor(rpcCalls(fetchSpy), 'signMessage') + const calls = rpcCalls(fetchSpy) + const call = findRpcCallFor(calls, 'signMessage') assertRpcCallShape(call, 'signMessage', { params, authenticated: true, }) + await pushMockSseEvent('messageSignature', { + messageId: SIGN_MESSAGE_ID, + signature: 'signature123', + }) + await waitPromise + + await expect(waitPromise).resolves.toEqual({ + signature: 'signature123', + }) await sdk.disconnect() }) }) diff --git a/sdk/dapp-sdk/src/integration-test/mock-remote/json-rpc-handlers.ts b/sdk/dapp-sdk/src/integration-test/mock-remote/json-rpc-handlers.ts index d65f3dc4b..34f579d30 100644 --- a/sdk/dapp-sdk/src/integration-test/mock-remote/json-rpc-handlers.ts +++ b/sdk/dapp-sdk/src/integration-test/mock-remote/json-rpc-handlers.ts @@ -13,6 +13,9 @@ import type { export const USER_URL = '/login' export const APPROVE_URL = '/approve' +export const SIGN_MESSAGE_ID = '123' +export const SIGN_MESSAGE_URL = `/sign-message/index.html?messageId=${SIGN_MESSAGE_ID}&closeafteraction` + export const MOCK_DAPP_API_PATH = '/api/v0/dapp' // Not a path that wallet would have, it's just to trigger emitting SSE from mock wallet, on demand within a test @@ -122,7 +125,7 @@ export function handleMockJsonRpc( return { status: 200, json: jsonRpcResult(id, { - signature: 'integration-test-signature', + userUrl: `${rpcBase}${SIGN_MESSAGE_URL}`, }), } default: From 5ba786d3b3248107022ac94e9f3f7929524491eb Mon Sep 17 00:00:00 2001 From: Alex Matson Date: Mon, 11 May 2026 10:49:39 +0200 Subject: [PATCH 13/13] make nonce optional, default to uuid Signed-off-by: Alex Matson --- examples/ping/src/hooks/useConnect.ts | 2 +- sdk/dapp-sdk/package.json | 3 ++- sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts | 9 ++++++--- yarn.lock | 1 + 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/ping/src/hooks/useConnect.ts b/examples/ping/src/hooks/useConnect.ts index 434cafaa4..c73357fe3 100644 --- a/examples/ping/src/hooks/useConnect.ts +++ b/examples/ping/src/hooks/useConnect.ts @@ -20,7 +20,7 @@ const wcAdapter = wcProjectId domain: 'http://localhost:3000', uri: 'http://localhost:3000/login', version: '1.0.0', - nonce: '1234567890', + nonce: '1234567890', // optional, defaults to a unique UUID }, onSignInWithCanton: (result) => { console.log('onSignInWithCanton:', result) diff --git a/sdk/dapp-sdk/package.json b/sdk/dapp-sdk/package.json index ac2e89bcf..1ab719c20 100644 --- a/sdk/dapp-sdk/package.json +++ b/sdk/dapp-sdk/package.json @@ -33,7 +33,8 @@ "@canton-network/core-wallet-dapp-rpc-client": "workspace:^", "@canton-network/core-wallet-discovery": "workspace:^", "@canton-network/core-wallet-ui-components": "workspace:^", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "uuid": "^14.0.0" }, "peerDependencies": { "@walletconnect/sign-client": "^2.23.8", diff --git a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts index f41fb8907..3c9d6df59 100644 --- a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts +++ b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts @@ -23,6 +23,7 @@ import type { } from '@canton-network/core-wallet-dapp-rpc-client' import { WALLETCONNECT_ICON } from '../assets' import { composeSIWXMessage } from '../util' +import { v4 as uuidv4 } from 'uuid' const CANTON_WC_METHODS = [ 'canton_prepareSignExecute', @@ -55,7 +56,7 @@ export interface Metadata { domain: string uri: string version: string - nonce: string + nonce?: string notBefore?: Timestamp statement?: string resources?: string[] @@ -426,6 +427,8 @@ export class WalletConnectAdapter this.session = await approval() this.setupSessionEvents() if (this.signInWithCanton) { + const nonce = this.signInWithCanton.nonce || uuidv4() + try { const account = this.session?.namespaces?.canton?.accounts?.[0] const address = decodeURIComponent(account?.split(':')[2]) @@ -444,7 +447,7 @@ export class WalletConnectAdapter }) this.onSignInWithCanton?.({ requestId: this.signInWithCanton.requestId, - nonce: this.signInWithCanton.nonce, + nonce, account: account, chainId: chainId, message: message, @@ -455,7 +458,7 @@ export class WalletConnectAdapter const err = error as Error this.onSignInWithCanton?.({ requestId: this.signInWithCanton.requestId, - nonce: this.signInWithCanton.nonce, + nonce, account: '', chainId: '', message: '', diff --git a/yarn.lock b/yarn.lock index e71c8acfd..541fc20fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2211,6 +2211,7 @@ __metadata: qrcode: "npm:^1.5.4" tsup: "npm:^8.5.1" typescript: "npm:^5.9.3" + uuid: "npm:^14.0.0" vitest: "npm:^4.1.2" peerDependencies: "@walletconnect/sign-client": ^2.23.8