Skip to content

Commit e1ae716

Browse files
committed
Support typed JSON values in transaction metadata
Replace Map<String, String> with Map<String, JsonValue> so metadata can carry JSON numbers and booleans alongside strings. Iroha rejects gas_limit encoded as a JSON string; the new JsonValue.number() emits a raw JSON number on the wire. Signed-off-by: Dmitry Selivanov <diselivanov@gmail.com>
1 parent 39635a9 commit e1ae716

8 files changed

Lines changed: 81 additions & 128 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.hyperledger.iroha.sdk.core.model
2+
3+
@JvmInline
4+
value class JsonValue(val rawJson: String) {
5+
companion object {
6+
fun string(value: String): JsonValue {
7+
val sb = StringBuilder(value.length + 2)
8+
sb.append('"')
9+
for (c in value) {
10+
when (c) {
11+
'"' -> sb.append("\\\"")
12+
'\\' -> sb.append("\\\\")
13+
'\b' -> sb.append("\\b")
14+
'\u000C' -> sb.append("\\f")
15+
'\n' -> sb.append("\\n")
16+
'\r' -> sb.append("\\r")
17+
'\t' -> sb.append("\\t")
18+
else -> {
19+
if (c < ' ') {
20+
sb.append("\\u00")
21+
sb.append(HEX_DIGITS[(c.code shr 4) and 0xF])
22+
sb.append(HEX_DIGITS[c.code and 0xF])
23+
} else {
24+
sb.append(c)
25+
}
26+
}
27+
}
28+
}
29+
sb.append('"')
30+
return JsonValue(sb.toString())
31+
}
32+
33+
fun number(value: Long): JsonValue = JsonValue(value.toString())
34+
35+
fun bool(value: Boolean): JsonValue = JsonValue(if (value) "true" else "false")
36+
37+
fun raw(json: String): JsonValue = JsonValue(json)
38+
39+
private val HEX_DIGITS = charArrayOf(
40+
'0', '1', '2', '3', '4', '5', '6', '7',
41+
'8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
42+
)
43+
}
44+
}

