Skip to content

Commit cebd007

Browse files
committed
feat: add CryptoAddress parser (BTC + ETH)
1 parent 6f7b0ae commit cebd007

4 files changed

Lines changed: 346 additions & 32 deletions

File tree

README.md

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ npx jsr add @claudiu-ceia/ts-duckling
110110

111111
### Extract entities
112112

113-
Call `Duckling()` with no arguments to use **all 18 built-in parsers**:
113+
Call `Duckling()` with no arguments to use **all 19 built-in parsers**:
114114

115115
```ts
116116
import { Duckling } from "@claudiu-ceia/ts-duckling";
@@ -362,33 +362,35 @@ const entities = Duckling([Email.parser, Hashtag.parser]).extract(
362362

363363
## Supported entities
364364

365-
| Entity | Kind | Example matches |
366-
| --------------- | -------------- | --------------------------------------------------------- |
367-
| **Time** | `time` | `tomorrow at 3pm`, `next Friday`, `2024-01-15T10:30:00Z` |
368-
| **Range** | `range` | `2020-2024`, `20°C to 30°C`, `Monday to Friday` |
369-
| **Temperature** | `temperature` | `72°F`, `20 celsius`, `-5°C` |
370-
| **Quantity** | `quantity` | `5 kg`, `100 miles`, `3,500.00` |
371-
| **Location** | `location` | `United States`, `Germany`, `Japan` |
372-
| **URL** | `url` | `https://example.com/path?q=1` |
373-
| **Institution** | `institution` | `University of Oxford`, `New York City Hall` |
374-
| **Language** | `language` | `English`, `Japanese`, `Portuguese` |
365+
| Entity | Kind | Example matches |
366+
| --------------- | ------------- | -------------------------------------------------------- |
367+
| **Time** | `time` | `tomorrow at 3pm`, `next Friday`, `2024-01-15T10:30:00Z` |
368+
| **Range** | `range` | `2020-2024`, `20°C to 30°C`, `Monday to Friday` |
369+
| **Temperature** | `temperature` | `72°F`, `20 celsius`, `-5°C` |
370+
| **Quantity** | `quantity` | `5 kg`, `100 miles`, `3,500.00` |
371+
| **Location** | `location` | `United States`, `Germany`, `Japan` |
372+
| **URL** | `url` | `https://example.com/path?q=1` |
373+
| **Institution** | `institution` | `University of Oxford`, `New York City Hall` |
374+
| **Language** | `language` | `English`, `Japanese`, `Portuguese` |
375375

376376
### PII
377377

378-
Available via `PIIParsers` for targeted redaction with `Duckling(PIIParsers).redact(…)`.
379-
380-
| Entity | Kind | Example matches |
381-
| --------------- | -------------- | --------------------------------------------------------- |
382-
| **Email** | `email` | `user@example.com`, `first.last@company.io` |
383-
| **Phone** | `phone` | `+14155552671`, `+44 20 7123 4567`, `(415) 555-2671` |
384-
| **IP address** | `ip` | `192.168.1.1`, `2001:db8::1`, `::1` |
385-
| **SSN** | `ssn` | `123-45-6789` |
386-
| **Credit card** | `credit_card` | `4111 1111 1111 1111`, `5500-0000-0000-0004` |
387-
| **UUID** | `uuid` | `550e8400-e29b-41d4-a716-446655440000` |
388-
| **API key** | `api_key` | `sk-proj-abc123…`, `ghp_abc123…`, `AKIA…` |
389-
| **IBAN** | `iban` | `GB29NWBK60161331926819`, `DE89 3704 0044 0532 0130 00` |
390-
| **MAC address** | `mac_address` | `00:1A:2B:3C:4D:5E`, `001A.2B3C.4D5E` |
391-
| **JWT** | `jwt` | `eyJhbGciOiJIUzI1NiIs…` |
378+
Available via `PIIParsers` for targeted redaction with
379+
`Duckling(PIIParsers).redact(…)`.
380+
381+
| Entity | Kind | Example matches |
382+
| --------------- | ---------------- | ------------------------------------------------------- |
383+
| **Email** | `email` | `user@example.com`, `first.last@company.io` |
384+
| **Phone** | `phone` | `+14155552671`, `+44 20 7123 4567`, `(415) 555-2671` |
385+
| **IP address** | `ip` | `192.168.1.1`, `2001:db8::1`, `::1` |
386+
| **SSN** | `ssn` | `123-45-6789` |
387+
| **Credit card** | `credit_card` | `4111 1111 1111 1111`, `5500-0000-0000-0004` |
388+
| **UUID** | `uuid` | `550e8400-e29b-41d4-a716-446655440000` |
389+
| **API key** | `api_key` | `sk-proj-abc123…`, `ghp_abc123…`, `AKIA…` |
390+
| **IBAN** | `iban` | `GB29NWBK60161331926819`, `DE89 3704 0044 0532 0130 00` |
391+
| **MAC address** | `mac_address` | `00:1A:2B:3C:4D:5E`, `001A.2B3C.4D5E` |
392+
| **JWT** | `jwt` | `eyJhbGciOiJIUzI1NiIs…` |
393+
| **Crypto** | `crypto_address` | `0x742d35Cc…f2bD18`, `bc1qar0srrr…`, `1BvBMSEY…` |
392394

