Skip to content
Open
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
13 changes: 13 additions & 0 deletions core/amulet-service/src/amulet-service-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ export abstract class AmuletServiceBase {
]
}

async cancelFeaturedAppRight(
contractId: string,
templateId: string
): Promise<[ExerciseCommand, DisclosedContract[]]> {
const exercise: ExerciseCommand = {
templateId,
contractId,
choice: 'FeaturedAppRight_Cancel',
choiceArgument: {},
}
return [exercise, []]
}

async cancelTransferPreapproval(
contractId: string,
templateId: string,
Expand Down
22 changes: 22 additions & 0 deletions core/amulet-service/src/amulet-service-impl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,17 @@ describe('AmuletServiceScanProxy', () => {
expect(dc.length).toBe(0)
})

it('should correctly build the cancelFeaturedAppRight command', async () => {
const [command, dc] = await service.cancelFeaturedAppRight(
'0022871f63af26ccb13dc48f58d189568618bea77a5e7ff6f49d273096f0eee5b7ca1212200b214acf13730a0296c9910174d26822baf45c52dbb3e09d01a4e428e7a9f1f2',
'6c5802f86709a0ad4784af81f0bab40f3070b2f58128d8843da1e1784c147802:Splice.Amulet:FeaturedAppRight'
)

expect(command.choice).toEqual('FeaturedAppRight_Cancel')
expect(command.choiceArgument).toEqual({})
expect(dc.length).toBe(0)
})

it('should correctly build the renewPreapproval command', async () => {
vi.mocked(mockScanProxyClient.getAmuletRules).mockResolvedValue(
amuletRules
Expand Down Expand Up @@ -615,6 +626,17 @@ describe('AmuletServiceScanClient', () => {
expect(dc.length).toBe(0)
})

it('should correctly build the cancelFeaturedAppRight command', async () => {
const [command, dc] = await service.cancelFeaturedAppRight(
'0022871f63af26ccb13dc48f58d189568618bea77a5e7ff6f49d273096f0eee5b7ca1212200b214acf13730a0296c9910174d26822baf45c52dbb3e09d01a4e428e7a9f1f2',
'6c5802f86709a0ad4784af81f0bab40f3070b2f58128d8843da1e1784c147802:Splice.Amulet:FeaturedAppRight'
)

expect(command.choice).toEqual('FeaturedAppRight_Cancel')
expect(command.choiceArgument).toEqual({})
expect(dc.length).toBe(0)
})

it('should correctly build the renewPreapproval command', async () => {
vi.mocked(mockScanClient.getAmuletRules).mockResolvedValue(amuletRules)
vi.mocked(mockScanClient.getActiveOpenMiningRound).mockResolvedValue(
Expand Down
10 changes: 10 additions & 0 deletions core/amulet-service/src/amulet-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ describe('AmuletService (Forwarding Layer)', () => {
expect(result).toBe(true)
})

it('should correctly forward cancelFeaturedAppRight', async () => {
mockImplInstance.cancelFeaturedAppRight.mockResolvedValue(null)

await service.cancelFeaturedAppRight('cid1', 'tid1')

expect(
mockImplInstance.cancelFeaturedAppRight
).toHaveBeenCalledWith('cid1', 'tid1')
})

it('should correctly forward isDevNet', async () => {
mockImplInstance.isDevNet.mockResolvedValue(false)

Expand Down
6 changes: 6 additions & 0 deletions core/amulet-service/src/amulet-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ export class AmuletService {
return this.serviceImpl.selfGrantFeatureAppRight(...args)
}

async cancelFeaturedAppRight(
...args: Parameters<AmuletServiceBase['cancelFeaturedAppRight']>
) {
return this.serviceImpl.cancelFeaturedAppRight(...args)
}

async isDevNet(...args: Parameters<AmuletServiceBase['isDevNet']>) {
return this.serviceImpl.isDevNet(...args)
}
Expand Down
1 change: 1 addition & 0 deletions core/splice-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.9.4",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"playwright": "^1.61.1",
Expand Down
1 change: 1 addition & 0 deletions core/splice-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

export * from './scan-client.js'
export * from './scan-api-fetch.js'
export * from './scan-proxy-client.js'
export {
type GetResponse,
Expand Down
76 changes: 76 additions & 0 deletions core/splice-client/src/scan-api-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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 { fetchScanApiUrl } from './scan-api-fetch.js'

const isNode =
typeof process !== 'undefined' && typeof process.versions?.node === 'string'

describe('fetchScanApiUrl', () => {
let fetchMock: ReturnType<typeof vi.fn>

beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(new Response('ok'))
vi.stubGlobal('fetch', fetchMock)
})

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

it('uses global fetch for non-scan.localhost URLs', async () => {
await fetchScanApiUrl('https://scan.example/api/scan/v0/dso', {
method: 'GET',
})

expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'https://scan.example/api/scan/v0/dso'
)
})

describe.runIf(isNode)('scan.localhost Node bypass', () => {
it('dials 127.0.0.1 with Host scan.localhost', async () => {
const http = await import('node:http')

let seenHost: string | undefined
let seenUrl: string | undefined

const server = http.createServer((req, res) => {
seenHost = req.headers.host
seenUrl = req.url
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})

await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve())
})

const address = server.address()
if (address === null || typeof address === 'string') {
server.close()
throw new Error('Expected TCP server address')
}

try {
const response = await fetchScanApiUrl(
`http://scan.localhost:${address.port}/api/scan/v0/dso`,
{ method: 'GET', headers: { Accept: 'application/json' } }
)

expect(fetchMock).not.toHaveBeenCalled()
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(seenHost).toBe(`scan.localhost:${address.port}`)
expect(seenUrl).toBe('/api/scan/v0/dso')
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()))
})
}
})
})
})
192 changes: 192 additions & 0 deletions core/splice-client/src/scan-api-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