kotlin/core-jvm/src/main/java/org/hyperledger/iroha/sdk/core/model/TransactionPayload.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ class TransactionPayload(
2222
val executable: Executable = Executable.ivm(byteArrayOf()),
2323
val timeToLiveMs: Long? = null,
2424
val nonce: Int? = null,
25-
metadata: Map<String, String> = emptyMap(),
25+
metadata: Map<String, JsonValue> = emptyMap(),
2626
) {
27-
private val _metadata: Map<String, String> = metadata.toMap()
27+
private val _metadata: Map<String, JsonValue> = metadata.toMap()
2828

29-
val metadata: Map<String, String> get() = _metadata
29+
val metadata: Map<String, JsonValue> get() = _metadata
3030

3131
init {
3232
require(chainId.isNotBlank()) { "chainId must not be blank" }
@@ -50,7 +50,7 @@ class TransactionPayload(
5050
executable: Executable = this.executable,
5151
timeToLiveMs: Long? = this.timeToLiveMs,
5252
nonce: Int? = this.nonce,
53-
metadata: Map<String, String> = this.metadata,
53+
metadata: Map<String, JsonValue> = this.metadata,
5454
): TransactionPayload = TransactionPayload(
5555
chainId = chainId,
5656
authority = authority,

kotlin/core-jvm/src/main/java/org/hyperledger/iroha/sdk/tx/norito/TransactionPayloadAdapter.kt

Lines changed: 14 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import org.hyperledger.iroha.sdk.address.decodeCompactPublicKeyPayload
1313
import org.hyperledger.iroha.sdk.address.requireCanonicalI105Address
1414
import org.hyperledger.iroha.sdk.core.model.Executable
1515
import org.hyperledger.iroha.sdk.core.model.InstructionBox
16+
import org.hyperledger.iroha.sdk.core.model.JsonValue
1617
import org.hyperledger.iroha.sdk.core.model.TransactionPayload
1718
import org.hyperledger.iroha.sdk.core.model.WirePayload
1819
import org.hyperledger.iroha.sdk.norito.NoritoAdapters
@@ -302,23 +303,23 @@ internal class TransactionPayloadAdapter : TypeAdapter<TransactionPayload> {
302303
}
303304
}
304305

305-
private class JsonAdapter : TypeAdapter<String> {
306+
private class JsonValueFieldAdapter : TypeAdapter<String> {
306307
override fun encode(encoder: NoritoEncoder, value: String) {
307-
encodeSizedField(encoder, JSON_STRING_ADAPTER, value)
308+
encodeSizedField(encoder, STRING_ADAPTER, value)
308309
}
309310

310311
override fun decode(decoder: NoritoDecoder): String {
311-
return decodeSizedField(decoder, JSON_STRING_ADAPTER)
312+
return decodeSizedField(decoder, STRING_ADAPTER)
312313
}
313314

314315
override fun isSelfDelimiting(): Boolean = true
315316
}
316317

317-
private class MetadataAdapter : TypeAdapter<Map<String, String>> {
318+
private class MetadataAdapter : TypeAdapter<Map<String, JsonValue>> {
318319
private val entryListAdapter: TypeAdapter<List<MetadataEntry>> =
319320
NoritoAdapters.sequence(MetadataEntryAdapter())
320321

321-
override fun encode(encoder: NoritoEncoder, value: Map<String, String>) {
322+
override fun encode(encoder: NoritoEncoder, value: Map<String, JsonValue>) {
322323
val keys = value.keys.sorted()
323324
val entries = keys.map { key ->
324325
val entryValue = value[key]
@@ -328,50 +329,36 @@ internal class TransactionPayloadAdapter : TypeAdapter<TransactionPayload> {
328329
entryListAdapter.encode(encoder, entries)
329330
}
330331

331-
override fun decode(decoder: NoritoDecoder): Map<String, String> {
332+
override fun decode(decoder: NoritoDecoder): Map<String, JsonValue> {
332333
val entries = entryListAdapter.decode(decoder)
333-
val decoded = LinkedHashMap<String, String>(entries.size)
334+
val decoded = LinkedHashMap<String, JsonValue>(entries.size)
334335
for (entry in entries) {
335336
require(decoded.put(entry.key, entry.value) == null) { "Duplicate metadata key" }
336337
}
337338
return decoded
338339
}
339340
}
340341

341-
private class MetadataEntry(val key: String, val value: String)
342+
private class MetadataEntry(val key: String, val value: JsonValue)
342343

343344
private class MetadataEntryAdapter : TypeAdapter<MetadataEntry> {
344345
override fun encode(encoder: NoritoEncoder, value: MetadataEntry) {
345346
encodeSizedField(encoder, STRING_ADAPTER, value.key)
346-
encodeSizedField(encoder, JSON_ADAPTER, value.value)
347+
encodeSizedField(encoder, JSON_VALUE_ADAPTER, value.value.rawJson)
347348
}
348349

349350
override fun decode(decoder: NoritoDecoder): MetadataEntry {
350351
val key = decodeSizedField(decoder, STRING_ADAPTER)
351-
val value = decodeSizedField(decoder, JSON_ADAPTER)
352-
return MetadataEntry(key, value)
353-
}
354-
}
355-
356-
private class JsonStringAdapter : TypeAdapter<String> {
357-
override fun encode(encoder: NoritoEncoder, value: String) {
358-
STRING_ADAPTER.encode(encoder, encodeJsonString(value))
359-
}
360-
361-
override fun decode(decoder: NoritoDecoder): String {
362-
val raw = STRING_ADAPTER.decode(decoder)
363-
return decodeJsonString(raw)
352+
val raw = decodeSizedField(decoder, JSON_VALUE_ADAPTER)
353+
return MetadataEntry(key, JsonValue.raw(raw))
364354
}
365-
366-
override fun isSelfDelimiting(): Boolean = true
367355
}
368356

369357
companion object {
370358
private val STRING_ADAPTER: TypeAdapter<String> = NoritoAdapters.stringAdapter()
371359
private val ACCOUNT_ID_ADAPTER: TypeAdapter<String> = AccountIdAdapter()
372360
private val CHAIN_ID_ADAPTER: TypeAdapter<String> = ChainIdAdapter()
373-
private val JSON_STRING_ADAPTER: TypeAdapter<String> = JsonStringAdapter()
374-
private val JSON_ADAPTER: TypeAdapter<String> = JsonAdapter()
361+
private val JSON_VALUE_ADAPTER: TypeAdapter<String> = JsonValueFieldAdapter()
375362
private val UINT64_ADAPTER: TypeAdapter<Long> = NoritoAdapters.uint(64)
376363
private val UINT16_ADAPTER: TypeAdapter<Long> = NoritoAdapters.uint(16)
377364
private val UINT8_ADAPTER: TypeAdapter<Long> = NoritoAdapters.uint(8)
@@ -388,11 +375,9 @@ internal class TransactionPayloadAdapter : TypeAdapter<TransactionPayload> {
388375
private val TTL_ADAPTER: TypeAdapter<Optional<Long>> = NoritoAdapters.option(NoritoAdapters.uint(64))
389376
private val NONCE_ADAPTER: TypeAdapter<Optional<Long>> = NoritoAdapters.option(NoritoAdapters.uint(32))
390377
private val EXECUTABLE_ADAPTER: TypeAdapter<Executable> = ExecutableAdapter()
391-
private val METADATA_ADAPTER: TypeAdapter<Map<String, String>> = MetadataAdapter()
378+
private val METADATA_ADAPTER: TypeAdapter<Map<String, JsonValue>> = MetadataAdapter()
392379
private const val INSTRUCTION_BOX_SCHEMA = "iroha.data_model.isi.InstructionBox.v1"
393380

394-
private val HEX_DIGITS = "0123456789ABCDEF".toCharArray()
395-
396381
internal fun encodeInstructionBox(value: InstructionBox): ByteArray =
397382
NoritoCodec.encode(value, INSTRUCTION_BOX_SCHEMA, InstructionAdapter())
398383

@@ -479,85 +464,5 @@ internal class TransactionPayloadAdapter : TypeAdapter<TransactionPayload> {
479464
false
480465
}
481466
}
482-
483-
private fun encodeJsonString(value: String): String {
484-
val builder = StringBuilder(value.length + 2)
485-
builder.append('"')
486-
for (c in value) {
487-
when (c) {
488-
'"' -> builder.append("\\\"")
489-
'\\' -> builder.append("\\\\")
490-
'\b' -> builder.append("\\b")
491-
'\u000C' -> builder.append("\\f")
492-
'\n' -> builder.append("\\n")
493-
'\r' -> builder.append("\\r")
494-
'\t' -> builder.append("\\t")
495-
else -> {
496-
if (c < '\u0020') {
497-
builder.append("\\u00")
498-
builder.append(HEX_DIGITS[(c.code shr 4) and 0xF])
499-
builder.append(HEX_DIGITS[c.code and 0xF])
500-
} else {
501-
builder.append(c)
502-
}
503-
}
504-
}
505-
}
506-
builder.append('"')
507-
return builder.toString()
508-
}
509-
510-
private fun decodeJsonString(raw: String?): String {
511-
if (raw == null) return raw.toString()
512-
val trimmed = raw.trim()
513-
if (trimmed.length < 2 || trimmed[0] != '"' || trimmed[trimmed.length - 1] != '"') return raw
514-
return try {
515-
parseJsonString(trimmed)
516-
} catch (_: IllegalArgumentException) {
517-
raw
518-
}
519-
}
520-
521-
private fun parseJsonString(input: String): String {
522-
val builder = StringBuilder()
523-
var i = 1
524-
while (i < input.length - 1) {
525-
val c = input[i++]
526-
if (c == '\\') {
527-
require(i < input.length - 1) { "Invalid JSON escape" }
528-
val esc = input[i++]
529-
when (esc) {
530-
'"' -> builder.append('"')
531-
'\\' -> builder.append('\\')
532-
'/' -> builder.append('/')
533-
'b' -> builder.append('\b')
534-
'f' -> builder.append('\u000C')
535-
'n' -> builder.append('\n')
536-
'r' -> builder.append('\r')
537-
't' -> builder.append('\t')
538-
'u' -> {
539-
require(i + 4 <= input.length - 1) { "Invalid unicode escape" }
540-
var codePoint = 0
541-
for (j in 0 until 4) {
542-
codePoint = (codePoint shl 4) or hexNibble(input[i + j])
543-
}
544-
builder.append(codePoint.toChar())
545-
i += 4
546-
}
547-
else -> throw IllegalArgumentException("Unsupported escape: \\$esc")
548-
}
549-
} else {
550-
builder.append(c)
551-
}
552-
}
553-
return builder.toString()
554-
}
555-
556-
private fun hexNibble(c: Char): Int = when (c) {
557-
in '0'..'9' -> c - '0'
558-
in 'a'..'f' -> 10 + (c - 'a')
559-
in 'A'..'F' -> 10 + (c - 'A')
560-
else -> throw IllegalArgumentException("Invalid hex digit: $c")
561-
}
562467
}
563468
}

kotlin/core-jvm/src/test/kotlin/org/hyperledger/iroha/sdk/client/HttpClientTransportTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import org.hyperledger.iroha.sdk.address.encodePublicKeyMultihash
1919
import org.hyperledger.iroha.sdk.client.transport.TransportRequest
2020
import org.hyperledger.iroha.sdk.client.transport.TransportResponse
2121
import org.hyperledger.iroha.sdk.crypto.IrohaHash
22+
import org.hyperledger.iroha.sdk.core.model.JsonValue
2223
import org.hyperledger.iroha.sdk.core.model.TransactionPayload
2324
import org.hyperledger.iroha.sdk.norito.NoritoAdapters
2425
import org.hyperledger.iroha.sdk.norito.NoritoCodec
@@ -1341,7 +1342,7 @@ class HttpClientTransportTest {
13411342
creationTimeMs = 1_700_000_000_000L + seed,
13421343
timeToLiveMs = 5_000L,
13431344
nonce = seed + 1,
1344-
metadata = mapOf("note" to "tx-$seed"),
1345+
metadata = mapOf("note" to JsonValue.string("tx-$seed")),
13451346
),
13461347
)
13471348
val signature = ByteArray(64) { (seed + 1).toByte() }

kotlin/core-jvm/src/test/kotlin/org/hyperledger/iroha/sdk/core/model/TransactionPayloadTest.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,28 +93,28 @@ class TransactionPayloadTest {
9393
@Test
9494
fun `blank metadata key throws`() {
9595
assertFailsWith<IllegalArgumentException> {
96-
TransactionPayload(metadata = mapOf(" " to "value"), creationTimeMs = 1000L)
96+
TransactionPayload(metadata = mapOf(" " to JsonValue.string("value")), creationTimeMs = 1000L)
9797
}
9898
}
9999

100100
@Test
101101
fun `defensive copy on metadata input`() {
102-
val original = mutableMapOf("key" to "value")
102+
val original = mutableMapOf("key" to JsonValue.string("value"))
103103
val payload = TransactionPayload(metadata = original, creationTimeMs = 1000L)
104-
original["injected"] = "bad"
104+
original["injected"] = JsonValue.string("bad")
105105
assertEquals(1, payload.metadata.size)
106-
assertEquals("value", payload.metadata["key"])
106+
assertEquals(JsonValue.string("value"), payload.metadata["key"])
107107
}
108108

109109
@Test
110110
fun `metadata getter returns immutable snapshot`() {
111111
val payload = TransactionPayload(
112-
metadata = mapOf("a" to "1"),
112+
metadata = mapOf("a" to JsonValue.string("1")),
113113
creationTimeMs = 1000L,
114114
)
115115
val meta = payload.metadata
116116
assertFailsWith<UnsupportedOperationException> {
117-
(meta as MutableMap)["b"] = "2"
117+
(meta as MutableMap)["b"] = JsonValue.string("2")
118118
}
119119
}
120120

@@ -151,7 +151,7 @@ class TransactionPayloadTest {
151151
executable = executable,
152152
timeToLiveMs = 500,
153153
nonce = 1,
154-
metadata = mapOf("k" to "v"),
154+
metadata = mapOf("k" to JsonValue.string("v")),
155155
)
156156
val b = TransactionPayload(
157157
chainId = "c",
@@ -160,7 +160,7 @@ class TransactionPayloadTest {
160160
executable = executable,
161161
timeToLiveMs = 500,
162162
nonce = 1,
163-
metadata = mapOf("k" to "v"),
163+
metadata = mapOf("k" to JsonValue.string("v")),
164164
)
165165
assertEquals(a, b)
166166
assertEquals(a.hashCode(), b.hashCode())

kotlin/core-jvm/src/test/kotlin/org/hyperledger/iroha/sdk/tx/norito/AndroidFixtureSupport.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import kotlin.io.path.readText
77
import org.hyperledger.iroha.sdk.client.JsonParser
88
import org.hyperledger.iroha.sdk.core.model.Executable
99
import org.hyperledger.iroha.sdk.core.model.InstructionBox
10+
import org.hyperledger.iroha.sdk.core.model.JsonValue
1011
import org.hyperledger.iroha.sdk.core.model.TransactionPayload
1112

1213
internal data class TransactionPayloadFixture(
@@ -158,9 +159,9 @@ internal object AndroidFixtureSupport {
158159

159160
val metadata = payload["metadata"]?.let { raw ->
160161
asMap(raw, "$name.payload.metadata").mapValues { (_, value) ->
161-
value?.toString() ?: "null"
162+
JsonValue.string(value?.toString() ?: "null")
162163
}
163-
} ?: emptyMap()
164+
} ?: emptyMap<String, JsonValue>()
164165

165166
return TransactionPayload(
166167
chainId = requiredString(payload["chain"], "$name.payload.chain"),

kotlin/core-jvm/src/test/kotlin/org/hyperledger/iroha/sdk/tx/norito/NoritoJavaCodecAdapterParityTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import org.hyperledger.iroha.sdk.address.decodePublicKeyLiteral
1111
import org.hyperledger.iroha.sdk.address.encodePublicKeyMultihash
1212
import org.hyperledger.iroha.sdk.core.model.Executable
1313
import org.hyperledger.iroha.sdk.core.model.InstructionBox
14+
import org.hyperledger.iroha.sdk.core.model.JsonValue
1415
import org.hyperledger.iroha.sdk.core.model.TransactionPayload
1516
import org.hyperledger.iroha.sdk.core.model.WirePayload
1617
import org.hyperledger.iroha.sdk.norito.NoritoAdapters
@@ -43,7 +44,7 @@ class NoritoJavaCodecAdapterParityTest {
4344
executable = Executable.ivm(instructions),
4445
timeToLiveMs = 5_000L,
4546
nonce = 42,
46-
metadata = mapOf("purpose" to "unit-test"),
47+
metadata = mapOf("purpose" to JsonValue.string("unit-test")),
4748
)
4849

4950
val encoded = adapter.encodeTransaction(payload)

0 commit comments

Comments
 (0)