@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/README.md +103 -2
- package/dist/abi.d.ts +106 -0
- package/dist/abi.d.ts.map +1 -0
- package/dist/abi.js +34 -0
- package/dist/abi.js.map +1 -0
- package/dist/client.d.ts +142 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +304 -0
- package/dist/client.js.map +1 -0
- package/dist/coins.d.ts +34 -0
- package/dist/coins.d.ts.map +1 -0
- package/dist/coins.js +53 -0
- package/dist/coins.js.map +1 -0
- package/dist/constants.d.ts +31 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +38 -0
- package/dist/constants.js.map +1 -0
- package/dist/contenthash.d.ts +96 -0
- package/dist/contenthash.d.ts.map +1 -0
- package/dist/contenthash.js +385 -0
- package/dist/contenthash.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.d.ts +45 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +83 -0
- package/dist/labels.js.map +1 -0
- package/dist/xrp.d.ts +24 -0
- package/dist/xrp.d.ts.map +1 -0
- package/dist/xrp.js +43 -0
- package/dist/xrp.js.map +1 -0
- package/package.json +41 -4
- package/src/abi.ts +35 -0
- package/src/client.ts +478 -0
- package/src/coins.ts +76 -0
- package/src/constants.ts +41 -0
- package/src/contenthash.ts +408 -0
- package/src/index.ts +30 -0
- package/src/labels.ts +91 -0
- package/src/xrp.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoodfi/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
})
|