@hoodfi/sdk 0.0.0-stage → 0.1.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/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.1",
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,481 @@
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
+ /**
81
+ * ETH price in wei: a fixed per-tier amount the registrar owner sets, not an oracle
82
+ * price, so its dollar value moves with ETH. Read fresh on every call.
83
+ */
84
+ wei: bigint
85
+ /** USDG price, 6 decimals. */
86
+ usdg: bigint
87
+ }
88
+ }
89
+
90
+ export interface NameProfile {
91
+ name: string
92
+ node: Hex
93
+ owner: Address
94
+ /**
95
+ * Robinhood Chain and Ethereum are separate records. They normally hold the same
96
+ * address, but reported apart so a name where they differ is visible.
97
+ */
98
+ addresses: Record<SendChain, string | null>
99
+ /** Text records that are set: avatar, description, url, com.twitter, com.github, email. */
100
+ records: Partial<Record<TextKey, string>>
101
+ /** The name's website, or null when it has none (or one in a namespace not decoded here). */
102
+ website: (ContentHash & { url: string; gateway: string }) | null
103
+ explorer: string
104
+ }
105
+
106
+ export interface SendAddress {
107
+ name: string
108
+ chain: SendChain
109
+ address: string
110
+ /** E.g. the Robinhood and Ethereum records disagree. Show these to the sender. */
111
+ warnings: string[]
112
+ /** What a well-formed record can't prove (Solana has no checksum, XRP r-addresses no tag). */
113
+ caveat: string | null
114
+ }
115
+
116
+ export interface OwnedName {
117
+ name: string
118
+ node: Hex
119
+ /** The name's ETH address record, or null when unset. */
120
+ addr: Address | null
121
+ /** True when the name's record points back at this address. Only these may be shown as "this wallet is X". */
122
+ resolves: boolean
123
+ avatar: string | null
124
+ }
125
+
126
+ export interface AddressLookup {
127
+ address: Address
128
+ /** The name to display for this address: it resolves back to it. Null when none does. */
129
+ primary: string | null
130
+ /** "ens": the holder set it as their ENS primary. "inferred": shortest name that resolves back. */
131
+ primarySource: 'ens' | 'inferred' | null
132
+ /** Every hoodfi name this address holds now, resolving ones first. */
133
+ names: OwnedName[]
134
+ }
135
+
136
+ export interface TxStep {
137
+ description: string
138
+ to: Address
139
+ data: Hex
140
+ value: bigint
141
+ chainId: typeof ROBINHOOD_CHAIN_ID
142
+ }
143
+
144
+ export interface Registration {
145
+ name: string
146
+ label: string
147
+ payWith: 'eth' | 'usdg'
148
+ /** The price at quote time. ETH steps send `price.wei` plus the overpay buffer. */
149
+ price: { wei: bigint; usdg: bigint }
150
+ /** Send in order; wait for each receipt (and check its status) before the next. */
151
+ steps: TxStep[]
152
+ /** E.g. the account can't afford it yet. The steps still build. */
153
+ warnings: string[]
154
+ }
155
+
156
+ const TEXT_KEYS = ['avatar', 'description', 'url', 'com.twitter', 'com.github', 'email'] as const
157
+ export type TextKey = (typeof TEXT_KEYS)[number]
158
+
159
+ const UNREADABLE_MSG =
160
+ 'Could not read Robinhood Chain: the reads failed rather than coming back empty, so this says nothing about the name. Try again shortly.'
161
+
162
+ function unavailableReason(status: number, label: string): string | undefined {
163
+ switch (status) {
164
+ case 0:
165
+ return undefined
166
+ case 1:
167
+ return `${fullName(label)} is already registered.`
168
+ case 2:
169
+ return `${fullName(label)} is locked and not on sale right now.`
170
+ case 3:
171
+ return `${label} is not a valid label.`
172
+ case 4:
173
+ return `${label} is reserved and can never be registered.`
174
+ default:
175
+ return `${fullName(label)} is not available (status ${status}).`
176
+ }
177
+ }
178
+
179
+ /**
180
+ * A batch where nothing succeeded is a dead connection, not an answer.
181
+ *
182
+ * With `allowFailure: true`, a failed aggregate call (a 429 from a throttled RPC) marks
183
+ * every entry as failed, exactly like each one reverting. For a never-minted name only
184
+ * `ownerOf` reverts; `addr`/`text`/`contenthash` return empty successfully. So "all
185
+ * failed" can only mean the transport.
186
+ */
187
+ function batchFailed(results: readonly { status: 'success' | 'failure' }[]): boolean {
188
+ return results.every((r) => r.status === 'failure')
189
+ }
190
+
191
+ function requireName(input: string): { name: string; labels: string[] } {
192
+ if (typeof input !== 'string') throw new HoodfiError('INVALID_NAME', 'Name must be a string.')
193
+ const checked = checkHoodfiName(input)
194
+ if (!checked.ok) throw new HoodfiError('INVALID_NAME', checked.reason)
195
+ return checked
196
+ }
197
+
198
+ function requireLabel(input: string): string {
199
+ if (typeof input !== 'string') throw new HoodfiError('INVALID_NAME', 'Name must be a string.')
200
+ const checked = checkLabel(input)
201
+ if (!checked.ok) throw new HoodfiError('INVALID_NAME', checked.reason)
202
+ return checked.label
203
+ }
204
+
205
+ function requireAddress(value: string): Address {
206
+ if (typeof value !== 'string' || !isAddress(value, { strict: false }))
207
+ throw new HoodfiError('INVALID_NAME', 'Expected a 0x-prefixed 20-byte address.')
208
+ return getAddress(value)
209
+ }
210
+
211
+ export interface HoodfiClient {
212
+ checkName(name: string): Promise<NameCheck>
213
+ resolve(name: string): Promise<NameProfile | null>
214
+ getSendAddress(name: string, chain: SendChain): Promise<SendAddress>
215
+ lookupAddress(address: string): Promise<AddressLookup>
216
+ buildRegistration(args: { name: string; account: string; payWith?: 'eth' | 'usdg'; overpayBps?: number }): Promise<Registration>
217
+ /** The viem client in use, e.g. for `waitForTransactionReceipt`. */
218
+ publicClient: PublicClient
219
+ }
220
+
221
+ export function createHoodfiClient(options: HoodfiClientOptions = {}): HoodfiClient {
222
+ const client: PublicClient =
223
+ options.publicClient ??
224
+ (createPublicClient({
225
+ chain: robinhoodChain,
226
+ transport: fallback(
227
+ [...(options.rpcUrls ?? []), DEFAULT_ROBINHOOD_RPC, FALLBACK_ROBINHOOD_RPC].map((url) =>
228
+ http(url, { retryCount: 2, retryDelay: 200, timeout: 8_000 })
229
+ ),
230
+ // Ranking probes every endpoint in the background, spending the rate limit
231
+ // this fallback exists to conserve.
232
+ { rank: false }
233
+ ),
234
+ }) as PublicClient)
235
+ const apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, '')
236
+ const doFetch = options.fetch ?? globalThis.fetch
237
+
238
+ async function readRegistrar(label: string) {
239
+ try {
240
+ // allowFailure: false: none of these can revert for a valid label, so a failure
241
+ // is the RPC, and must not be folded into an answer.
242
+ const [status, prices, paused] = await client.multicall({
243
+ contracts: [
244
+ { address: CONTRACTS.registrar, abi: registrarAbi, functionName: 'status', args: [label] },
245
+ { address: CONTRACTS.registrar, abi: registrarAbi, functionName: 'priceOf', args: [label] },
246
+ { address: CONTRACTS.registrar, abi: registrarAbi, functionName: 'paused' },
247
+ ],
248
+ allowFailure: false,
249
+ })
250
+ return { status, wei: prices[0], usdg: prices[1], paused }
251
+ } catch (cause) {
252
+ throw new HoodfiError('UNREADABLE', UNREADABLE_MSG, { cause })
253
+ }
254
+ }
255
+
256
+ /** Is a name free, and what does it cost? Prices are read from the registrar on each call. */
257
+ async function checkName(name: string): Promise<NameCheck> {
258
+ const label = requireLabel(name)
259
+ const { status, wei, usdg, paused } = await readRegistrar(label)
260
+ const reason = paused ? 'Minting is paused by the registrar owner.' : unavailableReason(status, label)
261
+ return {
262
+ name: fullName(label),
263
+ label,
264
+ length: label.length,
265
+ tier: tierOf(label),
266
+ status: STATUS_TEXT[status] ?? 'invalid',
267
+ available: status === 0 && !paused,
268
+ paused,
269
+ ...(reason !== undefined && { reason }),
270
+ price: { wei, usdg },
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Everything a name holds, in one RPC round-trip. Returns null when the name was never
276
+ * minted. Throws UNREADABLE (never null) when the chain didn't answer.
277
+ *
278
+ * Accepts `gm`, `gm.hoodfi.eth` or a subname like `jack.gm.hoodfi.eth`. Does not accept
279
+ * `gm.eth`: that's a different name on Ethereum.
280
+ */
281
+ async function resolve(name: string): Promise<NameProfile | null> {
282
+ const checked = requireName(name)
283
+ const node = namehash(checked.name)
284
+ const r = CONTRACTS.registry
285
+ const addrAt = (coinType: bigint) =>
286
+ ({ address: r, abi: registryAbi, functionName: 'addr', args: [node, coinType] }) as const
287
+ const text = (key: TextKey) => ({ address: r, abi: registryAbi, functionName: 'text', args: [node, key] }) as const
288
+
289
+ let results
290
+ try {
291
+ results = await client.multicall({
292
+ contracts: [
293
+ { address: r, abi: registryAbi, functionName: 'ownerOf', args: [BigInt(node)] },
294
+ addrAt(CHAINS.robinhood.coinType),
295
+ addrAt(CHAINS.ethereum.coinType),
296
+ addrAt(CHAINS.bitcoin.coinType),
297
+ addrAt(CHAINS.solana.coinType),
298
+ addrAt(CHAINS.xrp.coinType),
299
+ { address: r, abi: registryAbi, functionName: 'contenthash', args: [node] },
300
+ text('avatar'),
301
+ text('description'),
302
+ text('url'),
303
+ text('com.twitter'),
304
+ text('com.github'),
305
+ text('email'),
306
+ ],
307
+ allowFailure: true,
308
+ // viem splits a multicall past 1024 bytes of calldata by default; these 13 reads
309
+ // are ~1.2 KB. One request is the point: two is the shape public RPCs throttle.
310
+ batchSize: 0,
311
+ })
312
+ } catch (cause) {
313
+ throw new HoodfiError('UNREADABLE', UNREADABLE_MSG, { cause })
314
+ }
315
+ if (batchFailed(results)) throw new HoodfiError('UNREADABLE', UNREADABLE_MSG)
316
+
317
+ const [owner, rh, eth, btc, sol, xrp, contenthash, ...texts] = results
318
+ if (!owner || owner.status === 'failure') return null
319
+
320
+ const stored = (e: (typeof results)[number] | undefined): Hex =>
321
+ e && e.status === 'success' && typeof e.result === 'string' ? (e.result as Hex) : '0x'
322
+
323
+ const records: Partial<Record<TextKey, string>> = {}
324
+ TEXT_KEYS.forEach((key, i) => {
325
+ const e = texts[i]
326
+ if (e && e.status === 'success' && typeof e.result === 'string' && e.result) records[key] = e.result
327
+ })
328
+
329
+ const content = decodeContenthash(stored(contenthash))
330
+ return {
331
+ name: checked.name,
332
+ node,
333
+ owner: getAddress(owner.result as Address),
334
+ addresses: {
335
+ robinhood: CHAINS.robinhood.decode(stored(rh)),
336
+ ethereum: CHAINS.ethereum.decode(stored(eth)),
337
+ bitcoin: CHAINS.bitcoin.decode(stored(btc)),
338
+ solana: CHAINS.solana.decode(stored(sol)),
339
+ xrp: CHAINS.xrp.decode(stored(xrp)),
340
+ },
341
+ records,
342
+ website: content
343
+ ? { ...content, url: `https://${checked.name}.link/`, gateway: contentGatewayUrl(content) }
344
+ : null,
345
+ explorer: `${ROBINHOOD_EXPLORER}/token/${r}/instance/${BigInt(node).toString()}`,
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The address to pay `name` at on `chain`, or a HoodfiError.
351
+ *
352
+ * Reads that chain's own record and nothing else. An empty Robinhood Chain record is
353
+ * NOT filled from the Ethereum one (or vice versa): a contract wallet like a Safe only
354
+ * exists on chains it was deployed to, and funds sent to its address elsewhere are
355
+ * stuck. Empty means ask the recipient.
356
+ */
357
+ async function getSendAddress(name: string, chain: SendChain): Promise<SendAddress> {
358
+ const spec = CHAINS[chain]
359
+ if (!spec) throw new HoodfiError('INVALID_NAME', `Unknown chain "${String(chain)}".`)
360
+ const profile = await resolve(name)
361
+ if (!profile) throw new HoodfiError('NOT_REGISTERED', `${requireName(name).name} is not registered.`)
362
+
363
+ const address = profile.addresses[chain]
364
+ const sibling = chain === 'robinhood' ? 'ethereum' : chain === 'ethereum' ? 'robinhood' : null
365
+ const other = sibling ? profile.addresses[sibling] : null
366
+
367
+ if (!address)
368
+ throw new HoodfiError(
369
+ 'NO_RECORD',
370
+ `${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.` +
371
+ (other && sibling ? ` It does have a ${CHAINS[sibling].label} address; the owner should set both.` : '')
372
+ )
373
+
374
+ const warnings: string[] = []
375
+ if (other && sibling && other !== address)
376
+ warnings.push(
377
+ `${profile.name}'s two ETH records disagree: ${spec.label} → ${address}, ${CHAINS[sibling].label} → ${other}. This uses the ${spec.label} one; confirm with the recipient.`
378
+ )
379
+ return { name: profile.name, chain, address, warnings, caveat: paymentCaveat(chain, address) }
380
+ }
381
+
382
+ /**
383
+ * The hoodfi names an address holds, and which one to display for it.
384
+ *
385
+ * Served by the HoodFi gateway (it indexes Transfer logs; the registry isn't
386
+ * enumerable), cached ~60s. Throws UNREADABLE on a failed read: an empty `names` list
387
+ * really means none.
388
+ */
389
+ async function lookupAddress(address: string): Promise<AddressLookup> {
390
+ const checksummed = requireAddress(address)
391
+ let response: Response
392
+ try {
393
+ response = await doFetch(`${apiUrl}/owner/${checksummed}/names`, { headers: { accept: 'application/json' } })
394
+ } catch (cause) {
395
+ throw new HoodfiError('UNREADABLE', 'Could not reach the HoodFi API. Try again shortly.', { cause })
396
+ }
397
+ if (!response.ok)
398
+ throw new HoodfiError('UNREADABLE', `The HoodFi API answered ${response.status}. This says nothing about the address; try again shortly.`)
399
+ return (await response.json()) as AddressLookup
400
+ }
401
+
402
+ /**
403
+ * Unsigned transactions that register `name` for `account`.
404
+ *
405
+ * The registrar mints to whoever SENDS the transaction: there is no recipient
406
+ * argument. Sign with `account` or the name goes to the actual signer.
407
+ *
408
+ * `payWith: 'usdg'` adds an approve step when the allowance is short. `overpayBps`
409
+ * (ETH only, default 100 = 1%) covers the owner repricing between quote and send;
410
+ * the registrar refunds anything above the price to the sender.
411
+ */
412
+ async function buildRegistration(args: {
413
+ name: string
414
+ account: string
415
+ payWith?: 'eth' | 'usdg'
416
+ overpayBps?: number
417
+ }): Promise<Registration> {
418
+ const label = requireLabel(args.name)
419
+ const account = requireAddress(args.account)
420
+ const payWith = args.payWith ?? 'eth'
421
+ if (payWith !== 'eth' && payWith !== 'usdg') throw new HoodfiError('INVALID_NAME', 'payWith must be "eth" or "usdg".')
422
+ const overpayBps = args.overpayBps ?? 100
423
+ if (!Number.isInteger(overpayBps) || overpayBps < 0 || overpayBps > 10_000)
424
+ throw new HoodfiError('INVALID_NAME', 'overpayBps must be an integer from 0 to 10000.')
425
+
426
+ const { status, wei, usdg, paused } = await readRegistrar(label)
427
+ if (paused) throw new HoodfiError('UNAVAILABLE', 'Minting is paused by the registrar owner.')
428
+ const reason = unavailableReason(status, label)
429
+ if (reason) throw new HoodfiError('UNAVAILABLE', reason)
430
+
431
+ const name = fullName(label)
432
+ const steps: TxStep[] = []
433
+ const warnings: string[] = []
434
+
435
+ try {
436
+ if (payWith === 'eth') {
437
+ const value = wei + (wei * BigInt(overpayBps)) / 10_000n
438
+ const balance = await client.getBalance({ address: account })
439
+ if (balance < value) warnings.push(`${account} holds less ETH on Robinhood Chain than the ${value} wei this sends.`)
440
+ steps.push({
441
+ description: `Register ${name}`,
442
+ to: CONTRACTS.registrar,
443
+ data: encodeFunctionData({ abi: registrarAbi, functionName: 'register', args: [label] }),
444
+ value,
445
+ chainId: ROBINHOOD_CHAIN_ID,
446
+ })
447
+ } else {
448
+ const [allowance, balance] = await client.multicall({
449
+ contracts: [
450
+ { address: CONTRACTS.usdg, abi: erc20Abi, functionName: 'allowance', args: [account, CONTRACTS.registrar] },
451
+ { address: CONTRACTS.usdg, abi: erc20Abi, functionName: 'balanceOf', args: [account] },
452
+ ],
453
+ allowFailure: false,
454
+ })
455
+ if (balance < usdg) warnings.push(`${account} holds less USDG than the price.`)
456
+ if (allowance < usdg)
457
+ steps.push({
458
+ description: 'Approve the registrar to spend the USDG price',
459
+ to: CONTRACTS.usdg,
460
+ data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [CONTRACTS.registrar, usdg] }),
461
+ value: 0n,
462
+ chainId: ROBINHOOD_CHAIN_ID,
463
+ })
464
+ steps.push({
465
+ description: `Register ${name}`,
466
+ to: CONTRACTS.registrar,
467
+ data: encodeFunctionData({ abi: registrarAbi, functionName: 'registerWithUsdc', args: [label] }),
468
+ value: 0n,
469
+ chainId: ROBINHOOD_CHAIN_ID,
470
+ })
471
+ }
472
+ } catch (cause) {
473
+ throw new HoodfiError('UNREADABLE', UNREADABLE_MSG, { cause })
474
+ }
475
+
476
+ return { name, label, payWith, price: { wei, usdg }, steps, warnings }
477
+ }
478
+
479
+ return { checkName, resolve, getSendAddress, lookupAddress, buildRegistration, publicClient: client }
480
+ }
481
+
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
+ })