Skip to content

feat: signMessage end-to-end and Sign-In-With-Canton over WalletConnect#1692

Closed
ganchoradkov wants to merge 13 commits into
canton-network:mainfrom
ganchoradkov:feat/sign-in-with-canton
Closed

feat: signMessage end-to-end and Sign-In-With-Canton over WalletConnect#1692
ganchoradkov wants to merge 13 commits into
canton-network:mainfrom
ganchoradkov:feat/sign-in-with-canton

Conversation

@ganchoradkov
Copy link
Copy Markdown
Contributor

feat(user-api + sdk): signMessage end-to-end and Sign-In-With-Canton over WalletConnect

Summary

Adds first-class arbitrary message signing to the wallet stack and uses it to implement a Sign-In-With-X style auth flow ("Sign In With Canton") on the dApp SDK's WalletConnect adapter.

Two cohesive changes shipped together:

  1. signMessage on the signing driver + user-api — wallets can now sign arbitrary UTF-8 messages with their Ed25519 key without going through the prepared-transaction pipeline.
  2. Sign In With Canton (SIWX) on the WalletConnect adapter — when a dApp configures signInWithCanton, the adapter automatically issues a canton_signMessage request right after the WC session is approved, returning a SIWX-formatted message + signature to the dApp.

End-to-end flow

sequenceDiagram
    autonumber
    participant dApp as dApp<br/>(examples/ping)
    participant Adapter as WalletConnectAdapter<br/>(sdk/dapp-sdk)
    participant WC as WalletConnect<br/>relay
    participant Wallet as Wallet<br/>(examples/walletconnect)
    participant Gateway as Gateway user-api<br/>(wallet-gateway/remote)
    participant Driver as WALLET_KERNEL driver<br/>(core/signing-internal)

    Note over dApp,Adapter: dApp configures signInWithCanton<br/>{ domain, uri, version, nonce, ... }

    dApp->>Adapter: connect()
    Adapter->>WC: signClient.connect()
    WC-->>Adapter: pairing URI
    Adapter-->>dApp: showUriInPopup(uri) (QR + URI)

    Wallet->>WC: pair(uri)
    WC->>Wallet: session_proposal
    Wallet-->>WC: approve(session)
    WC-->>Adapter: session approved

    Note over Adapter: signInWithCanton is set →<br/>compose SIWX message

    Adapter->>Adapter: composeSIWXMessage({domain, uri, nonce,<br/>account, chainId, ...})
    Adapter->>WC: request canton_signMessage<br/>{ message }
    WC->>Wallet: session_request canton_signMessage

    Wallet->>Gateway: callUserApi("signMessage",<br/>{ message, partyId? })
    Gateway->>Gateway: resolve wallet (primary or by partyId)<br/>assert signingProviderId === WALLET_KERNEL
    Gateway->>Driver: driver.signMessage({message, keyIdentifier:{publicKey}})
    Driver->>Driver: lookup privateKey by publicKey<br/>nacl.sign.detached(utf8(message), sk)
    Driver-->>Gateway: { signature }
    Gateway-->>Wallet: { signature, publicKey }

    Wallet-->>WC: respond { signature, publicKey }
    WC-->>Adapter: response

    Adapter->>dApp: onSignInWithCanton({ requestId, nonce,<br/>account, chainId, message,<br/>signature, publicKey })
Loading

Motivation

Today the gateway only knows how to sign prepared Canton transaction hashes via sign / execute. dApps that want lightweight off-chain proofs (login, ownership challenges, EIP-4361-style auth) had no path. This PR closes that gap and demonstrates the canonical use case (SIWX) on the WalletConnect adapter so any wallet implementing canton_signMessage works out of the box.

Changes

1. Signing driver — signMessage becomes part of the interface

  • core/signing-lib
    • New raw helper signMessage(message, privateKey) (Ed25519 over UTF-8 bytes via tweetnacl).
    • New RPC method, params and result added to the spec / typings (SignMessageParams, SignMessageResult).
  • core/signing-internal (WALLET_KERNEL) — implements signMessage end-to-end (looks up the private key by keyIdentifier.publicKey, signs, returns base64 signature). Keeps the transaction history clean — message signatures are not persisted as Transaction rows.
  • core/signing-fireblocks, core/signing-blockdaemon — return not_allowed stubs for now (custodial providers don't currently expose arbitrary message signing).

2. User API — new signMessage method

  • api-specs/openrpc-user-api.json — adds:

    signMessage(message: string, partyId?: string)
      -> { signature, publicKey }
    • signature — base64 Ed25519 over the UTF-8 bytes of the message.
    • publicKey — the public key of the wallet that signed (so the dApp can verify without an extra round-trip).

    The spec docstring notes that the bytes are signed verbatim — domain separation is the caller's responsibility (see "Security notes" below).

  • wallet-gateway/remote/src/user-api/controller.ts:

    • Resolves the wallet (primary if partyId is omitted).
    • Rejects non-WALLET_KERNEL wallets with a clear error (custodial providers go through their own SDK).
    • Delegates to driver.signMessage(...), which signs the UTF-8 bytes of the message directly. Callers are expected to embed any domain separation (e.g. EIP-4361 / SIWX text — see composeSIWXMessage) in the message itself.
  • core/wallet-user-rpc-client/*, wallet-gateway/remote/src/user-api/rpc-gen/* — regenerated client + server typings.

3. WalletConnect adapter — Sign In With Canton

  • sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts
    • Adds canton_signMessage to the supported methods.
    • New config:
      new WalletConnectAdapter({
        projectId,
        signInWithCanton: { domain, uri, version, nonce, /* + optional EIP-4361 fields */ },
        onSignInWithCanton: (result) => { ... },
      })
    • After a WC session is approved, if signInWithCanton is set, the adapter:
      1. Reads the connected account from the namespace.
      2. Composes an EIP-4361/SIWX-style message via composeSIWXMessage.
      3. Issues canton_signMessage over WC.
      4. Calls onSignInWithCanton with { requestId, nonce, account, chainId, message, signature, publicKey } (or an error payload on failure).
  • sdk/dapp-sdk/src/util.ts — adds composeSIWXMessage(...) that produces the canonical SIWX text block (domain, address, statement, URI, version, chain id, nonce, optional issuedAt / expirationTime / notBefore / requestId / resources).

