@hoodfi/sdk 0.0.0-stage → 0.1.0

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 CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@hoodfi/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Resolve, reverse-look-up and register *.hoodfi.eth names on Robinhood Chain. Typed, keyless, viem-based.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist", "src", "README.md"],
15
+ "sideEffects": false,
16
+ "scripts": {
17
+ "build": "rm -rf dist && tsc -p tsconfig.json",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit",
19
+ "test": "npm run build && node --test test/*.test.mjs",
20
+ "test:live": "npm run build && node --test test/live.mjs",
21
+ "prepublishOnly": "npm test"
22
+ },
23
+ "keywords": ["hoodfi", "ens", "names", "robinhood-chain", "viem", "web3", "identity"],
24
+ "homepage": "https://www.hoodfi.name",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/RWA-ID/hoodfi-eth.git",
28
+ "directory": "sdk"
29
+ },
30
+ "license": "MIT",
31
+ "engines": { "node": ">=18" },
32
+ "peerDependencies": {
33
+ "viem": "^2.21.0"
34
+ },
35
+ "dependencies": {
36
+ "@ensdomains/address-encoder": "^1.1.4"
37
+ },
38
+ "devDependencies": {
39
+ "typescript": "^5.8.2",
40
+ "viem": "^2.47.10"
41
+ },
42
+ "publishConfig": { "access": "public" }
43
+ }
package/src/abi.ts ADDED
@@ -0,0 +1,35 @@
1
+ /** Only the parts of each ABI this SDK actually calls. */
2
+
3
+ export const registrarAbi = [
4
+ { type: 'function', name: 'register', stateMutability: 'payable', inputs: [{ name: 'label', type: 'string' }], outputs: [] },
5
+ { type: 'function', name: 'registerWithUsdc', stateMutability: 'nonpayable', inputs: [{ name: 'label', type: 'string' }], outputs: [] },
6
+ // 0 = available, 1 = taken, 2 = locked, 3 = invalid, 4 = blocked.
7
+ { type: 'function', name: 'status', stateMutability: 'view', inputs: [{ name: 'label', type: 'string' }], outputs: [{ type: 'uint8' }] },
8
+ {
9
+ type: 'function',
10
+ name: 'priceOf',
11
+ stateMutability: 'view',
12
+ inputs: [{ name: 'label', type: 'string' }],
13
+ outputs: [{ name: 'weiPrice', type: 'uint256' }, { name: 'usdcPrice', type: 'uint256' }],
14
+ },
15
+ { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
16
+ ] as const
17
+
18
+ export const registryAbi = [
19
+ { type: 'function', name: 'ownerOf', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'address' }] },
20
+ {
21
+ type: 'function',
22
+ name: 'addr',
23
+ stateMutability: 'view',
24
+ inputs: [{ name: 'node', type: 'bytes32' }, { name: 'coinType', type: 'uint256' }],
25
+ outputs: [{ type: 'bytes' }],
26
+ },
27
+ {
28
+ type: 'function',
29
+ name: 'text',
30
+ stateMutability: 'view',
31
+ inputs: [{ name: 'node', type: 'bytes32' }, { name: 'key', type: 'string' }],
32
+ outputs: [{ type: 'string' }],
33
+ },
34
+ { type: 'function', name: 'contenthash', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ type: 'bytes' }] },
35
+ ] as const
package/src/client.ts ADDED
@@ -0,0 +1,478 @@
1
+ import {
2
+ type Address,
3
+ type Hex,
4
+ type PublicClient,
5
+ createPublicClient,
6
+ encodeFunctionData,
7
+ erc20Abi,
8
+ fallback,
9
+ getAddress,
10
+ http,
11
+ isAddress,
12
+ namehash,
13
+ } from 'viem'
14
+
15
+ import { registrarAbi, registryAbi } from './abi.js'
16
+ import { CHAINS, type SendChain, paymentCaveat } from './coins.js'
17
+ import {
18
+ CONTRACTS,
19
+ DEFAULT_API_URL,
20
+ DEFAULT_ROBINHOOD_RPC,
21
+ FALLBACK_ROBINHOOD_RPC,
22
+ ROBINHOOD_CHAIN_ID,
23
+ ROBINHOOD_EXPLORER,
24
+ robinhoodChain,
25
+ } from './constants.js'
26
+ import { type ContentHash, contentGatewayUrl, decodeContenthash } from './contenthash.js'
27
+ import { type NameStatus, STATUS_TEXT, checkHoodfiName, checkLabel, fullName, tierOf } from './labels.js'
28
+
29
+ export type HoodfiErrorCode =
30
+ /** The input isn't a valid hoodfi.eth name. Nothing was read. */
31
+ | 'INVALID_NAME'
32
+ /** The name exists, or can't be minted right now. `checkName` explains why. */
33
+ | 'UNAVAILABLE'
34
+ /** The name has never been minted. */
35
+ | 'NOT_REGISTERED'
36
+ /** The name has no record for the chain asked about. Do NOT fall back to another one. */
37
+ | 'NO_RECORD'
38
+ /**
39
+ * The chain or API didn't answer. This says nothing about the name: never show it as
40
+ * "not registered" or "no names". Retry.
41
+ */
42
+ | 'UNREADABLE'
43
+
44
+ export class HoodfiError extends Error {
45
+ readonly code: HoodfiErrorCode
46
+ constructor(code: HoodfiErrorCode, message: string, options?: { cause?: unknown }) {
47
+ super(message, options)
48
+ this.name = 'HoodfiError'
49
+ this.code = code
50
+ }
51
+ }
52
+
53
+ export interface HoodfiClientOptions {
54
+ /**
55
+ * Robinhood Chain RPC URLs, tried in order before the public defaults. The public RPC
56
+ * throttles per IP; a server doing volume should pass its own endpoint here.
57
+ */
58
+ rpcUrls?: string[]
59
+ /** Bring your own viem client instead. It must be connected to chain 4663. */
60
+ publicClient?: PublicClient
61
+ /** HoodFi gateway base URL, for reverse lookups. */
62
+ apiUrl?: string
63
+ /** Override `fetch` (tests, proxies). */
64
+ fetch?: typeof fetch
65
+ }
66
+
67
+ export interface NameCheck {
68
+ name: string
69
+ label: string
70
+ length: number
71
+ /** 0-3 for 1, 2, 3 and 4+ characters. */
72
+ tier: 0 | 1 | 2 | 3
73
+ status: NameStatus
74
+ /** True only when status is available AND minting isn't paused. */
75
+ available: boolean
76
+ paused: boolean
77
+ /** Why it can't be registered, in a sentence you can show. Undefined when available. */
78
+ reason?: string
79
+ price: {
80
+ /** ETH price in wei, from the registrar's live price feed. */
81
+ wei: bigint
82
+ /** USDG price, 6 decimals. */
83
+ usdg: bigint
84
+ }
85
+ }
86
+
87
+ export interface NameProfile {
88
+ name: string
89
+ node: Hex
90
+ owner: Address
91
+ /**
92
+ * Robinhood Chain and Ethereum are separate records. They normally hold the same
93
+ * address, but reported apart so a name where they differ is visible.
94
+ */
95
+ addresses: Record<SendChain, string | null>
96
+ /** Text records that are set: avatar, description, url, com.twitter, com.github, email. */
97
+ records: Partial<Record<TextKey, string>>
98
+ /** The name's website, or null when it has none (or one in a namespace not decoded here). */
99
+ website: (ContentHash & { url: string; gateway: string }) | null
100
+ explorer: string
101
+ }
102
+
103
+ export interface SendAddress {
104
+ name: string
105
+ chain: SendChain
106
+ address: string
107
+ /** E.g. the Robinhood and Ethereum records disagree. Show these to the sender. */
108
+ warnings: string[]
109
+ /** What a well-formed record can't prove (Solana has no checksum, XRP r-addresses no tag). */
110
+ caveat: string | null
111
+ }
112
+
113
+ export interface OwnedName {
114
+ name: string
115
+ node: Hex
116
+ /** The name's ETH address record, or null when unset. */
117
+ addr: Address | null
118
+ /** True when the name's record points back at this address. Only these may be shown as "this wallet is X". */
119
+ resolves: boolean
120
+ avatar: string | null
121
+ }
122
+
123
+ export interface AddressLookup {
124
+ address: Address
125
+ /** The name to display for this address: it resolves back to it. Null when none does. */
126
+ primary: string | null
127
+ /** "ens": the holder set it as their ENS primary. "inferred": shortest name that resolves back. */
128
+ primarySource: 'ens' | 'inferred' | null
129
+ /** Every hoodfi name this address holds now, resolving ones first. */
130
+ names: OwnedName[]
131
+ }
132
+
133
+ export interface TxStep {
134
+ description: string
135
+ to: Address
136
+ data: Hex
137
+ value: bigint
138
+ chainId: typeof ROBINHOOD_CHAIN_ID
139
+ }
140
+
141
+ export interface Registration {
142
+ name: string
143
+ label: string
144
+ payWith: 'eth' | 'usdg'
145
+ /** The price at quote time. ETH steps send `price.wei` plus the overpay buffer. */
146
+ price: { wei: bigint; usdg: bigint }
147
+ /** Send in order; wait for each receipt (and check its status) before the next. */
148
+ steps: TxStep[]
149
+ /** E.g. the account can't afford it yet. The steps still build. */
150
+ warnings: string[]
151
+ }
152
+
153
+ const TEXT_KEYS = ['avatar', 'description', 'url', 'com.twitter', 'com.github', 'email'] as const
154
+ export type TextKey = (typeof TEXT_KEYS)[number]
155
+
156
+ const UNREADABLE_MSG =
157
+ 'Could not read Robinhood Chain: the reads failed rather than coming back empty, so this says nothing about the name. Try again shortly.'
158
+
159
+ function unavailableReason(status: number, label: string): string | undefined {
160
+ switch (status) {
161
+ case 0:
162
+ return undefined
163
+ case 1:
164
+ return `${fullName(label)} is already registered.`
165
+ case 2:
166
+ return `${fullName(label)} is locked and not on sale right now.`
167
+ case 3:
168
+ return `${label} is not a valid label.`
169
+ case 4:
170
+ return `${label} is reserved and can never be registered.`
171
+ default:
172
+ return `${fullName(label)} is not available (status ${status}).`
173
+ }
174
+ }
175
+
176
+ /**
177
+ * A batch where nothing succeeded is a dead connection, not an answer.
178
+ *
179
+ * With `allowFailure: true`, a failed aggregate call (a 429 from a throttled RPC) marks
180
+ * every entry as failed, exactly like each one reverting. For a never-minted name only
181
+ * `ownerOf` reverts; `addr`/`text`/`contenthash` return empty successfully. So "all
182
+ * failed" can only mean the transport.
183
+ */
184
+ function batchFailed(results: readonly { status: 'success' | 'failure' }[]): boolean {
185
+ return results.every((r) => r.status === 'failure')
186
+ }
187
+
188
+ function requireName(input: string): { name: string; labels: string[] } {
189
+ if (typeof input !== 'string') throw new HoodfiError('INVALID_NAME', 'Name must be a string.')
190
+ const checked = checkHoodfiName(input)
191
+ if (!checked.ok) throw new HoodfiError('INVALID_NAME', checked.reason)
192
+ return checked
193
+ }
194
+
195
+ function requireLabel(input: string): string {
196
+ if (typeof input !== 'string') throw new HoodfiError('INVALID_NAME', 'Name must be a string.')
197
+ const checked = checkLabel(input)
198
+ if (!checked.ok) throw new HoodfiError('INVALID_NAME', checked.reason)
199
+ return checked.label
200
+ }
201
+
202
+ function requireAddress(value: string): Address {
203
+ if (typeof value !== 'string' || !isAddress(value, { strict: false }))
204
+ throw new HoodfiError('INVALID_NAME', 'Expected a 0x-prefixed 20-byte address.')
205
+ return getAddress(value)
206
+ }
207
+
208
+ export interface HoodfiClient {
209
+ checkName(name: string): Promise<NameCheck>
210
+ resolve(name: string): Promise<NameProfile | null>
211
+ getSendAddress(name: string, chain: SendChain): Promise<SendAddress>
212
+ lookupAddress(address: string): Promise<AddressLookup>
213
+ buildRegistration(args: { name: string; account: string; payWith?: 'eth' | 'usdg'; overpayBps?: number }): Promise<Registration>
214
+ /** The viem client in use, e.g. for `waitForTransactionReceipt`. */
215
+ publicClient: PublicClient
216
+ }
217
+
218
+ export function createHoodfiClient(options: HoodfiClientOptions = {}): HoodfiClient {
219
+ const client: PublicClient =
220
+ options.publicClient ??
221
+ (createPublicClient({
222
+ chain: robinhoodChain,
223
+ transport: fallback(
224
+ [...(options.rpcUrls ?? []), DEFAULT_ROBINHOOD_RPC, FALLBACK_ROBINHOOD_RPC].map((url) =>
225
+ http(url, { retryCount: 2, retryDelay: 200, timeout: 8_000 })
226
+ ),
227
+ // Ranking probes every endpoint in the background, spending the rate limit
228
+ // this fallback exists to conserve.
229
+ { rank: false }
230
+ ),
231
+ }) as PublicClient)
232
+ const apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, '')
233
+ const doFetch = options.fetch ?? globalThis.fetch
234
+
235
+ async function readRegistrar(label: string) {
236
+ try {
237
+ // allowFailure: false: none of these can revert for a valid label, so a failure
238
+ // is the RPC, and must not be folded into an answer.
239
+ const [status, prices, paused] = await client.multicall({
240
+ contracts: [
241
+ { address: CONTRACTS.registrar, abi: registrarAbi, functionName: 'status', args: [label] },
242
+ { address: CONTRACTS.registrar, abi: registrarAbi, functionName: 'priceOf', args: [label] },
243
+ { address: CONTRACTS.registrar, abi: registrarAbi, functionName: 'paused' },
244
+ ],
245
+ allowFailure: false,
246
+ })
247
+ return { status, wei: prices[0], usdg: prices[1], paused }
248
+ } catch (cause) {
249
+ throw new HoodfiError('UNREADABLE', UNREADABLE_MSG, { cause })
250
+ }
251
+ }
252
+
253
+ /** Is a name free, and what does it cost? Prices are live from the registrar. */
254
+ async function checkName(name: string): Promise<NameCheck> {
255
+ const label = requireLabel(name)
256
+ const { status, wei, usdg, paused } = await readRegistrar(label)
257
+ const reason = paused ? 'Minting is paused by the registrar owner.' : unavailableReason(status, label)
258
+ return {
259
+ name: fullName(label),
260
+ label,
261
+ length: label.length,
262
+ tier: tierOf(label),
263
+ status: STATUS_TEXT[status] ?? 'invalid',
264
+ available: status === 0 && !paused,
265
+ paused,
266
+ ...(reason !== undefined && { reason }),
267
+ price: { wei, usdg },
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Everything a name holds, in one RPC round-trip. Returns null when the name was never
273
+ * minted. Throws UNREADABLE (never null) when the chain didn't answer.
274
+ *
275
+ * Accepts `gm`, `gm.hoodfi.eth` or a subname like `jack.gm.hoodfi.eth`. Does not accept
276
+ * `gm.eth`: that's a different name on Ethereum.
277
+ */
278
+ async function resolve(name: string): Promise<NameProfile | null> {
279
+ const checked = requireName(name)
280
+ const node = namehash(checked.name)
281
+ const r = CONTRACTS.registry
282
+ const addrAt = (coinType: bigint) =>
283
+ ({ address: r, abi: registryAbi, functionName: 'addr', args: [node, coinType] }) as const
284
+ const text = (key: TextKey) => ({ address: r, abi: registryAbi, functionName: 'text', args: [node, key] }) as const
285
+
286
+ let results
287
+ try {
288
+ results = await client.multicall({
289
+ contracts: [
290
+ { address: r, abi: registryAbi, functionName: 'ownerOf', args: [BigInt(node)] },
291
+ addrAt(CHAINS.robinhood.coinType),
292
+ addrAt(CHAINS.ethereum.coinType),
293
+ addrAt(CHAINS.bitcoin.coinType),
294
+ addrAt(CHAINS.solana.coinType),
295
+ addrAt(CHAINS.xrp.coinType),
296
+ { address: r, abi: registryAbi, functionName: 'contenthash', args: [node] },
297
+ text('avatar'),
298
+ text('description'),
299
+ text('url'),
300
+ text('com.twitter'),
301
+ text('com.github'),
302
+ text('email'),
303
+ ],
304
+ allowFailure: true,
305
+ // viem splits a multicall past 1024 bytes of calldata by default; these 13 reads
306
+ // are ~1.2 KB. One request is the point: two is the shape public RPCs throttle.
307
+ batchSize: 0,
308
+ })
309
+ } catch (cause) {
310
+ throw new HoodfiError('UNREADABLE', UNREADABLE_MSG, { cause })
311
+ }
312
+ if (batchFailed(results)) throw new HoodfiError('UNREADABLE', UNREADABLE_MSG)
313
+
314
+ const [owner, rh, eth, btc, sol, xrp, contenthash, ...texts] = results
315
+ if (!owner || owner.status === 'failure') return null
316
+
317
+ const stored = (e: (typeof results)[number] | undefined): Hex =>
318
+ e && e.status === 'success' && typeof e.result === 'string' ? (e.result as Hex) : '0x'
319
+
320
+ const records: Partial<Record<TextKey, string>> = {}
321
+ TEXT_KEYS.forEach((key, i) => {
322
+ const e = texts[i]
323
+ if (e && e.status === 'success' && typeof e.result === 'string' && e.result) records[key] = e.result
324
+ })
325
+
326
+ const content = decodeContenthash(stored(contenthash))
327
+ return {
328
+ name: checked.name,
329
+ node,
330
+ owner: getAddress(owner.result as Address),
331
+ addresses: {
332
+ robinhood: CHAINS.robinhood.decode(stored(rh)),
333
+ ethereum: CHAINS.ethereum.decode(stored(eth)),
334
+ bitcoin: CHAINS.bitcoin.decode(stored(btc)),
335
+ solana: CHAINS.solana.decode(stored(sol)),
336
+ xrp: CHAINS.xrp.decode(stored(xrp)),
337
+ },
338
+ records,
339
+ website: content
340
+ ? { ...content, url: `https://${checked.name}.link/`, gateway: contentGatewayUrl(content) }
341
+ : null,
342
+ explorer: `${ROBINHOOD_EXPLORER}/token/${r}/instance/${BigInt(node).toString()}`,
343
+ }
344
+ }
345
+
346
+ /**
347
+ * The address to pay `name` at on `chain`, or a HoodfiError.
348
+ *
349
+ * Reads that chain's own record and nothing else. An empty Robinhood Chain record is
350
+ * NOT filled from the Ethereum one (or vice versa): a contract wallet like a Safe only
351
+ * exists on chains it was deployed to, and funds sent to its address elsewhere are
352
+ * stuck. Empty means ask the recipient.
353
+ */
354
+ async function getSendAddress(name: string, chain: SendChain): Promise<SendAddress> {
355
+ const spec = CHAINS[chain]
356
+ if (!spec) throw new HoodfiError('INVALID_NAME', `Unknown chain "${String(chain)}".`)
357
+ const profile = await resolve(name)
358
+ if (!profile) throw new HoodfiError('NOT_REGISTERED', `${requireName(name).name} is not registered.`)
359
+
360
+ const address = profile.addresses[chain]
361
+ const sibling = chain === 'robinhood' ? 'ethereum' : chain === 'ethereum' ? 'robinhood' : null
362
+ const other = sibling ? profile.addresses[sibling] : null
363
+
364
+ if (!address)
365
+ throw new HoodfiError(
366
+ 'NO_RECORD',
367
+ `${profile.name} has no ${spec.label} address set, so it can't be paid there. Don't send to one of its other addresses instead.` +
368
+ (other && sibling ? ` It does have a ${CHAINS[sibling].label} address; the owner should set both.` : '')
369
+ )
370
+
371
+ const warnings: string[] = []
372
+ if (other && sibling && other !== address)
373
+ warnings.push(
374
+ `${profile.name}'s two ETH records disagree: ${spec.label} → ${address}, ${CHAINS[sibling].label} → ${other}. This uses the ${spec.label} one; confirm with the recipient.`
375
+ )
376
+ return { name: profile.name, chain, address, warnings, caveat: paymentCaveat(chain, address) }
377
+ }
378
+
379
+ /**
380
+ * The hoodfi names an address holds, and which one to display for it.
381
+ *
382
+ * Served by the HoodFi gateway (it indexes Transfer logs; the registry isn't
383
+ * enumerable), cached ~60s. Throws UNREADABLE on a failed read: an empty `names` list
384
+ * really means none.
385
+ */
386
+ async function lookupAddress(address: string): Promise<AddressLookup> {
387
+ const checksummed = requireAddress(address)
388
+ let response: Response
389
+ try {
390
+ response = await doFetch(`${apiUrl}/owner/${checksummed}/names`, { headers: { accept: 'application/json' } })
391
+ } catch (cause) {
392
+ throw new HoodfiError('UNREADABLE', 'Could not reach the HoodFi API. Try again shortly.', { cause })
393
+ }
394
+ if (!response.ok)
395
+ throw new HoodfiError('UNREADABLE', `The HoodFi API answered ${response.status}. This says nothing about the address; try again shortly.`)
396
+ return (await response.json()) as AddressLookup
397
+ }
398
+
399
+ /**
400
+ * Unsigned transactions that register `name` for `account`.
401
+ *
402
+ * The registrar mints to whoever SENDS the transaction: there is no recipient
403
+ * argument. Sign with `account` or the name goes to the actual signer.
404
+ *
405
+ * `payWith: 'usdg'` adds an approve step when the allowance is short. `overpayBps`
406
+ * (ETH only, default 100 = 1%) absorbs price-feed movement between quote and send;
407
+ * the registrar refunds anything above the price to the sender.
408
+ */
409
+ async function buildRegistration(args: {
410
+ name: string
411
+ account: string
412
+ payWith?: 'eth' | 'usdg'
413
+ overpayBps?: number
414
+ }): Promise<Registration> {
415
+ const label = requireLabel(args.name)
416
+ const account = requireAddress(args.account)
417
+ const payWith = args.payWith ?? 'eth'
418
+ if (payWith !== 'eth' && payWith !== 'usdg') throw new HoodfiError('INVALID_NAME', 'payWith must be "eth" or "usdg".')
419
+ const overpayBps = args.overpayBps ?? 100
420
+ if (!Number.isInteger(overpayBps) || overpayBps < 0 || overpayBps > 10_000)
421
+ throw new HoodfiError('INVALID_NAME', 'overpayBps must be an integer from 0 to 10000.')
422
+
423
+ const { status, wei, usdg, paused } = await readRegistrar(label)
424
+ if (paused) throw new HoodfiError('UNAVAILABLE', 'Minting is paused by the registrar owner.')
425
+ const reason = unavailableReason(status, label)
426
+ if (reason) throw new HoodfiError('UNAVAILABLE', reason)
427
+
428
+ const name = fullName(label)
429
+ const steps: TxStep[] = []
430
+ const warnings: string[] = []
431
+
432
+ try {
433
+ if (payWith === 'eth') {
434
+ const value = wei + (wei * BigInt(overpayBps)) / 10_000n
435
+ const balance = await client.getBalance({ address: account })
436
+ if (balance < value) warnings.push(`${account} holds less ETH on Robinhood Chain than the ${value} wei this sends.`)
437
+ steps.push({
438
+ description: `Register ${name}`,
439
+ to: CONTRACTS.registrar,
440
+ data: encodeFunctionData({ abi: registrarAbi, functionName: 'register', args: [label] }),
441
+ value,
442
+ chainId: ROBINHOOD_CHAIN_ID,
443
+ })
444
+ } else {
445
+ const [allowance, balance] = await client.multicall({
446
+ contracts: [
447
+ { address: CONTRACTS.usdg, abi: erc20Abi, functionName: 'allowance', args: [account, CONTRACTS.registrar] },
448
+ { address: CONTRACTS.usdg, abi: erc20Abi, functionName: 'balanceOf', args: [account] },
449
+ ],
450
+ allowFailure: false,
451
+ })
452
+ if (balance < usdg) warnings.push(`${account} holds less USDG than the price.`)
453
+ if (allowance < usdg)
454
+ steps.push({
455
+ description: 'Approve the registrar to spend the USDG price',
456
+ to: CONTRACTS.usdg,
457
+ data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [CONTRACTS.registrar, usdg] }),
458
+ value: 0n,
459
+ chainId: ROBINHOOD_CHAIN_ID,
460
+ })
461
+ steps.push({
462
+ description: `Register ${name}`,
463
+ to: CONTRACTS.registrar,
464
+ data: encodeFunctionData({ abi: registrarAbi, functionName: 'registerWithUsdc', args: [label] }),
465
+ value: 0n,
466
+ chainId: ROBINHOOD_CHAIN_ID,
467
+ })
468
+ }
469
+ } catch (cause) {
470
+ throw new HoodfiError('UNREADABLE', UNREADABLE_MSG, { cause })
471
+ }
472
+
473
+ return { name, label, payWith, price: { wei, usdg }, steps, warnings }
474
+ }
475
+
476
+ return { checkName, resolve, getSendAddress, lookupAddress, buildRegistration, publicClient: client }
477
+ }
478
+
package/src/coins.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Decoding the address records a name can carry.
3
+ *
4
+ * ENSIP-9 stores an address as its chain's own bytes, not the text people copy (Bitcoin
5
+ * keeps its scriptPubKey, Solana its raw ed25519 key), so each record is re-derived
6
+ * through a per-chain codec rather than echoed. Same codecs as mcp/src/coins.ts and
7
+ * frontend/lib/ens.ts: `@ensdomains/address-encoder`.
8
+ */
9
+
10
+ import { encodeBtcAddress, encodeSolAddress } from '@ensdomains/address-encoder/coders'
11
+ import { type Hex, getAddress, hexToBytes } from 'viem'
12
+
13
+ import { BTC_COIN_TYPE, ETH_COIN_TYPE, ROBINHOOD_COIN_TYPE, SOL_COIN_TYPE, XRP_COIN_TYPE } from './constants.js'
14
+ import { encodeXrpRecord } from './xrp.js'
15
+
16
+ /** Where a payment goes. `robinhood` and `ethereum` are separate records on purpose. */
17
+ export type SendChain = 'robinhood' | 'ethereum' | 'bitcoin' | 'solana' | 'xrp'
18
+
19
+ export type Verification = 'checksummed' | 'if-mixed-case' | 'length-only'
20
+
21
+ interface Chain {
22
+ label: string
23
+ symbol: string
24
+ coinType: bigint
25
+ /**
26
+ * How much a well-formed record proves. Bitcoin and XRP carry a checksum. An EVM
27
+ * record is checksummed when written in EIP-55 case, which this SDK always returns.
28
+ * Solana has none: any 32 bytes is a valid-looking key, so a typo at write time is
29
+ * undetectable at read time.
30
+ */
31
+ verification: Verification
32
+ decode(stored: Hex): string | null
33
+ }
34
+
35
+ const ZERO = '0x0000000000000000000000000000000000000000'
36
+
37
+ function evm(stored: Hex): string | null {
38
+ if (!stored || stored.length !== 42) return null
39
+ const address = getAddress(stored)
40
+ return address === ZERO ? null : address
41
+ }
42
+
43
+ function via(toText: (bytes: Uint8Array) => string) {
44
+ return (stored: Hex): string | null => {
45
+ if (!stored || stored === '0x') return null
46
+ try {
47
+ return toText(hexToBytes(stored))
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+ }
53
+
54
+ // The library names directions from the address text's side: `encode…` turns stored
55
+ // bytes INTO text, which is the only direction a reader needs.
56
+ export const CHAINS: Record<SendChain, Chain> = {
57
+ robinhood: { label: 'Robinhood Chain', symbol: 'ETH', coinType: ROBINHOOD_COIN_TYPE, verification: 'if-mixed-case', decode: evm },
58
+ ethereum: { label: 'Ethereum', symbol: 'ETH', coinType: ETH_COIN_TYPE, verification: 'if-mixed-case', decode: evm },
59
+ bitcoin: { label: 'Bitcoin', symbol: 'BTC', coinType: BTC_COIN_TYPE, verification: 'checksummed', decode: via(encodeBtcAddress) },
60
+ solana: { label: 'Solana', symbol: 'SOL', coinType: SOL_COIN_TYPE, verification: 'length-only', decode: via(encodeSolAddress) },
61
+ xrp: { label: 'XRP Ledger', symbol: 'XRP', coinType: XRP_COIN_TYPE, verification: 'checksummed', decode: via(encodeXrpRecord) },
62
+ }
63
+
64
+ export const SEND_CHAINS = Object.keys(CHAINS) as SendChain[]
65
+
66
+ /**
67
+ * What to tell a user before they pay this address, or null when the record itself
68
+ * proves it is well-formed. Relay it; don't report the address as "validated".
69
+ */
70
+ export function paymentCaveat(chain: SendChain, address: string): string | null {
71
+ if (chain === 'xrp' && address.startsWith('r'))
72
+ return 'Classic XRP Ledger address (r…): it carries no destination tag. Fine for a personal wallet; an exchange deposit sent without its tag can be lost.'
73
+ if (chain === 'solana')
74
+ return 'Solana addresses carry no checksum, so a mistyped record looks exactly like a valid one. Confirm it with the recipient before a large payment.'
75
+ return null
76
+ }
@@ -0,0 +1,41 @@
1
+ import { type Chain, defineChain } from 'viem'
2
+
3
+ export const PARENT = 'hoodfi.eth'
4
+ export const ROBINHOOD_CHAIN_ID = 4663
5
+ export const DEFAULT_ROBINHOOD_RPC = 'https://rpc.mainnet.chain.robinhood.com'
6
+ /** Used when the public RPC rate-limits. It's throttled too, but on a separate budget. */
7
+ export const FALLBACK_ROBINHOOD_RPC = 'https://robinhoodchain.blockscout.com/api/eth-rpc'
8
+ export const ROBINHOOD_EXPLORER = 'https://robinhoodchain.blockscout.com'
9
+ /** Base URL of the HoodFi gateway that serves the reverse lookup. */
10
+ export const DEFAULT_API_URL = 'https://ccip.hoodfi-mcp.com'
11
+
12
+ /** Live contracts. Owners and sources: contracts/README.md in the repository. */
13
+ export const CONTRACTS = {
14
+ registry: '0xf2bABA012244bdD7445129597350054E1B3aEe5C',
15
+ registrar: '0x56be5565acc823f4195c2cf3b9046C083633209a',
16
+ partnerRouter: '0xb7bd5f2c7c445ddc5d55ebd9b8f6a20c2e6e4b5d',
17
+ usdg: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168',
18
+ /** HoodfiL1Resolver on Ethereum mainnet. */
19
+ l1Resolver: '0x37215Dd89D0Fd4ea0Dbce690bDe58490fB7f7cF2',
20
+ } as const
21
+
22
+ /**
23
+ * The ENSIP-11 coinType for Robinhood Chain, 0x80000000 | 4663.
24
+ *
25
+ * It's written as a literal on purpose. In JS, `0x80000000 | 4663` is a signed 32-bit
26
+ * operation and comes out negative, so every addr read silently returns nothing.
27
+ */
28
+ export const ROBINHOOD_COIN_TYPE = 2147488311n
29
+ export const ETH_COIN_TYPE = 60n
30
+ export const BTC_COIN_TYPE = 0n
31
+ export const SOL_COIN_TYPE = 501n
32
+ export const XRP_COIN_TYPE = 144n
33
+
34
+ export const robinhoodChain: Chain = defineChain({
35
+ id: ROBINHOOD_CHAIN_ID,
36
+ name: 'Robinhood Chain',
37
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
38
+ rpcUrls: { default: { http: [DEFAULT_ROBINHOOD_RPC] } },
39
+ blockExplorers: { default: { name: 'Blockscout', url: ROBINHOOD_EXPLORER } },
40
+ contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
41
+ })