UNPKG

@ylide/everscale

Version:

Ylide Protocol SDK implementation for EverScale blockchain

887 lines (818 loc) 25.1 kB
import SmartBuffer from '@ylide/smart-buffer'; import { EverscaleStandaloneClient } from 'everscale-standalone-client'; import core from 'everscale-standalone-client/core'; import { Address, ProviderRpcClient } from 'everscale-inpage-provider'; import nacl from 'tweetnacl'; import { AbstractBlockchainController, IMessage, IMessageContent, IMessageCorruptedContent, MessageContentFailure, unpackSymmetricalyEncryptedData, IExtraEncryptionStrateryBulk, IExtraEncryptionStrateryEntry, MessageKey, PublicKey, PublicKeyType, packSymmetricalyEncryptedData, BlockchainControllerFactory, Uint256, hexToUint256, ISourceSubject, BlockchainSourceType, AbstractNameService, } from '@ylide/sdk'; import { DEV_BROADCASTER_ADDRESS, DEV_MAILER_ADDRESS, DEV_REGISTRY_ADDRESS, BROADCASTER_EVERSCALE_ADDRESS, MAILER_EVERSCALE_ADDRESS, REGISTRY_EVERSCALE_ADDRESS, BROADCASTER_VENOM_ADDRESS, MAILER_VENOM_ADDRESS, REGISTRY_VENOM_ADDRESS, } from '../misc/constants'; import { IEverscaleContentMessageBody, IEverscaleMessage, uint256ToAddress } from '../misc'; import { getContractMessagesQuery } from '../misc'; import { GqlSender } from '../misc/GqlSender'; import { initAsync, encrypt, generate_ephemeral, get_public_key } from '../encrypt'; import moment from 'moment'; import { decodeAddressToPublicKeyMessageBody_old, decodeBroadcastMessageBody, decodeContentMessageBody, decodePushMessageBody, } from '../contracts/contractUtils'; export class EverscaleBlockchainController extends AbstractBlockchainController { ever: ProviderRpcClient; gql: GqlSender; readonly everscaleEncryptCore = initAsync(); readonly MESSAGES_FETCH_LIMIT = 50; readonly mailerContractAddress: string; readonly broadcasterContractAddress: string; readonly registryContractAddress: string; private readonly everscaleMainnetEndpoints = [ 'https://mainnet.evercloud.dev/695e40eeac6b4e3fa4a11666f6e0d6af/graphql', ]; private readonly venomTestnetEndpoints = ['https://gql-testnet.venom.foundation/graphql']; constructor( private options: { type?: 'everscale-mainnet' | 'venom-testnet'; dev?: boolean; mailerContractAddress?: string; broadcasterContractAddress?: string; registryContractAddress?: string; endpoints?: string[]; } = {}, ) { super(options); if (typeof options.type === 'undefined') { throw new Error('You must provide network type for Everscale controller'); } if (options.endpoints) { this.gql = new GqlSender({ endpoints: options.endpoints, local: false, }); } else if (options.dev) { this.gql = new GqlSender({ endpoints: ['localhost'], local: true, }); } else { this.gql = new GqlSender({ endpoints: options.type === 'everscale-mainnet' ? this.everscaleMainnetEndpoints : this.venomTestnetEndpoints, local: false, }); } this.ever = new ProviderRpcClient({ forceUseFallback: true, fallback: () => EverscaleStandaloneClient.create({ connection: options.dev ? 'local' : { id: 1, group: 'mainnet', type: 'graphql', data: { local: false, endpoints: options.type === 'everscale-mainnet' ? this.everscaleMainnetEndpoints : this.venomTestnetEndpoints, }, }, }), }); this.mailerContractAddress = options.mailerContractAddress || (options.dev ? DEV_MAILER_ADDRESS : options.type === 'venom-testnet' ? MAILER_VENOM_ADDRESS : MAILER_EVERSCALE_ADDRESS); this.broadcasterContractAddress = options.broadcasterContractAddress || (options.dev ? DEV_BROADCASTER_ADDRESS : options.type === 'venom-testnet' ? BROADCASTER_VENOM_ADDRESS : BROADCASTER_EVERSCALE_ADDRESS); this.registryContractAddress = options.registryContractAddress || (options.dev ? DEV_REGISTRY_ADDRESS : options.type === 'venom-testnet' ? REGISTRY_VENOM_ADDRESS : REGISTRY_EVERSCALE_ADDRESS); } isReadingBySenderAvailable(): boolean { return false; } defaultNameService(): AbstractNameService | null { return null; } async init(): Promise<void> { // np } async getBalance(address: string): Promise<string> { return this.ever.getBalance(new Address(address)); } getDefaultMailerAddress() { return this.mailerContractAddress; } async getRecipientReadingRules(address: Uint256): Promise<any> { return []; } private async getPublicKeyByAddress(address: string): Promise<Uint8Array | null> { await core.ensureNekotonLoaded(); const messages = await this.gql.queryContractMessages(address, this.registryContractAddress); if (messages.length) { const val = decodeAddressToPublicKeyMessageBody_old(messages[0].body); if (val) { return val; } else { return null; } } else { return null; } } async extractPublicKeyFromAddress(address: string): Promise<PublicKey | null> { const rawKey = await this.getPublicKeyByAddress(':' + address.split(':')[1]); if (!rawKey) { return null; } return PublicKey.fromBytes(PublicKeyType.YLIDE, rawKey); } private async _retrieveMessageHistoryByTime( contractAddress: string, subject: ISourceSubject, fromTimestamp?: number, toTimestamp?: number, limit?: number, ): Promise<IMessage[]> { await core.ensureNekotonLoaded(); if (!contractAddress) { contractAddress = this.getDefaultMailerAddress(); } const events = await this.queryMessagesList(contractAddress, subject, limit, { fromDate: fromTimestamp, toDate: toTimestamp, }); const result = events.map(m => subject.type === BlockchainSourceType.DIRECT ? this.formatPushMessage(m) : this.formatBroadcastMessage(subject.sender!, m), ); return result.filter( r => (!fromTimestamp || r.$$blockchainMetaDontUseThisField.block.timestamp > fromTimestamp) && (!toTimestamp || r.$$blockchainMetaDontUseThisField.block.timestamp <= toTimestamp), ); } private async _retrieveMessageHistoryByBounds( contractAddress: string, subject: ISourceSubject, fromMessage?: IMessage, toMessage?: IMessage, limit?: number, ): Promise<IMessage[]> { await core.ensureNekotonLoaded(); const events = await this.queryMessagesList(contractAddress, subject, limit, { fromMessage: fromMessage?.$$blockchainMetaDontUseThisField, toMessage: toMessage?.$$blockchainMetaDontUseThisField, }); const result = events.map(m => subject.type === BlockchainSourceType.DIRECT ? this.formatPushMessage(m) : this.formatBroadcastMessage(subject.sender!, m), ); const topBound = toMessage ? result.findIndex(r => r.msgId === toMessage.msgId) : -1; const bottomBound = fromMessage ? result.findIndex(r => r.msgId === fromMessage.msgId) : -1; return result.slice(bottomBound === -1 ? 0 : bottomBound + 1, topBound === -1 ? undefined : topBound); } async retrieveMessageHistoryByTime( sender: string | null, recipient: Uint256 | null, fromTimestamp?: number, toTimestamp?: number, limit?: number, ): Promise<IMessage[]> { const mailerAddress = this.getDefaultMailerAddress(); return this._retrieveMessageHistoryByTime( mailerAddress, { type: BlockchainSourceType.DIRECT, sender, recipient }, fromTimestamp, toTimestamp, limit, ); } async retrieveMessageHistoryByBounds( sender: string | null, recipient: Uint256 | null, fromMessage?: IMessage, toMessage?: IMessage, limit?: number, ): Promise<IMessage[]> { const mailerAddress = this.getDefaultMailerAddress(); return this._retrieveMessageHistoryByBounds( mailerAddress, { type: BlockchainSourceType.DIRECT, sender, recipient }, fromMessage, toMessage, limit, ); } async retrieveBroadcastHistoryByTime( sender: string | null, fromTimestamp?: number, toTimestamp?: number, limit?: number, ): Promise<IMessage[]> { const broadcasterAddress = this.broadcasterContractAddress; return this._retrieveMessageHistoryByTime( broadcasterAddress, { type: BlockchainSourceType.BROADCAST, sender }, fromTimestamp, toTimestamp, limit, ); } async retrieveBroadcastHistoryByBounds( sender: string | null, fromMessage?: IMessage, toMessage?: IMessage, limit?: number, ): Promise<IMessage[]> { const broadcasterAddress = this.broadcasterContractAddress; return this._retrieveMessageHistoryByBounds( broadcasterAddress, { type: BlockchainSourceType.BROADCAST, sender }, fromMessage, toMessage, limit, ); } private convertMsgIdToAddress(msgId: string) { return `:${msgId}`; } async retrieveAndVerifyMessageContent(msg: IMessage): Promise<IMessageContent | IMessageCorruptedContent | null> { const result = await this.retrieveMessageContentByMsgId(msg.msgId); if (!result) { return null; } if (result.corrupted) { return result; } if (result.senderAddress.split(':')[1] !== msg.senderAddress.split(':')[1]) { return { msgId: msg.msgId, corrupted: true, chunks: [], reason: MessageContentFailure.NON_INTEGRITY_PARTS, }; } return result; } async retrieveMessageContentByMsgId(msgId: string): Promise<IMessageContent | IMessageCorruptedContent | null> { await core.ensureNekotonLoaded(); const fakeAddress = this.convertMsgIdToAddress(msgId); let messages = await this.gql.queryContractMessages(fakeAddress, this.mailerContractAddress); if (!messages.length) { messages = await this.gql.queryContractMessages(fakeAddress, this.broadcasterContractAddress); } if (!messages.length) { return null; } let decodedChunks: { msg: IEverscaleMessage; body: IEverscaleContentMessageBody }[]; try { decodedChunks = messages.map((m: IEverscaleMessage) => ({ msg: m, body: decodeContentMessageBody(m.body), })); } catch (err) { return { msgId, corrupted: true, chunks: messages.map((m: IEverscaleMessage) => ({ createdAt: m.created_at })), reason: MessageContentFailure.NON_DECRYPTABLE, }; } const parts = decodedChunks[0].body.parts; const sender = decodedChunks[0].body.sender; if (!decodedChunks.every(t => t.body.parts === parts) || !decodedChunks.every(t => t.body.sender === sender)) { return { msgId, corrupted: true, chunks: decodedChunks.map(m => ({ createdAt: m.msg.created_at })), reason: MessageContentFailure.NON_INTEGRITY_PARTS, }; } for (let idx = 0; idx < parts; idx++) { if (!decodedChunks.find(d => d.body.partIdx === idx)) { return { msgId, corrupted: true, chunks: decodedChunks.map(m => ({ createdAt: m.msg.created_at })), reason: MessageContentFailure.NOT_ALL_PARTS, }; } } if (decodedChunks.length !== parts) { return { msgId, corrupted: true, chunks: decodedChunks.map(m => ({ createdAt: m.msg.created_at })), reason: MessageContentFailure.DOUBLED_PARTS, }; } const sortedChunks = decodedChunks .sort((a, b) => { return a.body.partIdx - b.body.partIdx; }) .map(m => m.body.content); const contentSize = sortedChunks.reduce((p, c) => p + c.length, 0); const buf = SmartBuffer.ofSize(contentSize); for (const chunk of sortedChunks) { buf.writeBytes(chunk); } return { msgId, corrupted: false, storage: this.options.type === 'everscale-mainnet' ? 'everscale' : 'venom', createdAt: Math.min(...decodedChunks.map(d => d.msg.created_at)), senderAddress: sender, parts, content: buf.bytes, }; } private formatPushMessage(message: IEverscaleMessage): IMessage { const body = decodePushMessageBody(message.body); return { isBroadcast: false, msgId: body.msgId, createdAt: message.created_at, senderAddress: body.sender, recipientAddress: this.addressToUint256(message.dst.startsWith(':') ? `0${message.dst}` : message.dst), blockchain: this.options.type === 'everscale-mainnet' ? 'everscale' : 'venom', key: body.key, $$blockchainMetaDontUseThisField: message, }; } private formatBroadcastMessage(sender: string, message: IEverscaleMessage): IMessage { const body = decodeBroadcastMessageBody(message.body); return { isBroadcast: true, msgId: body.msgId, createdAt: message.created_at, senderAddress: message.dst, recipientAddress: this.addressToUint256(message.dst.startsWith(':') ? `0${message.dst}` : message.dst), blockchain: this.options.type === 'everscale-mainnet' ? 'everscale' : 'venom', key: new Uint8Array(), $$blockchainMetaDontUseThisField: message, }; } isAddressValid(address: string): boolean { if (address.length !== 66) { return false; } else if (!address.includes(':')) { return false; } const splitAddress = address.split(':'); if (splitAddress[0] !== '0') { return false; } if (splitAddress[1].includes('_')) return false; const regExp = new RegExp('^[^\\W]+$'); return regExp.test(splitAddress[1]); } private async queryMessagesListDescRaw( gql: GqlSender, contractAddress: string, dst: string | null, fromMessage: IEverscaleMessage | null, // includeFromMessage: boolean, toMessage: IEverscaleMessage | null, // includeToMessage: boolean, limit?: number, nextPageAfterMessage?: IEverscaleMessage, ): Promise<IEverscaleMessage[]> { let fromCursor: string | null = null; if (fromMessage && !fromMessage.cursor) { throw new Error('fromMessage.cursor is not defined'); } if (toMessage && !toMessage.cursor) { throw new Error('toMessage.cursor is not defined'); } if (fromMessage) { if ( nextPageAfterMessage && nextPageAfterMessage.created_lt && BigInt(nextPageAfterMessage.created_lt) < BigInt(fromMessage.created_lt) ) { fromCursor = nextPageAfterMessage.cursor; } else { fromCursor = fromMessage.cursor; } } else { if (nextPageAfterMessage && nextPageAfterMessage.created_lt) { fromCursor = nextPageAfterMessage.cursor; } } const result: IEverscaleMessage[] = await gql.queryMessages(` query { blockchain { account(address:"${contractAddress}") { messages( msg_type: [ExtOut], ${dst ? `counterparties: ["${dst}"]` : ''} ${fromCursor ? `before: "${fromCursor}"` : ''} last: ${Math.min(limit || this.MESSAGES_FETCH_LIMIT, this.MESSAGES_FETCH_LIMIT)} ) { edges { node { body msg_type id src created_at created_lt dst } cursor } } } } } `); let end = false; const findTo = toMessage ? result.findIndex(x => x.id === toMessage.id) : -1; if (findTo !== -1) { result.splice(findTo); end = true; } if (end || (limit && result.length === limit)) { return result; } else { if (result.length === 0) { return []; } else { const after = await this.queryMessagesListDescRaw( gql, contractAddress, dst, fromMessage, // includeFromMessage, toMessage, // includeToMessage, limit ? limit - result.length : undefined, result[result.length - 1], ); return result.concat(after); } } } // static async queryMessagesListDesc( // gql: GqlSender, // contractAddress: string, // dst: string | null, // fromMessage: IEverscaleMessage | null, // // includeFromMessage: boolean, // toMessage: IEverscaleMessage | null, // // includeToMessage: boolean, // limit?: number, // ): Promise<IEverscaleMessage[]> { // return this.queryMessagesListDescRaw( // gql, // contractAddress, // dst, // fromMessage ? fromMessage.$$meta : null, // // includeFromMessage, // toMessage ? toMessage.$$meta : null, // // includeToMessage, // limit, // ); // } // Query messages by interval sinceDate(excluded) - untilDate (excluded) private async queryMessagesList( contractAddress: string, subject: ISourceSubject, limit?: number, filter?: { fromDate?: number; toDate?: number; fromMessage?: IEverscaleMessage; toMessage?: IEverscaleMessage; }, nextPageAfterMessage?: IEverscaleMessage, ): Promise<IEverscaleMessage[]> { const address = subject.type === BlockchainSourceType.DIRECT ? subject.recipient ? uint256ToAddress(subject.recipient, true, true) : null : subject.sender ? subject.sender : null; return this.queryMessagesListDescRaw( this.gql, contractAddress, address, filter?.fromMessage || null, filter?.toMessage || null, limit, ); // const createdAt: { // gt?: number; // lt?: number; // } = {}; // const createdLt: { // gt?: bigint; // lt?: bigint; // } = {}; // if (nextPageAfterMessage && nextPageAfterMessage.created_lt) { // createdLt.lt = BigInt(nextPageAfterMessage.created_lt); // } // if (filter?.fromMessage) { // createdLt.gt = BigInt(filter.fromMessage.created_lt); // } // if (filter?.toMessage) { // const v = BigInt(filter.toMessage.created_lt); // if (createdLt.lt === undefined || v < createdLt.lt) { // createdLt.lt = v; // } // } // if (filter?.fromDate !== undefined) { // createdAt.gt = filter?.fromDate; // } // if (filter?.toDate !== undefined) { // createdAt.lt = filter?.toDate; // } // // ${filter?.fromMessage ? `, created_lt: { gt: "${filter.fromMessage.created_lt}" }` : ``} // // ${filter?.toMessage ? `, created_lt: { lt: "${filter.toMessage.created_lt}" }` : ``} // // created_lt: { ${nextPageAfterMessage?.created_lt ? `lt: "${nextPageAfterMessage.created_lt}"` : ''} } // // ${filter?.fromDate ? `, created_at: { gt: ${filter.fromDate} }` : ``} // // ${filter?.toDate ? `, created_at: { lt: ${filter.toDate} }` : ``} // const _at = createdAt.gt !== undefined || createdAt.lt !== undefined; // const _lt = createdLt.gt !== undefined || createdLt.lt !== undefined; // let result: IEverscaleMessage[]; // if (subject.type === BlockchainSourceType.DIRECT) { // result = await this.gqlQueryMessages( // ` // query { // messages( // filter: { // msg_type: { eq: 2 }, // ${address ? `dst: { eq: "${address}" },` : ''} // src: { eq: "${contractAddress}" }, // ${ // _at // ? `created_at: { ${ // createdAt.lt !== undefined // ? `lt: "${moment.unix(createdAt.lt).utc().toISOString()}", ` // : '' // } ${ // createdAt.gt !== undefined // ? `gt: "${moment.unix(createdAt.gt).utc().toISOString()}", ` // : '' // } }, ` // : '' // } // ${ // _lt // ? `created_lt: { ${ // createdLt.lt !== undefined ? `lt: "${'0x' + createdLt.lt.toString(16)}", ` : '' // } ${ // createdLt.gt !== undefined ? `gt: "${'0x' + createdLt.gt.toString(16)}", ` : '' // } }, ` // : '' // } // } // orderBy: [{path: "created_at", direction: DESC}] // limit: ${Math.min(limit || this.MESSAGES_FETCH_LIMIT, this.MESSAGES_FETCH_LIMIT)} // ) { // body // id // src // created_at // created_lt // dst // } // } // `, // ); // } else { // result = await this.gqlQueryMessages( // ` // query { // messages( // filter: { // msg_type: { eq: 2 }, // ${address ? `dst: { eq: "${address}" },` : ''} // src: { eq: "${contractAddress}" }, // ${ // _at // ? `created_at: { ${ // createdAt.lt !== undefined // ? `lt: "${moment.unix(createdAt.lt).utc().toISOString()}", ` // : '' // } ${ // createdAt.gt !== undefined // ? `gt: "${moment.unix(createdAt.gt).utc().toISOString()}", ` // : '' // } }, ` // : '' // } // ${ // _lt // ? `created_lt: { ${ // createdLt.lt !== undefined // ? `lt: "${'0x' + createdLt.lt.toString(16)}", ` // : '' // } ${ // createdLt.gt !== undefined // ? `gt: "${'0x' + createdLt.gt.toString(16)}", ` // : '' // } }, ` // : '' // } // } // orderBy: [{path: "created_at", direction: DESC}] // limit: ${Math.min(limit || this.MESSAGES_FETCH_LIMIT, this.MESSAGES_FETCH_LIMIT)} // ) { // body // id // src // created_at // created_lt // dst // } // } // `, // ); // } // if (limit && result.length === limit) { // return result; // } else { // if (result.length === 0) { // return []; // } else { // const after = await this.queryMessagesList( // contractAddress, // subject, // limit ? limit - result.length : undefined, // filter, // result[result.length - 1], // ); // return result.concat(after); // } // } } async extractNativePublicKeyFromAddress(addressStr: string): Promise<Uint8Array | null> { const nt = core.nekoton; await core.ensureNekotonLoaded(); const address = new Address(addressStr); const boc = await this.ever.getFullContractState({ address }); if (!boc.state) { return null; } try { const pk = nt.extractPublicKey(boc.state.boc); return pk ? SmartBuffer.ofHexString(pk).bytes : null; } catch (err) { return null; } } async decodeNativeKey( senderPublicKey: Uint8Array, recipientPublicKey: Uint8Array, key: Uint8Array, ): Promise<Uint8Array> { try { const { encData, nonce } = unpackSymmetricalyEncryptedData(key); const decryptedText = await this.ever.decryptData({ algorithm: 'ChaCha20Poly1305', data: new SmartBuffer(encData).toBase64String(), nonce: new SmartBuffer(nonce).toBase64String(), recipientPublicKey: new SmartBuffer(recipientPublicKey).toHexString(), sourcePublicKey: new SmartBuffer(senderPublicKey).toHexString(), }); if (decryptedText) { return SmartBuffer.ofBase64String(decryptedText).bytes; } else { throw new Error('Error decrypting message text'); } } catch (e) { throw e; } } async getExtraEncryptionStrategiesFromAddress(address: string): Promise<IExtraEncryptionStrateryEntry[]> { const native = await this.extractNativePublicKeyFromAddress(address); if (native) { return [ { ylide: false, blockchain: this.options.type === 'everscale-mainnet' ? 'everscale' : 'venom', address, type: this.options.type === 'everscale-mainnet' ? 'everscale-native' : 'venom-native', data: { nativePublicKey: native, }, }, ]; } else { return []; } } getSupportedExtraEncryptionStrategies(): string[] { return [this.options.type === 'everscale-mainnet' ? 'everscale-native' : 'venom-native']; } async prepareExtraEncryptionStrategyBulk( entries: IExtraEncryptionStrateryEntry[], ): Promise<IExtraEncryptionStrateryBulk> { await core.ensureNekotonLoaded(); const ephemeralSecret = generate_ephemeral(); const ephemeralPublic = get_public_key(ephemeralSecret); return { addedPublicKey: { key: PublicKey.fromHexString(PublicKeyType.EVERSCALE_NATIVE, ephemeralPublic), }, blockchain: this.options.type === 'everscale-mainnet' ? 'everscale' : 'venom', type: this.options.type === 'everscale-mainnet' ? 'everscale-native' : 'venom-native', data: { nativeEphemeralKeySecret: ephemeralSecret, }, }; } async executeExtraEncryptionStrategy( entries: IExtraEncryptionStrateryEntry[], bulk: IExtraEncryptionStrateryBulk, addedPublicKeyIndex: number | null, messageKey: Uint8Array, ): Promise<MessageKey[]> { const nativeSenderPrivateKey = SmartBuffer.ofHexString(bulk.data.nativeEphemeralKeySecret); return entries.map(entry => { const recipientNativePublicKey = new SmartBuffer(entry.data.nativePublicKey); const nonce = new SmartBuffer(nacl.randomBytes(12)); const encryptedKey = SmartBuffer.ofHexString( encrypt( nativeSenderPrivateKey.toHexString(), recipientNativePublicKey.toHexString(), new SmartBuffer(messageKey).toHexString(), nonce.toHexString(), ), ); const packedKey = packSymmetricalyEncryptedData(encryptedKey.bytes, nonce.bytes); return new MessageKey(addedPublicKeyIndex!, packedKey); }); } addressToUint256(address: string): Uint256 { return hexToUint256(address.split(':')[1].toLowerCase()); } compareMessagesTime(a: IMessage, b: IMessage): number { if (a.createdAt === b.createdAt) { return 0; } else { return a.createdAt - b.createdAt; } } } // https://gql-testnet.venom.foundation/graphql export const everscaleBlockchainFactory: BlockchainControllerFactory = { create: async (options?: any) => new EverscaleBlockchainController(Object.assign({ type: 'everscale-mainnet' }, options || {})), blockchain: 'everscale', blockchainGroup: 'everscale', }; export const venomBlockchainFactory: BlockchainControllerFactory = { create: async (options?: any) => new EverscaleBlockchainController(Object.assign({ type: 'venom-testnet' }, options || {})), blockchain: 'venom', blockchainGroup: 'venom', };