393395
## API reference
394396

@@ -415,7 +417,7 @@ function Duckling<T>(parsers: ParserTuple<T>): {
415417
};
416418
```
417419

418-
Creates an extractor/renderer/redactor. Without arguments, uses all 18 built-in
420+
Creates an extractor/renderer/redactor. Without arguments, uses all 19 built-in
419421
parsers and returns `AnyEntity[]`. When given an explicit parser array, the
420422
return type narrows to the union of those entity types.
421423

@@ -520,6 +522,7 @@ const PIIParsers: [
520522
IBANParser,
521523
MACAddressParser,
522524
JWTParser,
525+
CryptoAddressParser,
523526
];
524527
```
525528

@@ -563,13 +566,13 @@ replace the span.
563566

564567
### `AnyEntity`
565568

566-
Union of all 18 built-in entity types. This is the return element type of
569+
Union of all 19 built-in entity types. This is the return element type of
567570
`Duckling().extract(...)`.
568571

569572
### `PIIEntity`
570573

571-
Union of the 10 PII entity types:
572-
`EmailEntity | PhoneEntity | IPAddressEntity | SSNEntity | CreditCardEntity | UUIDEntity | ApiKeyEntity | IBANEntity | MACAddressEntity | JWTEntity`.
574+
Union of the 11 PII entity types:
575+
`EmailEntity | PhoneEntity | IPAddressEntity | SSNEntity | CreditCardEntity | UUIDEntity | ApiKeyEntity | IBANEntity | MACAddressEntity | JWTEntity | CryptoAddressEntity`.
573576

574577
## Caveats
575578

mod.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import { ApiKey, type ApiKeyEntity } from "./src/ApiKey.ts";
3535
import { IBAN, type IBANEntity } from "./src/IBAN.ts";
3636
import { MACAddress, type MACAddressEntity } from "./src/MACAddress.ts";
3737
import { JWT, type JWTEntity } from "./src/JWT.ts";
38+
import {
39+
CryptoAddress,
40+
type CryptoAddressEntity,
41+
} from "./src/CryptoAddress.ts";
3842

