diff --git a/core/amulet-service/src/amulet-service-base.ts b/core/amulet-service/src/amulet-service-base.ts index 09a45e316..09337315d 100644 --- a/core/amulet-service/src/amulet-service-base.ts +++ b/core/amulet-service/src/amulet-service-base.ts @@ -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, diff --git a/core/amulet-service/src/amulet-service-impl.test.ts b/core/amulet-service/src/amulet-service-impl.test.ts index 5743a1b44..e150e39fd 100644 --- a/core/amulet-service/src/amulet-service-impl.test.ts +++ b/core/amulet-service/src/amulet-service-impl.test.ts @@ -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 @@ -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( diff --git a/core/amulet-service/src/amulet-service.test.ts b/core/amulet-service/src/amulet-service.test.ts index deb2b1e39..cdd56ac56 100644 --- a/core/amulet-service/src/amulet-service.test.ts +++ b/core/amulet-service/src/amulet-service.test.ts @@ -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) diff --git a/core/amulet-service/src/amulet-service.ts b/core/amulet-service/src/amulet-service.ts index b2dc8466f..2f790ccc5 100644 --- a/core/amulet-service/src/amulet-service.ts +++ b/core/amulet-service/src/amulet-service.ts @@ -72,6 +72,12 @@ export class AmuletService { return this.serviceImpl.selfGrantFeatureAppRight(...args) } + async cancelFeaturedAppRight( + ...args: Parameters + ) { + return this.serviceImpl.cancelFeaturedAppRight(...args) + } + async isDevNet(...args: Parameters) { return this.serviceImpl.isDevNet(...args) } diff --git a/core/splice-client/package.json b/core/splice-client/package.json index 99bdddbe9..84ce00d25 100644 --- a/core/splice-client/package.json +++ b/core/splice-client/package.json @@ -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", diff --git a/core/splice-client/src/index.ts b/core/splice-client/src/index.ts index ba6210f4a..8b2d00fa5 100644 --- a/core/splice-client/src/index.ts +++ b/core/splice-client/src/index.ts @@ -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, diff --git a/core/splice-client/src/scan-api-fetch.test.ts b/core/splice-client/src/scan-api-fetch.test.ts new file mode 100644 index 000000000..b0ac2b40b --- /dev/null +++ b/core/splice-client/src/scan-api-fetch.test.ts @@ -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 + + 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((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((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())) + }) + } + }) + }) +}) diff --git a/core/splice-client/src/scan-api-fetch.ts b/core/splice-client/src/scan-api-fetch.ts new file mode 100644 index 000000000..84214eb83 --- /dev/null +++ b/core/splice-client/src/scan-api-fetch.ts @@ -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 { + if (!headers) { + return {} + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers) + } + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()) + } + + const result: Record = {} + 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 { + 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 { + 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, + }) + ) + }) + } + ) + + 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 { + const targetUrl = resolveUrl(input) + + if (isScanLocalhostUrl(targetUrl) && isNodeRuntime()) { + return fetchViaNodeHttp(targetUrl, mergeRequestInit(input, init)) + } + + return fetch(input, init) +} diff --git a/core/splice-client/src/scan-client.ts b/core/splice-client/src/scan-client.ts index 89c7de268..9eb9e64e1 100644 --- a/core/splice-client/src/scan-client.ts +++ b/core/splice-client/src/scan-client.ts @@ -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'] @@ -86,7 +87,7 @@ export class ScanClient { const accessToken = await this.accessTokenProvider.getAccessToken() - return fetch(url, { + return fetchScanApiUrl(url, { ...options, headers: { ...(options.headers || {}), diff --git a/core/splice-client/tsdown.config.ts b/core/splice-client/tsdown.config.ts index 29033f72f..2cc068589 100644 --- a/core/splice-client/tsdown.config.ts +++ b/core/splice-client/tsdown.config.ts @@ -7,4 +7,7 @@ import { base } from '../../tsdown.base.ts' export default defineConfig({ ...base, entry: ['src/index.ts'], + deps: { + neverBundle: ['node:http', 'node:https'], + }, }) diff --git a/docs/wallet-integration-guide/examples/scripts/16-amulet-namespace-no-validator-url.ts b/docs/wallet-integration-guide/examples/scripts/16-amulet-namespace-no-validator-url.ts index 50795f279..6bc65ed1b 100644 --- a/docs/wallet-integration-guide/examples/scripts/16-amulet-namespace-no-validator-url.ts +++ b/docs/wallet-integration-guide/examples/scripts/16-amulet-namespace-no-validator-url.ts @@ -332,3 +332,29 @@ if (!featuredAppRights) { 'Featured app rights for validator operator party' ) } + +const revoked = await sdk.amulet.featuredApp.revoke({ + validatorParty: validatorParty, +}) + +if (!revoked) { + throw new Error( + 'Failed to revoke featured app rights for validator operator party' + ) +} + +const rightsAfterRevoke = await sdk.amulet.featuredApp.rights({ + partyId: validatorParty, + maxRetries: 1, + delayMs: 0, +}) + +if (rightsAfterRevoke) { + throw new Error( + 'Featured app rights still present after revoke for validator operator party' + ) +} + +logger.info( + 'Successfully revoked featured app rights for validator operator party' +) diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts index 56e6a71e0..1709105ab 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts @@ -36,6 +36,7 @@ const mockTokenStandard = { const mockAmuletService = { createTap: vi.fn(), selfGrantFeatureAppRight: vi.fn(), + cancelFeaturedAppRight: vi.fn(), getTransferPreApprovalByParty: vi.fn(), getFeaturedAppsByParty: vi.fn(), cancelTransferPreapproval: vi.fn(), @@ -211,6 +212,75 @@ describe('AmuletNamespace', () => { expect(mockSubmit).not.toHaveBeenCalled() }) }) + + describe('revoke sequence execution', () => { + it('skip revoking featured rights if none are found', async () => { + vi.mocked( + mockAmuletService.getFeaturedAppsByParty + ).mockResolvedValue(undefined) + + const result = await amuletNamespace.featuredApp.revoke() + + expect(result).toBe(true) + expect(mockSubmit).not.toHaveBeenCalled() + }) + + it('should submit cancel and wait until rights are gone', async () => { + const featuredRight = { + template_id: 'Splice.Amulet:FeaturedAppRight', + contract_id: 'right-cid-123', + payload: {}, + created_event_blob: 'blob', + created_at: '2026-01-01T00:00:00Z', + } + vi.mocked(mockAmuletService.getFeaturedAppsByParty) + .mockResolvedValueOnce(featuredRight) + .mockResolvedValueOnce(featuredRight) + .mockResolvedValue(undefined) + vi.mocked( + mockAmuletService.cancelFeaturedAppRight + ).mockResolvedValue([ + { + templateId: featuredRight.template_id, + contractId: featuredRight.contract_id, + choice: 'FeaturedAppRight_Cancel', + choiceArgument: {}, + }, + [], + ]) + + const trackingPromise = amuletNamespace.featuredApp.revoke({ + maxRetries: 3, + delayMs: 10, + }) + + await vi.runAllTimersAsync() + const result = await trackingPromise + + expect(result).toBe(true) + expect( + mockAmuletService.cancelFeaturedAppRight + ).toHaveBeenCalledWith( + featuredRight.contract_id, + featuredRight.template_id + ) + expect(mockSubmit).toHaveBeenCalledWith({ + commands: [ + { + ExerciseCommand: { + templateId: featuredRight.template_id, + contractId: featuredRight.contract_id, + choice: 'FeaturedAppRight_Cancel', + choiceArgument: {}, + }, + }, + ], + disclosedContracts: [], + synchronizerId: config.commonCtx.defaultSynchronizerId, + actAs: [config.validatorParty], + }) + }) + }) }) }) @@ -326,5 +396,20 @@ describe('AmuletNamespace with no validator party', () => { expect(mockSubmit).not.toHaveBeenCalled() }) }) + + describe('revoke sequence execution', () => { + it('skip revoking featured rights if none are found', async () => { + vi.mocked( + mockAmuletService.getFeaturedAppsByParty + ).mockResolvedValue(undefined) + + const result = await amuletNamespace.featuredApp.revoke({ + validatorParty: 'providerParty::123', + }) + + expect(result).toBe(true) + expect(mockSubmit).not.toHaveBeenCalled() + }) + }) }) }) diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts index aa44eeb57..b28fdd955 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts @@ -9,6 +9,7 @@ import { FeaturedAppRight, GrantFeaturedAppRightsOptions, LookupFeaturedAppRightsOptions, + RevokeFeaturedAppRightsOptions, } from './types.js' import { AmuletService } from '@canton-network/core-amulet-service' import { TokenStandardService } from '@canton-network/core-token-standard-service' @@ -21,6 +22,9 @@ import { resolveProviderParty } from './utils.js' const defaultMaxRetries = 10 const defaultDelayMs = 5000 +/** Scan can lag well beyond ledger completion; match preapproval cancel polling. */ +const defaultRevokeMaxRetries = 30 +const defaultRevokeDelayMs = 10_000 export type AmuletNamespaceConfig = { commonCtx: SDKContext @@ -118,6 +122,11 @@ export class AmuletNamespace { ): Promise => { return this.grantFeatureAppRightsForValidator(options) }, + revoke: async ( + options: RevokeFeaturedAppRightsOptions = {} + ): Promise => { + return this.revokeFeatureAppRightsForValidator(options) + }, } private async grantFeatureAppRightsForValidator( @@ -161,6 +170,49 @@ export class AmuletNamespace { }) } + private async revokeFeatureAppRightsForValidator( + options: RevokeFeaturedAppRightsOptions + ): Promise { + const providerParty = resolveProviderParty( + this.sdkContext, + 'revokeFeatureAppRightsForValidator', + options.validatorParty + ) + const featuredAppRights = await this.lookUpFeaturedAppRights({ + partyId: providerParty, + maxRetries: 1, + delayMs: 0, + }) + + if (!featuredAppRights) { + return true + } + + const synchronizerId = + options.synchronizerId ?? + this.sdkContext.commonCtx.defaultSynchronizerId + + const [cancelCommand, dc] = + await this.sdkContext.amuletService.cancelFeaturedAppRight( + featuredAppRights.contract_id, + featuredAppRights.template_id + ) + + await this.ledger.internal.submit({ + commands: [{ ExerciseCommand: cancelCommand }], + disclosedContracts: dc, + synchronizerId, + actAs: [providerParty], + }) + + return this.waitUntilNoFeaturedAppRights({ + partyId: providerParty, + contractId: featuredAppRights.contract_id, + maxRetries: options.maxRetries ?? defaultRevokeMaxRetries, + delayMs: options.delayMs ?? defaultRevokeDelayMs, + }) + } + private async lookUpFeaturedAppRights( options: LookupFeaturedAppRightsOptions ): Promise { @@ -192,6 +244,44 @@ export class AmuletNamespace { return undefined } + + private async waitUntilNoFeaturedAppRights(options: { + partyId: string + contractId: string + maxRetries?: number + delayMs?: number + }): Promise { + const { partyId, contractId } = options + const maxRetries = options.maxRetries ?? defaultRevokeMaxRetries + const delayMs = options.delayMs ?? defaultRevokeDelayMs + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const result = + await this.sdkContext.amuletService.getFeaturedAppsByParty( + partyId + ) + + const stillPresent = + result && + typeof result === 'object' && + Object.keys(result).length > 0 && + result.contract_id === contractId + + if (!stillPresent) { + return true + } + + this.sdkContext.commonCtx.logger.info( + `featured app rights still present after revoke attempt ${attempt}. retrying again...` + ) + + if (attempt < maxRetries) { + await new Promise((res) => setTimeout(res, delayMs)) + } + } + + return false + } } interface FeaturedAppNamespace { @@ -210,6 +300,13 @@ interface FeaturedAppNamespace { grant: ( options?: GrantFeaturedAppRightsOptions ) => Promise + /** + * Submits a command to revoke featured app rights for validator operator. + * Polls Scan until the revoked contract is no longer visible (Scan can lag + * behind ledger completion; default wait is up to ~5 minutes). + * @returns `true` if no featured app rights remain after revoke. + */ + revoke: (options?: RevokeFeaturedAppRightsOptions) => Promise } export async function fetchAmulet( diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts index 8a350240a..19a75f773 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts @@ -24,6 +24,13 @@ export interface FeaturedAppService { grant: ( options?: GrantFeaturedAppRightsOptions ) => Promise + /** + * Submits a command to revoke featured app rights for validator operator. + * Polls Scan until the revoked contract is no longer visible (Scan can lag + * behind ledger completion; default wait is up to ~5 minutes). + * @returns `true` if no featured app rights remain after revoke. + */ + revoke: (options?: RevokeFeaturedAppRightsOptions) => Promise } export type FeaturedAppRight = { @@ -46,3 +53,10 @@ export type GrantFeaturedAppRightsOptions = { delayMs?: number validatorParty?: PartyId } + +export type RevokeFeaturedAppRightsOptions = { + synchronizerId?: string + maxRetries?: number + delayMs?: number + validatorParty?: PartyId +} diff --git a/yarn.lock b/yarn.lock index f893362f7..80f7aeec5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1712,6 +1712,7 @@ __metadata: dependencies: "@canton-network/core-types": "workspace:^" "@canton-network/core-wallet-auth": "workspace:^" + "@types/node": "npm:^25.9.4" "@vitest/browser-playwright": "npm:^4.1.10" "@vitest/coverage-v8": "npm:^4.1.10" openapi-fetch: "npm:^0.17.0"