Skip to content

Commit 859fd47

Browse files
committed
added ChainDirectLightClient (chainDLC)
1 parent cc28c83 commit 859fd47

3 files changed

Lines changed: 396 additions & 1 deletion

File tree

configs/tsup.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ export default defineConfig([
77
string: "src/StringUtils/index.ts",
88
address: "src/Address/index.ts",
99
extension: "src/ExtensionTools/index.ts",
10+
chainDLC: "src/ChainDirectLightClient/index.ts",
1011
},
1112
format: [
1213
"esm",
1314
"cjs"
1415
],
1516
target: 'es2020',
1617
dts: true,
17-
splitting: false,
18+
// splitting: false,
1819
sourcemap: true,
1920
// noExternal: [/^base/, /\^@noble/],
2021
},
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
import {Address, StringUtils, CrossAccountIdUncapitalized, EnhancedCrossAccountId} from '../index'
2+
3+
type MakeFieldsNullable<Ob> = { [K in keyof Ob]: Ob[K] | null }
4+
5+
export enum UNIQUE_CHAINS {
6+
unique = 'unique',
7+
quartz = 'quartz',
8+
opal = 'opal',
9+
sapphire = 'sapphire',
10+
}
11+
12+
const UNIQUE_RPCs: { [K in UNIQUE_CHAINS]: string } = {
13+
quartz: 'https://rpc-quartz.unique.network/',
14+
opal: 'https://rpc-opal.unique.network/',
15+
unique: 'https://rpc.unique.network/',
16+
sapphire: 'https://rpc-sapphire.unique.network/',
17+
}
18+
19+
const requestRPC = async <T = any>(rpcUrl: string, method: string, params: unknown[]): Promise<T> => {
20+
const fetch = globalThis.fetch
21+
const response = await fetch(rpcUrl, {
22+
method: 'POST',
23+
headers: {'Content-Type': 'application/json'},
24+
body: JSON.stringify({jsonrpc: "2.0", id: 1, method, params}),
25+
})
26+
const result = await response.json()
27+
return result.result as T
28+
}
29+
30+
export type TokenPropertyPermissionValue = {
31+
mutable: boolean
32+
collection_admin: boolean
33+
token_owner: boolean
34+
}
35+
export type TokenPropertyPermission = {
36+
key: string
37+
permission: TokenPropertyPermissionValue
38+
}
39+
40+
const decodeTPPArray = (arr: Array<{ key: number[], permission: any }>): TokenPropertyPermission[] => {
41+
return arr.map(({key, permission}) => {
42+
return {
43+
key: StringUtils.Utf8.numberArrayToString(key),
44+
permission: permission as TokenPropertyPermissionValue,
45+
}
46+
})
47+
}
48+
49+
export interface DecodedProperty {
50+
key: string
51+
value: string
52+
keyBytes: string
53+
valueByres: string
54+
}
55+
56+
export type DecodedPropertiesMap = Record<string, DecodedProperty>
57+
58+
const decodeProperties = (arr: Array<{ key: number[], value: number[] }>): { properties: DecodedProperty[], propertiesMap: DecodedPropertiesMap } => {
59+
const properties: DecodedProperty[] = []
60+
const propertiesMap: Record<string, DecodedProperty> = {}
61+
62+
for (const elem of arr) {
63+
const {key, value} = elem
64+
const decoded: DecodedProperty = {
65+
key: StringUtils.Utf8.numberArrayToString(key),
66+
keyBytes: StringUtils.HexString.fromArray(key),
67+
value: StringUtils.Utf8.numberArrayToString(value),
68+
valueByres: StringUtils.HexString.fromArray(value),
69+
}
70+
properties.push(decoded)
71+
propertiesMap[decoded.key] = decoded
72+
}
73+
return {
74+
properties,
75+
propertiesMap,
76+
}
77+
}
78+
79+
export interface CollectionEffectiveLimits { // default value
80+
account_token_ownership_limit: number // 100000
81+
owner_can_destroy: boolean // true
82+
owner_can_transfer: boolean // false
83+
sponsor_approve_timeout: number // 5
84+
sponsor_transfer_timeout: number // 5
85+
sponsored_data_rate_limit: 'SponsoringDisabled' | { Blocks: number } // "SponsoringDisabled"
86+
sponsored_data_size: number // 2048
87+
token_limit: number // 4294967295
88+
transfers_enabled: boolean // true
89+
}
90+
91+
export type CollectionLimits = MakeFieldsNullable<CollectionEffectiveLimits>
92+
93+
export type DecodedCollectionLimits = {
94+
[K in keyof CollectionEffectiveLimits]: {
95+
key: K
96+
value: CollectionEffectiveLimits[K]
97+
isDefaultValue: boolean
98+
}
99+
}
100+
101+
export interface CollectionPermissions { // default value
102+
access: 'Normal' | 'AllowList' // 'Normal'
103+
mint_mode: boolean // false
104+
nesting: {
105+
token_owner: boolean // false
106+
collection_admin: boolean // false
107+
restricted: null | number[] // null
108+
}
109+
}
110+
111+
export type CollectionType = 'NFT' | 'RFT' | 'FT'
112+
113+
export interface ICollection {
114+
collectionId: number
115+
collectionAddress: string
116+
owner: EnhancedCrossAccountId
117+
adminList: EnhancedCrossAccountId[]
118+
mode: 'NFT' | 'ReFungible' | { Fungible: number }
119+
name: string
120+
description: string
121+
tokenPrefix: string
122+
sponsorship: 'Disabled' | { Confirmed: string } | { Unconfirmed: string }
123+
decodedSponsorship: {
124+
enabled: boolean
125+
confirmed: boolean
126+
sponsor: EnhancedCrossAccountId | null
127+
}
128+
lastTokenId: number
129+
limits: CollectionLimits
130+
decodedLimits: DecodedCollectionLimits
131+
permissions: CollectionPermissions
132+
tokenPropertyPermissions: TokenPropertyPermission[]
133+
properties: DecodedProperty[]
134+
propertiesMap: DecodedPropertiesMap
135+
readOnly: boolean
136+
additionalInfo: {
137+
isNFT: boolean
138+
isRFT: boolean
139+
isFT: boolean
140+
type: CollectionType
141+
}
142+
}
143+
144+
export interface INftToken {
145+
collectionId: number
146+
tokenId: number
147+
collectionAddress: string
148+
tokenAddress: string
149+
owner: EnhancedCrossAccountId
150+
properties: DecodedProperty[]
151+
propertiesMap: DecodedPropertiesMap
152+
}
153+
154+
export interface IRftToken {
155+
collectionId: number
156+
tokenId: number
157+
collectionAddress: string
158+
tokenAddress: string
159+
160+
pieces: number
161+
162+
owners: EnhancedCrossAccountId[]
163+
allOwnersAreKnown: boolean
164+
isOnlyOneOwner: boolean
165+
166+
properties: DecodedProperty[]
167+
propertiesMap: DecodedPropertiesMap
168+
}
169+
170+
171+
export const requestCollection = async (rpcUrl: string, collectionId: number, ss58Prefix: number): Promise<ICollection | null> => {
172+
const rawCollection = await requestRPC(rpcUrl, "unique_collectionById", [collectionId])
173+
if (!rawCollection) return null
174+
175+
const {properties, propertiesMap} = decodeProperties(rawCollection.properties)
176+
177+
const [
178+
adminListResult,
179+
effectiveLimits,
180+
lastTokenId,
181+
] = await Promise.all([
182+
requestRPC(rpcUrl, "unique_adminlist", [collectionId]) as Promise<CrossAccountIdUncapitalized[]>,
183+
requestRPC(rpcUrl, 'unique_effectiveCollectionLimits', [collectionId]) as Promise<CollectionEffectiveLimits>,
184+
requestRPC(rpcUrl, 'unique_lastTokenId', [collectionId]) as Promise<number>,
185+
])
186+
187+
const adminList = adminListResult.map(crossAccountId => Address.extract.enhancedCrossAccountId(crossAccountId, ss58Prefix))
188+
189+
const decodedLimits = Object.entries(effectiveLimits).reduce((acc, elem) => {
190+
const [key, value] = elem as [keyof CollectionEffectiveLimits, CollectionEffectiveLimits[keyof CollectionEffectiveLimits]]
191+
192+
acc[key] = {
193+
key,
194+
value,
195+
isDefaultValue: rawCollection.limits[key] === null
196+
} as any
197+
198+
return acc
199+
}, {} as DecodedCollectionLimits)
200+
201+
const isNFT = rawCollection.mode === 'NFT'
202+
const isRFT = rawCollection.mode === 'ReFungible'
203+
const isFT = typeof rawCollection.mode === 'object' && typeof rawCollection.mode?.Fungible === 'number'
204+
205+
const decodedSponsorship: ICollection['decodedSponsorship'] = {
206+
enabled: typeof rawCollection.sponsorship !== 'string',
207+
confirmed: !!rawCollection.sponsorship?.Confirmed,
208+
sponsor: typeof rawCollection.sponsorship === 'object' && !!rawCollection.sponsorship
209+
? rawCollection.sponsorship.Confirmed
210+
? Address.extract.enhancedCrossAccountId(rawCollection.sponsorship.Confirmed, ss58Prefix)
211+
: Address.extract.enhancedCrossAccountId(rawCollection.sponsorship.Unconfirmed, ss58Prefix)
212+
: null
213+
}
214+
215+
const collection: ICollection = {
216+
collectionId,
217+
collectionAddress: Address.collection.idToAddress(collectionId),
218+
owner: Address.extract.enhancedCrossAccountId(rawCollection.owner, ss58Prefix),
219+
adminList,
220+
mode: rawCollection.mode,
221+
name: StringUtils.Utf16.numberArrayToString(rawCollection.name),
222+
description: StringUtils.Utf16.numberArrayToString(rawCollection.description),
223+
tokenPrefix: StringUtils.Utf8.numberArrayToString(rawCollection.token_prefix),
224+
sponsorship: rawCollection.sponsorship,
225+
decodedSponsorship,
226+
lastTokenId,
227+
limits: rawCollection.limits,
228+
decodedLimits,
229+
permissions: rawCollection.permissions,
230+
tokenPropertyPermissions: decodeTPPArray(rawCollection.token_property_permissions),
231+
properties,
232+
propertiesMap,
233+
readOnly: rawCollection.read_only as boolean,
234+
additionalInfo: {
235+
isNFT, isRFT, isFT,
236+
type: isRFT ? 'RFT' : isFT ? 'FT' : 'NFT'
237+
},
238+
}
239+
return collection
240+
}
241+
242+
export const requestNftToken = async (rpcUrl: string, collectionId: number, tokenId: number, ss58Prefix: number): Promise<INftToken | null> => {
243+
const rawToken = await requestRPC(rpcUrl, "unique_tokenData", [collectionId, tokenId])
244+
if (!rawToken || !rawToken.owner) {
245+
return null
246+
}
247+
248+
const {properties, propertiesMap} = decodeProperties(rawToken.properties)
249+
250+
const nftToken: INftToken = {
251+
collectionId,
252+
tokenId,
253+
collectionAddress: Address.collection.idToAddress(collectionId),
254+
tokenAddress: Address.nesting.idsToAddress(collectionId, tokenId),
255+
256+
owner: Address.extract.enhancedCrossAccountId(rawToken.owner, ss58Prefix),
257+
258+
properties,
259+
propertiesMap,
260+
}
261+
return nftToken
262+
}
263+
264+
265+
export const requestRftToken = async (rpcUrl: string, collectionId: number, tokenId: number, ss58Prefix: number): Promise<IRftToken | null> => {
266+
const rawToken = await requestRPC(rpcUrl, "unique_tokenData", [collectionId, tokenId])
267+
if (!rawToken || typeof rawToken.pieces !== 'number') { // protects from NFT/FT collections and from chains below 929030
268+
return null
269+
}
270+
271+
let owners: EnhancedCrossAccountId[] = []
272+
let allOwnersAreKnown = true
273+
274+
if (rawToken.owner) {
275+
owners = [Address.extract.enhancedCrossAccountId(rawToken.owner, ss58Prefix)]
276+
} else {
277+
owners = (await requestRPC<CrossAccountIdUncapitalized[]>(rpcUrl, 'unique_tokenOwners', [collectionId, tokenId]))
278+
.map(crossAccountId => Address.extract.enhancedCrossAccountId(crossAccountId, ss58Prefix))
279+
allOwnersAreKnown = owners.length < 10
280+
}
281+
282+
const {properties, propertiesMap} = decodeProperties(rawToken.properties)
283+
284+
const rftToken: IRftToken = {
285+
collectionId,
286+
tokenId,
287+
collectionAddress: Address.collection.idToAddress(collectionId),
288+
tokenAddress: Address.nesting.idsToAddress(collectionId, tokenId),
289+
290+
owners,
291+
allOwnersAreKnown,
292+
isOnlyOneOwner: !!rawToken.owner,
293+
294+
pieces: rawToken.pieces,
295+
296+
properties,
297+
propertiesMap,
298+
}
299+
return rftToken
300+
}
301+
302+
const collectionIdOrAddressToCollectionId = (collectionIdOrAddress: number | string): number => {
303+
return typeof collectionIdOrAddress === 'string'
304+
? Address.collection.addressToId(collectionIdOrAddress)
305+
: collectionIdOrAddress
306+
}
307+
308+
export interface ChainDirectLightClientOptions {
309+
ss58Prefix: number
310+
}
311+
312+
export const generateChainDirectLightClient = (rpcBaseUrl: string, options: ChainDirectLightClientOptions = {ss58Prefix: 42}) => {
313+
let rpcUrl = rpcBaseUrl
314+
315+
const ss58Prefix = options.ss58Prefix
316+
317+
return {
318+
get rpcUrl() {
319+
return rpcUrl
320+
},
321+
set rpcUrl(newRpcUrl: string) {
322+
rpcUrl = newRpcUrl
323+
},
324+
get ss58Prefix() {
325+
return ss58Prefix
326+
},
327+
328+
requestRPC: async <T = any>(method: string, params: unknown[]): Promise<T> => {
329+
return requestRPC(rpcUrl, method, params)
330+
},
331+
requestCollection: async (collectionIdOrAddress: number | string) => {
332+
const collectionId = collectionIdOrAddressToCollectionId(collectionIdOrAddress)
333+
334+
return requestCollection(rpcUrl, collectionId, ss58Prefix)
335+
},
336+
requestNftToken: async (collectionIdOrAddress: number | string, tokenId: number) => {
337+
const collectionId = collectionIdOrAddressToCollectionId(collectionIdOrAddress)
338+
339+
return requestNftToken(rpcUrl, collectionId, tokenId, ss58Prefix)
340+
},
341+
requestNftTokenByAddress: async (tokenAddress: string) => {
342+
const {collectionId, tokenId} = Address.nesting.addressToIds(tokenAddress)
343+
344+
return requestNftToken(rpcUrl, collectionId, tokenId, ss58Prefix)
345+
},
346+
347+
requestRftToken: async (collectionIdOrAddress: number | string, tokenId: number) => {
348+
const collectionId = collectionIdOrAddressToCollectionId(collectionIdOrAddress)
349+
350+
return requestRftToken(rpcUrl, collectionId, tokenId, ss58Prefix)
351+
},
352+
requestRftTokenByAddress: async (tokenAddress: string) => {
353+
const {collectionId, tokenId} = Address.nesting.addressToIds(tokenAddress)
354+
355+
return requestRftToken(rpcUrl, collectionId, tokenId, ss58Prefix)
356+
},
357+
}
358+
}
359+
360+
export type IChainDirectLightClient = ReturnType<typeof generateChainDirectLightClient>
361+
362+
export const ChainDirectLightClients: { [K in UNIQUE_CHAINS]: IChainDirectLightClient } = {
363+
unique: generateChainDirectLightClient(UNIQUE_RPCs.unique, {ss58Prefix: 7391}),
364+
quartz: generateChainDirectLightClient(UNIQUE_RPCs.quartz, {ss58Prefix: 255}),
365+
opal: generateChainDirectLightClient(UNIQUE_RPCs.opal, {ss58Prefix: 42}),
366+
sapphire: generateChainDirectLightClient(UNIQUE_RPCs.sapphire, {ss58Prefix: 8883}),
367+
}

0 commit comments

Comments
 (0)