function isScanLocalhostUrl(targetUrl: string): boolean {
return new URL(targetUrl).hostname === 'scan.localhost'
}

function isNodeRuntime(): boolean {
return (
typeof process !== 'undefined' &&
typeof process.versions?.node === 'string'
)
}

function resolveUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') {
return input
}
if (input instanceof URL) {
return input.href
}
return input.url
}

function headersToRecord(
headers?: RequestInit['headers']
): Record<string, string> {
if (!headers) {
return {}
}

if (Array.isArray(headers)) {
return Object.fromEntries(headers)
}

if (headers instanceof Headers) {
return Object.fromEntries(headers.entries())
}

const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers)) {
result[key] = Array.isArray(value) ? value.join(', ') : String(value)
}
return result
}

async function readRequestBody(
body: RequestInit['body']
): Promise<string | Uint8Array | undefined> {
if (body === null || body === undefined) {
return undefined
}

if (typeof body === 'string') {
return body
}

if (body instanceof Uint8Array) {
return body
}

return new Uint8Array(await new Response(body).arrayBuffer())
}

function mergeRequestInit(
input: RequestInfo | URL,
init?: RequestInit
): RequestInit {
if (!(input instanceof Request)) {
return init ?? {}
}

const requestHeaders = headersToRecord(input.headers)
const initHeaders = headersToRecord(init?.headers)

return {
method: init?.method ?? input.method,
headers: { ...requestHeaders, ...initHeaders },
body: init?.body ?? input.body,
signal: init?.signal ?? input.signal,
redirect: init?.redirect ?? input.redirect,
credentials: init?.credentials ?? input.credentials,
cache: init?.cache ?? input.cache,
integrity: init?.integrity ?? input.integrity,
keepalive: init?.keepalive ?? input.keepalive,
mode: init?.mode ?? input.mode,
referrer: init?.referrer ?? input.referrer,
referrerPolicy: init?.referrerPolicy ?? input.referrerPolicy,
}
}

async function fetchViaNodeHttp(
targetUrl: string,
init?: RequestInit
): Promise<Response> {
const [{ default: http }, { default: https }] = await Promise.all([
import('node:http'),
import('node:https'),
])

const parsed = new URL(targetUrl)
const isHttps = parsed.protocol === 'https:'
const transport = isHttps ? https : http
const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80

const headers = headersToRecord(init?.headers)
headers.Host = parsed.host

const requestBody = await readRequestBody(init?.body)

return new Promise((resolve, reject) => {
const req = transport.request(
{
hostname: '127.0.0.1',
port,
path: `${parsed.pathname}${parsed.search}`,
method: init?.method ?? 'GET',
headers,
},
(res: import('node:http').IncomingMessage) => {
const chunks: Uint8Array[] = []
res.on('data', (chunk: Uint8Array) => chunks.push(chunk))
res.on('end', () => {
const totalLength = chunks.reduce(
(sum, chunk) => sum + chunk.length,
0
)
const body = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.length
}

resolve(
new Response(body, {
status: res.statusCode ?? 500,
headers: res.headers as Record<string, string>,
})
)
})
}
)

const signal = init?.signal
if (signal) {
if (signal.aborted) {
req.destroy()
reject(signal.reason ?? new Error('The operation was aborted'))
return
}

signal.addEventListener(
'abort',
() => {
req.destroy()
reject(
signal.reason ?? new Error('The operation was aborted')
)
},
{ once: true }
)
}

req.on('error', reject)
if (requestBody !== undefined) {
req.write(requestBody)
}
req.end()
})
}

/**
* Fetch helper for Scan API URLs.
*
* Node's fetch resolves `scan.localhost` to IPv6 (`::1`) and forbids setting a
* custom `Host` header. Localnet nginx routes on the `scan.localhost` vhost, so
* in Node we dial `127.0.0.1` via `node:http` while preserving that Host header.
* Browsers already treat `*.localhost` as loopback, so they use global fetch.
*/
export function fetchScanApiUrl(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const targetUrl = resolveUrl(input)

if (isScanLocalhostUrl(targetUrl) && isNodeRuntime()) {
return fetchViaNodeHttp(targetUrl, mergeRequestInit(input, init))
}

return fetch(input, init)
}
3 changes: 2 additions & 1 deletion core/splice-client/src/scan-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { components, paths } from './generated-clients/scan'
import createClient, { Client } from 'openapi-fetch'
import { Logger } from '@canton-network/core-types'
import { AccessTokenProvider } from '@canton-network/core-wallet-auth'
import { fetchScanApiUrl } from './scan-api-fetch.js'

export type ScanTypes = components['schemas']

Expand Down Expand Up @@ -86,7 +87,7 @@ export class ScanClient {
const accessToken =
await this.accessTokenProvider.getAccessToken()

return fetch(url, {
return fetchScanApiUrl(url, {
...options,
headers: {
...(options.headers || {}),
Expand Down
3 changes: 3 additions & 0 deletions core/splice-client/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ import { base } from '../../tsdown.base.ts'
export default defineConfig({
...base,
entry: ['src/index.ts'],
deps: {
neverBundle: ['node:http', 'node:https'],
},
})
Loading
Loading