@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.
- package/.editorconfig +11 -0
- package/.github/workflows/build.yml +26 -0
- package/.github/workflows/public-publish.yml +33 -0
- package/LICENSE +176 -0
- package/README.md +126 -0
- package/bare.js +20 -0
- package/index.js +1 -0
- package/jest.config.js +4 -0
- package/package.json +56 -0
- package/src/address-validation/bitcoin.js +244 -0
- package/src/address-validation/evm.js +90 -0
- package/src/address-validation/index.js +20 -0
- package/src/address-validation/lightning.js +153 -0
- package/src/address-validation/spark.js +71 -0
- package/src/address-validation/uma.js +71 -0
- package/tests/bitcoin.test.js +138 -0
- package/tests/evm.test.js +39 -0
- package/tests/lightning.test.js +121 -0
- package/tests/spark.test.js +56 -0
- package/tests/uma.test.js +60 -0
- package/tsconfig.json +17 -0
- package/types/index.d.ts +1 -0
- package/types/src/address-validation/bitcoin.d.ts +35 -0
- package/types/src/address-validation/evm.d.ts +22 -0
- package/types/src/address-validation/index.d.ts +5 -0
- package/types/src/address-validation/lightning.d.ts +55 -0
- package/types/src/address-validation/spark.d.ts +18 -0
- package/types/src/address-validation/uma.d.ts +28 -0
|
@@ -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
|
+
}
|