@reticulum/dacar 1.1.2 → 1.2.1
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.
- package/package.json +4 -3
- package/src/challenge.js +1 -1
- package/src/cli/dacar.js +204 -62
- package/src/cli/fileStore.js +166 -0
- package/src/cli/session.js +187 -20
- package/src/cli/store.js +421 -137
- package/src/crdt.js +2 -2
- package/src/operation.js +2 -2
- package/src/transport/lxmfSync.js +7 -7
- package/src/transport/rfedSync.js +93 -83
- package/src/tuple.js +1 -1
package/src/crdt.js
CHANGED
|
@@ -305,7 +305,7 @@ export class StateVector {
|
|
|
305
305
|
}
|
|
306
306
|
|
|
307
307
|
/**
|
|
308
|
-
* @param {
|
|
308
|
+
* @param {Uint8Array} value
|
|
309
309
|
* @param {number} len
|
|
310
310
|
* @param {string} name
|
|
311
311
|
* @returns {Uint8Array}
|
|
@@ -317,7 +317,7 @@ function expectBytes(value, len, name) {
|
|
|
317
317
|
return value;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
/** @param {
|
|
320
|
+
/** @param {boolean} value @param {string} name @returns {boolean} */
|
|
321
321
|
function expectBool(value, name) {
|
|
322
322
|
if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
|
|
323
323
|
return value;
|
package/src/operation.js
CHANGED
|
@@ -232,7 +232,7 @@ export class Operation {
|
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
/**
|
|
235
|
-
* @param {
|
|
235
|
+
* @param {Uint8Array} value
|
|
236
236
|
* @param {number} len
|
|
237
237
|
* @param {string} name
|
|
238
238
|
* @returns {Uint8Array}
|
|
@@ -245,7 +245,7 @@ function expectBytes(value, len, name) {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
* @param {
|
|
248
|
+
* @param {boolean} value
|
|
249
249
|
* @param {string} name
|
|
250
250
|
* @returns {boolean}
|
|
251
251
|
*/
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* never pulls it in. It depends only on `@reticulum/core`, which the core
|
|
19
19
|
* already depends on, so no new dependency is added.
|
|
20
20
|
*
|
|
21
|
-
* Typical use (receiver, wired to a {@link import("@reticulum/core").LXMRouter
|
|
21
|
+
* Typical use (receiver, wired to a {@link import("@reticulum/core/src/lxmf/index.js").LXMRouter
|
|
22
22
|
* LXMRouter}):
|
|
23
23
|
*
|
|
24
24
|
* ```js
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* await delivery.handleMessage(event.detail.message);
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import { LXMessage as LXMFMessage } from "@reticulum/core";
|
|
41
|
+
import { LXMessage as LXMFMessage } from "@reticulum/core/src/lxmf/index.js";
|
|
42
42
|
import { LXMF_DELIVERY_TITLE } from "../naming.js";
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -99,13 +99,13 @@ export class LxmfDeltaDelivery {
|
|
|
99
99
|
* @param {import("../delta.js").DeltaReceiver | null} [opts.receiver]
|
|
100
100
|
* The shared DeltaReceiver (state + key resolver). May be omitted on a
|
|
101
101
|
* send-only node (then `handleMessage` throws if called).
|
|
102
|
-
* @param {import("@reticulum/core").LXMRouter | null} [opts.router]
|
|
102
|
+
* @param {import("@reticulum/core/src/lxmf/index.js").LXMRouter | null} [opts.router]
|
|
103
103
|
* Optional bound `LXMRouter` for `deliver` / `ingestPaperUri`.
|
|
104
104
|
*/
|
|
105
105
|
constructor({ receiver = null, router = null } = {}) {
|
|
106
106
|
/** @type {import("../delta.js").DeltaReceiver | null} */
|
|
107
107
|
this._receiver = receiver;
|
|
108
|
-
/** @type {import("@reticulum/core").LXMRouter | null} */
|
|
108
|
+
/** @type {import("@reticulum/core/src/lxmf/index.js").LXMRouter | null} */
|
|
109
109
|
this._router = router;
|
|
110
110
|
}
|
|
111
111
|
|
|
@@ -121,7 +121,7 @@ export class LxmfDeltaDelivery {
|
|
|
121
121
|
* @param {Uint8Array} deltaPayload
|
|
122
122
|
* @param {Uint8Array} destinationHash The recipient `lxmf.delivery` hash.
|
|
123
123
|
* @param {Uint8Array} sourceHash The sender's `lxmf.delivery` hash.
|
|
124
|
-
* @returns {import("@reticulum/core").LXMessage}
|
|
124
|
+
* @returns {import("@reticulum/core/src/lxmf/index.js").LXMessage}
|
|
125
125
|
*/
|
|
126
126
|
makeMessage(deltaPayload, destinationHash, sourceHash) {
|
|
127
127
|
if (!(deltaPayload instanceof Uint8Array)) {
|
|
@@ -145,7 +145,7 @@ export class LxmfDeltaDelivery {
|
|
|
145
145
|
* @param {Uint8Array} destinationHash The recipient `lxmf.delivery` hash.
|
|
146
146
|
* @param {Object} [opts]
|
|
147
147
|
* @param {Uint8Array | null} [opts.linkId] Reuse an existing DIRECT link id.
|
|
148
|
-
* @returns {Promise<import("@reticulum/core").LXMessage>}
|
|
148
|
+
* @returns {Promise<import("@reticulum/core/src/lxmf/index.js").LXMessage>}
|
|
149
149
|
*/
|
|
150
150
|
async deliver(deltaPayload, destinationHash, { linkId = null } = {}) {
|
|
151
151
|
if (!this._router) {
|
|
@@ -210,7 +210,7 @@ export class LxmfDeltaDelivery {
|
|
|
210
210
|
* ingest result (the reconstructed message, or `null` if it was not for this
|
|
211
211
|
* node / already ingested).
|
|
212
212
|
* @param {string} uri
|
|
213
|
-
* @returns {Promise<import("@reticulum/core").LXMessage | null>}
|
|
213
|
+
* @returns {Promise<import("@reticulum/core/src/lxmf/index.js").LXMessage | null>}
|
|
214
214
|
*/
|
|
215
215
|
async ingestPaperUri(uri) {
|
|
216
216
|
if (!this._router) {
|
|
@@ -10,60 +10,80 @@
|
|
|
10
10
|
* delivery — **never** through the unauthenticated `StateVector.merge()` path.
|
|
11
11
|
* A forged or stale Delta is simply dropped before it can mutate state.
|
|
12
12
|
*
|
|
13
|
-
* `RfedDeltaSync` wraps a `@reticulum/core` `RFedClient`. A Delta
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `RfedDeltaSync` wraps a `@reticulum/core` `RFedClient`. A Delta travels in
|
|
14
|
+
* Dacar's **compact inner format** (§11.1.1): the raw §5.3 payload is placed
|
|
15
|
+
* straight after the RTID source-identity prelude and EC-encrypted to the
|
|
16
|
+
* derived channel identity — no LXMF envelope, which would only duplicate the
|
|
17
|
+
* Delta's own destination/source/signature/timestamp and push a typical
|
|
18
|
+
* 170-byte Delta past the 500-byte RNS MTU. On receipt the channel is the feed
|
|
19
|
+
* discriminator (every message on it is a Dacar Delta), and the recovered Delta
|
|
20
|
+
* bytes are fed to `DeltaReceiver`.
|
|
17
21
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
22
|
+
* The compact format is built on `@reticulum/core`'s raw RFed primitives —
|
|
23
|
+
* `subscribeRaw` / `publishRaw` / `unwrapRawChannelMessage` — which carry an
|
|
24
|
+
* arbitrary self-authenticating payload in place of the LXMF tail. The Delta's
|
|
25
|
+
* own Ed25519 signature (§5.3 field [7]) is the authenticity check at
|
|
26
|
+
* verify-on-ingest; the prelude's `sender_identity_pub` only identifies the
|
|
27
|
+
* transport sender.
|
|
28
|
+
*
|
|
29
|
+
* §11.2 targeted delivery and §11.3 air-gapped/optical transport still use full
|
|
30
|
+
* LXMF framing (`./lxmfSync.js`, Paper Messages); only the RFed broadcast
|
|
31
|
+
* channel uses the compact format.
|
|
20
32
|
*
|
|
21
33
|
* This module is part of the optional transport layer: importing the pure core
|
|
22
34
|
* never pulls it in. It depends only on `@reticulum/core`, which the core
|
|
23
|
-
* already depends on, so no new dependency is added.
|
|
35
|
+
* already depends on, so no new dependency is added. It mirrors
|
|
36
|
+
* `python/dacar/transport/rfed_sync.py` (the canonical implementation).
|
|
24
37
|
*
|
|
25
38
|
* Typical use:
|
|
26
39
|
*
|
|
27
40
|
* ```js
|
|
28
41
|
* const client = new RFedClient({ identity, rns });
|
|
29
42
|
* const sync = new RfedDeltaSync({ receiver: new DeltaReceiver(state, resolver), client });
|
|
30
|
-
* await sync.subscribe(nodeHash); // cache
|
|
31
|
-
* await sync.listen(); // receive live fanout Deltas
|
|
43
|
+
* await sync.subscribe(nodeHash); // subscribeRaw: cache stamp cost + mark raw
|
|
44
|
+
* await sync.listen(); // receive live fanout Deltas (kind: "raw")
|
|
32
45
|
* await sync.publish(deltaPayload, nodeHash);
|
|
33
46
|
* ```
|
|
34
47
|
*/
|
|
35
48
|
|
|
36
49
|
import {
|
|
37
|
-
LXMessage,
|
|
38
|
-
deliveryHashFor,
|
|
39
50
|
deriveChannel,
|
|
40
|
-
|
|
41
|
-
} from "@reticulum/core";
|
|
42
|
-
import {
|
|
43
|
-
import { messageContent } from "./lxmfSync.js";
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The shape of the decoded fanout callback argument from `RFedClient.listen`.
|
|
47
|
-
* Only `message` is consumed here.
|
|
48
|
-
* @typedef {Object} RfedDecoded
|
|
49
|
-
* @property {import("@reticulum/core").LXMessage} message
|
|
50
|
-
* @property {unknown} [senderIdentity]
|
|
51
|
-
* @property {Uint8Array} [senderPub]
|
|
52
|
-
* @property {Uint8Array} [sourceHash]
|
|
53
|
-
* @property {boolean} [signatureValid]
|
|
54
|
-
* @property {Uint8Array} [channelHash]
|
|
55
|
-
* @property {string | null} [channelName]
|
|
56
|
-
*/
|
|
51
|
+
unwrapRawChannelMessage,
|
|
52
|
+
} from "@reticulum/core/src/rfed/index.js";
|
|
53
|
+
import { RFED_TOPIC } from "../naming.js";
|
|
57
54
|
|
|
58
55
|
/**
|
|
59
56
|
* The minimal `RFedClient` surface this adapter relies on. The real client from
|
|
60
|
-
* `@reticulum/core` satisfies it; tests inject a fake.
|
|
57
|
+
* `@reticulum/core` satisfies it; tests inject a fake. The raw-publish API
|
|
58
|
+
* (`subscribeRaw` / `publishRaw`) carries a self-authenticating payload in the
|
|
59
|
+
* RTID prelude instead of an LXMF envelope (§11.1.1).
|
|
60
|
+
*
|
|
61
61
|
* @typedef {Object} RFedClientLike
|
|
62
|
-
* @property {(nodeHash: Uint8Array, channelName: string) => Promise<
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* @property {(nodeHash: Uint8Array, channelName: string) => Promise<{ ok: boolean, stampCost: number | null }>} subscribeRaw
|
|
63
|
+
* Subscribes and marks the channel raw so fanout is decoded via
|
|
64
|
+
* `unwrapRawChannelMessage` (not the LXMF path).
|
|
65
|
+
* @property {(nodeHash: Uint8Array, channelName: string) => Promise<{ ok: boolean }>} [unsubscribe]
|
|
66
|
+
* @property {(nodeHash: Uint8Array, channelName: string, payload: Uint8Array) => Promise<void>} publishRaw
|
|
67
|
+
* Fire-and-forget SEND of a raw application payload (wrapped in the RTID
|
|
68
|
+
* prelude + EC-encrypted to the channel identity by the client).
|
|
65
69
|
* @property {(nodeHash: Uint8Array, channelName: string) => Promise<{ items: Array<{ channelHash: Uint8Array, blob: Uint8Array }>, morePending: boolean }>} pull
|
|
66
|
-
* @property {(onMessage: (decoded:
|
|
70
|
+
* @property {(onMessage: (decoded: RfedDecodedRaw) => void) => Promise<Uint8Array>} listen
|
|
71
|
+
* Delivers a decoded fanout object; channels subscribed via `subscribeRaw`
|
|
72
|
+
* carry `kind: "raw"` with a `payload` field (the unwrapped application
|
|
73
|
+
* bytes). The client performs the EC-decrypt + RTID-prelude unwrap.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The shape of the decoded fanout callback argument from `RFedClient.listen` for
|
|
78
|
+
* a raw channel. Only `kind` and `payload` are consumed here.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {Object} RfedDecodedRaw
|
|
81
|
+
* @property {"raw"|"lxmf"} kind
|
|
82
|
+
* @property {Uint8Array} [payload] Raw application payload (`kind === "raw"`).
|
|
83
|
+
* @property {import("@reticulum/core/src/core/identity.js").Identity} [senderIdentity]
|
|
84
|
+
* @property {Uint8Array} [senderPub]
|
|
85
|
+
* @property {Uint8Array} [channelHash]
|
|
86
|
+
* @property {string} [channelName]
|
|
67
87
|
*/
|
|
68
88
|
|
|
69
89
|
/**
|
|
@@ -97,42 +117,25 @@ export class RfedDeltaSync {
|
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
/**
|
|
100
|
-
*
|
|
120
|
+
* Subscribes to the channel on a node (raw mode) and caches its advertised
|
|
121
|
+
* stamp cost. Call at least once per session and after any publish seems
|
|
122
|
+
* dropped.
|
|
101
123
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* @param {Uint8Array} deltaPayload
|
|
107
|
-
* @returns {import("@reticulum/core").LXMessage}
|
|
108
|
-
*/
|
|
109
|
-
makeMessage(deltaPayload) {
|
|
110
|
-
if (!(deltaPayload instanceof Uint8Array)) {
|
|
111
|
-
throw new TypeError("deltaPayload must be a Uint8Array");
|
|
112
|
-
}
|
|
113
|
-
return new LXMessage({
|
|
114
|
-
// Overwritten by the rfed codec before going on the wire.
|
|
115
|
-
destinationHash: new Uint8Array(16),
|
|
116
|
-
sourceHash: new Uint8Array(16),
|
|
117
|
-
content: new Uint8Array(deltaPayload),
|
|
118
|
-
title: LXMF_DELIVERY_TITLE,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Subscribes to the channel on a node, caching its advertised stamp cost.
|
|
124
|
-
* Call at least once per session and after any publish seems dropped.
|
|
124
|
+
* Uses `subscribeRaw` so incoming fanout is decoded via
|
|
125
|
+
* `unwrapRawChannelMessage` (the Dacar compact inner format), not the LXMF
|
|
126
|
+
* path. The wire protocol is identical to `subscribe` — the node never
|
|
127
|
+
* inspects the `inner_blob` — only this client's local decode changes.
|
|
125
128
|
* @param {Uint8Array} nodeHash Any `rfed.*` destination hash of the node.
|
|
126
|
-
* @returns {Promise<
|
|
129
|
+
* @returns {Promise<{ ok: boolean, stampCost: number | null }>}
|
|
127
130
|
*/
|
|
128
131
|
async subscribe(nodeHash) {
|
|
129
|
-
return this._client.
|
|
132
|
+
return this._client.subscribeRaw(nodeHash, this._topic);
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/**
|
|
133
136
|
* Removes the subscription.
|
|
134
137
|
* @param {Uint8Array} nodeHash
|
|
135
|
-
* @returns {Promise<
|
|
138
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
136
139
|
*/
|
|
137
140
|
async unsubscribe(nodeHash) {
|
|
138
141
|
if (!this._client.unsubscribe) {
|
|
@@ -144,26 +147,34 @@ export class RfedDeltaSync {
|
|
|
144
147
|
/**
|
|
145
148
|
* Publishes a Delta to the channel (fire-and-forget, §11.1).
|
|
146
149
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
150
|
+
* The client wraps the raw Delta in the RTID prelude + EC-encrypts it to the
|
|
151
|
+
* channel identity (`publishRaw` → `wrapRawChannelMessage`) and sends it as a
|
|
152
|
+
* fire-and-forget DATA packet. Call {@link subscribe} first so the channel's
|
|
153
|
+
* stamp cost is cached; an unstamped publish may be silently dropped by a
|
|
154
|
+
* cost-enforcing node. Returns `true` if the transport accepted the outbound
|
|
155
|
+
* packet (no throw) — transport acceptance ≠ node storage (fire-and-forget).
|
|
149
156
|
* @param {Uint8Array} deltaPayload
|
|
150
157
|
* @param {Uint8Array} nodeHash
|
|
151
|
-
* @returns {Promise<
|
|
158
|
+
* @returns {Promise<boolean>}
|
|
152
159
|
*/
|
|
153
160
|
async publish(deltaPayload, nodeHash) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
if (!(deltaPayload instanceof Uint8Array)) {
|
|
162
|
+
throw new TypeError("deltaPayload must be a Uint8Array");
|
|
163
|
+
}
|
|
164
|
+
await this._client.publishRaw(nodeHash, this._topic, new Uint8Array(deltaPayload));
|
|
165
|
+
return true; // fire-and-forget: no throw ⇒ transport accepted the packet
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
/**
|
|
160
169
|
* Starts listening for live fanout Deltas and routes each through
|
|
161
|
-
* verify-on-ingest (§11.1, §11.2
|
|
170
|
+
* verify-on-ingest (§11.1, §11.2).
|
|
162
171
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
172
|
+
* Because the channel was subscribed via `subscribeRaw`, the client decodes
|
|
173
|
+
* each fanout delivery with `unwrapRawChannelMessage` and the callback
|
|
174
|
+
* receives `{ kind: "raw", payload }`. The `payload` is the carried §5.3
|
|
175
|
+
* Delta, fed to `DeltaReceiver.applyPayload()`, which authenticates it by
|
|
176
|
+
* signature and swallows any malformed/forged payload so a bad message can
|
|
177
|
+
* never crash the transport or mutate state.
|
|
167
178
|
* @returns {Promise<Uint8Array>} The local `rfed.delivery` destination hash.
|
|
168
179
|
*/
|
|
169
180
|
async listen() {
|
|
@@ -172,11 +183,10 @@ export class RfedDeltaSync {
|
|
|
172
183
|
}
|
|
173
184
|
const receiver = this._receiver;
|
|
174
185
|
return this._client.listen((decoded) => {
|
|
186
|
+
if (decoded?.kind !== "raw") return; // not a raw channel payload
|
|
175
187
|
// RFedClient.invoke does not await the callback; run applyPayload without
|
|
176
188
|
// leaving an unhandled rejection (it swallows malformed payloads itself).
|
|
177
|
-
Promise.resolve(receiver.applyPayload(
|
|
178
|
-
() => {},
|
|
179
|
-
);
|
|
189
|
+
Promise.resolve(receiver.applyPayload(decoded.payload)).catch(() => {});
|
|
180
190
|
});
|
|
181
191
|
}
|
|
182
192
|
|
|
@@ -184,10 +194,12 @@ export class RfedDeltaSync {
|
|
|
184
194
|
* Drains the node's deferred queue (offline catch-up) and routes each blob
|
|
185
195
|
* through verify-on-ingest (§11.1).
|
|
186
196
|
*
|
|
187
|
-
* Each blob is EC-
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
197
|
+
* Each blob is the EC-encrypted `inner_blob` (the node serves it verbatim, as
|
|
198
|
+
* stored); it is EC-decrypted with the derived channel identity and the
|
|
199
|
+
* recovered Dacar Delta (`unwrapRawChannelMessage`) is applied. Foreign/
|
|
200
|
+
* undecryptable blobs are dropped, not fatal. Repeats until the node reports
|
|
201
|
+
* no more pending pages. Returns the count of Deltas newly applied to the
|
|
202
|
+
* CRDT.
|
|
191
203
|
*
|
|
192
204
|
* > **Assumption:** the node serves each deferred entry's `blob` as the rfed
|
|
193
205
|
* > `inner_blob` (the EC-encrypted channel message), matching the fanout
|
|
@@ -201,7 +213,6 @@ export class RfedDeltaSync {
|
|
|
201
213
|
throw new Error("RfedDeltaSync.pull requires a receiver");
|
|
202
214
|
}
|
|
203
215
|
const { identity: channelIdentity } = await deriveChannel(this._topic);
|
|
204
|
-
const channelDeliveryHash = await deliveryHashFor(channelIdentity);
|
|
205
216
|
const receiver = this._receiver;
|
|
206
217
|
let applied = 0;
|
|
207
218
|
let morePending = true;
|
|
@@ -209,19 +220,18 @@ export class RfedDeltaSync {
|
|
|
209
220
|
const page = await this._client.pull(nodeHash, this._topic);
|
|
210
221
|
for (const item of page.items) {
|
|
211
222
|
try {
|
|
212
|
-
const decoded = await
|
|
223
|
+
const decoded = await unwrapRawChannelMessage({
|
|
213
224
|
innerBlob: item.blob,
|
|
214
225
|
channelIdentity,
|
|
215
|
-
channelDeliveryHash,
|
|
216
226
|
});
|
|
217
|
-
if (await receiver.applyPayload(
|
|
227
|
+
if (await receiver.applyPayload(decoded.payload)) {
|
|
218
228
|
applied++;
|
|
219
229
|
}
|
|
220
230
|
} catch {
|
|
221
231
|
// a foreign/undecryptable blob is dropped, never fatal
|
|
222
232
|
}
|
|
223
233
|
}
|
|
224
|
-
morePending = page.morePending;
|
|
234
|
+
morePending = !!page.morePending;
|
|
225
235
|
}
|
|
226
236
|
return applied;
|
|
227
237
|
}
|
package/src/tuple.js
CHANGED
|
@@ -105,7 +105,7 @@ export class Tuple {
|
|
|
105
105
|
return toHex(this.preimage);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** Structural equality with another Tuple. @param {
|
|
108
|
+
/** Structural equality with another Tuple. @param {Tuple} other @returns {boolean} */
|
|
109
109
|
equals(other) {
|
|
110
110
|
if (!(other instanceof Tuple)) return false;
|
|
111
111
|
return (
|