4. Examples wired up

  • examples/walletconnect/src/walletkit/handler.ts — routes canton_signMessage requests to callUserApi('signMessage', params) instead of the dApp API.
  • examples/ping/src/hooks/useConnect.ts — demonstrates configuring WalletConnectAdapter with signInWithCanton + onSignInWithCanton.

Security notes

  • Domain separation is the caller's responsibility. signMessage signs the UTF-8 bytes verbatim. The SIWX flow added in this PR uses composeSIWXMessage, which produces an EIP-4361-style text block starting with <domain> wants you to sign in with your Canton account: — that text cannot collide with the 32-byte hashes consumed by sign / execute. If you build a custom flow on top of signMessage, make sure your message format can't be confused with a prepared-transaction hash.
  • Provider gating: the user-api refuses to call signMessage for non-WALLET_KERNEL wallets, so custodial flows aren't silently bypassed.
  • No private key leaves the gateway — same trust model as sign.

Files changed

api-specs/openrpc-user-api.json
core/signing-blockdaemon/src/index.ts
core/signing-fireblocks/src/index.ts
core/signing-internal/src/controller.ts
core/signing-lib/src/index.ts
core/signing-lib/src/rpc-gen/{index,typings}.ts
core/wallet-user-rpc-client/src/{index.ts, openrpc.json}
examples/ping/src/hooks/useConnect.ts
examples/walletconnect/src/walletkit/handler.ts
sdk/dapp-sdk/src/adapter/walletconnect-adapter.ts
sdk/dapp-sdk/src/util.ts
wallet-gateway/remote/src/user-api/controller.ts
wallet-gateway/remote/src/user-api/rpc-gen/{index,typings}.ts

How to test

Manual — end-to-end SIWX flow

  1. Start the localnet, gateway and SDK:
    yarn start:localnet     # in its own terminal
    yarn start:all
  2. Open the gateway UI (http://localhost:3030) and create a wallet-kernel wallet (the only provider that supports message signing today).
  3. In a second terminal start the wallet example:
    yarn workspace @canton-network/example-walletconnect dev
  4. In a third terminal start the dApp:
    yarn workspace @canton-network/example-ping dev
  5. In the dApp click Connect, choose WalletConnect, paste the URI into the wallet example and approve.
  6. Right after approval the dApp logs onSignInWithCanton: { signature, publicKey, message, ... } — that's the SIWX result coming back from the gateway via WC.

Backward compatibility

  • Pure addition: no existing user-api or driver method changes shape.
  • WC adapter behaviour without signInWithCanton is unchanged.
  • wallet-kernel is the only provider that returns a signature today; other providers respond with not_allowed, which dApps can detect.

Follow-ups (not in this PR)

  • Implement signMessage for Fireblocks / Blockdaemon when their APIs support EdDSA arbitrary-message signing (or stipulate a different signing endpoint per provider).
  • Surface the SIWX result in examples/ping UI (currently just console.log).
  • Optional: expose the same signMessage on the dApp API as a convenience for non-WC adapters.

…alletConnect

Signed-off-by: Gancho Radkov <ganchoradkov93@gmail.com>
Signed-off-by: Gancho Radkov <ganchoradkov93@gmail.com>
@ganchoradkov ganchoradkov requested review from a team as code owners April 30, 2026 07:59
@mjuchli-da mjuchli-da self-assigned this Apr 30, 2026
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
@mjuchli-da mjuchli-da changed the title feat(user-api + sdk): signMessage end-to-end and Sign-In-With-Canton over WalletConnect feat: signMessage end-to-end and Sign-In-With-Canton over WalletConnect Apr 30, 2026
Copy link
Copy Markdown
Contributor

@mjuchli-da mjuchli-da left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the introduced method signMessage to the openrpc-signing specification (otherwise these types are not available to the signing drivers). Given that we only support this method for the WalletKernel (internal, non-prod) use case, I'm wondering if this is needed. In addition, we should likely make the controller method of the user api a bit more secure:

  • require user approval
  • restrict arbitrary message signing

I will make a second pass of this PR on Monday and address the mentioned points in more detail.

Comment thread wallet-gateway/remote/src/user-api/controller.ts
mjuchli-da and others added 5 commits May 4, 2026 10:40
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
@mjuchli-da mjuchli-da requested review from a team as code owners May 6, 2026 08:31
pawelstepien-da
pawelstepien-da previously approved these changes May 6, 2026
Comment thread core/wallet-store-sql/src/migrations/012-add-messages-to-sign.ts Outdated

/**
* This interface represents the SIWX message identifier.
* Here must contain the request id and the timestamps.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If all fields are required, then maybe we should set them as non-optional in the interface?

Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
mjuchli-da and others added 2 commits May 6, 2026 23:45
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
@mjuchli-da mjuchli-da mentioned this pull request May 7, 2026
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants