@tetherto/wdk-utils 1.0.0-beta.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.
@@ -0,0 +1,244 @@
1
+ // Copyright 2026 Tether Operations Limited
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict'
15
+
16
+ import { createBase58check, bech32, bech32m } from '@scure/base'
17
+ import { sha256 } from '@noble/hashes/sha2'
18
+
19
+ /**
20
+ * Bitcoin address validation.
21
+ * Validates format and checksum for mainnet and testnet addresses.
22
+ * Supports P2PKH, P2SH, SegWit v0 (Bech32), and SegWit v1+ (Bech32m).
23
+ */
24
+
25
+ const base58check = createBase58check(sha256)
26
+
27
+ const NETWORKS = {
28
+ mainnet: {
29
+ bech32: 'bc',
30
+ p2pkh: 0x00,
31
+ p2sh: 0x05
32
+ },
33
+ testnet: {
34
+ bech32: 'tb',
35
+ p2pkh: 0x6f,
36
+ p2sh: 0xc4
37
+ },
38
+ regtest: {
39
+ bech32: 'bcrt'
40
+ // Regtest uses testnet Base58 version bytes
41
+ }
42
+ }
43
+
44
+ const WITNESS_VERSION_BECH32 = 0
45
+
46
+ /**
47
+ * @typedef {{ success: true, type: 'p2pkh' | 'p2sh' | 'bech32' | 'bech32m', network: 'mainnet' | 'testnet' | 'regtest' }} BtcAddressValidationSuccess
48
+ * @typedef {{ success: false, reason: string }} BtcAddressValidationFailure
49
+ * @typedef {BtcAddressValidationSuccess | BtcAddressValidationFailure} BtcAddressValidationResult
50
+ */
51
+
52
+ /**
53
+ * Decodes a Base58Check address and validates its payload length.
54
+ * Returns decoded bytes or a failure result.
55
+ * @param {string} address
56
+ * @returns {{ decoded: Uint8Array } | BtcAddressValidationFailure}
57
+ */
58
+ function _decodeBase58 (address) {
59
+ let decoded
60
+
61
+ try {
62
+ decoded = base58check.decode(address)
63
+ } catch (e) {
64
+ const msg = e && e.message ? e.message.toLowerCase() : ''
65
+
66
+ if (msg.includes('checksum')) {
67
+ return { success: false, reason: 'INVALID_CHECKSUM' }
68
+ }
69
+
70
+ return { success: false, reason: 'INVALID_FORMAT' }
71
+ }
72
+
73
+ if (decoded.length !== 21) {
74
+ return { success: false, reason: 'INVALID_LENGTH' }
75
+ }
76
+
77
+ return { decoded }
78
+ }
79
+
80
+ /**
81
+ * Validates a P2PKH address for any supported network.
82
+ * @param {Uint8Array} decoded - The decoded address
83
+ * @returns {BtcAddressValidationResult}
84
+ */
85
+ function _validateP2PKH (decoded) {
86
+ const version = decoded[0]
87
+ if (version === NETWORKS.mainnet.p2pkh) {
88
+ return { success: true, type: 'p2pkh', network: 'mainnet' }
89
+ }
90
+ if (version === NETWORKS.testnet.p2pkh) {
91
+ return { success: true, type: 'p2pkh', network: 'testnet' }
92
+ }
93
+
94
+ return { success: false, reason: 'INVALID_VERSION_BYTE' }
95
+ }
96
+
97
+ /**
98
+ * Validates a P2SH address for any supported network.
99
+ * @param {Uint8Array} decoded - The decoded address
100
+ * @returns {BtcAddressValidationResult}
101
+ */
102
+ function _validateP2SH (decoded) {
103
+ const version = decoded[0]
104
+
105
+ if (version === NETWORKS.mainnet.p2sh) {
106
+ return { success: true, type: 'p2sh', network: 'mainnet' }
107
+ }
108
+
109
+ if (version === NETWORKS.testnet.p2sh) {
110
+ return { success: true, type: 'p2sh', network: 'testnet' }
111
+ }
112
+
113
+ return { success: false, reason: 'INVALID_VERSION_BYTE' }
114
+ }
115
+
116
+ export function validateBase58 (address) {
117
+ const result = _decodeBase58(address)
118
+ if (!result.decoded) return result
119
+
120
+ const validateP2pkh = _validateP2PKH(result.decoded)
121
+ if (validateP2pkh.success) {
122
+ return validateP2pkh
123
+ }
124
+
125
+ const validateP2sh = _validateP2SH(result.decoded)
126
+ if (validateP2sh.success) {
127
+ return validateP2sh
128
+ }
129
+
130
+ return { success: false, reason: 'INVALID_VERSION_BYTE' }
131
+ }
132
+
133
+ /**
134
+ * Validates a Bech32 address for any supported network.
135
+ * @param {string} address - The address to validate.
136
+ * @returns {BtcAddressValidationResult}
137
+ */
138
+ export function validateBech32 (address) {
139
+ try {
140
+ const decoded = bech32.decode(address)
141
+ const { words } = decoded
142
+
143
+ if (words[0] !== WITNESS_VERSION_BECH32) {
144
+ return { success: false, reason: 'INVALID_WITNESS_VERSION' }
145
+ }
146
+
147
+ const programBytes = bech32.fromWords(words.slice(1))
148
+ if (programBytes.length !== 20 && programBytes.length !== 32) {
149
+ return { success: false, reason: 'INVALID_LENGTH' }
150
+ }
151
+
152
+ const prefix = address.toLowerCase().substring(0, address.lastIndexOf('1'))
153
+ if (prefix === NETWORKS.mainnet.bech32) return { success: true, type: 'bech32', network: 'mainnet' }
154
+ if (prefix === NETWORKS.testnet.bech32) return { success: true, type: 'bech32', network: 'testnet' }
155
+ // Note: Regtest addresses are Bech32m, not Bech32. This validator will correctly fail them.
156
+
157
+ return { success: false, reason: 'INVALID_HRP' }
158
+ } catch (e) {
159
+ if (e && e.message && e.message.toLowerCase().includes('lowercase or uppercase')) {
160
+ return { success: false, reason: 'MIXED_CASE' }
161
+ }
162
+ return { success: false, reason: 'INVALID_BECH32_FORMAT' }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Validates a Bech32m address for any supported network.
168
+ * @param {string} address - The address to validate.
169
+ * @returns {BtcAddressValidationResult}
170
+ */
171
+ export function validateBech32m (address) {
172
+ try {
173
+ const decoded = bech32m.decode(address)
174
+ const { words } = decoded
175
+
176
+ if (words[0] === WITNESS_VERSION_BECH32) {
177
+ return { success: false, reason: 'INVALID_WITNESS_VERSION' }
178
+ }
179
+
180
+ const programBytes = bech32m.fromWords(words.slice(1))
181
+ // As per BIP-173, valid witness programs are 2-40 bytes.
182
+ if (programBytes.length < 2 || programBytes.length > 40) {
183
+ return { success: false, reason: 'INVALID_LENGTH' }
184
+ }
185
+
186
+ const prefix = address.toLowerCase().substring(0, address.lastIndexOf('1'))
187
+ if (prefix === NETWORKS.mainnet.bech32) return { success: true, type: 'bech32m', network: 'mainnet' }
188
+ if (prefix === NETWORKS.testnet.bech32) return { success: true, type: 'bech32m', network: 'testnet' }
189
+ if (prefix === NETWORKS.regtest.bech32) return { success: true, type: 'bech32m', network: 'regtest' }
190
+
191
+ return { success: false, reason: 'INVALID_HRP' }
192
+ } catch (e) {
193
+ if (e && e.message && e.message.toLowerCase().includes('lowercase or uppercase')) {
194
+ return { success: false, reason: 'MIXED_CASE' }
195
+ }
196
+ return { success: false, reason: 'INVALID_BECH32M_FORMAT' }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Validates a Bitcoin address for mainnet or testnet.
202
+ *
203
+ * @param {string} address The address to validate.
204
+ * @returns {BtcAddressValidationResult}
205
+ */
206
+ export function validateBitcoinAddress (address) {
207
+ if (address == null || typeof address !== 'string') {
208
+ return { success: false, reason: 'INVALID_FORMAT' }
209
+ }
210
+ const trimmed = address.trim()
211
+ if (trimmed.length === 0) {
212
+ return { success: false, reason: 'EMPTY_ADDRESS' }
213
+ }
214
+
215
+ const results = [
216
+ validateBase58(trimmed),
217
+ validateBech32(trimmed),
218
+ validateBech32m(trimmed)
219
+ ]
220
+
221
+ const successes = results.filter(r => r.success)
222
+
223
+ if (successes.length === 1) {
224
+ return successes[0]
225
+ }
226
+
227
+ if (successes.length > 1) {
228
+ return { success: false, reason: 'AMBIGUOUS_ADDRESS' }
229
+ }
230
+
231
+ const failures = results
232
+
233
+ const definitiveFailure = failures.find(f =>
234
+ f.reason !== 'INVALID_FORMAT' &&
235
+ f.reason !== 'INVALID_BECH32_FORMAT' &&
236
+ f.reason !== 'INVALID_BECH32M_FORMAT'
237
+ )
238
+
239
+ if (definitiveFailure) {
240
+ return definitiveFailure
241
+ }
242
+
243
+ return { success: false, reason: 'INVALID_FORMAT' }
244
+ }
@@ -0,0 +1,90 @@
1
+ // Copyright 2026 Tether Operations Limited
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict'
15
+
16
+ /**
17
+ * EVM address validation.
18
+ * EIP-55 checksum: if all lowercase, valid; if mixed case, must match checksum.
19
+ * @see https://eips.ethereum.org/EIPS/eip-55
20
+ */
21
+
22
+ // eslint-disable-next-line camelcase
23
+ import { keccak_256 } from '@noble/hashes/sha3'
24
+
25
+ function isValidEIP55Checksum (address) {
26
+ const hexPart = address.slice(2)
27
+ if (hexPart === hexPart.toLowerCase()) {
28
+ return true
29
+ }
30
+ const addressLower = address.toLowerCase()
31
+ const addressWithoutPrefix = addressLower.slice(2)
32
+ try {
33
+ const addressBytes = new TextEncoder().encode(addressWithoutPrefix)
34
+ const hashBytes = keccak_256(addressBytes)
35
+ const hash = Array.from(hashBytes)
36
+ .map((b) => b.toString(16).padStart(2, '0'))
37
+ .join('')
38
+ let checksummed = '0x'
39
+ for (let i = 0; i < addressWithoutPrefix.length; i++) {
40
+ const char = addressWithoutPrefix[i]
41
+ const hashChar = hash[i]
42
+ if (parseInt(hashChar, 16) >= 8) {
43
+ checksummed += char.toUpperCase()
44
+ } else {
45
+ checksummed += char
46
+ }
47
+ }
48
+ return address === checksummed
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @typedef {{ success: true, type: 'evm' }} EvmAddressValidationSuccess
56
+ * @typedef {{ success: false, reason: string }} EvmAddressValidationFailure
57
+ * @typedef {EvmAddressValidationSuccess | EvmAddressValidationFailure} EvmAddressValidationResult
58
+ */
59
+
60
+ /**
61
+ * Validates an EVM address (format + optional EIP-55 checksum).
62
+ * If mixed case, checksum must match; all lowercase or all uppercase is valid.
63
+ *
64
+ * @param {string} address The address to validate.
65
+ * @returns {EvmAddressValidationResult}
66
+ */
67
+ export function validateEVMAddress (address) {
68
+ if (address == null || typeof address !== 'string') {
69
+ return { success: false, reason: 'INVALID_FORMAT' }
70
+ }
71
+ const trimmed = address.trim()
72
+ if (trimmed.length === 0) {
73
+ return { success: false, reason: 'EMPTY_ADDRESS' }
74
+ }
75
+
76
+ if (!trimmed.startsWith('0x') || trimmed.length !== 42) {
77
+ return { success: false, reason: 'INVALID_FORMAT' }
78
+ }
79
+ const hexPart = trimmed.slice(2)
80
+ if (!/^[0-9a-fA-F]{40}$/.test(hexPart)) {
81
+ return { success: false, reason: 'INVALID_FORMAT' }
82
+ }
83
+ const isAllLowercase = hexPart === hexPart.toLowerCase()
84
+ const isAllUppercase = hexPart === hexPart.toUpperCase()
85
+ const hasMixedCase = !isAllLowercase && !isAllUppercase
86
+ if (hasMixedCase && !isValidEIP55Checksum(trimmed)) {
87
+ return { success: false, reason: 'INVALID_CHECKSUM' }
88
+ }
89
+ return { success: true, type: 'evm' }
90
+ }
@@ -0,0 +1,20 @@
1
+ // Copyright 2026 Tether Operations Limited
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict'
15
+
16
+ export * from './bitcoin.js'
17
+ export * from './evm.js'
18
+ export * from './lightning.js'
19
+ export * from './spark.js'
20
+ export * from './uma.js'
@@ -0,0 +1,153 @@
1
+ // Copyright 2026 Tether Operations Limited
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict'
15
+
16
+ /**
17
+ * Lightning validation.
18
+ * Invoice prefixes: lnbc, lntb, lnbcrt, lni.
19
+ * Lightning address: strict email (user@domain.tld).
20
+ */
21
+
22
+ import { bech32 } from '@scure/base'
23
+
24
+ const VALID_INVOICE_PREFIXES = ['lnbc', 'lntb', 'lnbcrt', 'lnsb']
25
+ /** Lightning address: must have dot in domain (user@domain.tld) */
26
+ const LIGHTNING_ADDRESS_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
27
+
28
+ /**
29
+ * Strips "lightning:" URI prefix (case-insensitive). The input is trimmed first.
30
+ *
31
+ * @param {string} input
32
+ * @returns {string} Returns a string. Returns an empty string if input is not a string.
33
+ */
34
+ export function stripLightningPrefix (input) {
35
+ if (typeof input !== 'string') {
36
+ return ''
37
+ }
38
+
39
+ const trimmed = input.trim()
40
+ if (trimmed.toLowerCase().startsWith('lightning:')) {
41
+ return trimmed.slice(10).trim()
42
+ }
43
+
44
+ return trimmed
45
+ }
46
+
47
+ /**
48
+ * @typedef {{ success: true, type: 'invoice' }} LightningInvoiceValidationSuccess
49
+ * @typedef {{ success: false, reason: string }} LightningInvoiceValidationFailure
50
+ * @typedef {LightningInvoiceValidationSuccess | LightningInvoiceValidationFailure} LightningInvoiceValidationResult
51
+ */
52
+
53
+ /**
54
+ * Validates a Lightning Network invoice (lnbc, lntb, lnbcrt, lni; length >= 20).
55
+ *
56
+ * @param {string} address The invoice to validate.
57
+ * @returns {LightningInvoiceValidationResult}
58
+ */
59
+ export function validateLightningInvoice (address) {
60
+ if (address == null || typeof address !== 'string') {
61
+ return { success: false, reason: 'INVALID_FORMAT' }
62
+ }
63
+
64
+ const invoice = stripLightningPrefix(address)
65
+ if (invoice.length === 0) {
66
+ return { success: false, reason: 'EMPTY_ADDRESS' }
67
+ }
68
+
69
+ const lowerInvoice = invoice.toLowerCase()
70
+
71
+ const hasValidPrefix = VALID_INVOICE_PREFIXES.some((prefix) =>
72
+ lowerInvoice.startsWith(prefix)
73
+ )
74
+ if (!hasValidPrefix) {
75
+ return { success: false, reason: 'INVALID_PREFIX' }
76
+ }
77
+ if (invoice.length < 20) {
78
+ return { success: false, reason: 'INVALID_LENGTH' }
79
+ }
80
+ try {
81
+ bech32.decode(invoice, false)
82
+ return { success: true, type: 'invoice' }
83
+ } catch (e) {
84
+ if (e && e.message && e.message.toLowerCase().includes('lowercase or uppercase')) {
85
+ return { success: false, reason: 'MIXED_CASE' }
86
+ }
87
+
88
+ return { success: false, reason: 'INVALID_BECH32_FORMAT' }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * @typedef {{ success: true, type: 'lnurl' }} LnurlValidationSuccess
94
+ * @typedef {{ success: false, reason: string }} LnurlValidationFailure
95
+ * @typedef {LnurlValidationSuccess | LnurlValidationFailure} LnurlValidationResult
96
+ */
97
+
98
+ /**
99
+ * Validates an LNURL address (lnurl1... bech32 encoded URL).
100
+ *
101
+ * @param {string} address The LNURL to validate.
102
+ * @returns {LnurlValidationResult}
103
+ */
104
+ export function validateLnurl (address) {
105
+ if (address == null || typeof address !== 'string') {
106
+ return { success: false, reason: 'INVALID_FORMAT' }
107
+ }
108
+ const lnurl = address.trim()
109
+ if (lnurl.length === 0) {
110
+ return { success: false, reason: 'EMPTY_ADDRESS' }
111
+ }
112
+
113
+ if (!lnurl.toLowerCase().startsWith('lnurl1')) {
114
+ return { success: false, reason: 'INVALID_PREFIX' }
115
+ }
116
+
117
+ try {
118
+ bech32.decode(lnurl, false)
119
+ return { success: true, type: 'lnurl' }
120
+ } catch (e) {
121
+ if (e && e.message && e.message.toLowerCase().includes('lowercase or uppercase')) {
122
+ return { success: false, reason: 'MIXED_CASE' }
123
+ }
124
+ return { success: false, reason: 'INVALID_BECH32_FORMAT' }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @typedef {{ success: true, type: 'address' }} LightningAddressValidationSuccess
130
+ * @typedef {{ success: false, reason: string }} LightningAddressValidationFailure
131
+ * @typedef {LightningAddressValidationSuccess | LightningAddressValidationFailure} LightningAddressValidationResult
132
+ */
133
+
134
+ /**
135
+ * Validates Lightning Address format (email: user@domain.tld).
136
+ *
137
+ * @param {string} address The address to validate.
138
+ * @returns {LightningAddressValidationResult}
139
+ */
140
+ export function validateLightningAddress (address) {
141
+ if (address == null || typeof address !== 'string') {
142
+ return { success: false, reason: 'INVALID_FORMAT' }
143
+ }
144
+ const trimmed = address.trim()
145
+ if (trimmed.length === 0) {
146
+ return { success: false, reason: 'EMPTY_ADDRESS' }
147
+ }
148
+
149
+ if (LIGHTNING_ADDRESS_EMAIL_REGEX.test(trimmed.toLowerCase())) {
150
+ return { success: true, type: 'address' }
151
+ }
152
+ return { success: false, reason: 'INVALID_FORMAT' }
153
+ }
@@ -0,0 +1,71 @@
1
+ // Copyright 2026 Tether Operations Limited
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict'
15
+
16
+ import { bech32m } from '@scure/base'
17
+ import { validateBech32m } from './bitcoin.js'
18
+
19
+ /**
20
+ * @typedef {{ success: true, type: 'spark' | 'btc' }} SparkAddressValidationSuccess
21
+ * @typedef {{ success: false, reason: string }} SparkAddressValidationFailure
22
+ * @typedef {SparkAddressValidationSuccess | SparkAddressValidationFailure} SparkAddressValidationResult
23
+ */
24
+
25
+ const VALID_PREFIXES = ['spark', 'sparkrt', 'sparkt', 'sparks', 'sparkl']
26
+
27
+ /**
28
+ * Validates a Spark address.
29
+ * A Spark address can be a native Bech32m encoded address or a standard
30
+ * Bitcoin address for L1 deposits.
31
+ *
32
+ * @param {string} address The address to validate.
33
+ * @returns {SparkAddressValidationResult}
34
+ */
35
+ export function validateSparkAddress (address) {
36
+ if (address == null || typeof address !== 'string') {
37
+ return { success: false, reason: 'INVALID_FORMAT' }
38
+ }
39
+
40
+ const trimmed = address.trim()
41
+ if (trimmed.length === 0) {
42
+ return { success: false, reason: 'EMPTY_ADDRESS' }
43
+ }
44
+
45
+ const lower = trimmed.toLowerCase()
46
+ const upper = trimmed.toUpperCase()
47
+ if (trimmed !== lower && trimmed !== upper) {
48
+ return { success: false, reason: 'MIXED_CASE' }
49
+ }
50
+
51
+ let decoded
52
+ try {
53
+ decoded = bech32m.decode(lower)
54
+ } catch (e) {
55
+ if (validateBech32m(trimmed).success) {
56
+ return { success: true, type: 'btc' }
57
+ }
58
+
59
+ return { success: false, reason: 'INVALID_FORMAT' }
60
+ }
61
+
62
+ if (VALID_PREFIXES.includes(decoded.prefix)) {
63
+ return { success: true, type: 'spark' }
64
+ }
65
+
66
+ if (validateBech32m(trimmed).success) {
67
+ return { success: true, type: 'btc' }
68
+ }
69
+
70
+ return { success: false, reason: 'INVALID_FORMAT' }
71
+ }
@@ -0,0 +1,71 @@
1
+ // Copyright 2026 Tether Operations Limited
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict'
15
+
16
+ /**
17
+ * Universal Money Address (UMA) validation.
18
+ * Format: $user@domain.tld (human-readable, like email for money; built on Lightning).
19
+ * Resolves UMA usernames into the underlying Lightning Address (user@domain).
20
+ */
21
+
22
+ /** UMA format: leading $ then local@domain with dot in domain */
23
+ const UMA_REGEX = /^\$([^\s@]+)@([^\s@]+\.[^\s@]+)$/
24
+
25
+ /**
26
+ * @typedef {{ success: true, type: 'uma' }} UmaAddressValidationSuccess
27
+ * @typedef {{ success: false, reason: string }} UmaAddressValidationFailure
28
+ * @typedef {UmaAddressValidationSuccess | UmaAddressValidationFailure} UmaAddressValidationResult
29
+ */
30
+
31
+ /**
32
+ * Validates a Universal Money Address (format: $user@domain.tld).
33
+ *
34
+ * @param {string} address The address to validate.
35
+ * @returns {UmaAddressValidationResult}
36
+ */
37
+ export function validateUmaAddress (address) {
38
+ if (address == null || typeof address !== 'string') {
39
+ return { success: false, reason: 'INVALID_FORMAT' }
40
+ }
41
+ const trimmed = address.trim()
42
+ if (trimmed.length === 0) {
43
+ return { success: false, reason: 'EMPTY_ADDRESS' }
44
+ }
45
+
46
+ if (UMA_REGEX.test(trimmed.toLowerCase())) {
47
+ return { success: true, type: 'uma' }
48
+ }
49
+ return { success: false, reason: 'INVALID_FORMAT' }
50
+ }
51
+
52
+ /**
53
+ * Resolves UMA username into address components and the underlying Lightning Address.
54
+ * UMA is built on Lightning Addresses; this returns the user@domain form used for resolution.
55
+ *
56
+ * @param {string} uma - UMA string (e.g. $you@uma.money)
57
+ * @returns {{ localPart: string; domain: string; lightningAddress: string } | null} Parsed parts and lightningAddress (user@domain), or null if invalid
58
+ */
59
+ export function resolveUmaUsername (uma) {
60
+ if (typeof uma !== 'string') {
61
+ return null
62
+ }
63
+ const trimmed = uma.trim()
64
+ const match = trimmed.toLowerCase().match(UMA_REGEX)
65
+ if (!match) {
66
+ return null
67
+ }
68
+ const [, localPart, domain] = match
69
+ const lightningAddress = `${localPart}@${domain}`
70
+ return { localPart, domain, lightningAddress }
71
+ }