import { publicKey, PublicKey } from '@metaplex-foundation/umi-public-keys'; import type { Serializer, StructToSerializerTuple, } from '@metaplex-foundation/umi-serializers'; import { base10, base58, base64 } from '@metaplex-foundation/umi-serializers'; import type { RpcAccount } from './Account'; import type { Context } from './Context'; import { SdkError } from './errors'; import type { RpcDataFilter, RpcDataSlice, RpcGetProgramAccountsOptions, } from './RpcInterface'; /** * Callback for sorting raw accounts from `getProgramAccounts` RPC requests. * @category Utils — GpaBuilder */ export type GpaBuilderSortCallback = (a: RpcAccount, b: RpcAccount) => number; /** * Callback for mapping raw accounts into any type. * @category Utils — GpaBuilder */ export type GpaBuilderMapCallback = (account: RpcAccount) => T; /** * Get the GPA field offsets and serializers from their object definition. * @category Utils — GpaBuilder */ export type GpaBuilderFieldSerializers = { [K in keyof T]: [number | null, Serializer]; }; /** * Builder for `getProgramAccounts` RPC requests. * @category Utils — GpaBuilder */ export class GpaBuilder< Account extends object = RpcAccount, Fields extends object = {} > { constructor( protected readonly context: Pick, readonly programId: PublicKey, readonly options: { readonly fields?: GpaBuilderFieldSerializers; readonly deserializeCallback?: GpaBuilderMapCallback; readonly dataSlice?: RpcDataSlice; readonly filters?: RpcDataFilter[]; readonly sortCallback?: GpaBuilderSortCallback; } = {} ) {} reset(): GpaBuilder { return new GpaBuilder(this.context, this.programId, { fields: this.options.fields, deserializeCallback: this.options.deserializeCallback, }); } registerFields( fields: GpaBuilderFieldSerializers ): GpaBuilder { return new GpaBuilder(this.context, this.programId, { ...this.options, fields, }); } registerFieldsFromStruct( structFields: StructToSerializerTuple ): GpaBuilder { let offset: number | null = 0; const fields = structFields.reduce((acc, [field, serializer]) => { acc[field] = [offset, serializer]; offset = offset === null || serializer.fixedSize === null ? null : offset + serializer.fixedSize; return acc; }, {} as GpaBuilderFieldSerializers); return this.registerFields(fields); } /** * Registers nested struct fields with a parent path prefix. * This allows filtering on nested fields using dot-notation paths. * * @example * ```ts * gpaBuilder * .registerFieldsFromStruct([ * ['authority', publicKeySerializer], * ['metadata', metadataSerializer], * ]) * .registerNestedFieldsFromStruct('metadata', 32, [ * ['name', stringSerializer], * ['creator', publicKeySerializer], * ]) * .whereField('metadata.creator', somePublicKey) * ``` * * @param parentPath - The dot-notation path prefix for the nested fields * @param parentOffset - The byte offset where the parent struct starts * @param structFields - The nested struct's field definitions */ registerNestedFieldsFromStruct( parentPath: string, parentOffset: number, structFields: StructToSerializerTuple ): GpaBuilder< Account, Fields & { [K in keyof T as `${typeof parentPath}.${K & string}`]: T[K] } > { let offset: number | null = parentOffset; const nestedFields = structFields.reduce((acc, [field, serializer]) => { const nestedPath = `${parentPath}.${field as string}`; acc[nestedPath as keyof typeof acc] = [offset, serializer] as any; offset = offset === null || serializer.fixedSize === null ? null : offset + serializer.fixedSize; return acc; }, {} as Record]>); const mergedFields = { ...this.options.fields, ...nestedFields, } as GpaBuilderFieldSerializers< Fields & { [K in keyof T as `${typeof parentPath}.${K & string}`]: T[K] }, Fields & { [K in keyof T as `${typeof parentPath}.${K & string}`]: T[K] } >; return new GpaBuilder< Account, Fields & { [K in keyof T as `${typeof parentPath}.${K & string}`]: T[K] } >(this.context, this.programId, { ...this.options, fields: mergedFields, }); } deserializeUsing( callback: GpaBuilderMapCallback ): GpaBuilder { return new GpaBuilder(this.context, this.programId, { ...this.options, deserializeCallback: callback, }); } slice(offset: number, length: number): GpaBuilder { return new GpaBuilder(this.context, this.programId, { ...this.options, dataSlice: { offset, length }, }); } sliceField( field: keyof Fields, offset?: number ): GpaBuilder { const [effectiveOffset, serializer] = this.getField(field, offset); if (!serializer.fixedSize) { throw new SdkError( `Cannot slice field [${field as string}] because its size is variable.` ); } return this.slice(effectiveOffset, serializer.fixedSize); } withoutData(): GpaBuilder { return this.slice(0, 0); } addFilter(...filters: RpcDataFilter[]): GpaBuilder { return new GpaBuilder(this.context, this.programId, { ...this.options, filters: [...(this.options.filters ?? []), ...filters], }); } where( offset: number, data: string | bigint | number | boolean | Uint8Array | PublicKey ): GpaBuilder { let bytes: Uint8Array; if (typeof data === 'string') { bytes = base58.serialize(data); } else if ( typeof data === 'number' || typeof data === 'bigint' || typeof data === 'boolean' ) { bytes = base10.serialize(BigInt(data).toString()); } else { bytes = new Uint8Array(data); } return this.addFilter({ memcmp: { offset, bytes } }); } whereField( field: K, data: Fields[K], offset?: number ): GpaBuilder { const [effectiveOffset, serializer] = this.getField(field, offset); return this.where(effectiveOffset, serializer.serialize(data)); } whereSize(dataSize: number): GpaBuilder { return this.addFilter({ dataSize }); } sortUsing(callback: GpaBuilderSortCallback): GpaBuilder { return new GpaBuilder(this.context, this.programId, { ...this.options, sortCallback: callback, }); } async get(options: RpcGetProgramAccountsOptions = {}): Promise { const accounts = await this.context.rpc.getProgramAccounts(this.programId, { ...options, dataSlice: options.dataSlice ?? this.options.dataSlice, filters: [...(options.filters ?? []), ...(this.options.filters ?? [])], }); if (this.options.sortCallback) { accounts.sort(this.options.sortCallback); } return accounts; } async getAndMap( callback: GpaBuilderMapCallback, options: RpcGetProgramAccountsOptions = {} ): Promise { return (await this.get(options)).map(callback); } async getDeserialized( options: RpcGetProgramAccountsOptions = {} ): Promise { const rpcAccounts = await this.get(options); if (!this.options.deserializeCallback) return rpcAccounts as Account[]; return rpcAccounts.map(this.options.deserializeCallback); } async getPublicKeys( options: RpcGetProgramAccountsOptions = {} ): Promise { return this.getAndMap((account) => account.publicKey, options); } async getDataAsPublicKeys( options: RpcGetProgramAccountsOptions = {} ): Promise { return this.getAndMap((account) => { try { return publicKey(account.data); } catch (error) { const message = `Following a getProgramAccount call, you are trying to use an ` + `account's data (or a slice of it) as a public key. ` + `However, we encountered an account ` + `[${account.publicKey}] whose data ` + `[base64=${base64.deserialize(account.data)}] ` + `is not a valid public key.`; throw new SdkError(message); } }, options); } protected getField( fieldName: K, forcedOffset?: number ): [number, Serializer] { if (!this.options.fields) { throw new SdkError('Fields are not defined in this GpaBuilder.'); } const field = this.options.fields[fieldName]; if (!field) { throw new SdkError( `Field [${fieldName as string}] is not defined in this GpaBuilder.` ); } const [offset, serializer] = field; if (forcedOffset !== undefined) { return [forcedOffset, serializer]; } if (offset === null) { throw new SdkError( `Field [${fieldName as string}] does not have a fixed offset. ` + `This is likely because it is not in the fixed part of ` + `the account's data. In other words, it is located after ` + `a field of variable length which means we cannot find a ` + `fixed offset for the filter. You may go around this by ` + `providing an offset explicitly.` ); } return [offset, serializer]; } } /** * Creates a new {@link GpaBuilder} instance. * @category Utils — GpaBuilder */ export const gpaBuilder = ( context: Pick, programId: PublicKey ): GpaBuilder => new GpaBuilder(context, programId);