diff --git a/api-specs/openrpc-dapp-api.json b/api-specs/openrpc-dapp-api.json index e8a7afdcd..57e34daf5 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" @@ -186,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": { @@ -404,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.", @@ -542,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 987956149..3df626aec 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" @@ -102,10 +103,21 @@ "result": { "name": "result", "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" + "title": "signMessageResult", + "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": ["messageId", "userUrl"] } }, - "description": "Signs a message." + "description": "Requests a message signature. The wallet will prompt the user for confirmation." }, { "name": "ledgerApi", @@ -187,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/api-specs/openrpc-signing-api.json b/api-specs/openrpc-signing-api.json index 29e391bdb..5bc4e2b34 100644 --- a/api-specs/openrpc-signing-api.json +++ b/api-specs/openrpc-signing-api.json @@ -57,6 +57,54 @@ }, "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", + "title": "SignatureResult", + "additionalProperties": false, + "properties": { + "signature": { + "$ref": "#/components/schemas/Signature" + } + }, + "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.", @@ -413,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": { @@ -430,6 +477,11 @@ } }, "required": ["txId", "status"] + }, + "Signature": { + "title": "signature", + "type": "string", + "description": "Base64-encoded Ed25519 signature." } } } diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index 3991eb616..130c84f91 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -528,6 +528,134 @@ }, "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": { + "messageId": { + "$ref": "#/components/schemas/MessageId" + }, + "partyId": { + "title": "partyId", + "type": "string", + "description": "Party that should sign the message. If omitted, the primary wallet is used." + } + }, + "required": ["messageId"] + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "SignMessageResult", + "type": "object", + "additionalProperties": false, + "properties": { + "signature": { + "title": "signature", + "type": "string", + "description": "Base64-encoded Ed25519 signature over the message." + }, + "publicKey": { + "title": "publicKey", + "type": "string", + "description": "Base64-encoded Ed25519 public key of the wallet that produced the signature." + } + }, + "required": ["signature", "publicKey"] + } + }, + "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": [ @@ -1127,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/provider-dapp/src/DappAsyncProvider.ts b/core/provider-dapp/src/DappAsyncProvider.ts index 7acb2d161..a38760feb 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/signing-blockdaemon/src/index.ts b/core/signing-blockdaemon/src/index.ts index 46887cda3..d53304f80 100644 --- a/core/signing-blockdaemon/src/index.ts +++ b/core/signing-blockdaemon/src/index.ts @@ -16,6 +16,7 @@ import { type SetConfigurationResult, type SigningDriverInterface, SigningProvider, + SignMessageResult, type SignTransactionParams, type SignTransactionResult, type SubscribeTransactionsParams, @@ -89,6 +90,14 @@ export default class BlockdaemonSigningDriver implements SigningDriverInterface } }, + signMessage: async (): 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-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.') + }, }) } diff --git a/core/signing-fireblocks/src/index.ts b/core/signing-fireblocks/src/index.ts index 1b7889cb2..684cee655 100644 --- a/core/signing-fireblocks/src/index.ts +++ b/core/signing-fireblocks/src/index.ts @@ -8,6 +8,7 @@ import { PartyMode, SigningDriverInterface, SigningProvider, + SignMessageResult, } from '@canton-network/core-signing-lib' import { @@ -102,6 +103,14 @@ export default class FireblocksSigningDriver implements SigningDriverInterface { } }, + signMessage: async (): 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..cc4648149 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..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' @@ -12,6 +13,7 @@ import { SubscribeTransactions } from './typings.js' export type Methods = { signTransaction: SignTransaction + signMessage: SignMessage getTransaction: GetTransaction getTransactions: GetTransactions getKeys: GetKeys @@ -24,6 +26,7 @@ export type Methods = { 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..9eb4a55ef 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 SignatureResult { + signature: Signature +} /** * * List of transactions matching the provided filters @@ -156,6 +165,10 @@ export interface SignTransactionParams { internalTxId?: InternalTxId [k: string]: any } +export interface SignMessageParams { + message: Message + keyIdentifier?: KeyIdentifier +} export interface GetTransactionParams { txId: TxId [k: string]: any @@ -181,6 +194,7 @@ export interface SubscribeTransactionsParams { txIds: TxIds } export type SignTransactionResult = Error | Transaction +export type SignMessageResult = Error | SignatureResult export type GetTransactionResult = Error | Transaction export type GetTransactionsResult = Error | TransactionsResult export type GetKeysResult = Error | Keys @@ -207,6 +221,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/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-dapp-remote-rpc-client/src/index.ts b/core/wallet-dapp-remote-rpc-client/src/index.ts index 9188e7e0d..17f87ba79 100644 --- a/core/wallet-dapp-remote-rpc-client/src/index.ts +++ b/core/wallet-dapp-remote-rpc-client/src/index.ts @@ -231,10 +231,10 @@ export interface Session { } /** * - * The signature of the transaction. + * The unique identifier of the message associated with the message to be signed. * */ -export type Signature = string +export type MessageId = string /** * * Set as primary wallet for dApp usage. @@ -324,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' @@ -339,10 +339,16 @@ export interface TxChangedPendingEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusSigned = 'signed' +/** + * + * The signature of the message. + * + */ +export type Signature = string /** * * The identifier of the provider that signed the transaction. @@ -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 @@ -465,13 +499,9 @@ export type Null = null export interface PrepareExecuteResult { userUrl: UserUrl } -/** - * - * Result of signing a message. - * - */ export interface SignMessageResult { - signature: Signature + messageId: MessageId + userUrl: UserUrl } /** * @@ -503,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 @@ -527,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 @@ -606,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 44df6bb7a..c7078020d 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" @@ -102,10 +103,21 @@ "result": { "name": "result", "schema": { - "$ref": "api-specs/openrpc-dapp-api.json#/components/schemas/SignMessageResult" + "title": "signMessageResult", + "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": ["messageId", "userUrl"] } }, - "description": "Signs a message." + "description": "Requests a message signature. The wallet will prompt the user for confirmation." }, { "name": "ledgerApi", @@ -187,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 ea99511e6..a91622f13 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" @@ -186,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": { @@ -404,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.", @@ -542,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/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..2578f8662 --- /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', (col) => col.notNull()) + .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..529da7761 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 + 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,50 @@ 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(), + 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, + 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..b25153ffa 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 6fb1b1018..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 /** * @@ -161,6 +166,17 @@ export interface WalletFilter { * */ export type TransactionId = string +/** + * + * The internal identifier of the pending message-signing request. + * + */ +export type MessageId = string +/** + * + * The signature of the message. + * + */ export type Signature = string export type SignedBy = string export type Networks = Network[] @@ -293,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 /** * @@ -325,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. @@ -355,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 @@ -419,6 +453,16 @@ export interface SignParams { transactionId: TransactionId partyId: PartyId } +export interface SignMessageParams { + messageId: MessageId + partyId?: PartyId +} +export interface GetMessageToSignParams { + messageId: MessageId +} +export interface DeleteMessageToSignParams { + messageId: MessageId +} export interface ExecuteParams { signature: Signature partyId: PartyId @@ -479,6 +523,16 @@ export type SignResult = | SignResultPending | SignResultRejected | SignResultFailed +export interface SignMessageResult { + signature: Signature + publicKey: PublicKey +} +export interface GetMessageToSignResult { + message: MessageRaw +} +export interface ListMessagesToSignResult { + messages: Messages +} export interface ExecuteResult { [key: string]: any } @@ -546,6 +600,16 @@ 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 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 @@ -638,6 +702,26 @@ export type RpcTypes = { result: Result } + signMessage: { + params: Params + result: Result + } + + getMessageToSign: { + params: Params + result: Result + } + + listMessagesToSign: { + params: Params + result: Result + } + + deleteMessageToSign: { + params: Params + result: Result + } + execute: { params: Params result: Result @@ -679,7 +763,7 @@ export type RpcTypes = { } } -export class SpliceWalletJSONRPCUserAPI { +export class WalletJSONRPCUserAPI { public transport: RpcTransport constructor(transport: RpcTransport) { @@ -703,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 866139881..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." }, @@ -528,6 +528,134 @@ }, "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": { + "messageId": { + "$ref": "#/components/schemas/MessageId" + }, + "partyId": { + "title": "partyId", + "type": "string", + "description": "Party that should sign the message. If omitted, the primary wallet is used." + } + }, + "required": ["messageId"] + } + } + ], + "result": { + "name": "result", + "schema": { + "title": "SignMessageResult", + "type": "object", + "additionalProperties": false, + "properties": { + "signature": { + "title": "signature", + "type": "string", + "description": "Base64-encoded Ed25519 signature over the message." + }, + "publicKey": { + "title": "publicKey", + "type": "string", + "description": "Base64-encoded Ed25519 public key of the wallet that produced the signature." + } + }, + "required": ["signature", "publicKey"] + } + }, + "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": [ @@ -1127,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/examples/ping/src/hooks/useConnect.ts b/examples/ping/src/hooks/useConnect.ts index 949762584..c73357fe3 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', // optional, defaults to a unique UUID + }, + onSignInWithCanton: (result) => { + console.log('onSignInWithCanton:', result) + }, + }) : undefined const additionalAdapters = wcAdapter ? [loopAdapter, wcAdapter] : [loopAdapter] diff --git a/examples/walletconnect/src/walletkit/gateway.ts b/examples/walletconnect/src/walletkit/gateway.ts index 6f87b6e69..ed58eb39c 100644 --- a/examples/walletconnect/src/walletkit/gateway.ts +++ b/examples/walletconnect/src/walletkit/gateway.ts @@ -161,3 +161,93 @@ export async function prepareSignExecute( }, } } + +export interface SignMessageFlowResult { + signature: string + publicKey?: string +} + +export async function signMessageFlow( + message: string +): Promise { + const token = await getAccessToken() + 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. + 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( + () => { + es.close() + reject(new Error('Timed out waiting for message signature')) + }, + 5 * 60 * 1000 + ) + + const cleanup = () => { + window.clearTimeout(timeout) + es.close() + } + + 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))) + } + }) + + es.addEventListener('error', () => { + cleanup() + reject(new Error('Disconnected from wallet event stream')) + }) + }) +} diff --git a/examples/walletconnect/src/walletkit/handler.ts b/examples/walletconnect/src/walletkit/handler.ts index 88893aa8c..747345acb 100644 --- a/examples/walletconnect/src/walletkit/handler.ts +++ b/examples/walletconnect/src/walletkit/handler.ts @@ -9,6 +9,7 @@ import { bootstrapSession, getPrimaryPartyId, prepareSignExecute, + signMessageFlow, } from './gateway' import type { PendingProposal, @@ -407,8 +408,15 @@ export const walletHandler: WalletHandler = { result = await prepareSignExecute( params as Record ) + } else if (method === 'canton_signMessage') { + 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 { - // 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/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 6cad8bbfd..3c9d6df59 100644 --- a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts +++ b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts @@ -22,6 +22,8 @@ import type { StatusEvent, } 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', @@ -40,6 +42,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 +103,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 +131,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 +150,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 +309,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 +426,51 @@ 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]) + 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, + 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, + account: '', + chainId: '', + message: '', + publicKey: '', + signature: '', + error: { + message: err.message, + code: -32603, + }, + }) + } + } } private async showUriInPopup(uri: string): Promise { 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/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: diff --git a/sdk/dapp-sdk/src/sdk-controller.ts b/sdk/dapp-sdk/src/sdk-controller.ts index ae84fa78e..bdade3560 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, @@ -153,13 +154,61 @@ export const dappSDKController = (provider: DappAsyncProvider) => }), signMessage: async ( params: SignMessageParams - ): Promise => - provider.request({ + ): Promise => { + const response = await provider.request({ method: 'signMessage', params, - }), + }) + const { userUrl } = response + 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: dappAsyncAPI.MessageSignatureEvent + ) => { + if (event.messageId !== messageId) return + + // pending is informational; continue waiting + if (event.status === 'pending') return + + provider.removeListener('messageSignature', listener) + clearTimeout(timeout) + + if (event.status === 'failed') { + reject({ + status: 'error', + error: ErrorCode.TransactionFailed, + details: `Message signing failed for messageId ${event.messageId}.`, + }) + return + } + + 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 769c086ee..11423558c 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() @@ -647,6 +667,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) @@ -663,6 +686,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/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/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 f72e7a2fe..9b2a04043 100644 --- a/wallet-gateway/remote/src/dapp-api/controller.ts +++ b/wallet-gateway/remote/src/dapp-api/controller.ts @@ -11,8 +11,10 @@ import { ConnectResult, LedgerApiParams, LedgerApiResult, + MessageSignatureEvent, Network, PrepareExecuteParams, + SignMessageParams, SignMessageResult, StatusEvent, Wallet, @@ -335,8 +337,43 @@ 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 notifier = notificationService.getNotifier(context.userId) + 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(), + }) + + notifier.emit('messageSignature', { + status: 'pending', + messageId, + } satisfies MessageSignatureEvent) + + return { + messageId, + userUrl: `${userUrl}/sign-message/index.html?messageId=${messageId}&closeafteraction`, + } }, getPrimaryAccount: async function (): Promise { const wallet = await store.getPrimaryWallet() @@ -345,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 5e77ce966..c48789526 100644 --- a/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/dapp-api/rpc-gen/typings.ts @@ -230,10 +230,10 @@ export interface Session { } /** * - * The signature of the transaction. + * The unique identifier of the message associated with the message to be signed. * */ -export type Signature = string +export type MessageId = string /** * * Set as primary wallet for dApp usage. @@ -323,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' @@ -338,10 +338,16 @@ export interface TxChangedPendingEvent { } /** * - * The status of the transaction. + * The status of the message signature. * */ export type StatusSigned = 'signed' +/** + * + * The signature of the message. + * + */ +export type Signature = string /** * * The identifier of the provider that signed the transaction. @@ -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 @@ -464,13 +498,9 @@ export type Null = null export interface PrepareExecuteResult { userUrl: UserUrl } -/** - * - * Result of signing a message. - * - */ export interface SignMessageResult { - signature: Signature + messageId: MessageId + userUrl: UserUrl } /** * @@ -502,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 @@ -526,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 657219ff4..7dd74e43d 100644 --- a/wallet-gateway/remote/src/user-api/controller.ts +++ b/wallet-gateway/remote/src/user-api/controller.ts @@ -10,6 +10,12 @@ import { RemoveNetworkParams, ExecuteParams, SignParams, + SignMessageParams, + SignMessageResult, + GetMessageToSignParams, + GetMessageToSignResult, + ListMessagesToSignResult, + DeleteMessageToSignParams, AddSessionParams, AddSessionResult, ListSessionsResult, @@ -40,6 +46,7 @@ import { } from '@canton-network/core-wallet-auth' import { KernelInfo } from '../config/Config.js' import { + isRpcError, SigningDriverInterface, SigningProvider, } from '@canton-network/core-signing-lib' @@ -50,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 @@ -466,6 +474,185 @@ export const userController = ( ) } }, + signMessage: async ( + params: SignMessageParams + ): Promise => { + 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 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) { + return await emitFailedAndPersist( + `No wallet found for partyId ${pending.partyId} (from message request ${pending.id})` + ) + } + if (wallet.publicKey !== pending.publicKey) { + 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) { + return await emitFailedAndPersist( + `signMessage is only supported for ${SigningProvider.WALLET_KERNEL} wallets, got ${wallet.signingProviderId}` + ) + } + + const driver = + drivers[SigningProvider.WALLET_KERNEL]?.controller(userId) + if (!driver) { + return await emitFailedAndPersist( + 'Wallet Kernel signing driver not available' + ) + } + + const result = await driver.signMessage({ + message: pending.message, + keyIdentifier: { publicKey: wallet.publicKey }, + }) + + 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`) + } + + await store.setMessageRawStatus(pending.id, 'signed', { + signedAt: new Date(), + signature: result.signature, + }) + + notifier.emit('messageSignature', { + status: 'signed', + messageId: pending.id, + signature: result.signature, + } satisfies MessageSignatureEvent) + + 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 b28a2549b..4b0c3e318 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,10 @@ 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 { 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' @@ -39,6 +43,10 @@ export type Methods = { syncWallets: SyncWallets isWalletSyncNeeded: IsWalletSyncNeeded sign: Sign + signMessage: SignMessage + getMessageToSign: GetMessageToSign + listMessagesToSign: ListMessagesToSign + deleteMessageToSign: DeleteMessageToSign execute: Execute addSession: AddSession removeSession: RemoveSession @@ -65,6 +73,10 @@ function buildController(methods: Methods) { syncWallets: methods.syncWallets, 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 7e1e63424..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 /** * @@ -160,6 +165,17 @@ export interface WalletFilter { * */ export type TransactionId = string +/** + * + * The internal identifier of the pending message-signing request. + * + */ +export type MessageId = string +/** + * + * The signature of the message. + * + */ export type Signature = string export type SignedBy = string export type Networks = Network[] @@ -292,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 /** * @@ -324,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. @@ -354,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 @@ -418,6 +452,16 @@ export interface SignParams { transactionId: TransactionId partyId: PartyId } +export interface SignMessageParams { + messageId: MessageId + partyId?: PartyId +} +export interface GetMessageToSignParams { + messageId: MessageId +} +export interface DeleteMessageToSignParams { + messageId: MessageId +} export interface ExecuteParams { signature: Signature partyId: PartyId @@ -478,6 +522,16 @@ export type SignResult = | SignResultPending | SignResultRejected | SignResultFailed +export interface SignMessageResult { + signature: Signature + publicKey: PublicKey +} +export interface GetMessageToSignResult { + message: MessageRaw +} +export interface ListMessagesToSignResult { + messages: Messages +} export interface ExecuteResult { [key: string]: any } @@ -545,6 +599,16 @@ 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 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..d5d7234ca --- /dev/null +++ b/wallet-gateway/remote/src/web/frontend/sign-message/index.ts @@ -0,0 +1,245 @@ +// 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' + +@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 + + 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` + :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 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.closeOrGoToActivities() + } catch (err) { + console.error(err) + this.toastRpcError(err, 'Error rejecting message') + } finally { + this.isDeleting = false + } + } + + private async handleApprove() { + this.isApproving = true + try { + const userClient = await createUserClient( + stateManager.accessToken.get() + ) + await userClient.request({ + method: 'signMessage', + params: { messageId: this.messageId }, + }) + this.closeOrGoToActivities() + } catch (err) { + console.error(err) + this.toastRpcError(err, '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}
+ +
+ + +
+
+ ` + } +} 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