3943
/**
4044
* Union of all entity types produced by the built-in parsers.
@@ -61,7 +65,8 @@ export type AnyEntity =
6165
| ApiKeyEntity
6266
| IBANEntity
6367
| MACAddressEntity
64-
| JWTEntity;
68+
| JWTEntity
69+
| CryptoAddressEntity;
6570

6671
const DefaultParsers: [Parser<AnyEntity>, ...Parser<AnyEntity>[]] = [
6772
Range.parser,
@@ -82,14 +87,15 @@ const DefaultParsers: [Parser<AnyEntity>, ...Parser<AnyEntity>[]] = [
8287
IBAN.parser,
8388
MACAddress.parser,
8489
JWT.parser,
90+
CryptoAddress.parser,
8591
];
8692

8793
/**
8894
* Union of entity types considered Personally Identifiable Information (PII).
8995
*
9096
* Covers: email addresses, phone numbers, IP addresses, Social Security
9197
* Numbers, credit card numbers, UUIDs, API keys, IBANs, MAC addresses,
92-
* and JWTs.
98+
* JWTs, and cryptocurrency wallet addresses.
9399
*/
94100
export type PIIEntity =
95101
| EmailEntity
@@ -101,7 +107,8 @@ export type PIIEntity =
101107
| ApiKeyEntity
102108
| IBANEntity
103109
| MACAddressEntity
104-
| JWTEntity;
110+
| JWTEntity
111+
| CryptoAddressEntity;
105112

