|
| 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