libp2r2p 0.11.36 → 0.12.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/README.md +96 -1
- package/THIRD_PARTY_NOTICES.md +46 -0
- package/event/helpers/core.js +120 -0
- package/event/helpers/serialize.js +56 -0
- package/event/index.js +81 -74
- package/event/js/index.js +13 -0
- package/helpers/secp256k1-wasm/backend.js +98 -0
- package/helpers/secp256k1-wasm/binding.js +229 -0
- package/helpers/secp256k1-wasm/generated.js +25 -0
- package/key/helpers/core.js +149 -0
- package/key/index.js +57 -123
- package/key/js/index.js +13 -0
- package/package.json +7 -1
- package/private-channel/helpers/prepared.js +12 -0
- package/private-channel/index.js +271 -32
- package/private-message/index.js +44 -23
- package/private-messenger/file/index.js +4 -2
- package/private-messenger/helpers/fallback-race.js +13 -2
- package/private-messenger/helpers/send-routing.js +2 -1
- package/private-messenger/index.js +30 -15
- package/private-messenger/session/README.md +17 -3
- package/private-messenger/session/helpers/prepared-state.js +17 -0
- package/private-messenger/session/helpers/publication-journal.js +38 -10
- package/private-messenger/session/index.js +21 -9
- package/relay/services/relay-pool.js +127 -58
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js'
|
|
2
|
+
|
|
3
|
+
import { bytesToHex, hexToBytes } from '../../base16/index.js'
|
|
4
|
+
import { ValidationError } from '../../error/index.js'
|
|
5
|
+
import { finalizeEvent } from '../../event/js/index.js'
|
|
6
|
+
import { nsecDecode, nsecEncode, npubDecode, npubEncode } from '../../nip19/index.js'
|
|
7
|
+
|
|
8
|
+
const HEX_SECKEY_REGEX = /^[0-9a-f]{64}$/i
|
|
9
|
+
|
|
10
|
+
export function validateSecretKey (secretKey) {
|
|
11
|
+
if (!(secretKey instanceof Uint8Array) || secretKey.length !== 32 || !secp256k1.utils.isValidSecretKey(secretKey)) {
|
|
12
|
+
throw new ValidationError('INVALID_SECRET_KEY')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function generateSecretKey () {
|
|
17
|
+
return schnorr.utils.randomSecretKey()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getPublicKey (secretKey) {
|
|
21
|
+
validateSecretKey(secretKey)
|
|
22
|
+
return bytesToHex(schnorr.getPublicKey(secretKey))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function keypairFromSecretKey (secretKey, pubkey) {
|
|
26
|
+
return {
|
|
27
|
+
secretKey,
|
|
28
|
+
seckey: bytesToHex(secretKey),
|
|
29
|
+
pubkey,
|
|
30
|
+
nsec: nsecEncode(bytesToHex(secretKey)),
|
|
31
|
+
npub: npubEncode(pubkey)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function generateKeypair () {
|
|
36
|
+
const secretKey = generateSecretKey()
|
|
37
|
+
return keypairFromSecretKey(secretKey, getPublicKey(secretKey))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function decodeSecretKey (raw) {
|
|
41
|
+
if (HEX_SECKEY_REGEX.test(raw)) {
|
|
42
|
+
return hexToBytes(raw.toLowerCase())
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return hexToBytes(nsecDecode(raw))
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
throw new ValidationError('NOT_A_SECRET_KEY', { cause })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function keypairFromSeckey (raw) {
|
|
52
|
+
const secretKey = decodeSecretKey(raw)
|
|
53
|
+
return keypairFromSecretKey(secretKey, getPublicKey(secretKey))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function pubkeyFromNpub (npub) {
|
|
57
|
+
try {
|
|
58
|
+
return npubDecode(npub)
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
throw new ValidationError('NOT_AN_NPUB', { cause })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function nsecFromHex (hex) {
|
|
65
|
+
return nsecEncode(hex)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function npubFromPubkey (pubkey) {
|
|
69
|
+
return npubEncode(pubkey)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cleanProfileValue (value) {
|
|
73
|
+
return String(value ?? '').trim()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function profileContentFromEvent (event) {
|
|
77
|
+
if (!event?.content) return {}
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(event.content)
|
|
80
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
81
|
+
} catch {}
|
|
82
|
+
return {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function profileEventTemplate ({ name = '', picture = '', profileEvent = null } = {}) {
|
|
86
|
+
const cleanName = cleanProfileValue(name)
|
|
87
|
+
const cleanPicture = cleanProfileValue(picture)
|
|
88
|
+
const content = profileContentFromEvent(profileEvent)
|
|
89
|
+
|
|
90
|
+
if (cleanName) content.name = cleanName
|
|
91
|
+
else delete content.name
|
|
92
|
+
if (cleanPicture) content.picture = cleanPicture
|
|
93
|
+
else delete content.picture
|
|
94
|
+
|
|
95
|
+
const tags = Array.isArray(profileEvent?.tags)
|
|
96
|
+
? profileEvent.tags
|
|
97
|
+
.filter(t => Array.isArray(t) && t[0] !== 'name' && t[0] !== 'picture')
|
|
98
|
+
.map(t => t.slice())
|
|
99
|
+
: []
|
|
100
|
+
if (cleanName) tags.push(['name', cleanName])
|
|
101
|
+
if (cleanPicture) tags.push(['picture', cleanPicture])
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
kind: 0,
|
|
105
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
106
|
+
tags,
|
|
107
|
+
content: JSON.stringify(content)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function signProfileEvent ({ secretKey, name = '', picture, profileEvent = null }) {
|
|
112
|
+
return finalizeEvent(profileEventTemplate({ name, picture, profileEvent }), secretKey)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function relayListTags ({ writeRelays = [], readRelays = [] }) {
|
|
116
|
+
const write = new Set(writeRelays)
|
|
117
|
+
const read = new Set(readRelays)
|
|
118
|
+
const all = new Set([...write, ...read])
|
|
119
|
+
const tags = []
|
|
120
|
+
for (const url of all) {
|
|
121
|
+
const isWrite = write.has(url)
|
|
122
|
+
const isRead = read.has(url)
|
|
123
|
+
if (isWrite && isRead) tags.push(['r', url])
|
|
124
|
+
else if (isWrite) tags.push(['r', url, 'write'])
|
|
125
|
+
else tags.push(['r', url, 'read'])
|
|
126
|
+
}
|
|
127
|
+
return tags
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function signRelayListEvent ({ secretKey, writeRelays = [], readRelays = [] }) {
|
|
131
|
+
return finalizeEvent({
|
|
132
|
+
kind: 10002,
|
|
133
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
134
|
+
tags: relayListTags({ writeRelays, readRelays }),
|
|
135
|
+
content: ''
|
|
136
|
+
}, secretKey)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseProfileEvent (event) {
|
|
140
|
+
if (!event || event.kind !== 0) return { name: '', picture: '' }
|
|
141
|
+
let parsed = {}
|
|
142
|
+
try { parsed = JSON.parse(event.content) } catch { parsed = {} }
|
|
143
|
+
const fromTag = name => event.tags.find(t => t[0] === name)?.[1]
|
|
144
|
+
return {
|
|
145
|
+
name: (fromTag('name') || parsed.name || parsed.display_name || '').trim(),
|
|
146
|
+
about: (parsed.about || '').trim(),
|
|
147
|
+
picture: (fromTag('picture') || parsed.picture || '').trim()
|
|
148
|
+
}
|
|
149
|
+
}
|
package/key/index.js
CHANGED
|
@@ -1,114 +1,74 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { bytesToHex, hexToBytes } from '../base16/index.js'
|
|
4
|
-
import { ValidationError } from '../error/index.js'
|
|
1
|
+
import { bytesToBase16 } from '../base16/index.js'
|
|
5
2
|
import { finalizeEvent } from '../event/index.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
import {
|
|
4
|
+
getReadyEventCryptoBackend,
|
|
5
|
+
markEventCryptoUnavailable,
|
|
6
|
+
scheduleEventCryptoInitialization
|
|
7
|
+
} from '../helpers/secp256k1-wasm/backend.js'
|
|
8
|
+
import {
|
|
9
|
+
decodeSecretKey,
|
|
10
|
+
generateKeypair as generateKeypairWithJavaScript,
|
|
11
|
+
generateSecretKey,
|
|
12
|
+
getPublicKey as getPublicKeyWithJavaScript,
|
|
13
|
+
keypairFromSecretKey,
|
|
14
|
+
keypairFromSeckey as keypairFromSeckeyWithJavaScript,
|
|
15
|
+
npubFromPubkey,
|
|
16
|
+
nsecFromHex,
|
|
17
|
+
parseProfileEvent,
|
|
18
|
+
profileEventTemplate,
|
|
19
|
+
pubkeyFromNpub,
|
|
20
|
+
relayListTags,
|
|
21
|
+
validateSecretKey
|
|
22
|
+
} from './helpers/core.js'
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
generateSecretKey,
|
|
26
|
+
npubFromPubkey,
|
|
27
|
+
nsecFromHex,
|
|
28
|
+
parseProfileEvent,
|
|
29
|
+
profileEventTemplate,
|
|
30
|
+
pubkeyFromNpub
|
|
12
31
|
}
|
|
13
32
|
|
|
14
33
|
export function getPublicKey (secretKey) {
|
|
15
|
-
|
|
16
|
-
|
|
34
|
+
validateSecretKey(secretKey)
|
|
35
|
+
scheduleEventCryptoInitialization()
|
|
36
|
+
const backend = getReadyEventCryptoBackend()
|
|
37
|
+
if (!backend) return getPublicKeyWithJavaScript(secretKey)
|
|
38
|
+
try {
|
|
39
|
+
return bytesToBase16(backend.getPublicKey(secretKey))
|
|
40
|
+
} catch {
|
|
41
|
+
markEventCryptoUnavailable()
|
|
42
|
+
return getPublicKeyWithJavaScript(secretKey)
|
|
17
43
|
}
|
|
18
|
-
return bytesToHex(schnorr.getPublicKey(secretKey))
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
export function generateKeypair () {
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
return
|
|
25
|
-
secretKey,
|
|
26
|
-
seckey: bytesToHex(secretKey),
|
|
27
|
-
pubkey,
|
|
28
|
-
nsec: nsecEncode(bytesToHex(secretKey)),
|
|
29
|
-
npub: npubEncode(pubkey)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Accepts either an `nsec1...` bech32 string or a 64-char hex secret key.
|
|
34
|
-
// Returns both encodings so callers can store the hex form and display the
|
|
35
|
-
// nsec form without re-decoding.
|
|
36
|
-
export function keypairFromSeckey (raw) {
|
|
37
|
-
let secretKey
|
|
38
|
-
if (HEX_SECKEY_REGEX.test(raw)) {
|
|
39
|
-
secretKey = hexToBytes(raw.toLowerCase())
|
|
40
|
-
} else {
|
|
41
|
-
try {
|
|
42
|
-
secretKey = hexToBytes(nsecDecode(raw))
|
|
43
|
-
} catch (cause) {
|
|
44
|
-
throw new ValidationError('NOT_A_SECRET_KEY', { cause })
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
const pubkey = getPublicKey(secretKey)
|
|
48
|
-
return {
|
|
49
|
-
secretKey,
|
|
50
|
-
seckey: bytesToHex(secretKey),
|
|
51
|
-
pubkey,
|
|
52
|
-
nsec: nsecEncode(bytesToHex(secretKey)),
|
|
53
|
-
npub: npubEncode(pubkey)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
47
|
+
scheduleEventCryptoInitialization()
|
|
48
|
+
const backend = getReadyEventCryptoBackend()
|
|
49
|
+
if (!backend) return generateKeypairWithJavaScript()
|
|
56
50
|
|
|
57
|
-
// npub-only: hex pubkeys are not accepted for read-only imports because
|
|
58
|
-
// bech32 includes a checksum that protects against user-typo imports of a
|
|
59
|
-
// pubkey they cannot sign for.
|
|
60
|
-
export function pubkeyFromNpub (npub) {
|
|
61
51
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
const secretKey = generateSecretKey()
|
|
53
|
+
return keypairFromSecretKey(secretKey, bytesToBase16(backend.getPublicKey(secretKey)))
|
|
54
|
+
} catch {
|
|
55
|
+
markEventCryptoUnavailable()
|
|
56
|
+
return generateKeypairWithJavaScript()
|
|
65
57
|
}
|
|
66
58
|
}
|
|
67
59
|
|
|
68
|
-
export function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
export function npubFromPubkey (pubkey) {
|
|
73
|
-
return npubEncode(pubkey)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function cleanProfileValue (value) {
|
|
77
|
-
return String(value ?? '').trim()
|
|
78
|
-
}
|
|
60
|
+
export function keypairFromSeckey (raw) {
|
|
61
|
+
scheduleEventCryptoInitialization()
|
|
62
|
+
const backend = getReadyEventCryptoBackend()
|
|
63
|
+
if (!backend) return keypairFromSeckeyWithJavaScript(raw)
|
|
79
64
|
|
|
80
|
-
function profileContentFromEvent (event) {
|
|
81
|
-
if (!event?.content) return {}
|
|
82
65
|
try {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
} catch {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
export function profileEventTemplate ({ name = '', picture = '', profileEvent = null } = {}) {
|
|
90
|
-
const cleanName = cleanProfileValue(name)
|
|
91
|
-
const cleanPicture = cleanProfileValue(picture)
|
|
92
|
-
const content = profileContentFromEvent(profileEvent)
|
|
93
|
-
|
|
94
|
-
if (cleanName) content.name = cleanName
|
|
95
|
-
else delete content.name
|
|
96
|
-
if (cleanPicture) content.picture = cleanPicture
|
|
97
|
-
else delete content.picture
|
|
98
|
-
|
|
99
|
-
const tags = Array.isArray(profileEvent?.tags)
|
|
100
|
-
? profileEvent.tags
|
|
101
|
-
.filter(t => Array.isArray(t) && t[0] !== 'name' && t[0] !== 'picture')
|
|
102
|
-
.map(t => t.slice())
|
|
103
|
-
: []
|
|
104
|
-
if (cleanName) tags.push(['name', cleanName])
|
|
105
|
-
if (cleanPicture) tags.push(['picture', cleanPicture])
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
kind: 0,
|
|
109
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
110
|
-
tags,
|
|
111
|
-
content: JSON.stringify(content)
|
|
66
|
+
const secretKey = decodeSecretKey(raw)
|
|
67
|
+
return keypairFromSecretKey(secretKey, bytesToBase16(backend.getPublicKey(secretKey)))
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error?.code) throw error
|
|
70
|
+
markEventCryptoUnavailable()
|
|
71
|
+
return keypairFromSeckeyWithJavaScript(raw)
|
|
112
72
|
}
|
|
113
73
|
}
|
|
114
74
|
|
|
@@ -116,37 +76,11 @@ export function signProfileEvent ({ secretKey, name = '', picture, profileEvent
|
|
|
116
76
|
return finalizeEvent(profileEventTemplate({ name, picture, profileEvent }), secretKey)
|
|
117
77
|
}
|
|
118
78
|
|
|
119
|
-
// Signs a NIP-65 kind:10002 relay-list event. Each URL present in both
|
|
120
|
-
// `writeRelays` and `readRelays` is emitted as a bare `['r', url]` tag;
|
|
121
|
-
// otherwise it gets the explicit `'read'` or `'write'` marker.
|
|
122
79
|
export function signRelayListEvent ({ secretKey, writeRelays = [], readRelays = [] }) {
|
|
123
|
-
const write = new Set(writeRelays)
|
|
124
|
-
const read = new Set(readRelays)
|
|
125
|
-
const all = new Set([...write, ...read])
|
|
126
|
-
const tags = []
|
|
127
|
-
for (const url of all) {
|
|
128
|
-
const isWrite = write.has(url)
|
|
129
|
-
const isRead = read.has(url)
|
|
130
|
-
if (isWrite && isRead) tags.push(['r', url])
|
|
131
|
-
else if (isWrite) tags.push(['r', url, 'write'])
|
|
132
|
-
else tags.push(['r', url, 'read'])
|
|
133
|
-
}
|
|
134
80
|
return finalizeEvent({
|
|
135
81
|
kind: 10002,
|
|
136
82
|
created_at: Math.floor(Date.now() / 1000),
|
|
137
|
-
tags,
|
|
83
|
+
tags: relayListTags({ writeRelays, readRelays }),
|
|
138
84
|
content: ''
|
|
139
85
|
}, secretKey)
|
|
140
86
|
}
|
|
141
|
-
|
|
142
|
-
export function parseProfileEvent (event) {
|
|
143
|
-
if (!event || event.kind !== 0) return { name: '', picture: '' }
|
|
144
|
-
let parsed = {}
|
|
145
|
-
try { parsed = JSON.parse(event.content) } catch { parsed = {} }
|
|
146
|
-
const fromTag = name => event.tags.find(t => t[0] === name)?.[1]
|
|
147
|
-
return {
|
|
148
|
-
name: (fromTag('name') || parsed.name || parsed.display_name || '').trim(),
|
|
149
|
-
about: (parsed.about || '').trim(),
|
|
150
|
-
picture: (fromTag('picture') || parsed.picture || '').trim()
|
|
151
|
-
}
|
|
152
|
-
}
|
package/key/js/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
generateKeypair,
|
|
3
|
+
generateSecretKey,
|
|
4
|
+
getPublicKey,
|
|
5
|
+
keypairFromSeckey,
|
|
6
|
+
npubFromPubkey,
|
|
7
|
+
nsecFromHex,
|
|
8
|
+
parseProfileEvent,
|
|
9
|
+
profileEventTemplate,
|
|
10
|
+
pubkeyFromNpub,
|
|
11
|
+
signProfileEvent,
|
|
12
|
+
signRelayListEvent
|
|
13
|
+
} from '../helpers/core.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libp2r2p",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Peer-to-relay-to-peer",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"p2r2p",
|
|
@@ -46,10 +46,13 @@
|
|
|
46
46
|
"private-messenger",
|
|
47
47
|
"relay",
|
|
48
48
|
"temporary-storage",
|
|
49
|
+
"THIRD_PARTY_NOTICES.md",
|
|
49
50
|
"url",
|
|
50
51
|
"web-storage-queue"
|
|
51
52
|
],
|
|
52
53
|
"scripts": {
|
|
54
|
+
"benchmark:event": "node --expose-gc benchmark/event-crypto.js",
|
|
55
|
+
"build:wasm": "node wasm/build.js",
|
|
53
56
|
"test": "node --test --experimental-test-module-mocks 'tests/**/*.test.js'",
|
|
54
57
|
"test:files": "node --test --experimental-test-module-mocks",
|
|
55
58
|
"test:only": "node --test --test-only --experimental-test-module-mocks 'tests/**/*.test.js'"
|
|
@@ -67,10 +70,12 @@
|
|
|
67
70
|
"./ecdh": "./ecdh/index.js",
|
|
68
71
|
"./error": "./error/index.js",
|
|
69
72
|
"./event": "./event/index.js",
|
|
73
|
+
"./event/js": "./event/js/index.js",
|
|
70
74
|
"./idb": "./idb/index.js",
|
|
71
75
|
"./idb-queue": "./idb-queue/index.js",
|
|
72
76
|
"./irfs": "./irfs/index.js",
|
|
73
77
|
"./key": "./key/index.js",
|
|
78
|
+
"./key/js": "./key/js/index.js",
|
|
74
79
|
"./kind": "./kind/index.js",
|
|
75
80
|
"./network": "./network/index.js",
|
|
76
81
|
"./nip04": "./nip04/index.js",
|
|
@@ -105,6 +110,7 @@
|
|
|
105
110
|
},
|
|
106
111
|
"devDependencies": {
|
|
107
112
|
"eslint": "^9.39.5",
|
|
113
|
+
"esbuild": "^0.28.2",
|
|
108
114
|
"fake-indexeddb": "6.2.5",
|
|
109
115
|
"globals": "^17.8.0",
|
|
110
116
|
"neostandard": "^0.13.0"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ValidationError } from '../../error/index.js'
|
|
2
|
+
|
|
3
|
+
export function normalizePreparedPublications ({ preparedPublication, preparedPublications } = {}) {
|
|
4
|
+
if (preparedPublication !== undefined) throw new ValidationError('LEGACY_PREPARED_PUBLICATION')
|
|
5
|
+
if (preparedPublications !== undefined) {
|
|
6
|
+
if (!Array.isArray(preparedPublications) || preparedPublications.length === 0) {
|
|
7
|
+
throw new ValidationError('INVALID_PREPARED_PUBLICATIONS')
|
|
8
|
+
}
|
|
9
|
+
return preparedPublications
|
|
10
|
+
}
|
|
11
|
+
return []
|
|
12
|
+
}
|