@freedomofpress/cometbft 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { importCommit } from "../commit";
4
+ import { base64ToUint8Array, Uint8ArrayToBase64 } from "../encoding";
5
+ import type { CommitJson } from "../types";
6
+ import commitFixture from "./fixtures/commit-12.json";
7
+
8
+ // Deep-clone plain JSON-like objects
9
+ function clone<T>(x: T): T {
10
+ return JSON.parse(JSON.stringify(x));
11
+ }
12
+
13
+ describe("importCommit: happy path (fixture)", () => {
14
+ it("parses a valid signed_header into SignedHeader (ts-proto, useDate=false)", () => {
15
+ const resp = commitFixture as unknown as CommitJson;
16
+
17
+ const sh = importCommit(resp);
18
+
19
+ // header basics
20
+ expect(sh.header!.chainId).toBe("chain-ORcSeX");
21
+ expect(sh.header!.height).toBe(12n);
22
+ expect(sh.header!.version?.block).toBe(11n);
23
+ expect(sh.header!.version?.app).toBe(1n);
24
+ expect(sh.header!.time).toEqual({
25
+ seconds: expect.any(BigInt),
26
+ nanos: expect.any(Number),
27
+ });
28
+
29
+ // 32-byte hashes (app hash can be any length; in the fixture it's 8 bytes)
30
+ expect(sh.header!.lastCommitHash.length).toBe(32);
31
+ expect(sh.header!.dataHash.length).toBe(32);
32
+ expect(sh.header!.validatorsHash.length).toBe(32);
33
+ expect(sh.header!.nextValidatorsHash.length).toBe(32);
34
+ expect(sh.header!.consensusHash.length).toBe(32);
35
+ expect(sh.header!.appHash.length).toBe(8);
36
+ expect(sh.header!.lastResultsHash.length).toBe(32);
37
+ expect(sh.header!.evidenceHash.length).toBe(32);
38
+
39
+ // proposer 20 bytes
40
+ expect(sh.header!.proposerAddress.length).toBe(20);
41
+
42
+ // last_block_id + commit.block_id parts
43
+ expect(sh.header!.lastBlockId!.hash.length).toBe(32);
44
+ expect(sh.header!.lastBlockId!.partSetHeader!.hash.length).toBe(32);
45
+ expect(typeof sh.header!.lastBlockId!.partSetHeader!.total).toBe("number");
46
+
47
+ expect(sh.commit!.height).toBe(12n);
48
+ expect(sh.commit!.round).toBe(0);
49
+ expect(sh.commit!.blockId!.hash.length).toBe(32);
50
+ expect(sh.commit!.blockId!.partSetHeader!.hash.length).toBe(32);
51
+ expect(sh.commit!.signatures.length).toBeGreaterThan(0);
52
+
53
+ for (const s of sh.commit!.signatures) {
54
+ expect(typeof s.blockIdFlag).toBe("number");
55
+ expect(s.validatorAddress.length).toBe(20);
56
+ expect(s.signature.length).toBe(64); // fixture has present signatures
57
+ expect(s.timestamp).toEqual({
58
+ seconds: expect.any(BigInt),
59
+ nanos: expect.any(Number),
60
+ });
61
+ }
62
+ });
63
+ });
64
+
65
+ describe("importCommit: validation errors", () => {
66
+ it("fails on missing signed_header", () => {
67
+ const bad = {} as unknown as CommitJson;
68
+ expect(() => importCommit(bad)).toThrow(/Missing signed_header/);
69
+ });
70
+
71
+ it("fails on missing header", () => {
72
+ const bad = clone(commitFixture) as any;
73
+ delete bad.signed_header.header;
74
+ expect(() => importCommit(bad)).toThrow(/Missing header/);
75
+ });
76
+
77
+ it("fails on missing commit", () => {
78
+ const bad = clone(commitFixture) as any;
79
+ delete bad.signed_header.commit;
80
+ expect(() => importCommit(bad)).toThrow(/Missing commit/);
81
+ });
82
+
83
+ it("fails on missing header.height", () => {
84
+ const bad = clone(commitFixture) as any;
85
+ delete bad.signed_header.header.height;
86
+ expect(() => importCommit(bad)).toThrow(/Missing header\.height/);
87
+ });
88
+
89
+ it("fails on missing commit.height", () => {
90
+ const bad = clone(commitFixture) as any;
91
+ bad.signed_header.commit.height = "";
92
+ expect(() => importCommit(bad)).toThrow(/Missing commit\.height/);
93
+ });
94
+
95
+ it("fails on mismatched header/commit heights", () => {
96
+ const bad = clone(commitFixture) as any;
97
+ bad.signed_header.header.height = "13";
98
+ expect(() => importCommit(bad)).toThrow(/height mismatch/);
99
+ });
100
+
101
+ it("fails on invalid commit.round (negative / non-integer)", () => {
102
+ const bad1 = clone(commitFixture) as any;
103
+ bad1.signed_header.commit.round = -1;
104
+ expect(() => importCommit(bad1)).toThrow(/Invalid commit\.round/);
105
+
106
+ const bad2 = clone(commitFixture) as any;
107
+ bad2.signed_header.commit.round = 0.5;
108
+ expect(() => importCommit(bad2)).toThrow(/Invalid commit\.round/);
109
+ });
110
+
111
+ it("fails on invalid last_block_id (missing fields)", () => {
112
+ const bad = clone(commitFixture) as any;
113
+ delete bad.signed_header.header.last_block_id.parts;
114
+ expect(() => importCommit(bad)).toThrow(/Invalid last_block_id/);
115
+ });
116
+
117
+ it("fails on bad last_block_id.parts.total (negative / non-integer)", () => {
118
+ const bad1 = clone(commitFixture) as any;
119
+ bad1.signed_header.header.last_block_id.parts.total = -1;
120
+ expect(() => importCommit(bad1)).toThrow(
121
+ /Invalid last_block_id\.parts\.total/,
122
+ );
123
+
124
+ const bad2 = clone(commitFixture) as any;
125
+ bad2.signed_header.header.last_block_id.parts.total = 1.2;
126
+ expect(() => importCommit(bad2)).toThrow(
127
+ /Invalid last_block_id\.parts\.total/,
128
+ );
129
+ });
130
+
131
+ it("fails on malformed header hash lengths (32 bytes expected)", () => {
132
+ const fields = [
133
+ "last_commit_hash",
134
+ "data_hash",
135
+ "validators_hash",
136
+ "next_validators_hash",
137
+ "consensus_hash",
138
+ "last_results_hash",
139
+ "evidence_hash",
140
+ ] as const;
141
+
142
+ for (const f of fields) {
143
+ const bad = clone(commitFixture) as any;
144
+ // Make hex too short (trim 2 chars -> 31 bytes)
145
+ bad.signed_header.header[f] = bad.signed_header.header[f].slice(0, -2);
146
+ expect(() => importCommit(bad)).toThrow(
147
+ new RegExp(`${f.replace(/_/g, "\\_")}`),
148
+ );
149
+ }
150
+ });
151
+
152
+ it("fails on missing header.time", () => {
153
+ const bad = clone(commitFixture) as any;
154
+ delete bad.signed_header.header.time;
155
+ expect(() => importCommit(bad)).toThrow(/Missing header\.time/);
156
+ });
157
+
158
+ it("fails on invalid header.time (not RFC3339)", () => {
159
+ const bad = clone(commitFixture) as any;
160
+ bad.signed_header.header.time = "not-a-time";
161
+ expect(() => importCommit(bad)).toThrow(/Invalid RFC3339 time/);
162
+ });
163
+
164
+ it("fails on missing proposer_address", () => {
165
+ const bad = clone(commitFixture) as any;
166
+ delete bad.signed_header.header.proposer_address;
167
+ expect(() => importCommit(bad)).toThrow(/Missing proposer_address/);
168
+ });
169
+
170
+ it("fails on proposer_address wrong length", () => {
171
+ const bad = clone(commitFixture) as any;
172
+ bad.signed_header.header.proposer_address =
173
+ bad.signed_header.header.proposer_address.slice(0, 38); // 19 bytes
174
+ expect(() => importCommit(bad)).toThrow(
175
+ /proposer_address must be 20 bytes/,
176
+ );
177
+ });
178
+
179
+ it("fails on invalid commit.block_id (missing parts)", () => {
180
+ const bad = clone(commitFixture) as any;
181
+ delete bad.signed_header.commit.block_id.parts;
182
+ expect(() => importCommit(bad)).toThrow(/Invalid commit\.block_id/);
183
+ });
184
+
185
+ it("fails on bad commit.block_id.parts.total", () => {
186
+ const bad = clone(commitFixture) as any;
187
+ bad.signed_header.commit.block_id.parts.total = -1;
188
+ expect(() => importCommit(bad)).toThrow(
189
+ /Invalid commit\.block_id\.parts\.total/,
190
+ );
191
+ });
192
+
193
+ it("fails when commit has no signatures", () => {
194
+ const bad = clone(commitFixture) as any;
195
+ bad.signed_header.commit.signatures = [];
196
+ expect(() => importCommit(bad)).toThrow(/Commit has no signatures/);
197
+ });
198
+
199
+ it("fails on invalid signatures[*].block_id_flag type", () => {
200
+ const bad = clone(commitFixture) as any;
201
+ bad.signed_header.commit.signatures[0].block_id_flag = "2";
202
+ expect(() => importCommit(bad)).toThrow(/block_id_flag must be a number/);
203
+ });
204
+
205
+ it("fails on missing validator_address", () => {
206
+ const bad = clone(commitFixture) as any;
207
+ delete bad.signed_header.commit.signatures[0].validator_address;
208
+ expect(() => importCommit(bad)).toThrow(/validator_address missing/);
209
+ });
210
+
211
+ it("fails on short validator_address (hex < 40 chars)", () => {
212
+ const bad = clone(commitFixture) as any;
213
+ bad.signed_header.commit.signatures[0].validator_address =
214
+ bad.signed_header.commit.signatures[0].validator_address.slice(0, 38);
215
+ expect(() => importCommit(bad)).toThrow(/validator_address.*20 bytes/);
216
+ });
217
+
218
+ it("defaults header.version.{block,app} to 0n when version is missing", () => {
219
+ const resp = clone(commitFixture) as any;
220
+ delete resp.signed_header.header.version;
221
+
222
+ const sh = importCommit(resp as CommitJson);
223
+
224
+ expect(sh.header!.version?.block).toBe(0n);
225
+ expect(sh.header!.version?.app).toBe(0n);
226
+ });
227
+
228
+ it("parses header.time without fractional seconds (nanos=0)", () => {
229
+ const resp = clone(commitFixture) as any;
230
+ resp.signed_header.header.time = "2025-08-18T13:39:10Z"; // no .fraction
231
+
232
+ const sh = importCommit(resp as CommitJson);
233
+
234
+ expect(sh.header!.time?.nanos).toBe(0);
235
+ expect(typeof sh.header!.time?.seconds).toBe("bigint");
236
+ });
237
+
238
+ it("allows a signature with no timestamp (kept undefined)", () => {
239
+ const resp = clone(commitFixture) as any;
240
+ delete resp.signed_header.commit.signatures[0].timestamp;
241
+
242
+ const sh = importCommit(resp as CommitJson);
243
+
244
+ expect(sh.commit!.signatures[0].timestamp).toBeUndefined();
245
+ });
246
+
247
+ it("allows missing signature (proto3 bytes -> empty) and sets length to 0", () => {
248
+ const good = clone(commitFixture) as any;
249
+ delete good.signed_header.commit.signatures[0].signature;
250
+
251
+ const sh = importCommit(good as CommitJson);
252
+ expect(sh.commit!.signatures[0].signature).toBeInstanceOf(Uint8Array);
253
+ expect(sh.commit!.signatures[0].signature.length).toBe(0);
254
+ });
255
+
256
+ it("fails on wrong signature length (not 64 bytes) without Buffer", () => {
257
+ const bad = clone(commitFixture) as any;
258
+ const sigB64: string = bad.signed_header.commit.signatures[0].signature;
259
+
260
+ const sigBytes = base64ToUint8Array(sigB64);
261
+ const shorter = sigBytes.slice(0, 63);
262
+ bad.signed_header.commit.signatures[0].signature =
263
+ Uint8ArrayToBase64(shorter);
264
+
265
+ expect(() => importCommit(bad)).toThrow(/signature.*64 bytes/);
266
+ });
267
+
268
+ it("parses timestamps with fractional seconds (nano padding/truncation)", () => {
269
+ const withFrac = clone(commitFixture) as any;
270
+ withFrac.signed_header.header.time = "2025-08-18T13:39:10.618857123Z";
271
+ withFrac.signed_header.commit.signatures[0].timestamp =
272
+ "2025-08-18T13:39:11.9Z";
273
+
274
+ const sh = importCommit(withFrac as CommitJson);
275
+ expect(sh.header!.time?.nanos).toBe(618857123);
276
+ expect(sh.commit!.signatures[0].timestamp?.nanos).toBe(900000000);
277
+ });
278
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ hexToUint8Array,
5
+ Uint8ArrayToBase64,
6
+ Uint8ArrayToHex,
7
+ } from "../encoding";
8
+
9
+ describe("encoding", () => {
10
+ it("converts Uint8Array to Base64", () => {
11
+ const input = new Uint8Array([72, 101, 108, 108, 111]);
12
+ const result = Uint8ArrayToBase64(input);
13
+ expect(result).toBe("SGVsbG8=");
14
+ });
15
+
16
+ it("converts ArrayBuffer to Base64", () => {
17
+ const buffer = new TextEncoder().encode("Hi").buffer;
18
+ const result = Uint8ArrayToBase64(buffer);
19
+ expect(result).toBe("SGk=");
20
+ });
21
+
22
+ it("parses valid hex string", () => {
23
+ const hex = "48656c6c6f";
24
+ const uint8 = hexToUint8Array(hex);
25
+ expect([...uint8]).toEqual([72, 101, 108, 108, 111]);
26
+ });
27
+
28
+ it("throws on invalid characters", () => {
29
+ expect(() => hexToUint8Array("zz12")).toThrow(
30
+ "Hex string contains invalid characters",
31
+ );
32
+ });
33
+
34
+ it("throws on odd-length hex string", () => {
35
+ expect(() => hexToUint8Array("abc")).toThrow(
36
+ "Hex string must have an even length",
37
+ );
38
+ });
39
+
40
+ it("encodes Uint8Array to hex string", () => {
41
+ const input = new Uint8Array([72, 101, 108, 108, 111]);
42
+ const hex = Uint8ArrayToHex(input);
43
+ expect(hex).toBe("48656c6c6f");
44
+ });
45
+ });
@@ -0,0 +1,64 @@
1
+ {
2
+ "signed_header": {
3
+ "header": {
4
+ "version": { "block": "11", "app": "1" },
5
+ "chain_id": "chain-ORcSeX",
6
+ "height": "12",
7
+ "time": "2025-08-18T13:39:10.618857Z",
8
+ "last_block_id": {
9
+ "hash": "980DF9A358F01668EB6F289D223065C8A496A56411C11842FF89677AE427C3DF",
10
+ "parts": {
11
+ "total": 1,
12
+ "hash": "4CEBEE9DBC12129AED6B8D834FC7C5CED8F519C10EF6DE7A46376631A91E3FA6"
13
+ }
14
+ },
15
+ "last_commit_hash": "B3FAAB00E85339EED6ADE15BDCB136C3994F536ADF3BB6C45FAB3CD9F8E40B2A",
16
+ "data_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
17
+ "validators_hash": "3340D40CE35BC4A025795BBE753A1A1E0026D0904326DD20438ED0BE7A73EE94",
18
+ "next_validators_hash": "3340D40CE35BC4A025795BBE753A1A1E0026D0904326DD20438ED0BE7A73EE94",
19
+ "consensus_hash": "68ECD6F333119CE43751ECE583B981F23508AEAF4221FF582B1BB33BE42BCEFA",
20
+ "app_hash": "0600000000000000",
21
+ "last_results_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
22
+ "evidence_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
23
+ "proposer_address": "CCF25785244316D4A5EAFC187E0EAA36C5665599"
24
+ },
25
+ "commit": {
26
+ "height": "12",
27
+ "round": 0,
28
+ "block_id": {
29
+ "hash": "93232036CF95FF1890654092D560742F9305DD85F4FD80B3DFA71A1FF3A9B6D6",
30
+ "parts": {
31
+ "total": 1,
32
+ "hash": "08DAFA6B9653A474F6D9C18302B904C15BF1D72FD4B1B72B6390D55AC0CCB3E8"
33
+ }
34
+ },
35
+ "signatures": [
36
+ {
37
+ "block_id_flag": 2,
38
+ "validator_address": "4DBA7206E6BF35AFAEE7AEF523D450A4B10DC955",
39
+ "timestamp": "2025-08-18T13:39:11.924425Z",
40
+ "signature": "mLNmsIOnQgD/dIg5To83ig94MhgrUqGVWzFvmPWKv5A9kFDzhp7dAvn1FGSeS9yjA12WFc7oV9dNZk9+IxeNDg=="
41
+ },
42
+ {
43
+ "block_id_flag": 2,
44
+ "validator_address": "9A9BC61D31EF1673F6975AA7ACE356E7B6BE2412",
45
+ "timestamp": "2025-08-18T13:39:11.920418Z",
46
+ "signature": "/zzgiepmyjyASMZMsKmp1xdSmOnin0mW3g6irkU7DJX52oFuI9d3WNDX4VUzOccKrLS/vwLLiELOQBdcnAAVAg=="
47
+ },
48
+ {
49
+ "block_id_flag": 2,
50
+ "validator_address": "CAD8E57D78ABDFF7DF23C81C330335430EE97808",
51
+ "timestamp": "2025-08-18T13:39:11.916389Z",
52
+ "signature": "p1sbdl3iEH5RG/HhroyGqx3+p4VV42aNpcsEme31dq0rlLd4F8RHnmcsIbtqLCIs2L8MzlVEebuCVJheVJeqCg=="
53
+ },
54
+ {
55
+ "block_id_flag": 2,
56
+ "validator_address": "CCF25785244316D4A5EAFC187E0EAA36C5665599",
57
+ "timestamp": "2025-08-18T13:39:11.92732Z",
58
+ "signature": "aW7m0l/5yX2B9DeJ5qcuWdGoOp7Crj6Nwj6WbQA24B4NRgaY1PmBzDNr4SDSGOJUu4HnnVtm0zp6qNNm8JaNAA=="
59
+ }
60
+ ]
61
+ }
62
+ },
63
+ "canonical": true
64
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "block_height": "12",
3
+ "validators": [
4
+ {
5
+ "address": "4DBA7206E6BF35AFAEE7AEF523D450A4B10DC955",
6
+ "pub_key": {
7
+ "type": "tendermint/PubKeyEd25519",
8
+ "value": "BBqAcUZ6IPWzOZyUOHVK95ihP7s0McuRaYjbn6CqWfk="
9
+ },
10
+ "voting_power": "1",
11
+ "proposer_priority": "0"
12
+ },
13
+ {
14
+ "address": "9A9BC61D31EF1673F6975AA7ACE356E7B6BE2412",
15
+ "pub_key": {
16
+ "type": "tendermint/PubKeyEd25519",
17
+ "value": "/nX4LxZwh87dNEYD9t2cw8+HHwbwuBmq34Q7z7tvqEc="
18
+ },
19
+ "voting_power": "1",
20
+ "proposer_priority": "0"
21
+ },
22
+ {
23
+ "address": "CAD8E57D78ABDFF7DF23C81C330335430EE97808",
24
+ "pub_key": {
25
+ "type": "tendermint/PubKeyEd25519",
26
+ "value": "w3pPpAwVxmK0oTHeAmQFk8JviQAJUn7CGo7vAHtMqfg="
27
+ },
28
+ "voting_power": "1",
29
+ "proposer_priority": "0"
30
+ },
31
+ {
32
+ "address": "CCF25785244316D4A5EAFC187E0EAA36C5665599",
33
+ "pub_key": {
34
+ "type": "tendermint/PubKeyEd25519",
35
+ "value": "DZY3DLVKC8ZML3I/kuqMUaoXyAj+UGXskuWlDMAcnC0="
36
+ },
37
+ "voting_power": "1",
38
+ "proposer_priority": "0"
39
+ }
40
+ ]
41
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "signed_header": {
3
+ "header": {
4
+ "version": {
5
+ "block": "11",
6
+ "app": "0"
7
+ },
8
+ "chain_id": "test-chain-hGjZNv",
9
+ "height": "493",
10
+ "time": "2025-11-26T21:34:06.159025Z",
11
+ "last_block_id": {
12
+ "hash": "6ABE9483553796549941230EFFC982A4B389FE475D37C43815E6B735E5AEDE7B",
13
+ "parts": {
14
+ "total": 1,
15
+ "hash": "FA7002E34B341CC2625B2A2EB546ED9A1D2B77D1146A5650D9D2E3682940EE29"
16
+ }
17
+ },
18
+ "last_commit_hash": "C7B78BBAF834E298EB6570836DAA9116A56BED2F652D8BAF6C28B1F33ABC8AE8",
19
+ "data_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
20
+ "validators_hash": "04C72627103FD466C7B4C30C0E654D2B58EA140493C009B8BAB66749FFEBE60C",
21
+ "next_validators_hash": "04C72627103FD466C7B4C30C0E654D2B58EA140493C009B8BAB66749FFEBE60C",
22
+ "consensus_hash": "048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F",
23
+ "app_hash": "180B4624DAB5D631EAD41DC7BEC0F63D2A25DF826B068604B7D858206ED92DD6",
24
+ "last_results_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
25
+ "evidence_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
26
+ "proposer_address": "F588CF8C0CA8D88CDD2F909C8262018B361CF5D5"
27
+ },
28
+ "commit": {
29
+ "height": "493",
30
+ "round": 0,
31
+ "block_id": {
32
+ "hash": "2A1D00CC2A092465E85EA2C24986BEE0105285039DC1873BB6B0CA7F610EC89D",
33
+ "parts": {
34
+ "total": 1,
35
+ "hash": "379E62CC2B2741AD07E1F325307BBDECDAA10F2405A22C9B9350615A5DAF2725"
36
+ }
37
+ },
38
+ "signatures": [
39
+ {
40
+ "block_id_flag": 2,
41
+ "validator_address": "F588CF8C0CA8D88CDD2F909C8262018B361CF5D5",
42
+ "timestamp": "2025-11-26T21:34:07.1855Z",
43
+ "signature": "ttukjhivwXmf9YKEFSceZe0b6D98esb+9nMCeEz6e1z5MDfxMKun7O0nPis0PoDWcqBifWJy2NdbfO0zySILDQ=="
44
+ }
45
+ ]
46
+ }
47
+ },
48
+ "validator_set": {
49
+ "validators": [
50
+ {
51
+ "address": "F588CF8C0CA8D88CDD2F909C8262018B361CF5D5",
52
+ "pub_key": {
53
+ "type": "tendermint/PubKeyEd25519",
54
+ "value": "Pn7hPwDPYkoBBTKxdxL0QZLdPCupGXyds/qSyOIBSZg="
55
+ },
56
+ "power": "10",
57
+ "name": null
58
+ }
59
+ ],
60
+ "proposer": {
61
+ "address": "F588CF8C0CA8D88CDD2F909C8262018B361CF5D5",
62
+ "pub_key": {
63
+ "type": "tendermint/PubKeyEd25519",
64
+ "value": "Pn7hPwDPYkoBBTKxdxL0QZLdPCupGXyds/qSyOIBSZg="
65
+ },
66
+ "power": "10",
67
+ "name": null
68
+ },
69
+ "total_voting_power": "10"
70
+ }
71
+ }