Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions core/rpc-transport/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"dev": "tsup --watch --onSuccess \"tsc\"",
"flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"",
"clean": "tsc -b --clean; rm -rf dist",
"test": "vitest run --project node --project browser --passWithNoTests",
"test:coverage": "vitest run --project node --project browser --coverage --passWithNoTests"
"test": "vitest run --project node --project browser",
"test:coverage": "vitest run --project node --project browser --coverage"
},
"dependencies": {
"@canton-network/core-types": "workspace:^",
Expand Down
91 changes: 91 additions & 0 deletions core/rpc-transport/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpTransport, jsonRpcRequest, jsonRpcResponse } from './index.js'

describe('jsonRpc helpers', () => {
it('builds request and response envelopes', () => {
expect(jsonRpcRequest('1', { method: 'isConnected' })).toEqual({
jsonrpc: '2.0',
id: '1',
method: 'isConnected',
})
expect(jsonRpcResponse('1', { result: { isConnected: true } })).toEqual(
{
jsonrpc: '2.0',
id: '1',
result: { isConnected: true },
}
)
})
})

describe('HttpTransport', () => {
let fetchMock: ReturnType<typeof vi.fn>
const url = new URL('https://wallet.example/rpc')

beforeEach(() => {
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

it('sets authorization header when an access token is provided', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ result: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
})
)

await expect(
new HttpTransport(url, 'token').submit({ method: 'ledgerApi' })
).resolves.toEqual({ result: 'ok' })

const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(init.method).toBe('POST')
expect(new Headers(init.headers).get('Authorization')).toBe(
'Bearer token'
)

fetchMock.mockResolvedValue(
new Response(JSON.stringify({ result: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
})
)
await new HttpTransport(url).submit({ method: 'ledgerApi' })
const [, noAuthInit] = fetchMock.mock.calls[1] as [string, RequestInit]
expect(new Headers(noAuthInit.headers).get('Authorization')).toBeNull()
})

it('returns parsed success results', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ result: { isConnected: true } }), {
headers: { 'Content-Type': 'application/json' },
})
)

await expect(
new HttpTransport(url).submit({ method: 'isConnected' })
).resolves.toEqual({ result: { isConnected: true } })
})

it('throws wrapped errors for failed HTTP responses', async () => {
fetchMock.mockResolvedValue(
new Response('Internal server error', { status: 500 })
)

await expect(
new HttpTransport(url).submit({ method: 'isConnected' })
).rejects.toMatchObject({
error: {
code: 500,
data: 'Internal server error',
},
})
})
})
186 changes: 186 additions & 0 deletions core/rpc-transport/src/window-transport.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WalletEvent } from '@canton-network/core-types'
import { WindowTransport } from './index.js'

// only tested when running in browser environment

vi.mock('uuid', () => ({ v4: () => 'request-id' }))

describe('WindowTransport', () => {
beforeEach(() => {
vi.spyOn(window, 'postMessage')
})

afterEach(() => {
vi.restoreAllMocks()
})

function dispatchMessage(data: unknown) {
window.dispatchEvent(new MessageEvent('message', { data }))
}

it('submits requests and resolves matching responses', async () => {
const transport = new WindowTransport(window)
const resultPromise = transport.submit({ method: 'isConnected' })

expect(window.postMessage).toHaveBeenCalledWith(
{
type: WalletEvent.SPLICE_WALLET_REQUEST,
request: {
jsonrpc: '2.0',
id: 'request-id',
method: 'isConnected',
},
},
'*'
)

dispatchMessage({
type: WalletEvent.SPLICE_WALLET_RESPONSE,
response: {
jsonrpc: '2.0',
id: 'unmatched-response',
result: { messageSignature: 'abc' },
},
})

dispatchMessage({
type: WalletEvent.SPLICE_WALLET_RESPONSE,
response: {
jsonrpc: '2.0',
id: 'request-id',
result: { isConnected: true },
},
})

await expect(resultPromise).resolves.toEqual({
jsonrpc: '2.0',
id: 'request-id',
result: { isConnected: true },
})
})

it('rejects matching error responses', async () => {
const transport = new WindowTransport(window)
const resultPromise = transport.submit({ method: 'invalidRequest' })

dispatchMessage({
type: WalletEvent.SPLICE_WALLET_RESPONSE,
response: {
jsonrpc: '2.0',
id: 'request-id',
error: { code: -32600, message: 'Invalid Request' },
},
})

await expect(resultPromise).rejects.toEqual({
code: -32600,
message: 'Invalid Request',
})
})

it('posts responses via submitResponse', () => {
new WindowTransport(window).submitResponse('response-id', {
result: 'ok',
})

expect(window.postMessage).toHaveBeenCalledWith(
{
type: WalletEvent.SPLICE_WALLET_RESPONSE,
response: {
jsonrpc: '2.0',
id: 'response-id',
result: 'ok',
},
},
'*'
)
})

describe('onNotification', () => {
it('delivers wallet notifications', () => {
const transport = new WindowTransport(window)
const received: unknown[] = []
const unsubscribe = transport.onNotification((method, params) => {
received.push({ method, params })
})

dispatchMessage({
type: WalletEvent.SPLICE_WALLET_REQUEST,
request: {
jsonrpc: '2.0',
method: 'txChanged',
params: { commandId: 'cmd-1' },
},
})

expect(received).toEqual([
{ method: 'txChanged', params: { commandId: 'cmd-1' } },
])
unsubscribe()
})

it('ignores requests with id and not matching targets', () => {
const transport = new WindowTransport(window, {
target: 'wallet-a',
})
const received: unknown[] = []
transport.onNotification((method) => received.push(method))

dispatchMessage({
type: WalletEvent.SPLICE_WALLET_REQUEST,
target: 'wallet-a',
request: {
jsonrpc: '2.0',
id: 'shouldnt-be-here',
method: 'accountsChanged',
},
})
dispatchMessage({
type: WalletEvent.SPLICE_WALLET_REQUEST,
target: 'wallet-b',
request: {
jsonrpc: '2.0',
method: 'accountsChanged',
},
})
dispatchMessage({
type: WalletEvent.SPLICE_WALLET_REQUEST,
request: {
jsonrpc: '2.0',
method: 'accountsChanged',
},
})
dispatchMessage({
type: WalletEvent.SPLICE_WALLET_REQUEST,
target: 'wallet-a',
request: {
jsonrpc: '2.0',
method: 'txChanged',
},
})

expect(received).toEqual(['txChanged'])
})

it('removes the listener when the last handler unsubscribes', () => {
const transport = new WindowTransport(window)
const handler = vi.fn()
const unsubscribe = transport.onNotification(handler)

unsubscribe()
dispatchMessage({
type: WalletEvent.SPLICE_WALLET_REQUEST,
request: {
jsonrpc: '2.0',
method: 'txChanged',
},
})

expect(handler).not.toHaveBeenCalled()
})
})
})
3 changes: 2 additions & 1 deletion core/rpc-transport/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ export default defineConfig({
},
},
environment: 'node',
include: ['src/**/*.test.ts'],
projects: [
defineProject({
test: {
name: 'node',
environment: 'node',
include: ['src/**/*.test.ts'],
// don't test parts that rely on window in node env
exclude: ['src/**/*.browser.test.ts'],
},
}),
defineProject({
Expand Down