diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 6b88cc6f01..5941682d8d 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -1,6 +1,6 @@ --- name: quartz-integration -description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects. +description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects, (9) running a relay on Quartz and serving/building its NIP-11 relay information document (application/nostr+json). --- # Quartz Integration Guide @@ -755,7 +755,100 @@ val results = store.query(Filter(search = "bitcoin")) --- -## 15. Quick Reference +## 15. NIP-11 Relay Information Document + +If you're standing up a relay on Quartz's relay-server code, serve your NIP-11 +document with the **type-safe builder** — don't hand-write the JSON string. + +**Package:** `com.vitorpamplona.quartz.nip11RelayInfo` + +```kotlin +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation + +val info = + relayInformation { + name = "sot" + description = "NIP-50 profile search ranked by Nostr web-of-trust" + software = "https://github.com/vitorpamplona/sot" + version = "0.1" + supports(1, 11, 42, 50) // ints → spec-compliant [1,11,42,50] in the JSON + } + +val json = info.toJson() // null/empty fields are omitted +``` + +Serve it at the relay root, branching on the `Accept` header (Ktor example): + +```kotlin +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import io.ktor.http.ContentType + +get("/") { + val accept = call.request.headers[HttpHeaders.Accept].orEmpty() + if (accept.contains(Nip11RelayInformation.CONTENT_TYPE)) { // "application/nostr+json" + call.respondText(json, ContentType.parse(Nip11RelayInformation.CONTENT_TYPE)) + } else { + call.respondText("Open a WebSocket (NIP-01) or send Accept: ${Nip11RelayInformation.CONTENT_TYPE}") + } +} +``` + +### Nested objects, lists, and enforced limits + +```kotlin +val info = + relayInformation { + name = "Paid Relay" + supports(1, 11, 42) + supportsExtensions("nip50-search") // supported_nip_extensions + countries("US", "CA") // relay_countries; also languages(...), tags(...) + nip50Features("profile_search") // the `nip50` field + + // limitation { } — camelCase maps to NIP-11 snake_case fields + limitation { + maxSubscriptions = 20 + maxFilters = 10 + authRequired = true + } + + // fees { } — each helper is repeatable + fees { + admission(amount = 1000, unit = "msats") + publication(amount = 100, unit = "msats", kinds = listOf(1, 30023)) + } + + // retention(...) — call once per policy entry + retention(kinds = listOf(0, 3), count = 1) + } +``` + +**Keep advertised limits in sync with enforced ones.** If you build a +`RelayLimits` for the server's policy chain, hand the *same* object to the +builder so what you publish can never drift from what you enforce: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits + +val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true) + +val info = + relayInformation { + name = "My Relay" + supports(1, 11, 42, 45) + limitation(limits) // == limits.toNip11Limitation() + } +``` + +To load an operator-supplied doc from disk or a string instead of building it, +use `Nip11RelayInformation.fromJson(json)`. + +> `geode` (Quartz's standalone relay) builds its default document exactly this +> way — see `geode/.../RelayInfo.kt`. + +--- + +## 16. Quick Reference | Task | API | Package | |------|-----|---------| @@ -779,6 +872,8 @@ val results = store.query(Filter(search = "bitcoin")) | NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` | | Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` | | Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` | +| Build NIP-11 doc | `relayInformation { name = ...; supports(1, 11) }` | `nip11RelayInfo` | +| Serialize NIP-11 doc | `info.toJson()` (media type `Nip11RelayInformation.CONTENT_TYPE`) | `nip11RelayInfo` | ## Common Event Kinds diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt index 5cde5a1989..4170fa14b4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation import java.io.File /** @@ -68,13 +69,13 @@ data class RelayInfo( /** Pre-built default for `RelayEngine(url = ...)` — advertises the supported NIPs. */ fun default(url: NormalizedRelayUrl): RelayInfo = RelayInfo( - Nip11RelayInformation( - name = NAME, - description = DESCRIPTION, - software = SOFTWARE, - version = VERSION, - supported_nips = SUPPORTED_NIPS, - ), + relayInformation { + name = NAME + description = DESCRIPTION + software = SOFTWARE + version = VERSION + supports(*SUPPORTED_NIPS.toTypedArray()) + }, ) /** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt index fa2a3cd128..6ba3ce2fce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt @@ -31,6 +31,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonPrimitive @@ -87,8 +88,21 @@ object FlexibleIntListSerializer : KSerializer?> { ) { if (value == null) { encoder.encodeNull() - } else { - listSerializer.serialize(encoder, value) + return } + + require(encoder is JsonEncoder) { "This serializer can only be used with Json format" } + + // NIP-11 expects `supported_nips` as an array of integers ([1, 11, 42]), + // so emit numeric entries as JSON numbers. Non-numeric ids (rare, but the + // model tolerates them) fall back to JSON strings so nothing is lost. + val array = + JsonArray( + value.map { nip -> + val asLong = nip.toLongOrNull() + if (asLong != null) JsonPrimitive(asLong) else JsonPrimitive(nip) + }, + ) + encoder.encodeJsonElement(array) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt new file mode 100644 index 0000000000..8a5fbcd460 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationRetentionData + +/** + * Scopes the NIP-11 DSL so an inner builder (e.g. `limitation { }`) can't + * accidentally reach the outer builder's members. + */ +@DslMarker annotation class Nip11DslMarker + +/** + * Type-safe builder for a [Nip11RelayInformation] document, so a relay operator + * never has to hand-write the JSON. + * + * Instead of the string that smells: + * ``` + * private val NIP11 = + * """{"name":"sot","description":"...","supported_nips":[1,11,42,50],"software":"...","version":"0.1"}""" + * ``` + * write: + * ``` + * val info = relayInformation { + * name = "sot" + * description = "NIP-50 profile search ranked by Nostr web-of-trust" + * software = "https://github.com/vitorpamplona/sot" + * version = "0.1" + * supports(1, 11, 42, 50) + * } + * ``` + * and serve it with [Nip11RelayInformation.toJson] under + * [Nip11RelayInformation.CONTENT_TYPE]. Null/empty fields are omitted, and + * numeric NIP numbers serialize as the JSON integers the spec expects + * (`[1,11,42,50]`, not `["1","11","42","50"]`). + */ +inline fun relayInformation(initializer: Nip11RelayInformationBuilder.() -> Unit): Nip11RelayInformation = Nip11RelayInformationBuilder().apply(initializer).build() + +@Nip11DslMarker +class Nip11RelayInformationBuilder { + var id: String? = null + var name: String? = null + var description: String? = null + var banner: String? = null + var icon: String? = null + var pubkey: HexKey? = null + var self: HexKey? = null + var contact: String? = null + var software: String? = null + var version: String? = null + var postingPolicy: String? = null + var privacyPolicy: String? = null + var termsOfService: String? = null + var paymentsUrl: String? = null + + private val supportedNips = mutableListOf() + private val supportedNipExtensions = mutableListOf() + private val relayCountries = mutableListOf() + private val languageTags = mutableListOf() + private val tags = mutableListOf() + private val nip50Subfeatures = mutableListOf() + private val supportedGrasps = mutableListOf() + private val retention = mutableListOf() + + private var limitation: RelayInformationLimitation? = null + private var fees: RelayInformationFees? = null + + /** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */ + fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } } + + /** Advertise supported NIPs by string id (for non-numeric extensions). Repeatable. */ + fun supports(vararg nips: String) = apply { supportedNips.addAll(nips) } + + /** Advertise `supported_nip_extensions` (draft/experimental features). Repeatable. */ + fun supportsExtensions(vararg extensions: String) = apply { supportedNipExtensions.addAll(extensions) } + + /** ISO-3166 country codes the relay serves under (`relay_countries`). Repeatable. */ + fun countries(vararg codes: String) = apply { relayCountries.addAll(codes) } + + /** IETF language tags of content on the relay (`language_tags`). Repeatable. */ + fun languages(vararg codes: String) = apply { languageTags.addAll(codes) } + + /** Free-form topic tags describing the relay's focus. Repeatable. */ + fun tags(vararg values: String) = apply { tags.addAll(values) } + + /** NIP-50 search sub-features the relay implements (the `nip50` field). Repeatable. */ + fun nip50Features(vararg features: String) = apply { nip50Subfeatures.addAll(features) } + + /** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */ + fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) } + + /** Declare the relay's `limitation` object via a nested DSL. */ + fun limitation(initializer: LimitationBuilder.() -> Unit) = + apply { + limitation = LimitationBuilder().apply(initializer).build() + } + + /** + * Advertise the exact limits the relay actually enforces, keeping what is + * published in sync with what is applied. See [RelayLimits.toNip11Limitation]. + */ + fun limitation(limits: RelayLimits) = apply { limitation = limits.toNip11Limitation() } + + /** Declare the relay's `fees` object via a nested DSL. */ + fun fees(initializer: FeesBuilder.() -> Unit) = + apply { + fees = FeesBuilder().apply(initializer).build() + } + + /** Add a data-retention policy entry (`retention`). Call multiple times for multiple entries. */ + fun retention( + kinds: List? = null, + time: Int? = null, + count: Int? = null, + ) = apply { + retention.add(RelayInformationRetentionData(kinds?.let { ArrayList(it) }, time, count)) + } + + fun build(): Nip11RelayInformation = + Nip11RelayInformation( + id = id, + name = name, + description = description, + banner = banner, + icon = icon, + pubkey = pubkey, + self = self, + contact = contact, + supported_nips = supportedNips.ifEmpty { null }?.toList(), + supported_nip_extensions = supportedNipExtensions.ifEmpty { null }?.toList(), + software = software, + version = version, + limitation = limitation, + relay_countries = relayCountries.ifEmpty { null }?.toList(), + language_tags = languageTags.ifEmpty { null }?.toList(), + tags = tags.ifEmpty { null }?.toList(), + posting_policy = postingPolicy, + privacy_policy = privacyPolicy, + terms_of_service = termsOfService, + payments_url = paymentsUrl, + retention = retention.ifEmpty { null }?.toList(), + fees = fees, + nip50 = nip50Subfeatures.ifEmpty { null }?.toList(), + supported_grasps = supportedGrasps.ifEmpty { null }?.toList(), + ) + + @Nip11DslMarker + class LimitationBuilder { + var maxMessageLength: Int? = null + var maxSubscriptions: Int? = null + var maxFilters: Int? = null + var maxLimit: Int? = null + var defaultLimit: Int? = null + var maxSubidLength: Int? = null + var minPrefix: Int? = null + var maxEventTags: Int? = null + var maxContentLength: Int? = null + var minPowDifficulty: Int? = null + var authRequired: Boolean? = null + var paymentRequired: Boolean? = null + var restrictedWrites: Boolean? = null + var createdAtLowerLimit: Int? = null + var createdAtUpperLimit: Int? = null + + fun build(): RelayInformationLimitation = + RelayInformationLimitation( + max_message_length = maxMessageLength, + max_subscriptions = maxSubscriptions, + max_filters = maxFilters, + max_limit = maxLimit, + default_limit = defaultLimit, + max_subid_length = maxSubidLength, + min_prefix = minPrefix, + max_event_tags = maxEventTags, + max_content_length = maxContentLength, + min_pow_difficulty = minPowDifficulty, + auth_required = authRequired, + payment_required = paymentRequired, + restricted_writes = restrictedWrites, + created_at_lower_limit = createdAtLowerLimit, + created_at_upper_limit = createdAtUpperLimit, + ) + } + + @Nip11DslMarker + class FeesBuilder { + private val admission = mutableListOf() + private val subscription = mutableListOf() + private val publication = mutableListOf() + + /** A one-time fee to be allowed to write to the relay. Repeatable. */ + fun admission( + amount: Int? = null, + unit: String? = null, + period: Int? = null, + kinds: List? = null, + ) = apply { admission.add(RelayInformationFee(amount, unit, period, kinds)) } + + /** A recurring fee to keep writing to the relay. Repeatable. */ + fun subscription( + amount: Int? = null, + unit: String? = null, + period: Int? = null, + kinds: List? = null, + ) = apply { subscription.add(RelayInformationFee(amount, unit, period, kinds)) } + + /** A per-event fee to publish. Repeatable (e.g. one entry per kind group). */ + fun publication( + amount: Int? = null, + unit: String? = null, + period: Int? = null, + kinds: List? = null, + ) = apply { publication.add(RelayInformationFee(amount, unit, period, kinds)) } + + fun build(): RelayInformationFees = + RelayInformationFees( + admission = admission.ifEmpty { null }?.toList(), + subscription = subscription.ifEmpty { null }?.toList(), + publication = publication.ifEmpty { null }?.toList(), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt new file mode 100644 index 0000000000..621b4aef9e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Nip11RelayInformationBuilderTest { + @Test + fun buildsTheSotExampleWithoutHandWrittenJson() { + val info = + relayInformation { + name = "sot" + description = "NIP-50 profile search ranked by Nostr web-of-trust" + software = "https://github.com/vitorpamplona/sot" + version = "0.1" + supports(1, 11, 42, 50) + } + + assertEquals("sot", info.name) + assertEquals(listOf("1", "11", "42", "50"), info.supported_nips) + + // Numeric NIPs serialize as JSON integers, exactly as the hand-written string had them. + val json = info.toJson() + assertTrue(json.contains("\"supported_nips\":[1,11,42,50]"), json) + assertTrue(json.contains("\"name\":\"sot\""), json) + // Unset fields stay out of the document. + assertTrue(!json.contains("\"limitation\""), json) + assertTrue(!json.contains("\"fees\""), json) + + // Round-trips back to the same document. + assertEquals(info, Nip11RelayInformation.fromJson(json)) + } + + @Test + fun emptyBuilderProducesAllNullFields() { + val info = relayInformation {} + + assertNull(info.name) + assertNull(info.supported_nips) + assertNull(info.limitation) + assertNull(info.fees) + assertEquals("{}", info.toJson()) + } + + @Test + fun nestedLimitationDsl() { + val info = + relayInformation { + name = "R" + limitation { + maxSubscriptions = 20 + maxFilters = 10 + maxLimit = 500 + authRequired = true + } + } + + assertEquals(20, info.limitation?.max_subscriptions) + assertEquals(10, info.limitation?.max_filters) + assertEquals(500, info.limitation?.max_limit) + assertEquals(true, info.limitation?.auth_required) + // Untouched limitation fields remain null (and are omitted from JSON). + assertNull(info.limitation?.max_message_length) + } + + @Test + fun limitationFromEnforcedRelayLimitsStaysInSync() { + val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true) + val info = + relayInformation { + name = "R" + limitation(limits) + } + + assertEquals(limits.toNip11Limitation(), info.limitation) + } + + @Test + fun feesDslAccumulatesEntries() { + val info = + relayInformation { + name = "Paid Relay" + fees { + admission(amount = 1000, unit = "msats") + publication(amount = 100, unit = "msats", kinds = listOf(1, 30023)) + } + } + + assertEquals( + 1000, + info.fees + ?.admission + ?.first() + ?.amount, + ) + assertEquals( + "msats", + info.fees + ?.admission + ?.first() + ?.unit, + ) + assertEquals( + listOf(1, 30023), + info.fees + ?.publication + ?.first() + ?.kinds, + ) + assertNull(info.fees?.subscription) + } + + @Test + fun retentionEntriesAreRepeatable() { + val info = + relayInformation { + name = "R" + retention(kinds = listOf(0, 3), count = 1) + retention(time = 3600) + } + + assertEquals(2, info.retention?.size) + assertEquals(arrayListOf(0, 3), info.retention?.get(0)?.kinds) + assertEquals(1, info.retention?.get(0)?.count) + assertEquals(3600, info.retention?.get(1)?.time) + } + + @Test + fun listHelpersAreRepeatableAndCollapseWhenEmpty() { + val info = + relayInformation { + name = "R" + supports(1) + supports(11, 42) + supports("custom-ext") + countries("US", "CA") + languages("en") + tags("search") + } + + assertEquals(listOf("1", "11", "42", "custom-ext"), info.supported_nips) + assertEquals(listOf("US", "CA"), info.relay_countries) + assertEquals(listOf("en"), info.language_tags) + assertEquals(listOf("search"), info.tags) + // Untouched lists collapse to null instead of empty arrays. + assertNull(info.nip50) + assertNull(info.supported_grasps) + + // Mixed numeric + string ids: numbers stay numbers, the extension stays a string. + assertTrue(info.toJson().contains("\"supported_nips\":[1,11,42,\"custom-ext\"]"), info.toJson()) + } +}