import { AMMAN_RELAY_PORT, MSG_GET_KNOWN_ADDRESS_LABELS, MSG_UPDATE_ADDRESS_LABELS, MSG_UPDATE_ACCOUNT_STATES, ACK_UPDATE_ADDRESS_LABELS, MSG_REQUEST_ACCOUNT_STATES, MSG_RESPOND_ACCOUNT_STATES, MSG_REQUEST_AMMAN_VERSION, MSG_RESPOND_AMMAN_VERSION, MSG_REQUEST_ACCOUNT_SAVE, MSG_RESPOND_ACCOUNT_SAVE, MSG_REQUEST_SNAPSHOT_SAVE, MSG_RESPOND_SNAPSHOT_SAVE, MSG_REQUEST_STORE_KEYPAIR, MSG_RESPOND_STORE_KEYPAIR, MSG_REQUEST_LOAD_KEYPAIR, MSG_RESPOND_LOAD_KEYPAIR, AmmanAccountRendererMap, MSG_REQUEST_SET_ACCOUNT, MSG_RESPOND_SET_ACCOUNT, PersistedAccountInfo, MSG_REQUEST_LOAD_SNAPSHOT, MSG_RESPOND_LOAD_SNAPSHOT, } from '@metaplex-foundation/amman-client' import { AccountInfo, Keypair, PublicKey } from '@solana/web3.js' import { createServer, Server as HttpServer } from 'http' import { AddressInfo } from 'net' import { Server, Socket } from 'socket.io' import { AccountProvider } from '../accounts/providers' import { AccountStates } from '../accounts/state' import { AccountPersister, mapPersistedAccountInfos } from '../assets' import { AmmanAccountProvider } from '../types' import { scopedLog } from '../utils' import { killRunningServer } from '../utils/http' import { restartValidatorWithAccountOverrides, restartValidatorWithSnapshot, } from '../validator' import { Account, AmmanState, Program } from '../validator/types' import { AMMAN_VERSION } from './types' const { logError, logDebug, logTrace } = scopedLog('relay') /** * A simple socket.io server which communicates to the Amman Explorere as well as accepting connections * from other clients, i.e. via an {@link AmmanClient} which tests can use to communicate via the amman API. * * @private */ class RelayServer { constructor( readonly io: Server, readonly ammanState: AmmanState, readonly accountProvider: AccountProvider, readonly accountPersister: AccountPersister, readonly snapshotPersister: AccountPersister, private accountStates: AccountStates, // Keyed pubkey:label private readonly allKnownLabels: Record<string, string> = {} ) { this.hookConnectionEvents() } hookConnectionEvents() { this.io.on('connection', (socket) => { const client = `${socket.id} from ${socket.client.conn.remoteAddress}` socket.on('disconnect', () => logTrace(`socket.io ${client} disconnected`) ) logTrace(`socket.io ${client} connected`) this.hookMessages(socket) }) } hookMessages(socket: Socket) { const subscribedAccountStates = new Set<string>() socket .on(MSG_UPDATE_ADDRESS_LABELS, (labels: Record<string, string>) => { if (logTrace.enabled) { logTrace(MSG_UPDATE_ADDRESS_LABELS) const labelCount = Object.keys(labels).length logTrace(`Got ${labelCount} labels, broadcasting ...`) } for (const [key, val] of Object.entries(labels)) { this.allKnownLabels[key] = val } this.accountStates.labelKeypairs(this.allKnownLabels) socket.broadcast.emit(MSG_UPDATE_ADDRESS_LABELS, labels) socket.emit(ACK_UPDATE_ADDRESS_LABELS) }) .on(MSG_GET_KNOWN_ADDRESS_LABELS, () => { if (logTrace.enabled) { logTrace(MSG_GET_KNOWN_ADDRESS_LABELS) const labelCount = Object.keys(this.allKnownLabels).length logTrace(`Sending ${labelCount} known labels to requesting client.`) } socket.emit(MSG_UPDATE_ADDRESS_LABELS, this.allKnownLabels) }) .on(MSG_REQUEST_ACCOUNT_STATES, (pubkey: string) => { logTrace(MSG_REQUEST_ACCOUNT_STATES, pubkey) const states = this.accountStates.get(pubkey)?.relayStates socket.emit(MSG_RESPOND_ACCOUNT_STATES, pubkey, states ?? []) if (!subscribedAccountStates.has(pubkey)) { subscribedAccountStates.add(pubkey) this.accountStates.on(`account-changed:${pubkey}`, (states) => { socket.emit(MSG_UPDATE_ACCOUNT_STATES, pubkey, states) logTrace(MSG_UPDATE_ACCOUNT_STATES) }) } }) .on(MSG_REQUEST_ACCOUNT_SAVE, async (pubkey: string, slot?: number) => { logTrace(MSG_REQUEST_ACCOUNT_SAVE, pubkey) try { let data if (slot != null) { data = this.accountStates.accountDataForSlot(pubkey, slot) } const accountPath = await this.accountPersister.saveAccount( new PublicKey(pubkey), this.accountProvider.connection, data ) socket.emit(MSG_RESPOND_ACCOUNT_SAVE, pubkey, { accountPath }) } catch (err) { socket.emit(MSG_RESPOND_ACCOUNT_SAVE, pubkey, { err }) } }) .on(MSG_REQUEST_SNAPSHOT_SAVE, async (label: string) => { logTrace(MSG_REQUEST_SNAPSHOT_SAVE, label) try { const addresses = this.accountStates.allAccountAddresses() const snapshotDir = await this.snapshotPersister.snapshot( label, addresses, this.allKnownLabels, this.accountStates.allKeypairs ) socket.emit(MSG_RESPOND_SNAPSHOT_SAVE, { snapshotDir }) } catch (err: any) { socket.emit(MSG_RESPOND_SNAPSHOT_SAVE, { err: err.toString() }) } }) .on(MSG_REQUEST_LOAD_SNAPSHOT, async (label: string) => { logTrace(MSG_REQUEST_LOAD_SNAPSHOT, label) try { const { persistedAccountInfos, persistedSnapshotAccountInfos, keypairs, } = await restartValidatorWithSnapshot(this.ammanState, label) const accountInfos = mapPersistedAccountInfos([ ...persistedAccountInfos, ...persistedSnapshotAccountInfos, ]) this.accountStates = AccountStates.createInstance( this.accountProvider.connection, this.accountProvider, accountInfos, keypairs ) socket.emit(MSG_RESPOND_LOAD_SNAPSHOT) } catch (err: any) { socket.emit(MSG_RESPOND_LOAD_SNAPSHOT, err.toString()) } }) .on(MSG_REQUEST_STORE_KEYPAIR, (id: string, secretKey: Uint8Array) => { logTrace(MSG_REQUEST_STORE_KEYPAIR, id) try { const keypair = Keypair.fromSecretKey(secretKey) this.accountStates.storeKeypair(id, keypair) socket.emit(MSG_RESPOND_STORE_KEYPAIR) logTrace(MSG_RESPOND_STORE_KEYPAIR) } catch (err: any) { logError(err) socket.emit(MSG_RESPOND_STORE_KEYPAIR, err.toString()) } }) .on(MSG_REQUEST_LOAD_KEYPAIR, (id: string) => { logTrace(MSG_REQUEST_LOAD_KEYPAIR, id) const keypair = this.accountStates.getKeypairById(id) socket.emit(MSG_RESPOND_LOAD_KEYPAIR, keypair?.secretKey) }) .on(MSG_REQUEST_SET_ACCOUNT, async (account: PersistedAccountInfo) => { logTrace(MSG_REQUEST_SET_ACCOUNT) const addresses = this.accountStates.allAccountAddresses() await restartValidatorWithAccountOverrides( this.ammanState, addresses, this.allKnownLabels, this.accountStates.allKeypairs, new Map([[account.pubkey, account]]) ) const { persistedAccountInfos, persistedSnapshotAccountInfos, keypairs, } = await restartValidatorWithAccountOverrides( this.ammanState, addresses, this.allKnownLabels, this.accountStates.allKeypairs, new Map([[account.pubkey, account]]) ) const accountInfos = mapPersistedAccountInfos([ ...persistedAccountInfos, ...persistedSnapshotAccountInfos, ]) this.accountStates = AccountStates.createInstance( this.accountProvider.connection, this.accountProvider, accountInfos, keypairs ) socket.emit(MSG_RESPOND_SET_ACCOUNT) }) .on(MSG_REQUEST_AMMAN_VERSION, () => { logTrace(MSG_REQUEST_AMMAN_VERSION) socket.emit(MSG_RESPOND_AMMAN_VERSION, AMMAN_VERSION) }) } } /** * Sets up the Amman Relay which uses the given account provider to resolve account data. * @private * */ export class Relay { private static createApp( ammanState: AmmanState, accountProvider: AccountProvider, accountPersister: AccountPersister, snapshotPersister: AccountPersister, accountStates: AccountStates, knownLabels: Record<string, string> ) { const server = createServer() const io = new Server(server, { cors: { origin: '*', }, }) const relayServer = new RelayServer( io, ammanState, accountProvider, accountPersister, snapshotPersister, accountStates, knownLabels ) return { app: server, io, relayServer } } static async startServer( ammanState: AmmanState, accountProviders: Record<string, AmmanAccountProvider>, accountRenderers: AmmanAccountRendererMap, programs: Program[], accounts: Account[], loadedAccountInfos: Map<string, AccountInfo<Buffer>>, loadedKeypairs: Map<string, Keypair>, accountsFolder: string, snapshotRoot: string, killRunning: boolean = true ): Promise<{ app: HttpServer io: Server relayServer: RelayServer }> { if (killRunning) { await killRunningServer(AMMAN_RELAY_PORT) } const accountProvider = AccountProvider.fromRecord( accountProviders, accountRenderers ) AccountStates.createInstance( accountProvider.connection, accountProvider, loadedAccountInfos, loadedKeypairs ) const accountPersister = new AccountPersister( accountsFolder, accountProvider.connection ) const snapshotPersister = new AccountPersister( snapshotRoot, accountProvider.connection ) const programLabels = programs .filter((x) => x.label != null) .reduce((acc: Record<string, string>, x) => { acc[x.programId] = x.label! return acc }, {}) const accountLabels = accounts .filter((x) => x.label != null) .reduce((acc: Record<string, string>, x) => { acc[x.accountId] = x.label! return acc }, {}) const knownLabels = { ...programLabels, ...accountLabels } const { app, io, relayServer } = Relay.createApp( ammanState, accountProvider, accountPersister, snapshotPersister, AccountStates.instance, knownLabels ) return new Promise((resolve, reject) => { app.on('error', reject).listen(AMMAN_RELAY_PORT, () => { const addr = app.address() as AddressInfo const msg = `Amman Relay listening on ${addr.address}:${addr.port}` logDebug(msg) resolve({ app, io, relayServer }) }) }) } }