106113
/**
107114
* Pre-built parser tuple targeting PII entities.
@@ -128,6 +135,7 @@ export const PIIParsers: ParserTuple<
128135
IBANEntity,
129136
MACAddressEntity,
130137
JWTEntity,
138+
CryptoAddressEntity,
131139
]
132140
> = [
133141
Email.parser,
@@ -140,6 +148,7 @@ export const PIIParsers: ParserTuple<
140148
IBAN.parser,
141149
MACAddress.parser,
142150
JWT.parser,
151+
CryptoAddress.parser,
143152
];
144153

145154
type NonEmptyArray<T> = [T, ...T[]];
@@ -505,3 +514,4 @@ export * from "./src/ApiKey.ts";
505514
export * from "./src/IBAN.ts";
506515
export * from "./src/MACAddress.ts";
507516
export * from "./src/JWT.ts";
517+
export * from "./src/CryptoAddress.ts";

src/CryptoAddress.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import {
2+
any,
3+
type Context,
4+
createLanguage,
5+
map,
6+
regex,
7+
seq,
8+
str,
9+
} from "@claudiu-ceia/combine";
10+
import type { Parser } from "@claudiu-ceia/combine";
11+
import { dot } from "./common.ts";
12+
import { ent, type Entity } from "./Entity.ts";
13+
import { guard } from "./guard.ts";
14+
15+
/**
16+
* Cryptocurrency wallet address entity.
17+
*/
18+
export type CryptoAddressEntity = Entity<
19+
"crypto_address",
20+
{
21+
address: string;
22+
currency: "btc" | "eth";
23+
/** Address format variant. */
24+
format:
25+
| "p2pkh"
26+
| "p2sh"
27+
| "bech32"
28+
| "bech32m"
29+
| "erc20";
30+
}
31+
>;
32+
33+
/**
34+
* Helper for constructing a `CryptoAddressEntity`.
35+
*/
36+
export const cryptoAddress = (
37+
value: CryptoAddressEntity["value"],
38+
before: Context,
39+
after: Context,
40+
): CryptoAddressEntity => {
41+
return ent(value, "crypto_address", before, after);
42+
};
43+
44+
// -- Leaf tokens --
45+
46+
// Base58 character class (no 0, O, I, l)
47+
const base58Chars = regex(
48+
/[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+/,
49+
"base58",
50+
);
51+
52+
// Bech32 character class (lowercase alphanumeric minus 1, b, i, o)
53+
const bech32Chars = regex(/[023456789ac-hj-np-z]+/, "bech32");
54+
55+
// 40 hex characters (ETH address body)
56+
const hex40 = regex(/[0-9a-fA-F]{40}/, "hex-40");
57+
58+
// -- Validation helpers --
59+
60+
/**
61+
* Validate that a base58 string has the right length for a BTC address.
62+
* Full Base58Check decoding would require sha256 — we validate the
63+
* character set and length (25-34 total including prefix).
64+
*/
65+
const isValidBase58Address = (addr: string): boolean =>
66+
addr.length >= 25 && addr.length <= 34;
67+
68+
/**
69+
* Validate bech32 address body length.
70+
* - bc1q (P2WPKH): total 42 chars (bc1q + 38)
71+
* - bc1q (P2WSH): total 62 chars (bc1q + 58)
72+
* - bc1p (Taproot): total 62 chars (bc1p + 58)
73+
*/
74+
const isValidBech32 = (body: string, prefix: string): boolean => {
75+
const total = prefix.length + body.length;
76+
if (prefix === "bc1q") return total === 42 || total === 62;
77+
if (prefix === "bc1p") return total === 62;
78+
return false;
79+
};
80+
81+
type CryptoAddressLanguage = {
82+
/** BTC P2PKH: `1` + 25-33 base58 chars */
83+
BtcP2PKH: Parser<CryptoAddressEntity>;
84+
/** BTC P2SH: `3` + 25-33 base58 chars */
85+
BtcP2SH: Parser<CryptoAddressEntity>;
86+
/** BTC Bech32/Bech32m: `bc1q...` or `bc1p...` */
87+
BtcBech32: Parser<CryptoAddressEntity>;
88+
/** ETH: `0x` + 40 hex chars */
89+
Eth: Parser<CryptoAddressEntity>;
90+
Full: Parser<CryptoAddressEntity>;
91+
parser: Parser<CryptoAddressEntity>;
92+
};
93+
94+
/**
95+
* Cryptocurrency address parser language.
96+
*
97+
* Supports:
98+
* - **BTC P2PKH**: `1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2`
99+
* - **BTC P2SH**: `3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy`
100+
* - **BTC Bech32**: `bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq`
101+
* - **BTC Taproot**: `bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3s7a`
102+
* - **ETH (ERC-20)**: `0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18`
103+
*/
104+
export const CryptoAddress: CryptoAddressLanguage = createLanguage<
105+
CryptoAddressLanguage
106+
>({
107+
// BTC Legacy (P2PKH): starts with "1", followed by base58 chars
108+
BtcP2PKH: () =>
109+
guard(
110+
map(
111+
seq(str("1"), base58Chars),
112+
([prefix, body], b, a) =>
113+
cryptoAddress(
114+
{
115+
address: `${prefix}${body}`,
116+
currency: "btc",
117+
format: "p2pkh",
118+
},
119+
b,
120+
a,
121+
),
122+
),
123+
(e) => isValidBase58Address(e.value.address),
124+
),
125+
126+
// BTC Script (P2SH): starts with "3", followed by base58 chars
127+
BtcP2SH: () =>
128+
guard(
129+
map(
130+
seq(str("3"), base58Chars),
131+
([prefix, body], b, a) =>
132+
cryptoAddress(
133+
{
134+
address: `${prefix}${body}`,
135+
currency: "btc",
136+
format: "p2sh",
137+
},
138+
b,
139+
a,
140+
),
141+
),
142+
(e) => isValidBase58Address(e.value.address),
143+
),
144+
145+
// BTC Bech32 (SegWit) / Bech32m (Taproot): bc1q... or bc1p...
146+
BtcBech32: () => {
147+
const prefix = any(str("bc1q"), str("bc1p"));
148+
return guard(
149+
map(
150+
seq(prefix, bech32Chars),
151+
([pfx, body], b, a) =>
152+
cryptoAddress(
153+
{
154+
address: `${pfx}${body}`,
155+
currency: "btc",
156+
format: pfx === "bc1p" ? "bech32m" : "bech32",
157+
},
158+
b,
159+
a,
160+
),
161+
),
162+
(e) =>
163+
isValidBech32(
164+
e.value.address.slice(4),
165+
e.value.address.slice(0, 4),
166+
),
167+
);
168+
},
169+
170+
// ETH (ERC-20): 0x + 40 hex characters
171+
Eth: () =>
172+
map(
173+
seq(str("0x"), hex40),
174+
([prefix, body], b, a) =>
175+
cryptoAddress(
176+
{
177+
address: `${prefix}${body}`,
178+
currency: "eth",
179+
format: "erc20",
180+
},
181+
b,
182+
a,
183+
),
184+
),
185+
186+
Full: (s) => any(s.BtcBech32, s.BtcP2PKH, s.BtcP2SH, s.Eth),
187+
188+
parser: (s) => dot(s.Full),
189+
});

0 commit comments

Comments
 (0)