diff --git a/src/cmd.zig b/src/cmd.zig index 5150e6cc86..6768294fd7 100644 --- a/src/cmd.zig +++ b/src/cmd.zig @@ -1666,6 +1666,17 @@ fn validator( .slot_tracker = &replay_service_state.replay_state.slot_tracker, }); + const account_store = sig.accounts_db.AccountStore{ + .accounts_db_two = &new_db, + }; + try app_base.rpc_hooks.set( + allocator, + sig.rpc.methods.AccountHookContext{ + .slot_tracker = &replay_service_state.replay_state.slot_tracker, + .account_reader = account_store.reader(), + }, + ); + const replay_thread = try replay_service_state.spawnService( &app_base, if (maybe_vote_sockets) |*vs| vs else null, diff --git a/src/core/epoch_schedule.zig b/src/core/epoch_schedule.zig index bf6e79fb1d..a891e4971a 100644 --- a/src/core/epoch_schedule.zig +++ b/src/core/epoch_schedule.zig @@ -44,6 +44,21 @@ pub const EpochSchedule = extern struct { .warmup = true, }); + pub fn jsonStringify(self: EpochSchedule, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("slotsPerEpoch"); + try jw.write(self.slots_per_epoch); + try jw.objectField("leaderScheduleSlotOffset"); + try jw.write(self.leader_schedule_slot_offset); + try jw.objectField("warmup"); + try jw.write(self.warmup); + try jw.objectField("firstNormalEpoch"); + try jw.write(self.first_normal_epoch); + try jw.objectField("firstNormalSlot"); + try jw.write(self.first_normal_slot); + try jw.endObject(); + } + pub fn getEpoch(self: *const EpochSchedule, slot: Slot) Epoch { return self.getEpochAndSlotIndex(slot)[0]; } diff --git a/src/core/hash.zig b/src/core/hash.zig index 5182c71b7a..2fd64de36d 100644 --- a/src/core/hash.zig +++ b/src/core/hash.zig @@ -122,6 +122,10 @@ pub const Hash = extern struct { }; } + pub fn jsonStringify(self: Hash, jw: anytype) !void { + try jw.write(self.base58String().constSlice()); + } + /// Intended to be used in tests. pub fn initRandom(random: std.Random) Hash { var data: [SIZE]u8 = undefined; diff --git a/src/rpc/account_decoder/lib.zig b/src/rpc/account_decoder/lib.zig new file mode 100644 index 0000000000..032d8e6f1b --- /dev/null +++ b/src/rpc/account_decoder/lib.zig @@ -0,0 +1,600 @@ +/// This module provides support for `jsonParsed` decoding of Solana accounts. +const std = @import("std"); +const sig = @import("../../sig.zig"); +const Pubkey = sig.core.Pubkey; + +const parse_vote = @import("parse_vote.zig"); +const parse_stake = @import("parse_stake.zig"); +const parse_nonce = @import("parse_nonce.zig"); +const parse_address_lookup_table = @import("parse_account_lookup_table.zig"); +const parse_bpf_upgradeable_loader = @import("parse_bpf_upgradeable_loader.zig"); +const parse_sysvar = @import("parse_sysvar.zig"); +const parse_config = @import("parse_config.zig"); +pub const parse_token = @import("parse_token.zig"); +const parse_token_extension = @import("parse_token_extension.zig"); + +pub const ParseError = error{ + InvalidAccountData, + OutOfMemory, +}; + +/// A numeric value that serializes as a JSON string (e.g., "12345") for JavaScript compatibility. +/// JavaScript numbers cannot safely represent values > 2^53, so large integers must be strings. +pub fn Stringified(comptime T: type) type { + return struct { + value: T, + + const Self = @This(); + + pub fn init(value: T) Self { + return .{ .value = value }; + } + + pub fn jsonStringify(self: Self, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.print("\"{d}\"", .{self.value}); + } + }; +} + +/// Wrapper for fixed-size byte arrays that serialize as base64 strings. +pub fn Base64Encoded(comptime len: usize) type { + return struct { + data: [len]u8, + + const Self = @This(); + const encoded_len = std.base64.standard.Encoder.calcSize(len); + + /// Initialize from a pointer to avoid copying the array. + pub fn init(data: *const [len]u8) Self { + return .{ .data = data.* }; + } + + pub fn jsonStringify(self: Self, jw: anytype) @TypeOf(jw.*).Error!void { + var buf: [encoded_len]u8 = undefined; + _ = std.base64.standard.Encoder.encode(&buf, &self.data); + try jw.write(&buf); + } + }; +} + +/// Wrapper for BoundedArray(u8, N) that serializes as a JSON string. +/// BoundedArray by default serializes as {"buffer": ..., "len": N}, but we want just the string. +pub fn JsonString(comptime max_len: usize) type { + return struct { + inner: std.BoundedArray(u8, max_len), + + const Self = @This(); + + pub fn init(slice: []const u8) Self { + var result: Self = .{ .inner = .{} }; + result.inner.appendSliceAssumeCapacity(slice); + return result; + } + + pub fn fromBounded(bounded: std.BoundedArray(u8, max_len)) Self { + return .{ .inner = bounded }; + } + + pub fn constSlice(self: *const Self) []const u8 { + return self.inner.constSlice(); + } + + pub fn jsonStringify(self: Self, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.write(self.inner.constSlice()); + } + }; +} + +/// Wrapper for BoundedArray(T, N) that serializes as a JSON array. +/// BoundedArray by default serializes as {"buffer": ..., "len": N}, but we want just the array. +pub fn JsonArray(comptime T: type, comptime max_len: usize) type { + return struct { + inner: std.BoundedArray(T, max_len) = .{}, + + const Self = @This(); + + pub fn len(self: *const Self) usize { + return self.inner.len; + } + + pub fn get(self: *const Self, index: usize) T { + return self.inner.get(index); + } + + pub fn constSlice(self: *const Self) []const T { + return self.inner.constSlice(); + } + + pub fn append(self: *Self, item: T) error{Overflow}!void { + return self.inner.append(item); + } + + pub fn appendAssumeCapacity(self: *Self, item: T) void { + return self.inner.appendAssumeCapacity(item); + } + + pub fn jsonStringify(self: Self, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.write(self.inner.constSlice()); + } + }; +} + +/// The result of parsing account data for jsonParsed encoding. +/// [agave] https://github.com/anza-xyz/agave/blob/master/account-decoder-client-types/src/lib.rs#L101-L104 +pub const ParsedAccount = struct { + program: []const u8, + parsed: ParsedContent, + space: u64, + + pub fn jsonStringify(self: ParsedAccount, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("program"); + try jw.write(self.program); + try jw.objectField("parsed"); + try self.parsed.jsonStringify(jw); + try jw.objectField("space"); + try jw.write(self.space); + try jw.endObject(); + } +}; + +/// Tagged union of all parsable account types. +pub const ParsedContent = union(enum) { + vote: parse_vote.VoteAccountType, + stake: parse_stake.StakeAccountType, + nonce: parse_nonce.NonceAccountType, + address_lookup_table: parse_address_lookup_table.LookupTableAccountType, + bpf_upgradeable_loader: parse_bpf_upgradeable_loader.BpfUpgradeableLoaderAccountType, + sysvar: parse_sysvar.SysvarAccountType, + config: parse_config.ConfigAccountType, + token: parse_token.TokenAccountType, + + pub fn jsonStringify(self: ParsedContent, jw: anytype) @TypeOf(jw.*).Error!void { + switch (self) { + inline else => |content| try content.jsonStringify(jw), + } + } +}; + +/// Enum of programs that support jsonParsed. +const ParsableProgram = enum { + vote, + stake, + nonce, + address_lookup_table, + bpf_upgradeable_loader, + sysvar, + config, + token, + token_2022, + + pub fn fromProgramId(program_id: Pubkey) ?ParsableProgram { + if (program_id.equals(&sig.runtime.program.vote.ID)) return .vote; + if (program_id.equals(&sig.runtime.program.stake.ID)) return .stake; + // Nonce accounts are owned by the system program, so we check the program ID against the system program ID. + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_account_data.rs#L36 + if (program_id.equals(&sig.runtime.program.system.ID)) return .nonce; + if (program_id.equals(&sig.runtime.program.address_lookup_table.ID)) + return .address_lookup_table; + if (program_id.equals(&sig.runtime.program.bpf_loader.v3.ID)) return .bpf_upgradeable_loader; + // Sysvar accounts are owned by the sysvar program. + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_account_data.rs#L48 + if (program_id.equals(&sig.runtime.sysvar.OWNER_ID)) return .sysvar; + if (program_id.equals(&sig.runtime.program.config.ID)) return .config; + if (program_id.equals(&sig.runtime.ids.SPL_TOKEN_PROGRAM_ID)) return .token; + if (program_id.equals(&sig.runtime.ids.SPL_TOKEN_2022_PROGRAM_ID)) return .token_2022; + return null; + } + + pub fn programName(self: ParsableProgram) []const u8 { + // NOTE: use kebab-case names to match Agave + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_account_data.rs#L67 + // Agave converts enum variant names (e.g. AddressLookupTable) to kebab-case via .to_kebab_case() + return switch (self) { + .vote => "vote", + .stake => "stake", + .nonce => "nonce", + .address_lookup_table => "address-lookup-table", + .bpf_upgradeable_loader => "bpf-upgradeable-loader", + .sysvar => "sysvar", + .config => "config", + .token => "spl-token", + .token_2022 => "spl-token-2022", + }; + } +}; + +/// Additional data needed for parsing certain account types. +/// For SPL Token accounts, this includes mint decimals and interest-bearing/scaled config. +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_account_data.rs#L101-L106 +pub const AdditionalAccountData = struct { + spl_token: ?parse_token.SplTokenAdditionalData = null, +}; + +/// SPL Token Account state enum. +pub const AccountState = enum(u8) { + uninitialized = 0, + initialized = 1, + frozen = 2, +}; + +pub fn parse_account( + allocator: std.mem.Allocator, + pubkey: Pubkey, + program_id: Pubkey, + // std.io.Reader + reader: anytype, + data_len: u32, + additional_data: ?AdditionalAccountData, +) ParseError!?ParsedAccount { + const program = ParsableProgram.fromProgramId(program_id) orelse return null; + const parsed: ParsedContent = switch (program) { + .vote => .{ .vote = try parse_vote.parseVote(allocator, reader, pubkey) }, + .stake => .{ .stake = try parse_stake.parseStake(allocator, reader) }, + .nonce => .{ .nonce = try parse_nonce.parseNonce(allocator, reader) }, + .address_lookup_table => .{ + .address_lookup_table = try parse_address_lookup_table.parseAddressLookupTable( + allocator, + reader, + data_len, + ), + }, + .bpf_upgradeable_loader => .{ + .bpf_upgradeable_loader = try parse_bpf_upgradeable_loader.parseBpfUpgradeableLoader( + allocator, + reader, + data_len, + ), + }, + .sysvar => { + // Sysvar parsing dispatches by the account's pubkey, not its owner. + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L24 + const sysvar_parsed = try parse_sysvar.parseSysvar( + allocator, + pubkey, + reader, + ); + if (sysvar_parsed) |s| { + return ParsedAccount{ + .program = program.programName(), + .parsed = .{ .sysvar = s }, + .space = data_len, + }; + } + // Unknown sysvar pubkey - return null to fall back to base64 encoding + return null; + }, + .config => { + const config_parsed = try parse_config.parseConfig( + allocator, + pubkey, + reader, + data_len, + ); + if (config_parsed) |c| { + return ParsedAccount{ + .program = program.programName(), + .parsed = .{ .config = c }, + .space = data_len, + }; + } + // Unknown config account - return null to fall back to base64 encoding + return null; + }, + .token, .token_2022 => { + // Token parsing requires the full data slice. + const data = try allocator.alloc(u8, data_len); + defer allocator.free(data); + const bytes_read = reader.readAll(data) catch return ParseError.InvalidAccountData; + if (bytes_read != data_len) return ParseError.InvalidAccountData; + + const spl_token_data: ?*const parse_token.SplTokenAdditionalData = + if (additional_data) |ad| if (ad.spl_token) |*d| d else null else null; + const token_parsed = try parse_token.parseToken( + data, + spl_token_data, + ); + if (token_parsed) |t| { + return ParsedAccount{ + .program = program.programName(), + .parsed = .{ .token = t }, + .space = data_len, + }; + } + // Unknown token account - return null to fall back to base64 encoding + return null; + }, + }; + + return ParsedAccount{ + .program = program.programName(), + .parsed = parsed, + .space = data_len, + }; +} + +/// Build SplTokenAdditionalData by fetching mint account and Clock sysvar. +/// Returns empty additional data if not a token account or fetch fails +pub fn buildTokenAdditionalData( + allocator: std.mem.Allocator, + account: sig.core.Account, + slot_reader: sig.accounts_db.SlotAccountReader, +) AdditionalAccountData { + // Check if this is a token account + const is_token_program = account.owner.equals(&sig.runtime.ids.SPL_TOKEN_PROGRAM_ID) or + account.owner.equals(&sig.runtime.ids.SPL_TOKEN_2022_PROGRAM_ID); + if (!is_token_program) return .{}; + + // Read account data to extract mint pubkey + var data_iter = account.data.iterator(); + var data_buf: [parse_token.TokenAccount.LEN]u8 = undefined; + const bytes_read = data_iter.readBytes(&data_buf) catch return .{}; + if (bytes_read < 32) return .{}; + + // Extract mint pubkey from token account (first 32 bytes) + const mint_pubkey = parse_token.getTokenAccountMint(data_buf[0..bytes_read]) orelse return .{}; + + const spl_token = getMintAdditionalData(allocator, mint_pubkey, slot_reader) orelse return .{}; + return .{ .spl_token = spl_token }; +} + +/// Fetches a mint account by pubkey and extracts decimals, Token-2022 extension configs, +/// and the clock timestamp needed for interest-bearing/scaled calculations. +/// Returns null if the mint account cannot be found or parsed. +pub fn getMintAdditionalData( + allocator: std.mem.Allocator, + mint_pubkey: Pubkey, + slot_reader: sig.accounts_db.SlotAccountReader, +) ?parse_token.SplTokenAdditionalData { + // Fetch the mint account + const maybe_mint_account = slot_reader.get(allocator, mint_pubkey) catch return null; + const mint_account = maybe_mint_account orelse return null; + defer mint_account.deinit(allocator); + + // Read mint data + var mint_iter = mint_account.data.iterator(); + const mint_data = allocator.alloc(u8, mint_account.data.len()) catch return null; + defer allocator.free(mint_data); + _ = mint_iter.readBytes(mint_data) catch return null; + + // Parse mint to get decimals + const mint = parse_token.Mint.unpack(mint_data) catch return null; + + // Fetch Clock sysvar for timestamp + const clock_id = sig.runtime.sysvar.Clock.ID; + const maybe_clock_account = slot_reader.get(allocator, clock_id) catch return null; + const clock_account = maybe_clock_account orelse return null; + defer clock_account.deinit(allocator); + + var clock_iter = clock_account.data.iterator(); + const clock = sig.bincode.read( + allocator, + sig.runtime.sysvar.Clock, + clock_iter.reader(), + .{}, + ) catch return null; + + // Extract extension configs from mint data + const InterestCfg = parse_token_extension.InterestBearingConfigData; + const ScaledCfg = parse_token_extension.ScaledUiAmountConfigData; + const interest_config = InterestCfg.extractFromMint(mint_data); + const scaled_config = ScaledCfg.extractFromMint(mint_data); + + return .{ + .decimals = mint.decimals, + .unix_timestamp = clock.unix_timestamp, + .interest_bearing_config = interest_config, + .scaled_ui_amount_config = scaled_config, + }; +} + +// Tests +test "rpc.account_decoder.lib: parse account" { + const allocator = std.testing.allocator; + + // Unknown program returns null + { + const unknown_program_id = Pubkey{ .data = [_]u8{99} ** 32 }; + const pubkey = Pubkey{ .data = [_]u8{1} ** 32 }; + + const data = [_]u8{ 0, 1, 2, 3, 4, 5, 6, 7 }; + var stream = std.io.fixedBufferStream(&data); + + const result = try parse_account( + allocator, + pubkey, + unknown_program_id, + stream.reader(), + @intCast(data.len), + null, + ); + + try std.testing.expectEqual(@as(?ParsedAccount, null), result); + } + + // ParsableProgram.fromProgramId maps known programs + { + const prog = sig.runtime.program; + const ids = sig.runtime.ids; + + // Vote program + try std.testing.expectEqual( + ParsableProgram.vote, + ParsableProgram.fromProgramId(prog.vote.ID).?, + ); + try std.testing.expectEqualStrings("vote", ParsableProgram.vote.programName()); + + // Stake program + try std.testing.expectEqual( + ParsableProgram.stake, + ParsableProgram.fromProgramId(prog.stake.ID).?, + ); + try std.testing.expectEqualStrings("stake", ParsableProgram.stake.programName()); + + // System program (nonce) + try std.testing.expectEqual( + ParsableProgram.nonce, + ParsableProgram.fromProgramId(prog.system.ID).?, + ); + try std.testing.expectEqualStrings("nonce", ParsableProgram.nonce.programName()); + + // Address lookup table + try std.testing.expectEqual( + ParsableProgram.address_lookup_table, + ParsableProgram.fromProgramId(prog.address_lookup_table.ID).?, + ); + try std.testing.expectEqualStrings( + "address-lookup-table", + ParsableProgram.address_lookup_table.programName(), + ); + + // BPF upgradeable loader + try std.testing.expectEqual( + ParsableProgram.bpf_upgradeable_loader, + ParsableProgram.fromProgramId(prog.bpf_loader.v3.ID).?, + ); + try std.testing.expectEqualStrings( + "bpf-upgradeable-loader", + ParsableProgram.bpf_upgradeable_loader.programName(), + ); + + // Sysvar + try std.testing.expectEqual( + ParsableProgram.sysvar, + ParsableProgram.fromProgramId(sig.runtime.sysvar.OWNER_ID).?, + ); + try std.testing.expectEqualStrings("sysvar", ParsableProgram.sysvar.programName()); + + // Config + try std.testing.expectEqual( + ParsableProgram.config, + ParsableProgram.fromProgramId(prog.config.ID).?, + ); + try std.testing.expectEqualStrings("config", ParsableProgram.config.programName()); + + // SPL Token + try std.testing.expectEqual( + ParsableProgram.token, + ParsableProgram.fromProgramId(ids.SPL_TOKEN_PROGRAM_ID).?, + ); + try std.testing.expectEqualStrings("spl-token", ParsableProgram.token.programName()); + + // SPL Token 2022 + try std.testing.expectEqual( + ParsableProgram.token_2022, + ParsableProgram.fromProgramId(ids.SPL_TOKEN_2022_PROGRAM_ID).?, + ); + try std.testing.expectEqualStrings( + "spl-token-2022", + ParsableProgram.token_2022.programName(), + ); + + // Unknown program + const unknown = Pubkey{ .data = [_]u8{255} ** 32 }; + try std.testing.expectEqual( + @as(?ParsableProgram, null), + ParsableProgram.fromProgramId(unknown), + ); + } + + // Parse vote account dispatches correctly + { + const vote_pubkey = Pubkey{ .data = [_]u8{1} ** 32 }; + + // Use DEFAULT vote state (same pattern as parse_vote tests) + const vote_state = sig.runtime.program.vote.state.VoteStateV4.DEFAULT; + const versions = sig.runtime.program.vote.state.VoteStateVersions{ .v4 = vote_state }; + + const data = try sig.bincode.writeAlloc(allocator, versions, .{}); + defer allocator.free(data); + + var stream = std.io.fixedBufferStream(data); + + const result = try parse_account( + allocator, + vote_pubkey, + sig.runtime.program.vote.ID, + stream.reader(), + @intCast(data.len), + null, + ); + + try std.testing.expect(result != null); + try std.testing.expectEqualStrings("vote", result.?.program); + try std.testing.expectEqual(@as(u64, data.len), result.?.space); + + // Verify it's a vote account - DEFAULT has zeroes for nodePubkey and withdrawer + switch (result.?.parsed) { + .vote => |vote_type| { + switch (vote_type) { + .vote => |ui_vote| { + try std.testing.expectEqual(Pubkey.ZEROES, ui_vote.nodePubkey); + try std.testing.expectEqual(Pubkey.ZEROES, ui_vote.authorizedWithdrawer); + }, + } + }, + else => return error.UnexpectedParsedType, + } + } + + // Parse stake account dispatches correctly + { + const stake_pubkey = Pubkey{ .data = [_]u8{5} ** 32 }; + const authorized_staker = Pubkey{ .data = [_]u8{6} ** 32 }; + const authorized_withdrawer = Pubkey{ .data = [_]u8{7} ** 32 }; + + const StakeStateV2 = sig.runtime.program.stake.StakeStateV2; + const meta = StakeStateV2.Meta{ + .rent_exempt_reserve = 2282880, + .authorized = .{ + .staker = authorized_staker, + .withdrawer = authorized_withdrawer, + }, + .lockup = .{ + .unix_timestamp = 0, + .epoch = 0, + .custodian = Pubkey.ZEROES, + }, + }; + + const stake_state = StakeStateV2{ .initialized = meta }; + + const data = try sig.bincode.writeAlloc(allocator, stake_state, .{}); + defer allocator.free(data); + + var stream = std.io.fixedBufferStream(data); + + const result = try parse_account( + allocator, + stake_pubkey, + sig.runtime.program.stake.ID, + stream.reader(), + @intCast(data.len), + null, + ); + + try std.testing.expect(result != null); + try std.testing.expectEqualStrings("stake", result.?.program); + try std.testing.expectEqual(@as(u64, data.len), result.?.space); + + // Verify it's a stake account + switch (result.?.parsed) { + .stake => |stake_type| { + switch (stake_type) { + .initialized => |ui_stake| { + try std.testing.expectEqualStrings( + authorized_staker.base58String().constSlice(), + ui_stake.meta.authorized.staker.base58String().constSlice(), + ); + try std.testing.expectEqualStrings( + authorized_withdrawer.base58String().constSlice(), + ui_stake.meta.authorized.withdrawer.base58String().constSlice(), + ); + }, + else => return error.UnexpectedStakeState, + } + }, + else => return error.UnexpectedParsedType, + } + } +} diff --git a/src/rpc/account_decoder/parse_account_lookup_table.zig b/src/rpc/account_decoder/parse_account_lookup_table.zig new file mode 100644 index 0000000000..f37cececf7 --- /dev/null +++ b/src/rpc/account_decoder/parse_account_lookup_table.zig @@ -0,0 +1,204 @@ +/// Types for parsing a address lookup table accounts for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); + +const Allocator = std.mem.Allocator; +const Pubkey = sig.core.Pubkey; +const AddressLookupTable = sig.runtime.program.address_lookup_table.AddressLookupTable; +const ParseError = account_decoder.ParseError; +const address_lookup_table = sig.runtime.program.address_lookup_table; +const LOOKUP_TABLE_META_SIZE = address_lookup_table.state.LOOKUP_TABLE_META_SIZE; +const ProgramState = address_lookup_table.ProgramState; +const LookupTableMeta = address_lookup_table.LookupTableMeta; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L7-L20 +pub fn parseAddressLookupTable( + allocator: Allocator, + // std.io.Reader + reader: anytype, + data_len: u32, +) ParseError!LookupTableAccountType { + // Read all data into buffer since the AddressLookupTable deserialize impl doesn't support borrowing from the reader. + const data = allocator.alloc(u8, data_len) catch return ParseError.OutOfMemory; + defer allocator.free(data); + + const bytes_read = reader.readAll(data) catch return ParseError.InvalidAccountData; + if (bytes_read != data_len) return ParseError.InvalidAccountData; + + const lookup_table = AddressLookupTable.deserialize(data) catch |err| switch (err) { + error.UninitializedAccount => return .uninitialized, + error.InvalidAccountData => return ParseError.InvalidAccountData, + }; + + const addresses = allocator.alloc(Pubkey, lookup_table.addresses.len) catch + return ParseError.OutOfMemory; + @memcpy(addresses, lookup_table.addresses); + + return .{ .lookup_table = .{ + .deactivationSlot = .{ .value = lookup_table.meta.deactivation_slot }, + .lastExtendedSlot = .{ .value = lookup_table.meta.last_extended_slot }, + .lastExtendedSlotStartIndex = lookup_table.meta.last_extended_slot_start_index, + .authority = lookup_table.meta.authority, + .addresses = addresses, + } }; +} + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L22-L27 +pub const LookupTableAccountType = union(enum) { + uninitialized, + lookup_table: UiLookupTable, + + pub fn jsonStringify(self: LookupTableAccountType, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(typeNameFromTag(tag)); + if (@TypeOf(v) != void) { + try jw.objectField("info"); + try v.jsonStringify(jw); + } + }, + } + try jw.endObject(); + } + + fn typeNameFromTag(comptime tag: std.meta.Tag(@This())) []const u8 { + return switch (tag) { + .uninitialized => "uninitialized", + .lookup_table => "lookupTable", + }; + } +}; +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L29-L38 +pub const UiLookupTable = struct { + deactivationSlot: account_decoder.Stringified(u64), + lastExtendedSlot: account_decoder.Stringified(u64), + lastExtendedSlotStartIndex: u8, + authority: ?Pubkey, + addresses: []const Pubkey, + + pub fn jsonStringify(self: UiLookupTable, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("deactivationSlot"); + try jw.write(self.deactivationSlot); + try jw.objectField("lastExtendedSlot"); + try jw.write(self.lastExtendedSlot); + try jw.objectField("lastExtendedSlotStartIndex"); + try jw.write(self.lastExtendedSlotStartIndex); + // Skip authority if null to match Agave behavior + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L36 + if (self.authority) |auth| { + try jw.objectField("authority"); + try jw.write(auth); + } + try jw.objectField("addresses"); + try jw.write(self.addresses); + try jw.endObject(); + } +}; + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L49-L103 +test "rpc.account_decoder.parse_account_lookup_table: parse lookup tables" { + const allocator = std.testing.allocator; + + // Parse valid lookup table with addresses + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L49-L81 + { + const authority = Pubkey{ .data = [_]u8{1} ** 32 }; + const addr1 = Pubkey{ .data = [_]u8{2} ** 32 }; + const addr2 = Pubkey{ .data = [_]u8{3} ** 32 }; + + const meta = LookupTableMeta{ + .deactivation_slot = std.math.maxInt(u64), // Not deactivated + .last_extended_slot = 12345, + .last_extended_slot_start_index = 0, + .authority = authority, + ._padding = 0, + }; + + const program_state = ProgramState{ .LookupTable = meta }; + + // Build the account data: metadata + addresses + var data: [LOOKUP_TABLE_META_SIZE + 64]u8 = undefined; // 56 bytes meta + 2 * 32 bytes addresses + + // Serialize the metadata + _ = sig.bincode.writeToSlice(&data, program_state, .{}) catch unreachable; + + // Append addresses after metadata + @memcpy(data[LOOKUP_TABLE_META_SIZE..][0..32], &addr1.data); + @memcpy(data[LOOKUP_TABLE_META_SIZE + 32 ..][0..32], &addr2.data); + + // Parse the lookup table + var stream = std.io.fixedBufferStream(&data); + const result = try parseAddressLookupTable(allocator, stream.reader(), @intCast(data.len)); + defer allocator.free(result.lookup_table.addresses); + + // Verify the parsed result + const lt = result.lookup_table; + try std.testing.expectEqual(std.math.maxInt(u64), lt.deactivationSlot.value); + try std.testing.expectEqual(@as(u64, 12345), lt.lastExtendedSlot.value); + try std.testing.expectEqual(@as(u8, 0), lt.lastExtendedSlotStartIndex); + try std.testing.expectEqual(authority, lt.authority.?); + try std.testing.expectEqual(@as(usize, 2), lt.addresses.len); + try std.testing.expectEqual(addr1, lt.addresses[0]); + try std.testing.expectEqual(addr2, lt.addresses[1]); + } + + // Parse table without authority (frozen) + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L49-L81 + { + const meta = LookupTableMeta{ + .deactivation_slot = 99999, + .last_extended_slot = 50000, + .last_extended_slot_start_index = 5, + .authority = null, // No authority = frozen + ._padding = 0, + }; + + const program_state = ProgramState{ .LookupTable = meta }; + + var data: [LOOKUP_TABLE_META_SIZE]u8 = undefined; + _ = sig.bincode.writeToSlice(&data, program_state, .{}) catch unreachable; + + // Parse the lookup table + var stream = std.io.fixedBufferStream(&data); + const result = try parseAddressLookupTable(allocator, stream.reader(), @intCast(data.len)); + defer allocator.free(result.lookup_table.addresses); + + // Verify the parsed result + const lt = result.lookup_table; + try std.testing.expectEqual(@as(u64, 99999), lt.deactivationSlot.value); + try std.testing.expectEqual(@as(u64, 50000), lt.lastExtendedSlot.value); + try std.testing.expectEqual(@as(u8, 5), lt.lastExtendedSlotStartIndex); + try std.testing.expectEqual(@as(?Pubkey, null), lt.authority); + try std.testing.expectEqual(@as(usize, 0), lt.addresses.len); + } + + // Parse uninitialized table + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L83-L95 + { + const program_state = ProgramState{ .Uninitialized = {} }; + + var data: [LOOKUP_TABLE_META_SIZE]u8 = undefined; + _ = sig.bincode.writeToSlice(&data, program_state, .{}) catch unreachable; + + // Parse should return uninitialized + var stream = std.io.fixedBufferStream(&data); + const result = try parseAddressLookupTable(allocator, stream.reader(), @intCast(data.len)); + + try std.testing.expectEqual(LookupTableAccountType.uninitialized, result); + } + + // Bad data returns error + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_address_lookup_table.rs#L97-L103 + { + const bad_data = [_]u8{ 0, 1, 2, 3 }; + + var stream = std.io.fixedBufferStream(&bad_data); + const result = parseAddressLookupTable(allocator, stream.reader(), @intCast(bad_data.len)); + try std.testing.expectError(ParseError.InvalidAccountData, result); + } +} diff --git a/src/rpc/account_decoder/parse_bpf_upgradeable_loader.zig b/src/rpc/account_decoder/parse_bpf_upgradeable_loader.zig new file mode 100644 index 0000000000..3d9f99c801 --- /dev/null +++ b/src/rpc/account_decoder/parse_bpf_upgradeable_loader.zig @@ -0,0 +1,356 @@ +/// Types for parsing BPF upgradeable loader accounts for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_bpf_loader.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); + +const Allocator = std.mem.Allocator; +const Pubkey = sig.core.Pubkey; +const State = sig.runtime.program.bpf_loader.v3.State; +const ParseError = account_decoder.ParseError; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_bpf_loader.rs#L13 +pub fn parseBpfUpgradeableLoader( + allocator: Allocator, + // std.io.Reader + reader: anytype, + data_len: u32, +) ParseError!BpfUpgradeableLoaderAccountType { + const discriminant = reader.readInt(u32, .little) catch return ParseError.InvalidAccountData; + return switch (discriminant) { + 0 => .uninitialized, + 1 => blk: { + // Buffer: Option authority + bytecode + const maybe_authority = readOptionPubkey(reader) catch + return ParseError.InvalidAccountData; + // Buffer size: enum tag (u32) + option tag (1 byte) + optionally pubkey (32 bytes) + const auth_size: u32 = if (maybe_authority != null) Pubkey.SIZE else 0; + const metadata_size: u32 = @sizeOf(u32) + 1 + auth_size; + const data = readRemainingBytes( + allocator, + reader, + data_len, + metadata_size, + ) catch return ParseError.InvalidAccountData; + break :blk .{ .buffer = .{ + .authority = maybe_authority, + .data = data, + } }; + }, + 2 => blk: { + // Program: Pubkey programdata_address + const programdata_address = readPubkey(reader) catch + return ParseError.InvalidAccountData; + break :blk .{ .program = .{ .programData = programdata_address } }; + }, + 3 => blk: { + // ProgramData: u64 slot + Option upgrade_authority + bytecode + const slot = reader.readInt(u64, .little) catch return ParseError.InvalidAccountData; + const maybe_upgrade_authority = readOptionPubkey(reader) catch + return ParseError.InvalidAccountData; + // ProgramData size: enum tag (u32) + slot (u64) + option tag (1 byte) + optionally pubkey + const auth_size: u32 = if (maybe_upgrade_authority != null) Pubkey.SIZE else 0; + const metadata_size: u32 = @sizeOf(u32) + @sizeOf(u64) + 1 + auth_size; + const data = readRemainingBytes( + allocator, + reader, + data_len, + metadata_size, + ) catch return ParseError.InvalidAccountData; + break :blk .{ .program_data = .{ + .slot = slot, + .authority = maybe_upgrade_authority, + .data = data, + } }; + }, + else => return ParseError.InvalidAccountData, + }; +} + +// TODO: do we need this? does reader provide a typed read? +fn readPubkey(reader: anytype) !Pubkey { + var bytes: [Pubkey.SIZE]u8 = undefined; + const n = try reader.readAll(&bytes); + if (n != Pubkey.SIZE) return error.EndOfStream; + return Pubkey{ .data = bytes }; +} + +fn readOptionPubkey(reader: anytype) !?Pubkey { + const tag = try reader.readByte(); + return switch (tag) { + 0 => null, + 1 => try readPubkey(reader), + else => error.InvalidData, + }; +} + +fn readRemainingBytes( + allocator: Allocator, + reader: anytype, + data_len: u32, + metadata_size: usize, +) ![]const u8 { + if (data_len < metadata_size) return error.InvalidData; + const bytecode_len = data_len - metadata_size; + if (bytecode_len == 0) return &.{}; + const bytecode = allocator.alloc(u8, bytecode_len) catch return error.OutOfMemory; + const n = reader.readAll(bytecode) catch return error.InvalidData; + if (n != bytecode_len) return error.InvalidData; + return bytecode; +} + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_bpf_loader.rs#L68-L73 +pub const BpfUpgradeableLoaderAccountType = union(enum) { + uninitialized, + buffer: UiBuffer, + program: UiProgram, + program_data: UiProgramData, + + pub fn jsonStringify( + self: BpfUpgradeableLoaderAccountType, + jw: anytype, + ) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(typeNameFromTag(tag)); + if (@TypeOf(v) != void) { + try jw.objectField("info"); + try jw.write(v); + } + }, + } + try jw.endObject(); + } + + fn typeNameFromTag(comptime tag: std.meta.Tag(@This())) []const u8 { + return switch (tag) { + .uninitialized => "uninitialized", + .buffer => "buffer", + .program => "program", + .program_data => "programData", + }; + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_bpf_loader.rs#L77-L80 +pub const UiBuffer = struct { + authority: ?Pubkey, + data: []const u8, + + pub fn jsonStringify(self: UiBuffer, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("authority"); + try jw.write(self.authority); + try jw.objectField("data"); + try writeBase64DataTuple(jw, self.data); + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_bpf_loader.rs#L84-L86 +pub const UiProgram = struct { + programData: Pubkey, + + pub fn jsonStringify(self: UiProgram, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("programData"); + try jw.write(self.programData); + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_bpf_loader.rs#L90-L95 +pub const UiProgramData = struct { + slot: u64, + authority: ?Pubkey, + data: []const u8, + + pub fn jsonStringify(self: UiProgramData, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("authority"); + try jw.write(self.authority); + try jw.objectField("data"); + try writeBase64DataTuple(jw, self.data); + try jw.objectField("slot"); + try jw.write(self.slot); + try jw.endObject(); + } +}; + +/// Writes a ["", "base64"] tuple, streaming the base64 encoding directly to the JSON writer. +/// Weird, but to conform with Agave response format. +fn writeBase64DataTuple(jw: anytype, bytecode: []const u8) @TypeOf(jw.*).Error!void { + try jw.beginArray(); + // Stream base64-encoded bytecode directly to underlying writer + try jw.beginWriteRaw(); + try jw.stream.writeByte('"'); + var base64_stream = sig.utils.base64.EncodingStream.init(std.base64.standard.Encoder); + const ctx = base64_stream.writerCtx(jw.stream); + try ctx.writer().writeAll(bytecode); + try ctx.flush(); + try jw.stream.writeByte('"'); + jw.endWriteRaw(); + // Second element: encoding type + try jw.write("base64"); + try jw.endArray(); +} + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_bpf_loader.rs#L97 +test "rpc.account_decoder.parse_bpf_upgradeable_loader: parse accounts" { + const allocator = std.testing.allocator; + + // Unitialized + { + const state = State.uninitialized; + const serialized = try sig.bincode.writeAlloc(allocator, state, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseBpfUpgradeableLoader( + allocator, + &stream.reader(), + @intCast(serialized.len), + ); + + try std.testing.expectEqual( + BpfUpgradeableLoaderAccountType.uninitialized, + result, + ); + } + + // Buffer with authority and bytecode + { + const authority = Pubkey{ .data = [_]u8{1} ** 32 }; + const program_bytecode = [_]u8{7} ** 64; + const state = State{ .buffer = .{ .authority_address = authority } }; + const metadata = try sig.bincode.writeAlloc(allocator, state, .{}); + defer allocator.free(metadata); + + // Combine metadata + bytecode + const full_data = try allocator.alloc(u8, metadata.len + program_bytecode.len); + defer allocator.free(full_data); + + @memcpy(full_data[0..metadata.len], metadata); + @memcpy(full_data[metadata.len..], &program_bytecode); + var stream = std.io.fixedBufferStream(full_data); + const result = try parseBpfUpgradeableLoader( + allocator, + stream.reader(), + @intCast(full_data.len), + ); + defer allocator.free(result.buffer.data); + + try std.testing.expect(result == .buffer); + try std.testing.expectEqual(authority, result.buffer.authority.?); + try std.testing.expectEqualSlices(u8, &program_bytecode, result.buffer.data); + } + + // Buffer without authority + { + const program_bytecode = [_]u8{7} ** 64; + const state = State{ .buffer = .{ .authority_address = null } }; + const metadata = try sig.bincode.writeAlloc(allocator, state, .{}); + defer allocator.free(metadata); + + const full_data = try allocator.alloc(u8, metadata.len + program_bytecode.len); + defer allocator.free(full_data); + + @memcpy(full_data[0..metadata.len], metadata); + @memcpy(full_data[metadata.len..], &program_bytecode); + var stream = std.io.fixedBufferStream(full_data); + const result = try parseBpfUpgradeableLoader( + allocator, + stream.reader(), + @intCast(full_data.len), + ); + + defer allocator.free(result.buffer.data); + try std.testing.expect(result == .buffer); + try std.testing.expectEqual(@as(?Pubkey, null), result.buffer.authority); + try std.testing.expectEqualSlices(u8, &program_bytecode, result.buffer.data); + } + + // Program + { + const programdata_address = Pubkey{ .data = [_]u8{42} ** 32 }; + const state = State{ .program = .{ .programdata_address = programdata_address } }; + const serialized = try sig.bincode.writeAlloc(allocator, state, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseBpfUpgradeableLoader( + allocator, + stream.reader(), + @intCast(serialized.len), + ); + + try std.testing.expect(result == .program); + try std.testing.expectEqual(programdata_address, result.program.programData); + } + + // ProgramData with authority + { + const authority = Pubkey{ .data = [_]u8{99} ** 32 }; + const slot: u64 = 42; + const program_bytecode = [_]u8{7} ** 64; + const state = State{ .program_data = .{ + .slot = slot, + .upgrade_authority_address = authority, + } }; + + const metadata = try sig.bincode.writeAlloc(allocator, state, .{}); + defer allocator.free(metadata); + + const full_data = try allocator.alloc(u8, metadata.len + program_bytecode.len); + defer allocator.free(full_data); + + @memcpy(full_data[0..metadata.len], metadata); + @memcpy(full_data[metadata.len..], &program_bytecode); + var stream = std.io.fixedBufferStream(full_data); + const result = try parseBpfUpgradeableLoader( + allocator, + stream.reader(), + @intCast(full_data.len), + ); + defer allocator.free(result.program_data.data); + + try std.testing.expect(result == .program_data); + try std.testing.expectEqual(slot, result.program_data.slot); + try std.testing.expectEqual(authority, result.program_data.authority.?); + try std.testing.expectEqualSlices(u8, &program_bytecode, result.program_data.data); + } + + // ProgramData without authority + { + const slot: u64 = 42; + const program_bytecode = [_]u8{7} ** 64; + const state = State{ .program_data = .{ + .slot = slot, + .upgrade_authority_address = null, + } }; + + const metadata = try sig.bincode.writeAlloc(allocator, state, .{}); + defer allocator.free(metadata); + + const full_data = try allocator.alloc(u8, metadata.len + program_bytecode.len); + defer allocator.free(full_data); + + @memcpy(full_data[0..metadata.len], metadata); + @memcpy(full_data[metadata.len..], &program_bytecode); + var stream = std.io.fixedBufferStream(full_data); + + const result = try parseBpfUpgradeableLoader( + allocator, + stream.reader(), + @intCast(full_data.len), + ); + defer allocator.free(result.program_data.data); + + try std.testing.expect(result == .program_data); + try std.testing.expectEqual(slot, result.program_data.slot); + try std.testing.expectEqual(@as(?Pubkey, null), result.program_data.authority); + try std.testing.expectEqualSlices(u8, &program_bytecode, result.program_data.data); + } +} diff --git a/src/rpc/account_decoder/parse_config.zig b/src/rpc/account_decoder/parse_config.zig new file mode 100644 index 0000000000..3f74dec0e9 --- /dev/null +++ b/src/rpc/account_decoder/parse_config.zig @@ -0,0 +1,310 @@ +/// Types for parsing config accounts for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_config.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); +const Allocator = std.mem.Allocator; +const Pubkey = sig.core.Pubkey; +const bincode = sig.bincode; +const shortvec = bincode.shortvec; +const ParseError = account_decoder.ParseError; +const ids = sig.runtime.ids; + +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/validator_info.rs#L7 +const VALIDATOR_INFO_ID: Pubkey = .parse("Va1idator1nfo111111111111111111111111111111"); + +/// Parse a config account by its pubkey. +/// Returns null if the config type is unknown (not StakeConfig or ValidatorInfo). +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_config.rs#L14-L33 +pub fn parseConfig( + allocator: Allocator, + pubkey: Pubkey, + reader: anytype, + data_len: u32, +) ParseError!?ConfigAccountType { + // Read all data into buffer for simpler offset calculations + const data = allocator.alloc(u8, data_len) catch return ParseError.OutOfMemory; + defer allocator.free(data); + reader.readNoEof(data) catch return ParseError.InvalidAccountData; + if (pubkey.equals(&ids.STAKE_CONFIG_PROGRAM_ID)) { + return parseStakeConfig(allocator, data); + } else { + return parseValidatorInfo(allocator, data); + } +} + +fn parseStakeConfig(allocator: Allocator, data: []const u8) ParseError!?ConfigAccountType { + // First, deserialize ConfigKeys to find its serialized size + const config_keys = bincode.readFromSlice(allocator, ConfigKeys, data, .{}) catch + return ParseError.InvalidAccountData; + defer allocator.free(config_keys.keys); + // Calculate offset: ConfigKeys serialized size + const keys_size = getConfigKeysSerializedSize(config_keys.keys.len); + if (keys_size > data.len) return ParseError.InvalidAccountData; + const config_data = data[keys_size..]; + // Deserialize StakeConfig + const stake_config = bincode.readFromSlice(allocator, StakeConfig, config_data, .{}) catch + return ParseError.InvalidAccountData; + return ConfigAccountType{ + .stake_config = UiStakeConfig{ + .warmupCooldownRate = stake_config.warmup_cooldown_rate, + .slashPenalty = stake_config.slash_penalty, + }, + }; +} + +fn parseValidatorInfo(allocator: Allocator, data: []const u8) ParseError!?ConfigAccountType { + // Deserialize ConfigKeys + const config_keys = bincode.readFromSlice(allocator, ConfigKeys, data, .{}) catch + return ParseError.InvalidAccountData; + defer allocator.free(config_keys.keys); + // Check if this is a ValidatorInfo config + if (config_keys.keys.len == 0) return null; + if (!config_keys.keys[0].pubkey.equals(&VALIDATOR_INFO_ID)) return null; + // Calculate offset to skip ConfigKeys + const keys_size = getConfigKeysSerializedSize(config_keys.keys.len); + if (keys_size > data.len) return ParseError.InvalidAccountData; + const config_data = data[keys_size..]; + // Deserialize ValidatorInfo (length-prefixed string) + const validator_info = bincode.readFromSlice(allocator, ValidatorInfo, config_data, .{}) catch + return ParseError.InvalidAccountData; + defer allocator.free(validator_info.info); + // Build UI keys array + const ui_keys = allocator.alloc(UiConfigKey, config_keys.keys.len) catch + return ParseError.OutOfMemory; + for (config_keys.keys, 0..) |key, i| { + ui_keys[i] = UiConfigKey{ + .pubkey = key.pubkey, + .signer = key.is_signer, + }; + } + // Copy the info string (we need to own it since we're freeing validator_info) + const info_copy = allocator.dupe(u8, validator_info.info) catch + return ParseError.OutOfMemory; + return ConfigAccountType{ + .validator_info = UiConfig{ + .keys = ui_keys, + .configData = info_copy, + }, + }; +} + +/// Calculate the serialized size of ConfigKeys. +/// Format: short_vec length (1-3 bytes) + keys_count * (32 + 1) bytes +fn getConfigKeysSerializedSize(keys_count: usize) usize { + const key_size = 32 + 1; // Pubkey (32) + bool (1) + const len_u16: u16 = @intCast(keys_count); + const short_vec_len = shortVecEncodedLen(len_u16); + return short_vec_len + keys_count * key_size; +} + +/// Calculate how many bytes a u16 takes when LEB128 encoded. +fn shortVecEncodedLen(value: u16) usize { + if (value < 0x80) return 1; + if (value < 0x4000) return 2; + return 3; +} + +/// A key entry in ConfigKeys: (Pubkey, is_signer) +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/programs/config/src/lib.rs#L35 +const ConfigKey = struct { + pubkey: Pubkey, + is_signer: bool, +}; + +/// The keys header for config accounts, uses short_vec encoding. +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/programs/config/src/lib.rs#L38-L42 +const ConfigKeys = struct { + keys: []ConfigKey, + pub const @"!bincode-config:keys" = shortvec.sliceConfig([]ConfigKey); +}; + +/// StakeConfig data stored after ConfigKeys. +/// [agave] https://github.com/anza-xyz/solana-sdk/blob/v1.18.0/program/src/stake/config.rs +const StakeConfig = struct { + warmup_cooldown_rate: f64, + slash_penalty: u8, +}; + +/// ValidatorInfo data stored after ConfigKeys. +/// The `info` field is a JSON string containing validator metadata. +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/validator_info.rs#L17-L20 +const ValidatorInfo = struct { + info: []const u8, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_config.rs#L59-L63 +pub const UiStakeConfig = struct { + warmupCooldownRate: f64, + slashPenalty: u8, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_config.rs#L65-L70 +pub const UiConfigKey = struct { + pubkey: Pubkey, + signer: bool, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_config.rs#L77-L81 +pub const UiConfig = struct { + keys: []UiConfigKey, + configData: []const u8, // Raw JSON string, written verbatim + + pub fn jsonStringify(self: UiConfig, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("keys"); + try jw.write(self.keys); + try jw.objectField("configData"); + // Write raw JSON verbatim (no quotes, no escaping) + try jw.beginWriteRaw(); + try jw.stream.writeAll(self.configData); + jw.endWriteRaw(); + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_config.rs#L35-L38 +pub const ConfigAccountType = union(enum) { + stake_config: UiStakeConfig, + validator_info: UiConfig, + + pub fn jsonStringify(self: @This(), jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(typeNameFromTag(tag)); + try jw.objectField("info"); + try jw.write(v); + }, + } + try jw.endObject(); + } + + fn typeNameFromTag(comptime tag: std.meta.Tag(@This())) []const u8 { + return switch (tag) { + .stake_config => "stakeConfig", + .validator_info => "validatorInfo", + }; + } +}; + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_config.rs#L97 +test "rpc.account_decoder.parse_config: parse config accounts" { + const allocator = std.testing.allocator; + + // Test StakeConfig + { + // Build ConfigKeys + StakeConfig data + const keys = [_]ConfigKey{ + .{ .pubkey = ids.STAKE_CONFIG_PROGRAM_ID, .is_signer = false }, + }; + const config_keys = ConfigKeys{ .keys = @constCast(&keys) }; + const stake_config = StakeConfig{ + .warmup_cooldown_rate = 0.25, + .slash_penalty = 12, + }; + + // Serialize: ConfigKeys + StakeConfig + const keys_data = try bincode.writeAlloc(allocator, config_keys, .{}); + defer allocator.free(keys_data); + + const config_data = try bincode.writeAlloc(allocator, stake_config, .{}); + defer allocator.free(config_data); + + const full_data = try std.mem.concat(allocator, u8, &.{ keys_data, config_data }); + defer allocator.free(full_data); + + var stream = std.io.fixedBufferStream(full_data); + const result = try parseConfig( + allocator, + ids.STAKE_CONFIG_PROGRAM_ID, + stream.reader(), + @intCast(full_data.len), + ); + try std.testing.expect(result != null); + try std.testing.expect(result.? == .stake_config); + + const ui_stake = result.?.stake_config; + try std.testing.expectEqual(@as(f64, 0.25), ui_stake.warmupCooldownRate); + try std.testing.expectEqual(@as(u8, 12), ui_stake.slashPenalty); + } + + // Test ValidatorInfo + { + const validator_pubkey = Pubkey{ .data = [_]u8{1} ** 32 }; + const keys = [_]ConfigKey{ + .{ .pubkey = VALIDATOR_INFO_ID, .is_signer = false }, + .{ .pubkey = validator_pubkey, .is_signer = true }, + }; + const config_keys = ConfigKeys{ .keys = @constCast(&keys) }; + const info_json = "{\"name\":\"Test Validator\"}"; + const validator_info = ValidatorInfo{ .info = info_json }; + + const keys_data = try bincode.writeAlloc(allocator, config_keys, .{}); + defer allocator.free(keys_data); + + const info_data = try bincode.writeAlloc(allocator, validator_info, .{}); + defer allocator.free(info_data); + + const full_data = try std.mem.concat(allocator, u8, &.{ keys_data, info_data }); + defer allocator.free(full_data); + + // Use a random pubkey (not StakeConfig) to trigger ValidatorInfo path + const random_pubkey = Pubkey{ .data = [_]u8{0xAB} ** 32 }; + var stream = std.io.fixedBufferStream(full_data); + const result = try parseConfig( + allocator, + random_pubkey, + stream.reader(), + @intCast(full_data.len), + ); + try std.testing.expect(result != null); + try std.testing.expect(result.? == .validator_info); + + const ui_config = result.?.validator_info; + defer allocator.free(ui_config.keys); + defer allocator.free(ui_config.configData); + + try std.testing.expectEqual(@as(usize, 2), ui_config.keys.len); + try std.testing.expectEqual(VALIDATOR_INFO_ID, ui_config.keys[0].pubkey); + try std.testing.expectEqual(false, ui_config.keys[0].signer); + try std.testing.expectEqual(true, ui_config.keys[1].signer); + try std.testing.expectEqualStrings(info_json, ui_config.configData); + } + + // Test unknown config type (first key is not ValidatorInfo ID) + { + const random_key = Pubkey{ .data = [_]u8{0xFF} ** 32 }; + const keys = [_]ConfigKey{ + .{ .pubkey = random_key, .is_signer = false }, + }; + const config_keys = ConfigKeys{ .keys = @constCast(&keys) }; + const keys_data = try bincode.writeAlloc(allocator, config_keys, .{}); + defer allocator.free(keys_data); + + const random_pubkey = Pubkey{ .data = [_]u8{0xAB} ** 32 }; + var stream = std.io.fixedBufferStream(keys_data); + const result = try parseConfig( + allocator, + random_pubkey, + stream.reader(), + @intCast(keys_data.len), + ); + try std.testing.expect(result == null); + } + + // Test invalid data + { + const bad_data = [_]u8{ 0xFF, 0xFF, 0xFF, 0xFF }; + var stream = std.io.fixedBufferStream(&bad_data); + const result = parseConfig( + allocator, + ids.STAKE_CONFIG_PROGRAM_ID, + stream.reader(), + bad_data.len, + ); + + try std.testing.expectError(ParseError.InvalidAccountData, result); + } +} diff --git a/src/rpc/account_decoder/parse_nonce.zig b/src/rpc/account_decoder/parse_nonce.zig new file mode 100644 index 0000000000..c75f83de9e --- /dev/null +++ b/src/rpc/account_decoder/parse_nonce.zig @@ -0,0 +1,154 @@ +/// Types for parsing a nonce account for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); + +const Allocator = std.mem.Allocator; +const Pubkey = sig.core.Pubkey; +const Hash = sig.core.hash.Hash; +const nonce = sig.runtime.nonce; +const ParseError = account_decoder.ParseError; + +const Stringified = account_decoder.Stringified; + +/// [agave] https://github.com/anza-xyz/agave/blob/2717084afeeb7baad4342468c27f528ef617a3cf/account-decoder/src/parse_nonce.rs#L8 +pub fn parseNonce( + allocator: Allocator, + // std.io.Reader + reader: anytype, +) ParseError!NonceAccountType { + const versions = sig.bincode.read( + allocator, + nonce.Versions, + reader, + .{}, + ) catch return ParseError.InvalidAccountData; + const state = versions.getState(); + return switch (state) { + .initialized => |data| NonceAccountType{ + .initialized = UiNonceData{ + .authority = data.authority, + .blockhash = data.durable_nonce, + .feeCalculator = UiFeeCalculator{ + .lamportsPerSignature = Stringified(u64).init(data.lamports_per_signature), + }, + }, + }, + // Uninitialized nonces return error per Agave: + // "This prevents parsing an allocated System-owned account with empty data..." + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs#L11-L17 + .uninitialized => return ParseError.InvalidAccountData, + }; +} + +pub const NonceAccountType = union(enum) { + initialized: UiNonceData, + + pub fn jsonStringify(self: NonceAccountType, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(@tagName(tag)); + try jw.objectField("info"); + try jw.write(v); + }, + } + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs#L34-L40 +pub const UiNonceData = struct { + authority: Pubkey, + blockhash: Hash, + feeCalculator: UiFeeCalculator, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/lib.rs#L104-L108 +pub const UiFeeCalculator = struct { + lamportsPerSignature: Stringified(u64), +}; + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs#L57-L96 +test "rpc.account_decoder.parse_nonce: parse nonce accounts" { + const allocator = std.testing.allocator; + + // Parse initialized nonce state (current version) + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs#L57-L82 + { + const authority = Pubkey{ .data = [_]u8{1} ** 32 }; + const blockhash = Hash{ .data = [_]u8{2} ** 32 }; + const lamports_per_signature: u64 = 5000; + + const nonce_data = nonce.Data{ + .authority = authority, + .durable_nonce = blockhash, + .lamports_per_signature = lamports_per_signature, + }; + + const versions = nonce.Versions{ .current = .{ .initialized = nonce_data } }; + + const data = try sig.bincode.writeAlloc(allocator, versions, .{}); + defer allocator.free(data); + + var stream = std.io.fixedBufferStream(data); + const result = try parseNonce(allocator, stream.reader()); + + try std.testing.expectEqual(authority, result.initialized.authority); + try std.testing.expectEqual(blockhash, result.initialized.blockhash); + const lps = result.initialized.feeCalculator.lamportsPerSignature.value; + try std.testing.expectEqual(lamports_per_signature, lps); + } + + // Parse legacy initialized nonce state + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs#L57-L82 + { + const authority = Pubkey{ .data = [_]u8{5} ** 32 }; + const blockhash = Hash{ .data = [_]u8{9} ** 32 }; + const lamports_per_signature: u64 = 10000; + + const nonce_data = nonce.Data{ + .authority = authority, + .durable_nonce = blockhash, + .lamports_per_signature = lamports_per_signature, + }; + + const versions = nonce.Versions{ .legacy = .{ .initialized = nonce_data } }; + + const data = try sig.bincode.writeAlloc(allocator, versions, .{}); + defer allocator.free(data); + + var stream = std.io.fixedBufferStream(data); + const result = try parseNonce(allocator, stream.reader()); + + try std.testing.expectEqual(authority, result.initialized.authority); + try std.testing.expectEqual(blockhash, result.initialized.blockhash); + const lps = result.initialized.feeCalculator.lamportsPerSignature.value; + try std.testing.expectEqual(lamports_per_signature, lps); + } + + // Uninitialized nonce returns error + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs#L84-L89 + { + const versions = nonce.Versions{ .current = .uninitialized }; + + const data = try sig.bincode.writeAlloc(allocator, versions, .{}); + defer allocator.free(data); + + var stream = std.io.fixedBufferStream(data); + const result = parseNonce(allocator, stream.reader()); + try std.testing.expectError(ParseError.InvalidAccountData, result); + } + + // Bad data returns error + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_nonce.rs#L91-L96 + { + const bad_data = [_]u8{ 0, 1, 2, 3 }; + + var stream = std.io.fixedBufferStream(&bad_data); + const result = parseNonce(allocator, stream.reader()); + try std.testing.expectError(ParseError.InvalidAccountData, result); + } +} diff --git a/src/rpc/account_decoder/parse_stake.zig b/src/rpc/account_decoder/parse_stake.zig new file mode 100644 index 0000000000..52d51da77f --- /dev/null +++ b/src/rpc/account_decoder/parse_stake.zig @@ -0,0 +1,294 @@ +/// Types for parsing a stake account for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); + +const Allocator = std.mem.Allocator; +const Pubkey = sig.core.Pubkey; +const StakeStateV2 = sig.runtime.program.stake.state.StakeStateV2; +const ParseError = account_decoder.ParseError; + +const Stringified = account_decoder.Stringified; + +/// Parses a stake account's data into a `StakeAccountType` for JSON encoding in RPC responses. +pub fn parseStake( + allocator: Allocator, + // std.io.Reader + reader: anytype, +) ParseError!StakeAccountType { + const stake_state = sig.bincode.read( + allocator, + StakeStateV2, + reader, + .{}, + ) catch return ParseError.InvalidAccountData; + + return switch (stake_state) { + .uninitialized => .uninitialized, + .initialized => |meta| .{ + .initialized = UiStakeAccount{ + .meta = UiMeta.fromStakeStateMeta(meta), + .stake = null, + }, + }, + .stake => |s| .{ + .delegated = UiStakeAccount{ + .meta = UiMeta.fromStakeStateMeta(s.meta), + .stake = UiStake{ + .delegation = UiDelegation{ + .voter = s.stake.delegation.voter_pubkey, + .stake = .init(s.stake.delegation.stake), + .activationEpoch = .init(s.stake.delegation.activation_epoch), + .deactivationEpoch = .init(s.stake.delegation.deactivation_epoch), + .warmupCooldownRate = s.stake.delegation.deprecated_warmup_cooldown_rate, + }, + .creditsObserved = s.stake.credits_observed, + }, + }, + }, + .rewards_pool => .rewards_pool, + }; +} + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L30 +pub const StakeAccountType = union(enum) { + uninitialized, + initialized: UiStakeAccount, + delegated: UiStakeAccount, + rewards_pool, + + pub fn jsonStringify(self: StakeAccountType, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(comptime typeNameFromTag(tag)); + if (@TypeOf(v) != void) { + try jw.objectField("info"); + try jw.write(v); + } + }, + } + try jw.endObject(); + } + + fn typeNameFromTag(tag: std.meta.Tag(StakeAccountType)) []const u8 { + return switch (tag) { + .uninitialized => "uninitialized", + .initialized => "initialized", + .delegated => "delegated", + .rewards_pool => "rewardsPool", + }; + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L41 +pub const UiStakeAccount = struct { + meta: UiMeta, + stake: ?UiStake, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L48 +pub const UiMeta = struct { + rentExemptReserve: Stringified(u64), + authorized: UiAuthorized, + lockup: UiLockup, + + fn fromStakeStateMeta(meta: StakeStateV2.Meta) UiMeta { + return .{ + .rentExemptReserve = .init(meta.rent_exempt_reserve), + .authorized = .{ + .staker = meta.authorized.staker, + .withdrawer = meta.authorized.withdrawer, + }, + .lockup = .{ + .unixTimestamp = meta.lockup.unix_timestamp, + .epoch = meta.lockup.epoch, + .custodian = meta.lockup.custodian, + }, + }; + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L72 +pub const UiAuthorized = struct { + staker: Pubkey, + withdrawer: Pubkey, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L85 +pub const UiLockup = struct { + unixTimestamp: i64, + epoch: u64, + custodian: Pubkey, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L100 +pub const UiStake = struct { + delegation: UiDelegation, + creditsObserved: u64, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L113 +pub const UiDelegation = struct { + voter: Pubkey, + stake: Stringified(u64), + activationEpoch: Stringified(u64), + deactivationEpoch: Stringified(u64), + warmupCooldownRate: f64, +}; + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L142-L209 +test "rpc.account_decoder.parse_stake: parse stake accounts" { + const allocator = std.testing.allocator; + + // Uninitialized state + { + const stake_state = StakeStateV2{ .uninitialized = {} }; + const serialized = try sig.bincode.writeAlloc(allocator, stake_state, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseStake(allocator, stream.reader()); + + try std.testing.expect(result == .uninitialized); + } + + // Initialized state + { + const pubkey = Pubkey{ .data = [_]u8{1} ** 32 }; + const custodian = Pubkey{ .data = [_]u8{2} ** 32 }; + + const meta = StakeStateV2.Meta{ + .rent_exempt_reserve = 42, + .authorized = .{ + .staker = pubkey, + .withdrawer = pubkey, + }, + .lockup = .{ + .unix_timestamp = 0, + .epoch = 1, + .custodian = custodian, + }, + }; + + const stake_state = StakeStateV2{ .initialized = meta }; + const serialized = try sig.bincode.writeAlloc(allocator, stake_state, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseStake(allocator, stream.reader()); + + try std.testing.expect(result == .initialized); + + const ui_account = result.initialized; + try std.testing.expectEqual(@as(u64, 42), ui_account.meta.rentExemptReserve.value); + try std.testing.expectEqualStrings( + pubkey.base58String().constSlice(), + ui_account.meta.authorized.staker.base58String().constSlice(), + ); + try std.testing.expectEqualStrings( + pubkey.base58String().constSlice(), + ui_account.meta.authorized.withdrawer.base58String().constSlice(), + ); + try std.testing.expectEqual(@as(i64, 0), ui_account.meta.lockup.unixTimestamp); + try std.testing.expectEqual(@as(u64, 1), ui_account.meta.lockup.epoch); + try std.testing.expectEqualStrings( + custodian.base58String().constSlice(), + ui_account.meta.lockup.custodian.base58String().constSlice(), + ); + try std.testing.expect(ui_account.stake == null); + } + + // Delegated (Stake) state + { + const pubkey = Pubkey{ .data = [_]u8{1} ** 32 }; + const custodian = Pubkey{ .data = [_]u8{2} ** 32 }; + const voter_pubkey = Pubkey{ .data = [_]u8{3} ** 32 }; + + const meta = StakeStateV2.Meta{ + .rent_exempt_reserve = 42, + .authorized = .{ + .staker = pubkey, + .withdrawer = pubkey, + }, + .lockup = .{ + .unix_timestamp = 0, + .epoch = 1, + .custodian = custodian, + }, + }; + + const stake_data = StakeStateV2.Stake{ + .delegation = .{ + .voter_pubkey = voter_pubkey, + .stake = 20, + .activation_epoch = 2, + .deactivation_epoch = std.math.maxInt(u64), + .deprecated_warmup_cooldown_rate = 0.25, + }, + .credits_observed = 10, + }; + + const stake_state = StakeStateV2{ + .stake = .{ + .meta = meta, + .stake = stake_data, + .flags = StakeStateV2.StakeFlags.EMPTY, + }, + }; + const serialized = try sig.bincode.writeAlloc(allocator, stake_state, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseStake(allocator, stream.reader()); + + try std.testing.expect(result == .delegated); + + const ui_account = result.delegated; + + // Verify meta + try std.testing.expectEqual(@as(u64, 42), ui_account.meta.rentExemptReserve.value); + try std.testing.expectEqualStrings( + pubkey.base58String().constSlice(), + ui_account.meta.authorized.staker.base58String().constSlice(), + ); + + // Verify stake + try std.testing.expect(ui_account.stake != null); + const ui_stake = ui_account.stake.?; + try std.testing.expectEqualStrings( + voter_pubkey.base58String().constSlice(), + ui_stake.delegation.voter.base58String().constSlice(), + ); + try std.testing.expectEqual(@as(u64, 20), ui_stake.delegation.stake.value); + try std.testing.expectEqual(@as(u64, 2), ui_stake.delegation.activationEpoch.value); + const deact = ui_stake.delegation.deactivationEpoch.value; + try std.testing.expectEqual(std.math.maxInt(u64), deact); + try std.testing.expectEqual(@as(f64, 0.25), ui_stake.delegation.warmupCooldownRate); + try std.testing.expectEqual(@as(u64, 10), ui_stake.creditsObserved); + } + + // RewardsPool state + { + const stake_state = StakeStateV2{ .rewards_pool = {} }; + const serialized = try sig.bincode.writeAlloc(allocator, stake_state, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseStake(allocator, stream.reader()); + + try std.testing.expect(result == .rewards_pool); + } + + // Bad data returns error + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_stake.rs#L208 + { + const bad_data = [_]u8{ 1, 2, 3, 4 }; + var stream = std.io.fixedBufferStream(&bad_data); + const result = parseStake(allocator, stream.reader()); + + try std.testing.expectError(ParseError.InvalidAccountData, result); + } +} diff --git a/src/rpc/account_decoder/parse_sysvar.zig b/src/rpc/account_decoder/parse_sysvar.zig new file mode 100644 index 0000000000..ef1b7f95f8 --- /dev/null +++ b/src/rpc/account_decoder/parse_sysvar.zig @@ -0,0 +1,654 @@ +/// Types for parsing sysvar accounts for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); + +const Allocator = std.mem.Allocator; +const Pubkey = sig.core.Pubkey; +const Hash = sig.core.Hash; +const Slot = sig.core.Slot; +const Epoch = sig.core.Epoch; +const sysvar = sig.runtime.sysvar; +const bincode = sig.bincode; +const ParseError = account_decoder.ParseError; + +// Re-use types from lib.zig and parse_nonce.zig +const UiFeeCalculator = @import("parse_nonce.zig").UiFeeCalculator; +const Stringified = account_decoder.Stringified; + +/// Parse a sysvar account by its pubkey. +/// Returns null if the pubkey doesn't match any known sysvar. +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L24 +pub fn parseSysvar( + allocator: Allocator, + pubkey: Pubkey, + reader: anytype, +) ParseError!?SysvarAccountType { + if (pubkey.equals(&sysvar.Clock.ID)) { + const clock = bincode.read(allocator, sysvar.Clock, reader, .{}) catch + return ParseError.InvalidAccountData; + return SysvarAccountType{ + .clock = UiClock{ + .slot = clock.slot, + .epoch = clock.epoch, + .epochStartTimestamp = clock.epoch_start_timestamp, + .leaderScheduleEpoch = clock.leader_schedule_epoch, + .unixTimestamp = clock.unix_timestamp, + }, + }; + } else if (pubkey.equals(&sysvar.EpochSchedule.ID)) { + const schedule = bincode.read(allocator, sysvar.EpochSchedule, reader, .{}) catch + return ParseError.InvalidAccountData; + return SysvarAccountType{ .epoch_schedule = schedule }; + } else if (pubkey.equals(&sysvar.Fees.ID)) { + const fees = bincode.read(allocator, sysvar.Fees, reader, .{}) catch + return ParseError.InvalidAccountData; + return SysvarAccountType{ + .fees = UiFees{ + .feeCalculator = UiFeeCalculator{ + .lamportsPerSignature = Stringified(u64).init(fees.lamports_per_signature), + }, + }, + }; + } else if (pubkey.equals(&sysvar.RecentBlockhashes.ID)) { + const blockhashes = bincode.read( + allocator, + sysvar.RecentBlockhashes, + reader, + .{}, + ) catch + return ParseError.InvalidAccountData; + const max_entries = sysvar.RecentBlockhashes.MAX_ENTRIES; + var entries: std.BoundedArray(UiRecentBlockhashesEntry, max_entries) = .{}; + for (blockhashes.entries.constSlice()) |entry| { + entries.appendAssumeCapacity(UiRecentBlockhashesEntry{ + .blockhash = entry.blockhash, + .feeCalculator = UiFeeCalculator{ + .lamportsPerSignature = Stringified(u64).init(entry.lamports_per_signature), + }, + }); + } + return SysvarAccountType{ + .recent_blockhashes = UiRecentBlockhashes{ .entries = entries }, + }; + } else if (pubkey.equals(&sysvar.Rent.ID)) { + const rent = bincode.read(allocator, sysvar.Rent, reader, .{}) catch + return ParseError.InvalidAccountData; + return SysvarAccountType{ + .rent = UiRent{ + .lamportsPerByteYear = Stringified(u64).init(rent.lamports_per_byte_year), + .exemptionThreshold = rent.exemption_threshold, + .burnPercent = rent.burn_percent, + }, + }; + } else if (pubkey.equals(&sig.runtime.ids.SYSVAR_REWARDS_ID)) { + // Rewards sysvar is deprecated but still parsable. + // It's just a single f64, read as u64 and bitcast. + const bits = reader.readInt(u64, .little) catch return ParseError.InvalidAccountData; + return SysvarAccountType{ + .rewards = UiRewards{ + .validatorPointValue = @bitCast(bits), + }, + }; + } else if (pubkey.equals(&sysvar.SlotHashes.ID)) { + const slot_hashes = bincode.read( + allocator, + sysvar.SlotHashes, + reader, + .{}, + ) catch + return ParseError.InvalidAccountData; + var entries: std.BoundedArray(UiSlotHashEntry, sysvar.SlotHashes.MAX_ENTRIES) = .{}; + for (slot_hashes.entries.constSlice()) |entry| { + entries.appendAssumeCapacity(UiSlotHashEntry{ + .slot = entry.slot, + .hash = entry.hash, + }); + } + return SysvarAccountType{ + .slot_hashes = UiSlotHashes{ .entries = entries }, + }; + } else if (pubkey.equals(&sysvar.SlotHistory.ID)) { + const slot_history = bincode.read( + allocator, + sysvar.SlotHistory, + reader, + .{}, + ) catch + return ParseError.InvalidAccountData; + // Note: We move ownership of the bits to UiSlotHistory. + // The caller must ensure the allocator outlives the returned value, + // or use an arena allocator. + return SysvarAccountType{ + .slot_history = UiSlotHistory{ + .nextSlot = slot_history.next_slot, + .bits = slot_history.bits, + }, + }; + } else if (pubkey.equals(&sysvar.StakeHistory.ID)) { + const stake_history = bincode.read( + allocator, + sysvar.StakeHistory, + reader, + .{}, + ) catch + return ParseError.InvalidAccountData; + var entries: std.BoundedArray(UiStakeHistoryEntry, sysvar.StakeHistory.MAX_ENTRIES) = .{}; + for (stake_history.entries.constSlice()) |entry| { + entries.appendAssumeCapacity(UiStakeHistoryEntry{ + .epoch = entry.epoch, + .stakeHistory = .{ + .effective = entry.stake.effective, + .activating = entry.stake.activating, + .deactivating = entry.stake.deactivating, + }, + }); + } + return SysvarAccountType{ + .stake_history = UiStakeHistory{ .entries = entries }, + }; + } else if (pubkey.equals(&sysvar.LastRestartSlot.ID)) { + const last_restart = bincode.read( + allocator, + sysvar.LastRestartSlot, + reader, + .{}, + ) catch + return ParseError.InvalidAccountData; + return SysvarAccountType{ + .last_restart_slot = UiLastRestartSlot{ + .lastRestartSlot = last_restart.last_restart_slot, + }, + }; + } else if (pubkey.equals(&sysvar.EpochRewards.ID)) { + const epoch_rewards = bincode.read( + allocator, + sysvar.EpochRewards, + reader, + .{}, + ) catch + return ParseError.InvalidAccountData; + return SysvarAccountType{ + .epoch_rewards = UiEpochRewards{ + .distributionStartingBlockHeight = epoch_rewards.distribution_starting_block_height, + .numPartitions = epoch_rewards.num_partitions, + .parentBlockhash = epoch_rewards.parent_blockhash, + .totalPoints = Stringified(u128).init(epoch_rewards.total_points), + .totalRewards = Stringified(u64).init(epoch_rewards.total_rewards), + .distributedRewards = Stringified(u64).init(epoch_rewards.distributed_rewards), + .active = epoch_rewards.active, + }, + }; + } + return null; +} + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L99 +pub const SysvarAccountType = union(enum) { + clock: UiClock, + epoch_schedule: sysvar.EpochSchedule, + fees: UiFees, + recent_blockhashes: UiRecentBlockhashes, + rent: UiRent, + rewards: UiRewards, + slot_hashes: UiSlotHashes, + slot_history: UiSlotHistory, + stake_history: UiStakeHistory, + last_restart_slot: UiLastRestartSlot, + epoch_rewards: UiEpochRewards, + + pub fn jsonStringify(self: SysvarAccountType, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(comptime typeNameFromTag(tag)); + try jw.objectField("info"); + try jw.write(v); + }, + } + try jw.endObject(); + } + + fn typeNameFromTag(tag: std.meta.Tag(SysvarAccountType)) []const u8 { + return switch (tag) { + .clock => "clock", + .epoch_schedule => "epochSchedule", + .fees => "fees", + .recent_blockhashes => "recentBlockhashes", + .rent => "rent", + .rewards => "rewards", + .slot_hashes => "slotHashes", + .slot_history => "slotHistory", + .stake_history => "stakeHistory", + .last_restart_slot => "lastRestartSlot", + .epoch_rewards => "epochRewards", + }; + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L113 +pub const UiClock = struct { + slot: Slot, + epoch: Epoch, + epochStartTimestamp: i64, + leaderScheduleEpoch: Epoch, + unixTimestamp: i64, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L131 +pub const UiFees = struct { + feeCalculator: UiFeeCalculator, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L143 +pub const UiRent = struct { + lamportsPerByteYear: Stringified(u64), + exemptionThreshold: f64, + burnPercent: u8, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L159 +pub const UiRewards = struct { + validatorPointValue: f64, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L169 +pub const UiRecentBlockhashes = struct { + entries: std.BoundedArray(UiRecentBlockhashesEntry, sysvar.RecentBlockhashes.MAX_ENTRIES), + + pub fn jsonStringify(self: UiRecentBlockhashes, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.write(self.entries.constSlice()); + } +}; + +pub const UiRecentBlockhashesEntry = struct { + blockhash: Hash, + feeCalculator: UiFeeCalculator, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L176 +pub const UiSlotHashes = struct { + entries: std.BoundedArray(UiSlotHashEntry, sysvar.SlotHashes.MAX_ENTRIES), + + pub fn jsonStringify(self: UiSlotHashes, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.write(self.entries.constSlice()); + } +}; + +pub const UiSlotHashEntry = struct { + slot: Slot, + hash: Hash, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L183 +pub const UiSlotHistory = struct { + nextSlot: Slot, + bits: sig.bloom.bit_set.DynamicArrayBitSet(u64), + + pub fn jsonStringify(self: UiSlotHistory, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("nextSlot"); + try jw.write(self.nextSlot); + try jw.objectField("bits"); + // Agave formats bits as a string of MAX_ENTRIES 0s and 1s using Debug format. + // We stream-write this to avoid allocating 1MB+ string. + try jw.beginWriteRaw(); + try jw.stream.writeByte('"'); + for (0..sig.runtime.sysvar.SlotHistory.MAX_ENTRIES) |i| { + try jw.stream.writeByte(if (self.bits.isSet(i)) '1' else '0'); + } + try jw.stream.writeByte('"'); + jw.endWriteRaw(); + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L199 +pub const UiStakeHistory = struct { + entries: std.BoundedArray(UiStakeHistoryEntry, sysvar.StakeHistory.MAX_ENTRIES), + + pub fn jsonStringify(self: UiStakeHistory, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.write(self.entries.constSlice()); + } +}; + +pub const UiStakeHistoryEntry = struct { + epoch: Epoch, + stakeHistory: UiStakeHistoryEntryItem, +}; + +pub const UiStakeHistoryEntryItem = struct { + effective: u64, + activating: u64, + deactivating: u64, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L205 +pub const UiLastRestartSlot = struct { + lastRestartSlot: Slot, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L212 +pub const UiEpochRewards = struct { + distributionStartingBlockHeight: u64, + numPartitions: u64, + parentBlockhash: Hash, + totalPoints: Stringified(u128), + totalRewards: Stringified(u64), + distributedRewards: Stringified(u64), + active: bool, +}; + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_sysvar.rs#L225 +test "rpc.account_decoder.parse_sysvar: parse sysvars" { + const allocator = std.testing.allocator; + const hash = Hash{ .data = [_]u8{1} ** 32 }; + + // Clock sysvar (default) + { + const clock = sysvar.Clock.INIT; + const serialized = try bincode.writeAlloc(allocator, clock, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.Clock.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .clock); + const ui_clock = result.?.clock; + try std.testing.expectEqual(@as(Slot, 0), ui_clock.slot); + try std.testing.expectEqual(@as(Epoch, 0), ui_clock.epoch); + try std.testing.expectEqual(@as(i64, 0), ui_clock.epochStartTimestamp); + try std.testing.expectEqual(@as(Epoch, 0), ui_clock.leaderScheduleEpoch); + try std.testing.expectEqual(@as(i64, 0), ui_clock.unixTimestamp); + } + + // EpochSchedule sysvar (custom values matching Agave test) + { + const epoch_schedule = sysvar.EpochSchedule{ + .slots_per_epoch = 12, + .leader_schedule_slot_offset = 0, + .warmup = false, + .first_normal_epoch = 1, + .first_normal_slot = 12, + }; + const serialized = try bincode.writeAlloc(allocator, epoch_schedule, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.EpochSchedule.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .epoch_schedule); + const ui_epoch_schedule = result.?.epoch_schedule; + try std.testing.expectEqual(@as(u64, 12), ui_epoch_schedule.slots_per_epoch); + try std.testing.expectEqual(@as(u64, 0), ui_epoch_schedule.leader_schedule_slot_offset); + try std.testing.expectEqual(false, ui_epoch_schedule.warmup); + try std.testing.expectEqual(@as(Epoch, 1), ui_epoch_schedule.first_normal_epoch); + try std.testing.expectEqual(@as(Slot, 12), ui_epoch_schedule.first_normal_slot); + } + + // Fees sysvar (deprecated, default) + { + const fees = sysvar.Fees.INIT; + const serialized = try bincode.writeAlloc(allocator, fees, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.Fees.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .fees); + const lps = result.?.fees.feeCalculator.lamportsPerSignature.value; + try std.testing.expectEqual(@as(u64, 0), lps); + } + + // RecentBlockhashes sysvar (deprecated, one entry) + { + const recent_blockhashes = sysvar.RecentBlockhashes.initWithEntries(&.{ + .{ .blockhash = hash, .lamports_per_signature = 10 }, + }); + const serialized = try bincode.writeAlloc(allocator, recent_blockhashes, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.RecentBlockhashes.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .recent_blockhashes); + const entries = result.?.recent_blockhashes.entries.constSlice(); + try std.testing.expectEqual(@as(usize, 1), entries.len); + try std.testing.expectEqualStrings( + hash.base58String().constSlice(), + entries[0].blockhash.base58String().constSlice(), + ); + const lps = entries[0].feeCalculator.lamportsPerSignature.value; + try std.testing.expectEqual(@as(u64, 10), lps); + } + + // Rent sysvar (custom values) + { + const rent = sysvar.Rent{ + .lamports_per_byte_year = 10, + .exemption_threshold = 2.0, + .burn_percent = 5, + }; + const serialized = try bincode.writeAlloc(allocator, rent, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.Rent.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .rent); + const ui_rent = result.?.rent; + try std.testing.expectEqual(@as(u64, 10), ui_rent.lamportsPerByteYear.value); + try std.testing.expectEqual(@as(f64, 2.0), ui_rent.exemptionThreshold); + try std.testing.expectEqual(@as(u8, 5), ui_rent.burnPercent); + } + + // Rewards sysvar (deprecated, default = 0.0) + { + // Rewards is just a single f64, serialized as u64 bits + const validator_point_value: f64 = 0.0; + const bits: u64 = @bitCast(validator_point_value); + var serialized: [8]u8 = undefined; + std.mem.writeInt(u64, &serialized, bits, .little); + + var stream = std.io.fixedBufferStream(&serialized); + const result = try parseSysvar( + allocator, + sig.runtime.ids.SYSVAR_REWARDS_ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .rewards); + try std.testing.expectEqual(@as(f64, 0.0), result.?.rewards.validatorPointValue); + } + + // SlotHashes sysvar (one entry) + { + var slot_hashes: sysvar.SlotHashes = .INIT; + slot_hashes.add(1, hash); + const serialized = try bincode.writeAlloc(allocator, slot_hashes, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.SlotHashes.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .slot_hashes); + const entries = result.?.slot_hashes.entries.constSlice(); + try std.testing.expectEqual(@as(usize, 1), entries.len); + try std.testing.expectEqual(@as(Slot, 1), entries[0].slot); + try std.testing.expectEqual(hash, entries[0].hash); + } + + // SlotHistory sysvar (with slot 42 added) + { + var slot_history = try sysvar.SlotHistory.init(allocator); + defer slot_history.deinit(allocator); + slot_history.add(42); + + const serialized = try bincode.writeAlloc(allocator, slot_history, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.SlotHistory.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .slot_history); + const ui_slot_history = result.?.slot_history; + defer ui_slot_history.bits.deinit(allocator); + try std.testing.expectEqual(@as(Slot, 43), ui_slot_history.nextSlot); + // Verify bit 42 is set (and bit 0 from init) + try std.testing.expect(ui_slot_history.bits.isSet(42)); + try std.testing.expect(ui_slot_history.bits.isSet(0)); + } + + // StakeHistory sysvar (one entry) + { + var stake_history: sysvar.StakeHistory = .INIT; + try stake_history.insertEntry(1, .{ + .effective = 10, + .activating = 2, + .deactivating = 3, + }); + const serialized = try bincode.writeAlloc(allocator, stake_history, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.StakeHistory.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .stake_history); + const entries = result.?.stake_history.entries.constSlice(); + try std.testing.expectEqual(@as(usize, 1), entries.len); + try std.testing.expectEqual(@as(Epoch, 1), entries[0].epoch); + try std.testing.expectEqual(@as(u64, 10), entries[0].stakeHistory.effective); + try std.testing.expectEqual(@as(u64, 2), entries[0].stakeHistory.activating); + try std.testing.expectEqual(@as(u64, 3), entries[0].stakeHistory.deactivating); + } + + // Bad pubkey - unknown sysvar pubkey should return null + { + var stake_history: sysvar.StakeHistory = .INIT; + try stake_history.insertEntry( + 1, + .{ .effective = 10, .activating = 2, .deactivating = 3 }, + ); + const serialized = try bincode.writeAlloc(allocator, stake_history, .{}); + defer allocator.free(serialized); + + const bad_pubkey = Pubkey{ .data = [_]u8{0xAB} ** 32 }; + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + bad_pubkey, + stream.reader(), + ); + + try std.testing.expect(result == null); + } + + // Bad data - invalid data for a known sysvar should return error + { + const bad_data = [_]u8{ 0, 0, 0, 0 }; + var stream = std.io.fixedBufferStream(&bad_data); + const result = parseSysvar( + allocator, + sysvar.StakeHistory.ID, + stream.reader(), + ); + + try std.testing.expectError(ParseError.InvalidAccountData, result); + } + + // LastRestartSlot sysvar + { + const last_restart_slot = sysvar.LastRestartSlot{ + .last_restart_slot = 1282, + }; + const serialized = try bincode.writeAlloc(allocator, last_restart_slot, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.LastRestartSlot.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .last_restart_slot); + try std.testing.expectEqual(@as(Slot, 1282), result.?.last_restart_slot.lastRestartSlot); + } + + // EpochRewards sysvar + { + const epoch_rewards = sysvar.EpochRewards{ + .distribution_starting_block_height = 42, + .num_partitions = 0, + .parent_blockhash = Hash.ZEROES, + .total_points = 0, + .total_rewards = 100, + .distributed_rewards = 20, + .active = true, + }; + const serialized = try bincode.writeAlloc(allocator, epoch_rewards, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseSysvar( + allocator, + sysvar.EpochRewards.ID, + stream.reader(), + ); + + try std.testing.expect(result != null); + try std.testing.expect(result.? == .epoch_rewards); + const ui_epoch_rewards = result.?.epoch_rewards; + try std.testing.expectEqual(@as(u64, 42), ui_epoch_rewards.distributionStartingBlockHeight); + try std.testing.expectEqual(@as(u64, 0), ui_epoch_rewards.numPartitions); + try std.testing.expectEqual(Hash.ZEROES, ui_epoch_rewards.parentBlockhash); + try std.testing.expectEqual(@as(u128, 0), ui_epoch_rewards.totalPoints.value); + try std.testing.expectEqual(@as(u64, 100), ui_epoch_rewards.totalRewards.value); + try std.testing.expectEqual(@as(u64, 20), ui_epoch_rewards.distributedRewards.value); + try std.testing.expectEqual(true, ui_epoch_rewards.active); + } +} diff --git a/src/rpc/account_decoder/parse_token.zig b/src/rpc/account_decoder/parse_token.zig new file mode 100644 index 0000000000..a36ca2f3c8 --- /dev/null +++ b/src/rpc/account_decoder/parse_token.zig @@ -0,0 +1,1561 @@ +/// Types for parsing SPL Token accounts for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); +const parse_token_extension = @import("parse_token_extension.zig"); + +const Pubkey = sig.core.Pubkey; +const ParseError = account_decoder.ParseError; +const AccountState = account_decoder.AccountState; +const JsonArray = account_decoder.JsonArray; +const JsonString = account_decoder.JsonString; + +const UiExtension = parse_token_extension.UiExtension; +const InterestBearingConfigData = parse_token_extension.InterestBearingConfigData; +const ScaledUiAmountConfigData = parse_token_extension.ScaledUiAmountConfigData; + +const MAX_EXTENSIONS = parse_token_extension.MAX_EXTENSIONS; +const parseExtensions = parse_token_extension.parseExtensions; + +/// Index of the account state byte in TokenAccount. +/// Offset 108 = mint(32) + owner(32) + amount(8) + delegate(36) = 108 +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/generic_token_account.rs#L56 +const ACCOUNT_INITIALIZED_INDEX: usize = 108; + +/// Parse an SPL Token account. +/// Returns null if: +/// - Data doesn't match any known token account type +/// - Token account provided without decimals (additional_data) +/// - Account is uninitialized +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L37-L80 +pub fn parseToken( + data: []const u8, + additional_data: ?*const SplTokenAdditionalData, +) ParseError!?TokenAccountType { + const account_type = DetectedType.parse(data) orelse return null; + + return switch (account_type) { + .token_account => parseAsTokenAccount(data, additional_data), + .mint => parseAsMint(data), + .multisig => parseAsMultisig(data), + }; +} + +/// Token-2022 account type discriminator (placed after base account data for extended accounts). +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/mod.rs#L1038-L1047 +const AccountTypeDiscriminator = enum(u8) { + uninitialized = 0, + mint = 1, + account = 2, +}; + +const DetectedType = union(enum) { + token_account, + mint, + multisig, + + fn parse(data: []const u8) ?DetectedType { + // Multisig: exactly 355 bytes (never has extensions) + if (data.len == Multisig.LEN) return .multisig; + // Token-2022 extended accounts: discriminator is ALWAYS at offset 165 (TokenAccount.LEN) + // regardless of whether it's a mint or token account. Mints are padded with zeros + // from offset 82 to 165 to achieve this uniform layout. + // [spl] https://github.com/solana-program/token-2022/blob/main/program/src/extension/mod.rs + if (data.len > TokenAccount.LEN) { + return switch (data[TokenAccount.LEN]) { + @intFromEnum(AccountTypeDiscriminator.mint) => .mint, + @intFromEnum(AccountTypeDiscriminator.account) => .token_account, + else => null, + }; + } + // SPL Token v1: exact lengths (no extensions) + if (data.len == TokenAccount.LEN) return .token_account; + if (data.len == Mint.LEN) return .mint; + return null; + } +}; + +fn parseAsTokenAccount( + data: []const u8, + additional_data: ?*const SplTokenAdditionalData, +) ?TokenAccountType { + const account = TokenAccount.unpack(data) catch return null; + if (account.state == .uninitialized) return null; + + const add_data = additional_data orelse return null; + const is_native = account.is_native != null; + return .{ .account = .{ + .mint = account.mint, + .owner = account.owner, + .tokenAmount = UiTokenAmount.init(account.amount, add_data.*), + .delegate = account.delegate, + .state = account.state, + .isNative = is_native, + .rentExemptReserve = if (account.is_native) |r| + UiTokenAmount.init(r, add_data.*) + else + null, + .delegatedAmount = if (account.delegate != null and account.delegated_amount > 0) + UiTokenAmount.init(account.delegated_amount, add_data.*) + else + null, + .closeAuthority = account.close_authority, + .extensions = parseExtensions(data[TokenAccount.LEN..]), + } }; +} + +fn parseAsMint(data: []const u8) ?TokenAccountType { + const mint = Mint.unpack(data) catch return null; + if (!mint.is_initialized) return null; + // For Token-2022 mints with extensions, TLV data starts at offset 165 (TokenAccount.LEN). + // The discriminator is at offset 165, and TLV entries start at offset 166. + // For standard SPL Token mints (82 bytes), there are no extensions. + const extension_data = if (data.len > TokenAccount.LEN) data[TokenAccount.LEN..] else &[_]u8{}; + return .{ .mint = .{ + .mintAuthority = mint.mint_authority, + .supply = account_decoder.Stringified(u64).init(mint.supply), + .decimals = mint.decimals, + .isInitialized = mint.is_initialized, + .freezeAuthority = mint.freeze_authority, + .extensions = parseExtensions(extension_data), + } }; +} + +fn parseAsMultisig(data: []const u8) ?TokenAccountType { + const multisig = Multisig.unpack(data) catch return null; + if (!multisig.is_initialized) return null; + // Collect non-zero signers up to n valid signers + var signers: JsonArray(Pubkey, Multisig.MAX_SIGNERS) = .{}; + for (multisig.signers[0..multisig.n]) |signer| { + if (!signer.isZeroed()) { + signers.appendAssumeCapacity(signer); + } + } + return .{ .multisig = .{ + .numRequiredSigners = multisig.m, + .numValidSigners = multisig.n, + .isInitialized = multisig.is_initialized, + .signers = signers, + } }; +} + +/// Get the mint pubkey from token account data if valid. +/// Returns null if data is not a valid initialized token account. +/// Used by RPC layer to look up decimals from the mint account. +/// [agave] get_token_account_mint in account-decoder/src/parse_token.rs +pub fn getTokenAccountMint(data: []const u8) ?Pubkey { + if (!isValidTokenAccountData(data)) return null; + return Pubkey{ .data = data[0..32].* }; +} + +/// Get the owner pubkey from token account data if valid. +/// Returns null if data is not a valid initialized token account. +/// [spl] Account::unpack_account_owner in spl-token-2022 interface +pub fn getTokenAccountOwner(data: []const u8) ?Pubkey { + if (!isValidTokenAccountData(data)) return null; + return Pubkey{ .data = data[32..64].* }; +} + +/// Check if the account data represents a valid, initialized token account. +/// Handles both standard SPL Token (165 bytes) and Token-2022 extended accounts. +/// [spl] Account::valid_account_data in spl-token-2022 interface/src/state.rs +fn isValidTokenAccountData(data: []const u8) bool { + // Standard token account: exactly 165 bytes and initialized + if (data.len == TokenAccount.LEN) { + return isInitializedAccount(data); + } + // Token-2022 extended account: >165 bytes, NOT multisig size (355), + // and has AccountTypeDiscriminator.account at offset 165 + if (data.len > TokenAccount.LEN and data.len != Multisig.LEN) { + if (data[TokenAccount.LEN] == @intFromEnum(AccountTypeDiscriminator.account)) { + return isInitializedAccount(data); + } + } + return false; +} + +/// Check if the state byte at ACCOUNT_INITIALIZED_INDEX indicates initialized or frozen. +/// [spl] is_initialized_account in generic_token_account.rs +fn isInitializedAccount(data: []const u8) bool { + if (data.len <= ACCOUNT_INITIALIZED_INDEX) return false; + const state = data[ACCOUNT_INITIALIZED_INDEX]; + return state == @intFromEnum(AccountState.initialized) or + state == @intFromEnum(AccountState.frozen); +} + +/// Additional data needed for token account parsing (from mint lookup). +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L30-L35 +pub const SplTokenAdditionalData = struct { + decimals: u8, + unix_timestamp: i64 = 0, // From bank clock sysvar, used for interest/scaled calculations + // Token-2022 extension data + interest_bearing_config: ?InterestBearingConfigData = null, + scaled_ui_amount_config: ?ScaledUiAmountConfigData = null, +}; + +/// SPL Token Mint account layout (82 bytes). +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/state.rs#L49-L94 +pub const Mint = struct { + pub const LEN: usize = 82; + mint_authority: ?Pubkey, + supply: u64, + decimals: u8, + is_initialized: bool, + freeze_authority: ?Pubkey, + pub fn unpack(data: []const u8) ParseError!Mint { + if (data.len < LEN) return ParseError.InvalidAccountData; + return Mint{ + .mint_authority = readCOptionPubkey(data[0..36]), + .supply = std.mem.readInt(u64, data[36..44], .little), + .decimals = data[44], + .is_initialized = data[45] != 0, + .freeze_authority = readCOptionPubkey(data[46..82]), + }; + } +}; + +/// SPL Token Account layout (165 bytes). +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/state.rs#L146-L195 +pub const TokenAccount = struct { + pub const LEN: usize = 165; + mint: Pubkey, + owner: Pubkey, + amount: u64, + delegate: ?Pubkey, + state: AccountState, + is_native: ?u64, + delegated_amount: u64, + close_authority: ?Pubkey, + + pub fn unpack(data: []const u8) ParseError!TokenAccount { + if (data.len < LEN) return ParseError.InvalidAccountData; + const state_byte = data[108]; + if (state_byte > 2) return ParseError.InvalidAccountData; + return TokenAccount{ + .mint = Pubkey{ .data = data[0..32].* }, + .owner = Pubkey{ .data = data[32..64].* }, + .amount = std.mem.readInt(u64, data[64..72], .little), + .delegate = readCOptionPubkey(data[72..108]), + .state = @enumFromInt(state_byte), + .is_native = readCOptionU64(data[109..121]), + .delegated_amount = std.mem.readInt(u64, data[121..129], .little), + .close_authority = readCOptionPubkey(data[129..165]), + }; + } +}; + +/// SPL Token Multisig layout (355 bytes). +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/state.rs#L235-L270 +pub const Multisig = struct { + pub const LEN: usize = 355; + pub const MAX_SIGNERS: usize = 11; + m: u8, + n: u8, + is_initialized: bool, + signers: [MAX_SIGNERS]Pubkey, + pub fn unpack(data: []const u8) ParseError!Multisig { + if (data.len != LEN) return ParseError.InvalidAccountData; + var signers: [MAX_SIGNERS]Pubkey = undefined; + for (0..MAX_SIGNERS) |i| { + const start = 3 + i * 32; + signers[i] = Pubkey{ .data = data[start..][0..32].* }; + } + return Multisig{ + .m = data[0], + .n = data[1], + .is_initialized = data[2] != 0, + .signers = signers, + }; + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder-client-types/src/token.rs#L86-L93 +pub const TokenAccountType = union(enum) { + account: UiTokenAccount, + mint: UiMint, + multisig: UiMultisig, + + pub fn jsonStringify(self: TokenAccountType, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(@tagName(tag)); + try jw.objectField("info"); + try jw.write(v); + }, + } + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder-client-types/src/token.rs#L53-L64 +pub const UiTokenAccount = struct { + mint: Pubkey, + owner: Pubkey, + tokenAmount: UiTokenAmount, + delegate: ?Pubkey, + state: AccountState, + isNative: bool, + rentExemptReserve: ?UiTokenAmount, + delegatedAmount: ?UiTokenAmount, + closeAuthority: ?Pubkey, + // Token-2022. + extensions: JsonArray(UiExtension, MAX_EXTENSIONS), + + pub fn jsonStringify(self: UiTokenAccount, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + // Omit delegate when null (matches Agave's skip_serializing_if = "Option::is_none") + if (self.delegate) |d| { + try jw.objectField("delegate"); + try jw.write(d); + } + try jw.objectField("isNative"); + try jw.write(self.isNative); + try jw.objectField("mint"); + try jw.write(self.mint); + try jw.objectField("owner"); + try jw.write(self.owner); + try jw.objectField("state"); + try jw.write(switch (self.state) { + .uninitialized => "uninitialized", + .initialized => "initialized", + .frozen => "frozen", + }); + try jw.objectField("tokenAmount"); + try jw.write(self.tokenAmount); + // Omit closeAuthority when null (matches Agave's skip_serializing_if) + if (self.closeAuthority) |c| { + try jw.objectField("closeAuthority"); + try jw.write(c); + } + // Omit delegatedAmount when null (matches Agave's skip_serializing_if) + if (self.delegatedAmount) |d| { + try jw.objectField("delegatedAmount"); + try jw.write(d); + } + // Omit rentExemptReserve when null (matches Agave's skip_serializing_if) + if (self.rentExemptReserve) |r| { + try jw.objectField("rentExemptReserve"); + try jw.write(r); + } + if (self.extensions.len() > 0) { + try jw.objectField("extensions"); + try jw.write(self.extensions); + } + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder-client-types/src/token.rs#L66-L75 +pub const UiMint = struct { + mintAuthority: ?Pubkey, + supply: account_decoder.Stringified(u64), + decimals: u8, + isInitialized: bool, + freezeAuthority: ?Pubkey, + // Token-2022. + extensions: JsonArray(UiExtension, MAX_EXTENSIONS), + + pub fn jsonStringify(self: UiMint, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("mintAuthority"); + try jw.write(self.mintAuthority); + try jw.objectField("supply"); + try jw.write(self.supply); + try jw.objectField("decimals"); + try jw.write(self.decimals); + try jw.objectField("isInitialized"); + try jw.write(self.isInitialized); + try jw.objectField("freezeAuthority"); + try jw.write(self.freezeAuthority); + if (self.extensions.len() > 0) { + try jw.objectField("extensions"); + try jw.write(self.extensions); + } + try jw.endObject(); + } +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder-client-types/src/token.rs#L77-L84 +pub const UiMultisig = struct { + numRequiredSigners: u8, + numValidSigners: u8, + isInitialized: bool, + signers: JsonArray(Pubkey, Multisig.MAX_SIGNERS), +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder-client-types/src/token.rs#L27-L37 +pub const UiTokenAmount = struct { + ui_amount: ?f64, + decimals: u8, + amount: u64, + // max u64 digits + decimal point + null + ui_amount_string: JsonString(40), + + /// Create a UiTokenAmount from raw amount and additional data. + /// Handles interest-bearing and scaled UI amount calculations if configured. + /// Priority: interest-bearing > scaled > simple + pub fn init(amount: u64, additional_data: SplTokenAdditionalData) UiTokenAmount { + const decimals = additional_data.decimals; + + // Priority 1: Interest-bearing config + if (additional_data.interest_bearing_config) |config| { + if (interestBearingAmountToUi( + amount, + decimals, + config, + additional_data.unix_timestamp, + )) |result| { + return .{ + .ui_amount = result.ui_amount, + .decimals = decimals, + .amount = amount, + .ui_amount_string = result.ui_amount_string, + }; + } + } + + // Priority 2: Scaled UI amount config + if (additional_data.scaled_ui_amount_config) |config| { + const result = scaledAmountToUi( + amount, + decimals, + config, + additional_data.unix_timestamp, + ); + return .{ + .ui_amount = result.ui_amount, + .decimals = decimals, + .amount = amount, + .ui_amount_string = result.ui_amount_string, + }; + } + + // Default: Simple calculation + const ui_amount: ?f64 = if (decimals <= 20) blk: { + const divisor = std.math.pow(f64, 10.0, @floatFromInt(decimals)); + break :blk @as(f64, @floatFromInt(amount)) / divisor; + } else null; + + return .{ + .ui_amount = ui_amount, + .decimals = decimals, + .amount = amount, + .ui_amount_string = formatTokenAmount(amount, decimals), + }; + } + + pub fn jsonStringify(self: UiTokenAmount, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("uiAmount"); + if (self.ui_amount) |a| try jw.write(a) else try jw.write(null); + try jw.objectField("decimals"); + try jw.write(self.decimals); + try jw.objectField("amount"); + try jw.print("\"{d}\"", .{self.amount}); + try jw.objectField("uiAmountString"); + try jw.write(self.ui_amount_string); + try jw.endObject(); + } +}; + +/// Format amount with decimal point, trimming trailing zeros. +/// Examples: +/// formatTokenAmount(1000000, 6) → "1" +/// formatTokenAmount(1500000, 6) → "1.5" +/// formatTokenAmount(123, 6) → "0.000123" +/// formatTokenAmount(0, 6) → "0" +fn formatTokenAmount(amount: u64, decimals: u8) JsonString(40) { + var buf: JsonString(40) = .{ .inner = .{} }; + + if (decimals == 0) { + const written = std.fmt.bufPrint(&buf.inner.buffer, "{d}", .{amount}) catch unreachable; + buf.inner.len = @intCast(written.len); + return buf; + } + + // Format amount as string, left-padded with zeros to (decimals + 1) chars minimum + // e.g., amount=123, decimals=6 → "0000123" → "0.000123" + const min_len = decimals + 1; + const written = std.fmt.bufPrint( + &buf.inner.buffer, + "{d:0>[1]}", + .{ amount, min_len }, + ) catch unreachable; + buf.inner.len = @intCast(written.len); + + // Insert decimal point at position (len - decimals) + const decimal_pos = buf.inner.len - decimals; + // Shift right to make room for decimal point + const src = buf.inner.buffer[decimal_pos..buf.inner.len]; + const dst = buf.inner.buffer[decimal_pos + 1 .. buf.inner.len + 1]; + std.mem.copyBackwards(u8, dst, src); + buf.inner.buffer[decimal_pos] = '.'; + buf.inner.len += 1; + + // Trim trailing zeros + while (buf.inner.len > 0 and buf.inner.buffer[buf.inner.len - 1] == '0') { + buf.inner.len -= 1; + } + // Trim trailing decimal point + if (buf.inner.len > 0 and buf.inner.buffer[buf.inner.len - 1] == '.') { + buf.inner.len -= 1; + } + + return buf; +} + +// Constants for interest-bearing calculations +// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/interest_bearing_mint/mod.rs +const SECONDS_PER_YEAR: f64 = 31_556_736.0; // 60 * 60 * 24 * 365.24 +const ONE_IN_BASIS_POINTS: f64 = 10_000.0; + +/// Calculate UI amount for interest-bearing tokens using compound interest. +/// Returns null if timestamps are invalid (e.g., negative timespans). +fn interestBearingAmountToUi( + amount: u64, + decimals: u8, + config: InterestBearingConfigData, + unix_timestamp: i64, +) ?struct { ui_amount: ?f64, ui_amount_string: JsonString(40) } { + // pre_update_timespan = last_update_timestamp - initialization_timestamp + const pre_timespan = config.last_update_timestamp - config.initialization_timestamp; + if (pre_timespan < 0) return null; + + // post_update_timespan = current_timestamp - last_update_timestamp + const post_timespan = unix_timestamp - config.last_update_timestamp; + if (post_timespan < 0) return null; + + // pre_update_exp = exp(rate * time / SECONDS_PER_YEAR / 10000) + const pre_rate: f64 = @floatFromInt(config.pre_update_average_rate); + const pre_ts: f64 = @floatFromInt(pre_timespan); + const pre_exponent = pre_rate * pre_ts / SECONDS_PER_YEAR / ONE_IN_BASIS_POINTS; + const pre_exp = @exp(pre_exponent); + + // post_update_exp + const post_rate: f64 = @floatFromInt(config.current_rate); + const post_ts: f64 = @floatFromInt(post_timespan); + const post_exponent = post_rate * post_ts / SECONDS_PER_YEAR / ONE_IN_BASIS_POINTS; + const post_exp = @exp(post_exponent); + + // total_scale = pre_exp * post_exp / 10^decimals + const divisor = std.math.pow(f64, 10.0, @floatFromInt(decimals)); + const total_scale = pre_exp * post_exp / divisor; + + // scaled amount + const scaled_amount = @as(f64, @floatFromInt(amount)) * total_scale; + + // Format with decimals precision, then trim + var buf: JsonString(40) = .{ .inner = .{} }; + if (std.math.isInf(scaled_amount)) { + buf.inner.appendSliceAssumeCapacity("inf"); + } else { + // Format with fixed decimals precision + const written = std.fmt.bufPrint( + &buf.inner.buffer, + "{d:.[1]}", + .{ scaled_amount, decimals }, + ) catch return null; + buf.inner.len = @intCast(written.len); + trimUiAmountStringInPlace(&buf.inner, decimals); + } + + // ui_amount as f64 + const ui_amount: ?f64 = if (std.math.isInf(scaled_amount)) + scaled_amount + else + std.fmt.parseFloat(f64, buf.constSlice()) catch null; + + return .{ .ui_amount = ui_amount, .ui_amount_string = buf }; +} + +/// Calculate UI amount for scaled tokens using multiplier. +/// Truncates toward zero before applying decimals (Agave behavior). +fn scaledAmountToUi( + amount: u64, + decimals: u8, + config: ScaledUiAmountConfigData, + unix_timestamp: i64, +) struct { ui_amount: ?f64, ui_amount_string: JsonString(40) } { + // Pick current or new multiplier based on timestamp + const multiplier = if (unix_timestamp >= config.new_multiplier_effective_timestamp) + config.new_multiplier + else + config.multiplier; + + // scaled_amount = amount * multiplier + const scaled_amount = @as(f64, @floatFromInt(amount)) * multiplier; + + // TRUNCATE toward zero BEFORE applying decimals + const truncated = @trunc(scaled_amount); + + // Apply decimals + const divisor = std.math.pow(f64, 10.0, @floatFromInt(decimals)); + const ui_value = truncated / divisor; + + // Format + var buf: JsonString(40) = .{ .inner = .{} }; + if (std.math.isInf(ui_value)) { + buf.inner.appendSliceAssumeCapacity("inf"); + } else { + const written = std.fmt.bufPrint( + &buf.inner.buffer, + "{d:.[1]}", + .{ ui_value, decimals }, + ) catch unreachable; + buf.inner.len = @intCast(written.len); + trimUiAmountStringInPlace(&buf.inner, decimals); + } + + return .{ + .ui_amount = if (std.math.isInf(ui_value)) + ui_value + else + std.fmt.parseFloat(f64, buf.constSlice()) catch null, + .ui_amount_string = buf, + }; +} + +/// Trim trailing zeros and decimal point from a formatted number string. +fn trimUiAmountStringInPlace(buf: *std.BoundedArray(u8, 40), decimals: u8) void { + if (decimals == 0) return; + // Trim trailing zeros + while (buf.len > 0 and buf.buffer[buf.len - 1] == '0') { + buf.len -= 1; + } + // Trim trailing decimal point + if (buf.len > 0 and buf.buffer[buf.len - 1] == '.') { + buf.len -= 1; + } +} + +// SPL Token uses fixed-offset binary layout (Pack trait), not bincode. +// TODO: COption crate layout might be binary compatible with zig more directly? +fn readCOptionPubkey(data: *const [36]u8) ?Pubkey { + const tag = std.mem.readInt(u32, data[0..4], .little); + if (tag == 0) return null; + return Pubkey{ .data = data[4..36].* }; +} + +// COption = 4-byte tag (0=None, 1=Some) + T +// TODO: COption crate layout might be binary compatible with zig more directly? +fn readCOptionU64(data: *const [12]u8) ?u64 { + const tag = std.mem.readInt(u32, data[0..4], .little); + if (tag == 0) return null; + return std.mem.readInt(u64, data[4..12], .little); +} + +test "rpc.account_decoder.parse_token: basic token account parsing" { + const TEST_MINT_AUTHORITY = Pubkey{ .data = [_]u8{1} ** 32 }; + const TEST_FREEZE_AUTHORITY = Pubkey{ .data = [_]u8{2} ** 32 }; + + const TEST_MINT = Mint{ + .mint_authority = TEST_MINT_AUTHORITY, + .supply = 42, + .decimals = 7, + .is_initialized = true, + .freeze_authority = TEST_FREEZE_AUTHORITY, + }; + + const TEST_MINT_SLICE: [Mint.LEN]u8 = .{ + 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 42, 0, 0, 0, 0, 0, 0, 0, 7, 1, 1, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + }; + + const TEST_ACCOUNT = TokenAccount{ + .mint = Pubkey{ .data = [_]u8{1} ** 32 }, + .owner = Pubkey{ .data = [_]u8{2} ** 32 }, + .amount = 3, + .delegate = Pubkey{ .data = [_]u8{4} ** 32 }, + .state = .frozen, + .is_native = 5, + .delegated_amount = 6, + .close_authority = Pubkey{ .data = [_]u8{7} ** 32 }, + }; + + const TEST_ACCOUNT_SLICE: [TokenAccount.LEN]u8 = .{ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 3, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, + 0, 6, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + }; + + const TEST_MULTISIG = Multisig{ + .m = 1, + .n = 11, + .is_initialized = true, + .signers = .{ + Pubkey{ .data = [_]u8{1} ** 32 }, + Pubkey{ .data = [_]u8{2} ** 32 }, + Pubkey{ .data = [_]u8{3} ** 32 }, + Pubkey{ .data = [_]u8{4} ** 32 }, + Pubkey{ .data = [_]u8{5} ** 32 }, + Pubkey{ .data = [_]u8{6} ** 32 }, + Pubkey{ .data = [_]u8{7} ** 32 }, + Pubkey{ .data = [_]u8{8} ** 32 }, + Pubkey{ .data = [_]u8{9} ** 32 }, + Pubkey{ .data = [_]u8{10} ** 32 }, + Pubkey{ .data = [_]u8{11} ** 32 }, + }, + }; + + // zig fmt: off + const TEST_MULTISIG_SLICE: [Multisig.LEN]u8 = .{ + 1, 11, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8, + 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, + 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, + 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, + 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, + }; + // zig fmt: on + + // Mint - unpack from known bytes + { + const unpacked = try Mint.unpack(&TEST_MINT_SLICE); + try std.testing.expect(unpacked.mint_authority != null); + try std.testing.expectEqual(TEST_MINT_AUTHORITY, unpacked.mint_authority.?); + try std.testing.expectEqual(@as(u64, 42), unpacked.supply); + try std.testing.expectEqual(@as(u8, 7), unpacked.decimals); + try std.testing.expect(unpacked.is_initialized); + try std.testing.expect(unpacked.freeze_authority != null); + try std.testing.expectEqual(TEST_FREEZE_AUTHORITY, unpacked.freeze_authority.?); + } + + // Mint - too short should fail + { + const short_data: [Mint.LEN - 1]u8 = TEST_MINT_SLICE[0 .. Mint.LEN - 1].*; + const result = Mint.unpack(&short_data); + try std.testing.expectError(ParseError.InvalidAccountData, result); + } + + // Mint - unpack back to known struct + { + const unpacked = try Mint.unpack(&TEST_MINT_SLICE); + try std.testing.expectEqual(TEST_MINT, unpacked); + } + + // Account - unpack from known bytes + { + const unpacked = try TokenAccount.unpack(&TEST_ACCOUNT_SLICE); + try std.testing.expectEqual(Pubkey{ .data = [_]u8{1} ** 32 }, unpacked.mint); + try std.testing.expectEqual(Pubkey{ .data = [_]u8{2} ** 32 }, unpacked.owner); + try std.testing.expectEqual(@as(u64, 3), unpacked.amount); + try std.testing.expect(unpacked.delegate != null); + try std.testing.expectEqual(Pubkey{ .data = [_]u8{4} ** 32 }, unpacked.delegate.?); + try std.testing.expectEqual(AccountState.frozen, unpacked.state); + try std.testing.expect(unpacked.is_native != null); + try std.testing.expectEqual(@as(u64, 5), unpacked.is_native.?); + try std.testing.expectEqual(@as(u64, 6), unpacked.delegated_amount); + try std.testing.expect(unpacked.close_authority != null); + try std.testing.expectEqual(Pubkey{ .data = [_]u8{7} ** 32 }, unpacked.close_authority.?); + } + + // Account - too short should fail + { + const short_data: [TokenAccount.LEN - 1]u8 = TEST_ACCOUNT_SLICE[0 .. TokenAccount.LEN - 1].*; + const result = TokenAccount.unpack(&short_data); + try std.testing.expectError(ParseError.InvalidAccountData, result); + } + + // Account - unpack from known bytes + { + const unpacked = try TokenAccount.unpack(&TEST_ACCOUNT_SLICE); + try std.testing.expectEqual(TEST_ACCOUNT, unpacked); + } + + // Multisig - unpack from known bytes + { + const unpacked = try Multisig.unpack(&TEST_MULTISIG_SLICE); + try std.testing.expectEqual(@as(u8, 1), unpacked.m); + try std.testing.expectEqual(@as(u8, 11), unpacked.n); + try std.testing.expect(unpacked.is_initialized); + for (0..11) |i| { + const expected_byte: u8 = @intCast(i + 1); + try std.testing.expectEqual( + Pubkey{ .data = [_]u8{expected_byte} ** 32 }, + unpacked.signers[i], + ); + } + } + + // Multisig - wrong size should fail (too short) + { + const short_data: [Multisig.LEN - 1]u8 = TEST_MULTISIG_SLICE[0 .. Multisig.LEN - 1].*; + const result = Multisig.unpack(&short_data); + try std.testing.expectError(ParseError.InvalidAccountData, result); + } + + // Multisig - wrong size should fail (too long) + { + var long_data: [Multisig.LEN + 1]u8 = undefined; + @memcpy(long_data[0..Multisig.LEN], &TEST_MULTISIG_SLICE); + long_data[Multisig.LEN] = 0; + const result = Multisig.unpack(&long_data); + try std.testing.expectError(ParseError.InvalidAccountData, result); + } + + // Multisig - unpack from known bytes + { + const unpacked = try Multisig.unpack(&TEST_MULTISIG_SLICE); + try std.testing.expectEqual(TEST_MULTISIG, unpacked); + } + + // [agave] https://github.com/solana-program/token-2022/blob/v3.1.8/interface/src/state.rs#L398 + // Account data length < Account::LEN, unpack will not return a key + { + const src: [12]u8 = [_]u8{0} ** 12; + const result = getTokenAccountOwner(&src); + try std.testing.expect(result == null); + } + + // The right account data size and initialized, unpack will return some key + { + var src: [TokenAccount.LEN]u8 = [_]u8{0} ** TokenAccount.LEN; + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + const result = getTokenAccountOwner(&src); + try std.testing.expect(result != null); + } + + // The right account data size and frozen, unpack will return some key + { + var src: [TokenAccount.LEN]u8 = [_]u8{0} ** TokenAccount.LEN; + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.frozen); + const result = getTokenAccountOwner(&src); + try std.testing.expect(result != null); + } + + // Account data length > account data size, but not a valid extension, + // unpack will not return a key + { + var src: [TokenAccount.LEN + 5]u8 = [_]u8{0} ** (TokenAccount.LEN + 5); + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + const result = getTokenAccountOwner(&src); + try std.testing.expect(result == null); + } + + // Account data length > account data size with a valid extension and + // initialized, expect some key returned + { + var src: [TokenAccount.LEN + 5]u8 = [_]u8{0} ** (TokenAccount.LEN + 5); + src[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + const result = getTokenAccountOwner(&src); + try std.testing.expect(result != null); + } + + // Account data length > account data size with a valid extension but + // uninitialized, expect None + { + var src: [TokenAccount.LEN + 5]u8 = [_]u8{0} ** (TokenAccount.LEN + 5); + src[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.uninitialized); + const result = getTokenAccountOwner(&src); + try std.testing.expect(result == null); + } + + // Account data length is multi-sig data size with a valid extension and + // initialized, expect none + { + var src: [Multisig.LEN]u8 = [_]u8{0} ** Multisig.LEN; + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + src[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + const result = getTokenAccountOwner(&src); + try std.testing.expect(result == null); + } + + // [agave] https://github.com/solana-program/token-2022/blob/v3.1.8/interface/src/state.rs#L505 + // Account data length < Account::LEN, unpack will not return a key + { + const src: [12]u8 = [_]u8{0} ** 12; + const result = getTokenAccountMint(&src); + try std.testing.expect(result == null); + } + + // The right account data size and initialized, unpack will return some key + { + var src: [TokenAccount.LEN]u8 = [_]u8{0} ** TokenAccount.LEN; + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + const result = getTokenAccountMint(&src); + try std.testing.expect(result != null); + } + + // The right account data size and frozen, unpack will return some key + { + var src: [TokenAccount.LEN]u8 = [_]u8{0} ** TokenAccount.LEN; + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.frozen); + const result = getTokenAccountMint(&src); + try std.testing.expect(result != null); + } + + // Account data length > account data size, but not a valid extension, + // unpack will not return a key + { + var src: [TokenAccount.LEN + 5]u8 = [_]u8{0} ** (TokenAccount.LEN + 5); + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + const result = getTokenAccountMint(&src); + try std.testing.expect(result == null); + } + + // Account data length > account data size with a valid extension and + // initialized, expect some key returned + { + var src: [TokenAccount.LEN + 5]u8 = [_]u8{0} ** (TokenAccount.LEN + 5); + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + src[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + const result = getTokenAccountMint(&src); + try std.testing.expect(result != null); + } + + // Account data length > account data size with a valid extension but + // uninitialized, expect none + { + var src: [TokenAccount.LEN + 5]u8 = [_]u8{0} ** (TokenAccount.LEN + 5); + src[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.uninitialized); + const result = getTokenAccountMint(&src); + try std.testing.expect(result == null); + } + + // Account data length is multi-sig data size with a valid extension and + // initialized, expect none + { + var src: [Multisig.LEN]u8 = [_]u8{0} ** Multisig.LEN; + src[ACCOUNT_INITIALIZED_INDEX] = @intFromEnum(AccountState.initialized); + src[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + const result = getTokenAccountMint(&src); + try std.testing.expect(result == null); + } + + // Some additional tests + { + var account_data: [TokenAccount.LEN]u8 = undefined; + @memset(&account_data, 0); + + // Set mint pubkey (first 32 bytes) + const expected_mint = Pubkey.parse("So11111111111111111111111111111111111111112"); + @memcpy(account_data[0..32], &expected_mint.data); + + // Set state to initialized (byte 108) + account_data[108] = 1; + + const result = getTokenAccountMint(&account_data); + try std.testing.expect(result != null); + try std.testing.expectEqual(expected_mint, result.?); + } +} + +test "rpc.account_decoder.parse_token: basic extension parsing" { + // Test TLV parsing with marker extension + { + // make a minimal Token-2022 account with ImmutableOwner extension + // Layout: [165 bytes base][1 byte discriminator][4 byte TLV header][0 bytes value][2 byte terminator] + var data: [172]u8 = undefined; + @memset(&data, 0); + + // Account type discriminator at offset 165 + data[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + + // TLV entry: type=7 (ImmutableOwner), length=0 + // ExtensionType.immutable_owner (low byte) + data[166] = 7; + // (high byte) + data[167] = 0; + // Length (low byte) + data[168] = 0; + // Length (high byte) + data[169] = 0; + + // Terminator: type=0 (Uninitialized) + data[170] = 0; + data[171] = 0; + + const extensions = parseExtensions(data[TokenAccount.LEN..]); + try std.testing.expectEqual(1, extensions.len()); + try std.testing.expectEqual(UiExtension.immutable_owner, extensions.get(0)); + } + + // Test multiple extensions + { + var data: [180]u8 = undefined; + @memset(&data, 0); + + data[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + + // Extension 1: ImmutableOwner (type=7, len=0) + data[166] = 7; + data[167] = 0; + data[168] = 0; + data[169] = 0; + + // Extension 2: MemoTransfer (type=8, len=1, value=1) + data[170] = 8; + data[171] = 0; + data[172] = 1; + data[173] = 0; + // require_incoming_transfer_memos = true + data[174] = 1; + + // Terminator + data[175] = 0; + data[176] = 0; + + const extensions = parseExtensions(data[TokenAccount.LEN..]); + try std.testing.expectEqual(2, extensions.len()); + try std.testing.expectEqual(UiExtension.immutable_owner, extensions.get(0)); + + const memo = extensions.get(1); + switch (memo) { + .memo_transfer => |m| { + try std.testing.expect(m.requireIncomingTransferMemos); + }, + else => try std.testing.expect(false), + } + } + + // Test unknown extension type returns unparseable + { + var data: [174]u8 = undefined; + @memset(&data, 0); + + data[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.account); + + // Unknown extension type (255) + data[166] = 255; + data[167] = 0; + data[168] = 0; + data[169] = 0; + + // Terminator + data[170] = 0; + data[171] = 0; + + const extensions = parseExtensions(data[TokenAccount.LEN..]); + try std.testing.expectEqual(1, extensions.len()); + try std.testing.expectEqual(UiExtension.unparseable_extension, extensions.get(0)); + } + + // Test insufficient data returns null + { + const data: [1]u8 = .{0}; + const extensions = parseExtensions(&data); + try std.testing.expect(extensions.len() == 0); + } +} + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L484 +test "rpc.account_decoder.parse_token: token account with extensions" { + const mint_pubkey = Pubkey{ .data = [_]u8{2} ** 32 }; + const owner_pubkey = Pubkey{ .data = [_]u8{3} ** 32 }; + // Build token account data manually (165 bytes) + // Layout: mint(32) + owner(32) + amount(8) + delegate(36) + state(1) + is_native(12) + delegated_amount(8) + close_authority(36) + var account_data: [TokenAccount.LEN]u8 = [_]u8{0} ** TokenAccount.LEN; + // mint (bytes 0-31) + @memcpy(account_data[0..32], &mint_pubkey.data); + // owner (bytes 32-63) + @memcpy(account_data[32..64], &owner_pubkey.data); + // amount = 42 (bytes 64-71) + std.mem.writeInt(u64, account_data[64..72], 42, .little); + // delegate = None (COption: tag=0) (bytes 72-107) + std.mem.writeInt(u32, account_data[72..76], 0, .little); + // state = Initialized (byte 108) + account_data[108] = @intFromEnum(AccountState.initialized); + // is_native = None (COption: tag=0) (bytes 109-120) + std.mem.writeInt(u32, account_data[109..113], 0, .little); + // delegated_amount = 0 (bytes 121-128) + std.mem.writeInt(u64, account_data[121..129], 0, .little); + // close_authority = Some(owner_pubkey) (bytes 129-164) + std.mem.writeInt(u32, account_data[129..133], 1, .little); + @memcpy(account_data[133..165], &owner_pubkey.data); + + // Test: parsing without decimals returns null (token accounts require decimals) + { + const result = try parseToken(&account_data, null); + try std.testing.expect(result == null); + } + + // Test: parsing with decimals succeeds + { + const additional_data = SplTokenAdditionalData{ .decimals = 2 }; + const result = try parseToken(&account_data, &additional_data); + try std.testing.expect(result != null); + switch (result.?) { + .account => |ui_account| { + const mint_str = mint_pubkey.base58String().constSlice(); + const owner_str = owner_pubkey.base58String().constSlice(); + const acc_mint = ui_account.mint.base58String().constSlice(); + try std.testing.expectEqualStrings(mint_str, acc_mint); + const acc_owner = ui_account.owner.base58String().constSlice(); + try std.testing.expectEqualStrings(owner_str, acc_owner); + try std.testing.expectEqual(@as(u64, 42), ui_account.tokenAmount.amount); + try std.testing.expectEqual(@as(u8, 2), ui_account.tokenAmount.decimals); + try std.testing.expect(ui_account.tokenAmount.ui_amount != null); + try std.testing.expect(@abs(ui_account.tokenAmount.ui_amount.? - 0.42) < 0.001); + const ui_str = ui_account.tokenAmount.ui_amount_string.constSlice(); + try std.testing.expectEqualStrings("0.42", ui_str); + try std.testing.expect(ui_account.delegate == null); + try std.testing.expectEqual(AccountState.initialized, ui_account.state); + try std.testing.expect(!ui_account.isNative); + try std.testing.expect(ui_account.rentExemptReserve == null); + try std.testing.expect(ui_account.delegatedAmount == null); + try std.testing.expect(ui_account.closeAuthority != null); + const close_auth = ui_account.closeAuthority.?.base58String().constSlice(); + try std.testing.expectEqualStrings(owner_str, close_auth); + }, + else => try std.testing.expect(false), + } + } + + // Test: mint parsing (82 bytes) + var mint_data: [Mint.LEN]u8 = [_]u8{0} ** Mint.LEN; + // mint_authority = Some(owner_pubkey) (bytes 0-35) + std.mem.writeInt(u32, mint_data[0..4], 1, .little); + @memcpy(mint_data[4..36], &owner_pubkey.data); + // supply = 42 (bytes 36-43) + std.mem.writeInt(u64, mint_data[36..44], 42, .little); + // decimals = 3 (byte 44) + mint_data[44] = 3; + // is_initialized = true (byte 45) + mint_data[45] = 1; + // freeze_authority = Some(owner_pubkey) (bytes 46-81) + std.mem.writeInt(u32, mint_data[46..50], 1, .little); + @memcpy(mint_data[50..82], &owner_pubkey.data); + { + const result = try parseToken(&mint_data, null); + try std.testing.expect(result != null); + switch (result.?) { + .mint => |ui_mint| { + const owner_str = owner_pubkey.base58String().constSlice(); + try std.testing.expect(ui_mint.mintAuthority != null); + const mint_auth = ui_mint.mintAuthority.?.base58String().constSlice(); + try std.testing.expectEqualStrings(owner_str, mint_auth); + try std.testing.expectEqual(@as(u64, 42), ui_mint.supply.value); + try std.testing.expectEqual(@as(u8, 3), ui_mint.decimals); + try std.testing.expect(ui_mint.isInitialized); + try std.testing.expect(ui_mint.freezeAuthority != null); + const freeze_auth = ui_mint.freezeAuthority.?.base58String().constSlice(); + try std.testing.expectEqualStrings(owner_str, freeze_auth); + }, + else => try std.testing.expect(false), + } + } + + // Test: multisig parsing (355 bytes) + const signer1 = Pubkey{ .data = [_]u8{1} ** 32 }; + const signer2 = Pubkey{ .data = [_]u8{2} ** 32 }; + const signer3 = Pubkey{ .data = [_]u8{3} ** 32 }; + var multisig_data: [Multisig.LEN]u8 = [_]u8{0} ** Multisig.LEN; + multisig_data[0] = 2; // m (required signers) + multisig_data[1] = 3; // n (valid signers) + multisig_data[2] = 1; // is_initialized + @memcpy(multisig_data[3..35], &signer1.data); + @memcpy(multisig_data[35..67], &signer2.data); + @memcpy(multisig_data[67..99], &signer3.data); + { + const result = try parseToken(&multisig_data, null); + try std.testing.expect(result != null); + switch (result.?) { + .multisig => |ui_multisig| { + try std.testing.expectEqual(@as(u8, 2), ui_multisig.numRequiredSigners); + try std.testing.expectEqual(@as(u8, 3), ui_multisig.numValidSigners); + try std.testing.expect(ui_multisig.isInitialized); + try std.testing.expectEqual(@as(usize, 3), ui_multisig.signers.len()); + const s1_str = signer1.base58String().constSlice(); + const s2_str = signer2.base58String().constSlice(); + const s3_str = signer3.base58String().constSlice(); + const sig0 = ui_multisig.signers.get(0).base58String().constSlice(); + try std.testing.expectEqualStrings(s1_str, sig0); + const sig1 = ui_multisig.signers.get(1).base58String().constSlice(); + try std.testing.expectEqualStrings(s2_str, sig1); + const sig2 = ui_multisig.signers.get(2).base58String().constSlice(); + try std.testing.expectEqualStrings(s3_str, sig2); + }, + else => try std.testing.expect(false), + } + } + + // Test: bad data returns null + { + const bad_data: [4]u8 = [_]u8{0} ** 4; + const result = try parseToken(&bad_data, null); + try std.testing.expect(result == null); + } +} + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L300 +test "rpc.account_decoder.parse_token: formatTokenAmount conformance" { + + // Basic integers + try std.testing.expectEqualStrings("1", formatTokenAmount(1, 0).constSlice()); + try std.testing.expectEqualStrings("10", formatTokenAmount(10, 0).constSlice()); + + // Small amounts with decimals + try std.testing.expectEqualStrings("0.000000001", formatTokenAmount(1, 9).constSlice()); + + // Whole numbers that trim to clean result + try std.testing.expectEqualStrings("1", formatTokenAmount(1_000_000_000, 9).constSlice()); + + // Partial decimal trimming (trailing zero removed) + try std.testing.expectEqualStrings( + "1234567.89", + formatTokenAmount(1_234_567_890, 3).constSlice(), + ); + + // Large decimals (25 places) - tests precision + try std.testing.expectEqualStrings( + "0.000000000000000123456789", + formatTokenAmount(1_234_567_890, 25).constSlice(), + ); + + // Zero amounts + try std.testing.expectEqualStrings("0", formatTokenAmount(0, 0).constSlice()); + try std.testing.expectEqualStrings("0", formatTokenAmount(0, 9).constSlice()); + try std.testing.expectEqualStrings("0", formatTokenAmount(0, 25).constSlice()); +} + +test "rpc.account_decoder.parse_token: UiTokenAmount.init ui_amount" { + // ui_amount is Some when decimals <= 20 + { + const t = UiTokenAmount.init(1, .{ .decimals = 0 }); + try std.testing.expectEqual(@as(?f64, 1.0), t.ui_amount); + } + { + const t = UiTokenAmount.init(1_000_000_000, .{ .decimals = 9 }); + try std.testing.expectEqual(@as(?f64, 1.0), t.ui_amount); + } + // ui_amount is None when decimals > 20 + { + const t = UiTokenAmount.init(1_234_567_890, .{ .decimals = 25 }); + try std.testing.expect(t.ui_amount == null); + } +} + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L484 +test "rpc.account_decoder.parse_token: token account with extensions conformance" { + const mint_pubkey = Pubkey{ .data = [_]u8{2} ** 32 }; + const owner_pubkey = Pubkey{ .data = [_]u8{3} ** 32 }; + // Calculate account size: base(165) + discriminator(1) + extensions + // ImmutableOwner: 4 header + 0 value = 4 + // MemoTransfer: 4 header + 1 value = 5 + // Total: 165 + 1 + 4 + 5 = 175 bytes + const ACCOUNT_SIZE = TokenAccount.LEN + 1 + 4 + 5; + var account_data: [ACCOUNT_SIZE]u8 = [_]u8{0} ** ACCOUNT_SIZE; + // Build base account (same as existing test) + @memcpy(account_data[0..32], &mint_pubkey.data); + @memcpy(account_data[32..64], &owner_pubkey.data); + std.mem.writeInt(u64, account_data[64..72], 42, .little); + std.mem.writeInt(u32, account_data[72..76], 0, .little); // delegate = None + account_data[108] = @intFromEnum(AccountState.initialized); + std.mem.writeInt(u32, account_data[109..113], 0, .little); // is_native = None + std.mem.writeInt(u64, account_data[121..129], 0, .little); + std.mem.writeInt(u32, account_data[129..133], 1, .little); // close_authority = Some + @memcpy(account_data[133..165], &owner_pubkey.data); + // Account type discriminator + account_data[165] = @intFromEnum(AccountTypeDiscriminator.account); + // Extension 1: ImmutableOwner (type=7, len=0) + std.mem.writeInt(u16, account_data[166..168], 7, .little); + std.mem.writeInt(u16, account_data[168..170], 0, .little); + // Extension 2: MemoTransfer (type=8, len=1, value=1) + std.mem.writeInt(u16, account_data[170..172], 8, .little); + std.mem.writeInt(u16, account_data[172..174], 1, .little); + account_data[174] = 1; // require_incoming_transfer_memos = true + + // Parse and verify + const additional_data = SplTokenAdditionalData{ .decimals = 2 }; + const result = try parseToken(&account_data, &additional_data); + try std.testing.expect(result != null); + switch (result.?) { + .account => |ui_account| { + // Verify base fields (same assertions as before) + const mint_str = mint_pubkey.base58String().constSlice(); + const owner_str = owner_pubkey.base58String().constSlice(); + const acc_mint = ui_account.mint.base58String().constSlice(); + try std.testing.expectEqualStrings(mint_str, acc_mint); + const acc_owner = ui_account.owner.base58String().constSlice(); + try std.testing.expectEqualStrings(owner_str, acc_owner); + try std.testing.expectEqual(@as(u64, 42), ui_account.tokenAmount.amount); + try std.testing.expectEqual(AccountState.initialized, ui_account.state); + // Verify extensions + try std.testing.expectEqual(@as(usize, 2), ui_account.extensions.len()); + try std.testing.expectEqual(UiExtension.immutable_owner, ui_account.extensions.get(0)); + + switch (ui_account.extensions.get(1)) { + .memo_transfer => |m| { + try std.testing.expect(m.requireIncomingTransferMemos); + }, + else => try std.testing.expect(false), + } + }, + else => try std.testing.expect(false), + } +} + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L584 +test "rpc.account_decoder.parse_token: mint with extensions conformance" { + const owner_pubkey = Pubkey{ .data = [_]u8{3} ** 32 }; + // Token-2022 layout: mint is padded to 165 bytes (TokenAccount.LEN), then discriminator at 165, + // then TLV extensions starting at 166. + // Size: 165 (padded mint) + 1 discriminator + 4 TLV header + 32 value = 202 + const MINT_SIZE = TokenAccount.LEN + 1 + 4 + 32; + var mint_data: [MINT_SIZE]u8 = [_]u8{0} ** MINT_SIZE; + // Build base mint (first 82 bytes) + std.mem.writeInt(u32, mint_data[0..4], 1, .little); // mint_authority = Some + @memcpy(mint_data[4..36], &owner_pubkey.data); + std.mem.writeInt(u64, mint_data[36..44], 42, .little); // supply + mint_data[44] = 3; // decimals + mint_data[45] = 1; // is_initialized + std.mem.writeInt(u32, mint_data[46..50], 1, .little); // freeze_authority = Some + @memcpy(mint_data[50..82], &owner_pubkey.data); + // Bytes 82-164 are padding (zeros) - already initialized + // Account type discriminator at offset 165 (TokenAccount.LEN) + mint_data[TokenAccount.LEN] = @intFromEnum(AccountTypeDiscriminator.mint); + // Extension: MintCloseAuthority (type=3, len=32) starting at offset 166 + std.mem.writeInt(u16, mint_data[166..168], 3, .little); + std.mem.writeInt(u16, mint_data[168..170], 32, .little); + @memcpy(mint_data[170..202], &owner_pubkey.data); + // Parse and verify + const result = try parseToken(&mint_data, null); + try std.testing.expect(result != null); + switch (result.?) { + .mint => |ui_mint| { + try std.testing.expect(ui_mint.mintAuthority != null); + try std.testing.expectEqual(@as(u64, 42), ui_mint.supply.value); + try std.testing.expectEqual(@as(u8, 3), ui_mint.decimals); + try std.testing.expect(ui_mint.isInitialized); + // Verify extension + try std.testing.expectEqual(@as(usize, 1), ui_mint.extensions.len()); + switch (ui_mint.extensions.get(0)) { + .mint_close_authority => |mca| { + try std.testing.expect(mca.closeAuthority != null); + try std.testing.expectEqual(owner_pubkey, mca.closeAuthority.?); + }, + else => try std.testing.expect(false), + } + }, + else => try std.testing.expect(false), + } +} + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L368-L396 +test "rpc.account_decoder.parse_token: interest-bearing 5% rate" { + const INT_SECONDS_PER_YEAR: i64 = 31_556_736; // 6 * 6 * 24 * 36524 + const ONE: u64 = 1_000_000_000_000_000_000; // 1e18 + + // Constant 5% rate for 1 year + const config = InterestBearingConfigData{ + .rate_authority = null, + .initialization_timestamp = 0, + .pre_update_average_rate = 500, // 5% = 500 basis points + .last_update_timestamp = INT_SECONDS_PER_YEAR, + .current_rate = 500, + }; + + const additional_data = SplTokenAdditionalData{ + .decimals = 18, + .unix_timestamp = INT_SECONDS_PER_YEAR, + .interest_bearing_config = config, + }; + + const t = UiTokenAmount.init(ONE, additional_data); + + // exp(0.05) ≈ 1.051271096376024 + try std.testing.expect(t.ui_amount != null); + const ui_str = t.ui_amount_string.constSlice(); + try std.testing.expect(std.mem.startsWith(u8, ui_str, "1.051271096376024")); + // Check ui_amount is close to expected + try std.testing.expect(@abs(t.ui_amount.? - 1.051271096376024) < 0.000001); +} + +test "rpc.account_decoder.parse_token: interest-bearing infinity case" { + const INT_SECONDS_PER_YEAR: i64 = 31_556_736; + + // Max rate for 1000 years with max amount + const config = InterestBearingConfigData{ + .rate_authority = null, + .initialization_timestamp = 0, + .pre_update_average_rate = 32767, // max i16 + .last_update_timestamp = 0, + .current_rate = 32767, + }; + + const additional_data = SplTokenAdditionalData{ + .decimals = 0, + .unix_timestamp = INT_SECONDS_PER_YEAR * 1000, // 1000 years + .interest_bearing_config = config, + }; + + const t = UiTokenAmount.init(std.math.maxInt(u64), additional_data); + + try std.testing.expect(t.ui_amount != null); + try std.testing.expect(std.math.isInf(t.ui_amount.?)); + try std.testing.expectEqualStrings("inf", t.ui_amount_string.constSlice()); +} + +test "rpc.account_decoder.parse_token: interest-bearing negative rate" { + const INT_SECONDS_PER_YEAR: i64 = 31_556_736; + const ONE: u64 = 1_000_000_000_000_000_000; + + // -5% rate for 1 year + const config = InterestBearingConfigData{ + .rate_authority = null, + .initialization_timestamp = 0, + .pre_update_average_rate = -500, // -5% + .last_update_timestamp = INT_SECONDS_PER_YEAR, + .current_rate = -500, + }; + + const additional_data = SplTokenAdditionalData{ + .decimals = 18, + .unix_timestamp = INT_SECONDS_PER_YEAR, + .interest_bearing_config = config, + }; + + const t = UiTokenAmount.init(ONE, additional_data); + + // exp(-0.05) ≈ 0.951229424500714 + try std.testing.expect(t.ui_amount != null); + try std.testing.expect(@abs(t.ui_amount.? - 0.951229424500714) < 0.000001); +} + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token.rs#L398-L413 +test "rpc.account_decoder.parse_token: scaled UI 2x multiplier" { + const ONE: u64 = 1_000_000_000_000_000_000; // 1e18 + + const config = ScaledUiAmountConfigData{ + .multiplier = 2.0, + .new_multiplier_effective_timestamp = 0, + .new_multiplier = 2.0, + }; + + const additional_data = SplTokenAdditionalData{ + .decimals = 18, + .unix_timestamp = 0, + .scaled_ui_amount_config = config, + }; + + const t = UiTokenAmount.init(ONE, additional_data); + + try std.testing.expectEqualStrings("2", t.ui_amount_string.constSlice()); + try std.testing.expect(t.ui_amount != null); + try std.testing.expect(@abs(t.ui_amount.? - 2.0) < 0.000001); +} + +test "rpc.account_decoder.parse_token: scaled UI infinity case" { + const config = ScaledUiAmountConfigData{ + .multiplier = std.math.inf(f64), + .new_multiplier_effective_timestamp = 0, + .new_multiplier = std.math.inf(f64), + }; + + const additional_data = SplTokenAdditionalData{ + .decimals = 0, + .unix_timestamp = 0, + .scaled_ui_amount_config = config, + }; + + const t = UiTokenAmount.init(std.math.maxInt(u64), additional_data); + + try std.testing.expect(t.ui_amount != null); + try std.testing.expect(std.math.isInf(t.ui_amount.?)); + try std.testing.expectEqualStrings("inf", t.ui_amount_string.constSlice()); +} + +test "rpc.account_decoder.parse_token: scaled UI multiplier switch at timestamp" { + // 1e9 + const ONE: u64 = 1_000_000_000; + + // Before timestamp: use old multiplier (1x) + // At/after timestamp: use new multiplier (3x) + const config = ScaledUiAmountConfigData{ + .multiplier = 1.0, + .new_multiplier_effective_timestamp = 100, + .new_multiplier = 3.0, + }; + + // Before effective timestamp + { + const additional_data = SplTokenAdditionalData{ + .decimals = 9, + .unix_timestamp = 99, + .scaled_ui_amount_config = config, + }; + const t = UiTokenAmount.init(ONE, additional_data); + try std.testing.expectEqualStrings("1", t.ui_amount_string.constSlice()); + } + + // At effective timestamp + { + const additional_data = SplTokenAdditionalData{ + .decimals = 9, + .unix_timestamp = 100, + .scaled_ui_amount_config = config, + }; + const t = UiTokenAmount.init(ONE, additional_data); + try std.testing.expectEqualStrings("3", t.ui_amount_string.constSlice()); + } + + // After effective timestamp + { + const additional_data = SplTokenAdditionalData{ + .decimals = 9, + .unix_timestamp = 200, + .scaled_ui_amount_config = config, + }; + const t = UiTokenAmount.init(ONE, additional_data); + try std.testing.expectEqualStrings("3", t.ui_amount_string.constSlice()); + } +} + +test "rpc.account_decoder.parse_token: interest-bearing takes priority over scaled" { + const INT_SECONDS_PER_YEAR: i64 = 31_556_736; + const ONE: u64 = 1_000_000_000_000_000_000; + + // Both configs present - interest-bearing should take priority + const interest_config = InterestBearingConfigData{ + .rate_authority = null, + .initialization_timestamp = 0, + .pre_update_average_rate = 500, + .last_update_timestamp = INT_SECONDS_PER_YEAR, + .current_rate = 500, + }; + + const scaled_config = ScaledUiAmountConfigData{ + .multiplier = 10.0, // Would give 10.0 if used + .new_multiplier_effective_timestamp = 0, + .new_multiplier = 10.0, + }; + + const additional_data = SplTokenAdditionalData{ + .decimals = 18, + .unix_timestamp = INT_SECONDS_PER_YEAR, + .interest_bearing_config = interest_config, + .scaled_ui_amount_config = scaled_config, + }; + + const t = UiTokenAmount.init(ONE, additional_data); + + // Should use interest-bearing result (~1.05), not scaled (10.0) + try std.testing.expect(t.ui_amount != null); + // Interest-bearing gives ~1.05 + try std.testing.expect(t.ui_amount.? < 2.0); + try std.testing.expect(std.mem.startsWith(u8, t.ui_amount_string.constSlice(), "1.05")); +} diff --git a/src/rpc/account_decoder/parse_token_extension.zig b/src/rpc/account_decoder/parse_token_extension.zig new file mode 100644 index 0000000000..d529bc6785 --- /dev/null +++ b/src/rpc/account_decoder/parse_token_extension.zig @@ -0,0 +1,1672 @@ +/// Token-2022 extension parsing and UI representation for account decoder. +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token_extension.rs#L22 +const std = @import("std"); +const sig = @import("../../sig.zig"); +const account_decoder = @import("lib.zig"); +const base64 = std.base64.standard; + +const Pubkey = sig.core.Pubkey; +const AccountState = account_decoder.AccountState; +const Base64Encoded = account_decoder.Base64Encoded; +const JsonString = account_decoder.JsonString; +const JsonArray = account_decoder.JsonArray; + +/// TLV parsing constants for Token-2022 extensions. +/// TLV layout: 2 bytes type (ExtensionType as u16) + 2 bytes length (Length as u16) + value +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/mod.rs#L93-L99 +const TLV_HEADER_SIZE: usize = 4; +/// Implementation limit for extension parsing (not a protocol limit). +pub const MAX_EXTENSIONS: usize = 16; + +/// Token-2022 extension type discriminants. +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/mod.rs#L1055-L1130 +pub const ExtensionType = enum(u16) { + uninitialized = 0, + transfer_fee_config = 1, + transfer_fee_amount = 2, + mint_close_authority = 3, + confidential_transfer_mint = 4, + confidential_transfer_account = 5, + default_account_state = 6, + immutable_owner = 7, + memo_transfer = 8, + non_transferable = 9, + interest_bearing_config = 10, + cpi_guard = 11, + permanent_delegate = 12, + non_transferable_account = 13, + transfer_hook = 14, + transfer_hook_account = 15, + confidential_transfer_fee_config = 16, + confidential_transfer_fee_amount = 17, + metadata_pointer = 18, + token_metadata = 19, + group_pointer = 20, + token_group = 21, + group_member_pointer = 22, + token_group_member = 23, + confidential_mint_burn = 24, + scaled_ui_amount_config = 25, + pausable_config = 26, + pausable_account = 27, + _, + + /// Expected size of extension data (excluding TLV header). + /// Returns null for variable-length extensions (TokenMetadata). + /// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/mod.rs#L1167-L1212 + pub fn expectedSize(self: ExtensionType) ?usize { + return switch (self) { + .uninitialized => 0, + .immutable_owner => 0, + .non_transferable => 0, + .non_transferable_account => 0, + .pausable_account => 0, + .default_account_state => 1, + .memo_transfer => 1, + .cpi_guard => 1, + .transfer_hook_account => 1, + .transfer_fee_amount => 8, + .mint_close_authority => 32, + .permanent_delegate => 32, + .pausable_config => 33, + .interest_bearing_config => 52, + .scaled_ui_amount_config => 56, + .metadata_pointer => 64, + .group_pointer => 64, + .group_member_pointer => 64, + .transfer_hook => 64, + .confidential_transfer_fee_amount => 64, + .confidential_transfer_mint => 65, + .token_group_member => 72, + .token_group => 80, + .transfer_fee_config => 108, + .confidential_transfer_fee_config => 129, + .confidential_mint_burn => 196, + .confidential_transfer_account => 295, + // NOTE: TokenMetadata uses borsh serialization, so length can be variable. + .token_metadata => null, + _ => null, + }; + } + + /// Check if this is a mint extension (vs account extension). + /// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/mod.rs#L1255-L1295 + pub fn isMintExtension(self: ExtensionType) bool { + return switch (self) { + .transfer_fee_config, + .mint_close_authority, + .confidential_transfer_mint, + .default_account_state, + .non_transferable, + .interest_bearing_config, + .permanent_delegate, + .transfer_hook, + .confidential_transfer_fee_config, + .metadata_pointer, + .token_metadata, + .group_pointer, + .token_group, + .group_member_pointer, + .token_group_member, + .confidential_mint_burn, + .scaled_ui_amount_config, + .pausable_config, + => true, + else => false, + }; + } +}; + +/// UI representation of a Token-2022 extension for JSON output. +/// Serializes as: {"extension": "extensionName", "state": {...}} +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder-client-types/src/token.rs +pub const UiExtension = union(enum) { + uninitialized, + immutable_owner, + non_transferable, + non_transferable_account, + pausable_account, + + // Special: unparseable fallback + unparseable_extension, + + default_account_state: UiDefaultAccountState, + memo_transfer: UiMemoTransfer, + cpi_guard: UiCpiGuard, + transfer_hook_account: UiTransferHookAccount, + transfer_fee_amount: UiTransferFeeAmount, + mint_close_authority: UiMintCloseAuthority, + permanent_delegate: UiPermanentDelegate, + pausable_config: UiPausableConfig, + interest_bearing_config: UiInterestBearingConfig, + scaled_ui_amount_config: UiScaledUiAmountConfig, + metadata_pointer: UiMetadataPointer, + group_pointer: UiGroupPointer, + group_member_pointer: UiGroupMemberPointer, + transfer_hook: UiTransferHook, + confidential_transfer_fee_amount: UiConfidentialTransferFeeAmount, + confidential_transfer_mint: UiConfidentialTransferMint, + token_group_member: UiTokenGroupMember, + token_group: UiTokenGroup, + transfer_fee_config: UiTransferFeeConfig, + confidential_transfer_fee_config: UiConfidentialTransferFeeConfig, + confidential_mint_burn: UiConfidentialMintBurn, + confidential_transfer_account: UiConfidentialTransferAccount, + token_metadata: UiTokenMetadata, + + pub fn jsonStringify(self: @This(), jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("extension"); + switch (self) { + inline else => |v, tag| { + try jw.write(typeNameFromTag(tag)); + if (@TypeOf(v) != void) { + try jw.objectField("state"); + try jw.write(v); + } + }, + } + try jw.endObject(); + } + + fn typeNameFromTag(comptime tag: std.meta.Tag(@This())) []const u8 { + return switch (tag) { + .uninitialized => "uninitialized", + .immutable_owner => "immutableOwner", + .non_transferable => "nonTransferable", + .non_transferable_account => "nonTransferableAccount", + .pausable_account => "pausableAccount", + .unparseable_extension => "unparseableExtension", + .default_account_state => "defaultAccountState", + .memo_transfer => "memoTransfer", + .cpi_guard => "cpiGuard", + .transfer_hook_account => "transferHookAccount", + .transfer_fee_amount => "transferFeeAmount", + .mint_close_authority => "mintCloseAuthority", + .permanent_delegate => "permanentDelegate", + .pausable_config => "pausableConfig", + .interest_bearing_config => "interestBearingConfig", + .scaled_ui_amount_config => "scaledUiAmountConfig", + .metadata_pointer => "metadataPointer", + .group_pointer => "groupPointer", + .group_member_pointer => "groupMemberPointer", + .transfer_hook => "transferHook", + .confidential_transfer_fee_amount => "confidentialTransferFeeAmount", + .confidential_transfer_mint => "confidentialTransferMint", + .token_group_member => "tokenGroupMember", + .token_group => "tokenGroup", + .transfer_fee_config => "transferFeeConfig", + .confidential_transfer_fee_config => "confidentialTransferFeeConfig", + .confidential_mint_burn => "confidentialMintBurn", + .confidential_transfer_account => "confidentialTransferAccount", + .token_metadata => "tokenMetadata", + }; + } +}; + +/// Parse Token-2022 TLV extensions from account data. +/// Returns an empty array if data doesn't contain valid extensions. +/// Uses similar iteration logic to spl-token-2022's get_tlv_data_info. +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/mod.rs#L203-L245 +pub fn parseExtensions(data: []const u8) JsonArray(UiExtension, MAX_EXTENSIONS) { + var extensions: JsonArray(UiExtension, MAX_EXTENSIONS) = .{}; + + // data[0] is discriminator, TLV starts at data[1]. + // Need at least discriminator + one TLV header to have any extensions. + if (data.len <= 1) return extensions; + + var offset: usize = 1; + while (offset + TLV_HEADER_SIZE <= data.len) { + // Read extension type (2 bytes, little-endian) + const ext_type_raw = std.mem.readInt(u16, data[offset..][0..2], .little); + const ext_type: ExtensionType = @enumFromInt(ext_type_raw); + + // Uninitialized (0x0000) marks end of extensions + if (ext_type == .uninitialized) break; + + // Read length (2 bytes, little-endian) + const length = std.mem.readInt(u16, data[offset + 2 ..][0..2], .little); + offset += TLV_HEADER_SIZE; + + // Bounds check for value + if (offset + length > data.len) { + // Malformed TLV - return what we have so far. AGave's get_tlv_data_info returns + // Err(InvalidAccountData) here, but parse_extension gracefully degrades with + // UnparseableExtension, so we follow that pattern for UI display. + break; + } + + const value = data[offset..][0..length]; + + // Parse extension or fall back to unparseable + const parsed = parseExtension(ext_type, value) orelse .unparseable_extension; + extensions.append(parsed) catch { + // Too many extensions - stop parsing + break; + }; + + offset += length; + } + + return extensions; +} + +// Parse a single extension from its value bytes. +/// Returns null if parsing fails (caller should use unparseable_extension). +/// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_token_extension.rs#L22-L148 +fn parseExtension(ext_type: ExtensionType, value: []const u8) ?UiExtension { + // Validate size for fixed-length extensions. AGave's get_extension uses pod_from_bytes + // which fails on size mismatch, returning UnparseableExtension via unwrap_or. + if (ext_type.expectedSize()) |expected| { + if (value.len != expected) return null; + } + + return switch (ext_type) { + .uninitialized => .uninitialized, + .immutable_owner => .immutable_owner, + .non_transferable => .non_transferable, + .non_transferable_account => .non_transferable_account, + .pausable_account => .pausable_account, + .default_account_state => UiDefaultAccountState.parse(value), + .memo_transfer => UiMemoTransfer.parse(value), + .cpi_guard => UiCpiGuard.parse(value), + .transfer_hook_account => UiTransferHookAccount.parse(value), + .transfer_fee_amount => UiTransferFeeAmount.parse(value), + .mint_close_authority => UiMintCloseAuthority.parse(value), + .permanent_delegate => UiPermanentDelegate.parse(value), + .pausable_config => UiPausableConfig.parse(value), + .interest_bearing_config => UiInterestBearingConfig.parse(value), + .scaled_ui_amount_config => UiScaledUiAmountConfig.parse(value), + .metadata_pointer => UiMetadataPointer.parse(value), + .group_pointer => UiGroupPointer.parse(value), + .group_member_pointer => UiGroupMemberPointer.parse(value), + .transfer_hook => UiTransferHook.parse(value), + .confidential_transfer_fee_amount => UiConfidentialTransferFeeAmount.parse(value), + .confidential_transfer_mint => UiConfidentialTransferMint.parse(value), + .token_group_member => UiTokenGroupMember.parse(value), + .token_group => UiTokenGroup.parse(value), + .transfer_fee_config => UiTransferFeeConfig.parse(value), + .confidential_transfer_fee_config => UiConfidentialTransferFeeConfig.parse(value), + .confidential_mint_burn => UiConfidentialMintBurn.parse(value), + .confidential_transfer_account => UiConfidentialTransferAccount.parse(value), + .token_metadata => UiTokenMetadata.parse(value), + _ => null, + }; +} + +/// Subset of InterestBearingConfig needed for amount calculations. +/// [spl] https://github.com/solana-program/token-2022/blob/main/interface/src/extension/interest_bearing_mint/mod.rs +pub const InterestBearingConfigData = struct { + rate_authority: ?Pubkey, + initialization_timestamp: i64, + pre_update_average_rate: i16, + last_update_timestamp: i64, + current_rate: i16, + + /// Extract InterestBearingConfig data from mint extensions for calculations. + /// Returns null if extension not present or data invalid. + pub fn extractFromMint(mint_data: []const u8) ?InterestBearingConfigData { + const MINT_LEN = 82; // parse_token.Mint.LEN + if (mint_data.len <= MINT_LEN) return null; + + const ext_data = mint_data[MINT_LEN..]; + if (ext_data.len <= 1) return null; + + var offset: usize = 1; // Skip discriminator + while (offset + TLV_HEADER_SIZE <= ext_data.len) { + const ext_type_raw = std.mem.readInt(u16, ext_data[offset..][0..2], .little); + const length = std.mem.readInt(u16, ext_data[offset + 2 ..][0..2], .little); + offset += TLV_HEADER_SIZE; + + if (offset + length > ext_data.len or ext_type_raw == 0) break; + + const interest_bearing = @intFromEnum(ExtensionType.interest_bearing_config); + const is_interest_bearing = ext_type_raw == interest_bearing; + if (is_interest_bearing and length == 52) { + const value = ext_data[offset..][0..52]; + const pubkey = readOptionalNonZeroPubkey(value[0..32]); + return .{ + .rate_authority = pubkey, + .initialization_timestamp = std.mem.readInt(i64, value[32..40], .little), + .pre_update_average_rate = std.mem.readInt(i16, value[40..42], .little), + .last_update_timestamp = std.mem.readInt(i64, value[42..50], .little), + .current_rate = std.mem.readInt(i16, value[50..52], .little), + }; + } + + offset += length; + } + return null; + } +}; + +/// Subset of ScaledUiAmountConfig needed for amount calculations. +pub const ScaledUiAmountConfigData = struct { + multiplier: f64, + new_multiplier_effective_timestamp: i64, + new_multiplier: f64, + + /// Extract ScaledUiAmountConfig data from mint extensions for calculations. + /// Returns null if extension not present or data invalid. + pub fn extractFromMint(mint_data: []const u8) ?ScaledUiAmountConfigData { + const MINT_LEN = 82; // parse_token.Mint.LEN + if (mint_data.len <= MINT_LEN) return null; + + const ext_data = mint_data[MINT_LEN..]; + if (ext_data.len <= 1) return null; + + var offset: usize = 1; // Skip discriminator + while (offset + TLV_HEADER_SIZE <= ext_data.len) { + const ext_type_raw = std.mem.readInt(u16, ext_data[offset..][0..2], .little); + const length = std.mem.readInt(u16, ext_data[offset + 2 ..][0..2], .little); + offset += TLV_HEADER_SIZE; + + if (offset + length > ext_data.len or ext_type_raw == 0) break; + + const is_scaled_ui = ext_type_raw == @intFromEnum(ExtensionType.scaled_ui_amount_config); + if (is_scaled_ui and length == 56) { + const value = ext_data[offset..][0..56]; + const ts = std.mem.readInt(i64, value[40..48], .little); + return .{ + .multiplier = @bitCast(std.mem.readInt(u64, value[32..40], .little)), + .new_multiplier_effective_timestamp = ts, + .new_multiplier = @bitCast(std.mem.readInt(u64, value[48..56], .little)), + }; + } + + offset += length; + } + return null; + } +}; + +/// DefaultAccountState (1 byte) - sets the default state for new token accounts. +pub const UiDefaultAccountState = struct { + accountState: AccountState, + + pub fn parse(value: []const u8) ?UiExtension { + if (value.len != 1) return null; + const state_byte = value[0]; + if (state_byte > 2) return null; + return .{ .default_account_state = .{ + .accountState = @enumFromInt(state_byte), + } }; + } + + pub fn jsonStringify(self: UiDefaultAccountState, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("accountState"); + try jw.write(switch (self.accountState) { + .uninitialized => "uninitialized", + .initialized => "initialized", + .frozen => "frozen", + }); + try jw.endObject(); + } +}; + +/// MemoTransfer (1 byte) - Requires memos on incoming transfers. +pub const UiMemoTransfer = struct { + requireIncomingTransferMemos: bool, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 1) return null; + return .{ .memo_transfer = .{ + .requireIncomingTransferMemos = value[0] != 0, + } }; + } +}; + +/// CpiGuard (1 byte) - Restricts certain CPI operations. +pub const UiCpiGuard = struct { + lockCpi: bool, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 1) return null; + return .{ .cpi_guard = .{ + .lockCpi = value[0] != 0, + } }; + } +}; + +/// TransferHookAccount (1 byte) - Transfer hook execution state. +pub const UiTransferHookAccount = struct { + transferring: bool, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 1) return null; + return .{ .transfer_hook_account = .{ + .transferring = value[0] != 0, + } }; + } +}; + +/// TransferFeeAmount (8 bytes) - Withheld transfer fees on account. +pub const UiTransferFeeAmount = struct { + withheldAmount: u64, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 8) return null; + return .{ .transfer_fee_amount = .{ + .withheldAmount = std.mem.readInt(u64, value[0..8], .little), + } }; + } +}; + +/// MintCloseAuthority (32 bytes) - Authority that can close the mint. +pub const UiMintCloseAuthority = struct { + closeAuthority: ?Pubkey, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 32) return null; + return .{ .mint_close_authority = .{ + .closeAuthority = readOptionalNonZeroPubkey(value[0..32]), + } }; + } +}; + +/// PermanentDelegate (32 bytes) - Permanent delegate authority. +pub const UiPermanentDelegate = struct { + delegate: ?Pubkey, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 32) return null; + return .{ .permanent_delegate = .{ + .delegate = readOptionalNonZeroPubkey(value[0..32]), + } }; + } +}; + +/// PausableConfig (33 bytes) - Pause authority and state. +pub const UiPausableConfig = struct { + authority: ?Pubkey, + paused: bool, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 33) return null; + return .{ .pausable_config = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .paused = value[32] != 0, + } }; + } +}; + +/// InterestBearingConfig (52 bytes) - Interest-bearing token configuration. +pub const UiInterestBearingConfig = struct { + rateAuthority: ?Pubkey, + initializationTimestamp: i64, + preUpdateAverageRate: i16, + lastUpdateTimestamp: i64, + currentRate: i16, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 52) return null; + return .{ .interest_bearing_config = .{ + .rateAuthority = readOptionalNonZeroPubkey(value[0..32]), + .initializationTimestamp = std.mem.readInt(i64, value[32..40], .little), + .preUpdateAverageRate = std.mem.readInt(i16, value[40..42], .little), + .lastUpdateTimestamp = std.mem.readInt(i64, value[42..50], .little), + .currentRate = std.mem.readInt(i16, value[50..52], .little), + } }; + } +}; + +/// ScaledUiAmountConfig (56 bytes) - UI amount scaling configuration. +pub const UiScaledUiAmountConfig = struct { + authority: ?Pubkey, + multiplier: f64, + newMultiplierEffectiveTimestamp: i64, + newMultiplier: f64, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 56) return null; + return .{ .scaled_ui_amount_config = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .multiplier = @bitCast(std.mem.readInt(u64, value[32..40], .little)), + .newMultiplierEffectiveTimestamp = std.mem.readInt(i64, value[40..48], .little), + .newMultiplier = @bitCast(std.mem.readInt(u64, value[48..56], .little)), + } }; + } + + pub fn jsonStringify(self: UiScaledUiAmountConfig, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("authority"); + try jw.write(self.authority); + try jw.objectField("multiplier"); + try jw.print("\"{d}\"", .{self.multiplier}); + try jw.objectField("newMultiplierEffectiveTimestamp"); + try jw.write(self.newMultiplierEffectiveTimestamp); + try jw.objectField("newMultiplier"); + try jw.print("\"{d}\"", .{self.newMultiplier}); + try jw.endObject(); + } +}; + +/// MetadataPointer (64 bytes) - Pointer to token metadata. +pub const UiMetadataPointer = struct { + authority: ?Pubkey, + metadataAddress: ?Pubkey, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 64) return null; + return .{ .metadata_pointer = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .metadataAddress = readOptionalNonZeroPubkey(value[32..64]), + } }; + } +}; + +/// GroupPointer (64 bytes) - Pointer to token group data. +pub const UiGroupPointer = struct { + authority: ?Pubkey, + groupAddress: ?Pubkey, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 64) return null; + return .{ .group_pointer = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .groupAddress = readOptionalNonZeroPubkey(value[32..64]), + } }; + } +}; + +/// GroupMemberPointer (64 bytes) - Pointer to group member data. +pub const UiGroupMemberPointer = struct { + authority: ?Pubkey, + memberAddress: ?Pubkey, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 64) return null; + return .{ .group_member_pointer = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .memberAddress = readOptionalNonZeroPubkey(value[32..64]), + } }; + } +}; + +/// TransferHook (64 bytes) - Transfer hook program configuration. +pub const UiTransferHook = struct { + authority: ?Pubkey, + programId: ?Pubkey, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 64) return null; + return .{ .transfer_hook = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .programId = readOptionalNonZeroPubkey(value[32..64]), + } }; + } +}; + +/// ConfidentialTransferFeeAmount (64 bytes) - Encrypted withheld fees. +pub const UiConfidentialTransferFeeAmount = struct { + withheldAmount: Base64Encoded(64), + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 64) return null; + return .{ .confidential_transfer_fee_amount = .{ + .withheldAmount = Base64Encoded(64).init(value[0..64]), + } }; + } +}; + +/// ConfidentialTransferMint (65 bytes) - Confidential transfer mint configuration. +pub const UiConfidentialTransferMint = struct { + authority: ?Pubkey, + autoApproveNewAccounts: bool, + auditorElgamalPubkey: ?Base64Encoded(32), + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 65) return null; + const auditor = readOptionalNonZeroBytes(value[33..65]); + return .{ .confidential_transfer_mint = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .autoApproveNewAccounts = value[32] != 0, + .auditorElgamalPubkey = if (auditor) |bytes| + Base64Encoded(32).init(bytes[0..32]) + else + null, + } }; + } +}; + +/// TokenGroupMember (72 bytes) - Token group membership. +pub const UiTokenGroupMember = struct { + mint: Pubkey, + group: Pubkey, + memberNumber: u64, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 72) return null; + return .{ .token_group_member = .{ + .mint = Pubkey{ .data = value[0..32].* }, + .group = Pubkey{ .data = value[32..64].* }, + .memberNumber = std.mem.readInt(u64, value[64..72], .little), + } }; + } +}; + +/// TokenGroup (80 bytes) - Token group (collection) definition. +pub const UiTokenGroup = struct { + updateAuthority: ?Pubkey, + mint: Pubkey, + size: u64, + maxSize: u64, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 80) return null; + return .{ .token_group = .{ + .updateAuthority = readOptionalNonZeroPubkey(value[0..32]), + .mint = Pubkey{ .data = value[32..64].* }, + .size = std.mem.readInt(u64, value[64..72], .little), + .maxSize = std.mem.readInt(u64, value[72..80], .little), + } }; + } +}; + +/// TransferFee - shared struct for older/newer fees. +pub const UiTransferFee = struct { + epoch: u64, + maximumFee: u64, + transferFeeBasisPoints: u16, +}; + +/// TransferFeeConfig (108 bytes) - Transfer fee configuration. +pub const UiTransferFeeConfig = struct { + transferFeeConfigAuthority: ?Pubkey, + withdrawWithheldAuthority: ?Pubkey, + withheldAmount: u64, + olderTransferFee: UiTransferFee, + newerTransferFee: UiTransferFee, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 108) return null; + return .{ .transfer_fee_config = .{ + .transferFeeConfigAuthority = readOptionalNonZeroPubkey(value[0..32]), + .withdrawWithheldAuthority = readOptionalNonZeroPubkey(value[32..64]), + .withheldAmount = std.mem.readInt(u64, value[64..72], .little), + .olderTransferFee = .{ + .epoch = std.mem.readInt(u64, value[72..80], .little), + .maximumFee = std.mem.readInt(u64, value[80..88], .little), + .transferFeeBasisPoints = std.mem.readInt(u16, value[88..90], .little), + }, + .newerTransferFee = .{ + .epoch = std.mem.readInt(u64, value[90..98], .little), + .maximumFee = std.mem.readInt(u64, value[98..106], .little), + .transferFeeBasisPoints = std.mem.readInt(u16, value[106..108], .little), + }, + } }; + } +}; + +/// ConfidentialTransferFeeConfig (129 bytes) - Confidential transfer fee configuration. +pub const UiConfidentialTransferFeeConfig = struct { + authority: ?Pubkey, + withdrawWithheldAuthorityElgamalPubkey: Base64Encoded(32), + harvestToMintEnabled: bool, + withheldAmount: Base64Encoded(64), + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 129) return null; + return .{ .confidential_transfer_fee_config = .{ + .authority = readOptionalNonZeroPubkey(value[0..32]), + .withdrawWithheldAuthorityElgamalPubkey = Base64Encoded(32).init(value[32..64]), + .harvestToMintEnabled = value[64] != 0, + .withheldAmount = Base64Encoded(64).init(value[65..129]), + } }; + } +}; + +/// ConfidentialMintBurn (196 bytes) - Confidential minting and burning. +pub const UiConfidentialMintBurn = struct { + confidentialSupply: Base64Encoded(64), + decryptableSupply: Base64Encoded(36), + supplyElgamalPubkey: Base64Encoded(32), + pendingBurn: Base64Encoded(64), + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 196) return null; + return .{ .confidential_mint_burn = .{ + .confidentialSupply = Base64Encoded(64).init(value[0..64]), + .decryptableSupply = Base64Encoded(36).init(value[64..100]), + .supplyElgamalPubkey = Base64Encoded(32).init(value[100..132]), + .pendingBurn = Base64Encoded(64).init(value[132..196]), + } }; + } +}; + +/// ConfidentialTransferAccount (295 bytes) - Confidential transfer account state. +pub const UiConfidentialTransferAccount = struct { + approved: bool, + elgamalPubkey: Base64Encoded(32), + pendingBalanceLo: Base64Encoded(64), + pendingBalanceHi: Base64Encoded(64), + availableBalance: Base64Encoded(64), + decryptableAvailableBalance: Base64Encoded(36), + allowConfidentialCredits: bool, + allowNonConfidentialCredits: bool, + pendingBalanceCreditCounter: u64, + maximumPendingBalanceCreditCounter: u64, + expectedPendingBalanceCreditCounter: u64, + actualPendingBalanceCreditCounter: u64, + + fn parse(value: []const u8) ?UiExtension { + if (value.len != 295) return null; + return .{ .confidential_transfer_account = .{ + .approved = value[0] != 0, + .elgamalPubkey = Base64Encoded(32).init(value[1..33]), + .pendingBalanceLo = Base64Encoded(64).init(value[33..97]), + .pendingBalanceHi = Base64Encoded(64).init(value[97..161]), + .availableBalance = Base64Encoded(64).init(value[161..225]), + .decryptableAvailableBalance = Base64Encoded(36).init(value[225..261]), + .allowConfidentialCredits = value[261] != 0, + .allowNonConfidentialCredits = value[262] != 0, + .pendingBalanceCreditCounter = std.mem.readInt(u64, value[263..271], .little), + .maximumPendingBalanceCreditCounter = std.mem.readInt(u64, value[271..279], .little), + .expectedPendingBalanceCreditCounter = std.mem.readInt(u64, value[279..287], .little), + .actualPendingBalanceCreditCounter = std.mem.readInt(u64, value[287..295], .little), + } }; + } +}; + +/// Key-value pair for TokenMetadata additional_metadata. +/// Serializes as a JSON array: ["key", "value"] +const KeyValuePair = struct { + key: JsonString(64), + value: JsonString(256), + + pub fn jsonStringify(self: @This(), jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginArray(); + try jw.write(self.key.inner.constSlice()); + try jw.write(self.value.inner.constSlice()); + try jw.endArray(); + } +}; + +/// TokenMetadata (variable length, Borsh serialized). +/// NOTE: Strings are stored inline in the struct's bounded arrays. +pub const UiTokenMetadata = struct { + updateAuthority: ?Pubkey, + mint: Pubkey, + name: JsonString(128), + symbol: JsonString(32), + uri: JsonString(256), + additionalMetadata: JsonArray(KeyValuePair, 32), + + /// Parse TokenMetadata from Borsh-serialized bytes. + /// Borsh format: OptionalNonZeroPubkey(32) + Pubkey(32) + String(4+len) * 3 + Vec<(String,String)> + /// [spl] https://github.com/solana-program/token-metadata/blob/main/interface/src/state.rs + fn parse(value: []const u8) ?UiExtension { + var offset: usize = 0; + + // update_authority: OptionalNonZeroPubkey (32 bytes) + if (offset + 32 > value.len) return null; + const authority = readOptionalNonZeroPubkey(value[offset..][0..32]); + offset += 32; + + // mint: Pubkey (32 bytes) + if (offset + 32 > value.len) return null; + const mint = Pubkey{ .data = value[offset..][0..32].* }; + offset += 32; + + // name: String (4-byte len + UTF-8) + const name = readBorshString(value, &offset, 128) orelse return null; + + // symbol: String (4-byte len + UTF-8) + const symbol = readBorshString(value, &offset, 32) orelse return null; + + // uri: String (4-byte len + UTF-8) + const uri = readBorshString(value, &offset, 256) orelse return null; + + // additional_metadata: Vec<(String, String)> + // Return null (-> unparseable_extension) if limits exceeded + if (offset + 4 > value.len) return null; + const count = std.mem.readInt(u32, value[offset..][0..4], .little); + offset += 4; + + if (count > 32) return null; // Too many pairs + + var additional_metadata: JsonArray(KeyValuePair, 32) = .{}; + for (0..count) |_| { + const key = readBorshString(value, &offset, 64) orelse return null; + const val = readBorshString(value, &offset, 256) orelse return null; + additional_metadata.append(.{ + .key = key, + .value = val, + }) catch return null; + } + + return .{ .token_metadata = .{ + .updateAuthority = authority, + .mint = mint, + .name = name, + .symbol = symbol, + .uri = uri, + .additionalMetadata = additional_metadata, + } }; + } +}; + +/// Read a Borsh-encoded string: 4-byte little-endian length + UTF-8 bytes. +/// Returns a JsonString wrapper directly to avoid intermediate copies. +fn readBorshString( + data: []const u8, + offset: *usize, + comptime max_len: usize, +) ?JsonString(max_len) { + if (offset.* + 4 > data.len) return null; + const str_len = std.mem.readInt(u32, data[offset.*..][0..4], .little); + offset.* += 4; + + if (offset.* + str_len > data.len) return null; + if (str_len > max_len) return null; + + var result: JsonString(max_len) = .{ .inner = .{} }; + result.inner.appendSliceAssumeCapacity(data[offset.*..][0..str_len]); + offset.* += str_len; + + return result; +} + +/// Read an OptionalNonZeroPubkey (32 bytes, zero = None). +fn readOptionalNonZeroPubkey(data: *const [32]u8) ?Pubkey { + const pubkey = Pubkey{ .data = data.* }; + if (pubkey.isZeroed()) return null; + return pubkey; +} + +/// Check if bytes are all zero (for optional crypto types). +fn readOptionalNonZeroBytes(data: []const u8) ?[]const u8 { + for (data) |b| { + if (b != 0) return data; + } + return null; +} + +/// Helper to build Borsh-encoded TokenMetadata bytes for testing. +fn buildTokenMetadataBytes( + update_authority: ?Pubkey, + mint: Pubkey, + name: []const u8, + symbol: []const u8, + uri: []const u8, + additional_metadata: []const struct { key: []const u8, value: []const u8 }, +) std.BoundedArray(u8, 4096) { + var buf: std.BoundedArray(u8, 4096) = .{}; + + // update_authority: OptionalNonZeroPubkey (32 bytes) + if (update_authority) |auth| { + buf.appendSliceAssumeCapacity(&auth.data); + } else { + buf.appendNTimesAssumeCapacity(0, 32); + } + + // mint: Pubkey (32 bytes) + buf.appendSliceAssumeCapacity(&mint.data); + + // name: Borsh string + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, @intCast(name.len)))); + buf.appendSliceAssumeCapacity(name); + + // symbol: Borsh string + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, @intCast(symbol.len)))); + buf.appendSliceAssumeCapacity(symbol); + + // uri: Borsh string + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, @intCast(uri.len)))); + buf.appendSliceAssumeCapacity(uri); + + // additional_metadata: Vec<(String, String)> + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, @intCast(additional_metadata.len)))); + for (additional_metadata) |pair| { + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, @intCast(pair.key.len)))); + buf.appendSliceAssumeCapacity(pair.key); + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, @intCast(pair.value.len)))); + buf.appendSliceAssumeCapacity(pair.value); + } + + return buf; +} + +test "rpc.account_decoder.parse_token_extension: token_metadata empty additional_metadata" { + const mint = Pubkey{ .data = [_]u8{1} ** 32 }; + const authority = Pubkey{ .data = [_]u8{2} ** 32 }; + + const bytes = buildTokenMetadataBytes( + authority, + mint, + "Test Token", + "TEST", + "https://example.com/token.json", + &.{}, // empty additional_metadata + ); + + const result = UiTokenMetadata.parse(bytes.constSlice()); + try std.testing.expect(result != null); + + switch (result.?) { + .token_metadata => |tm| { + try std.testing.expectEqualStrings("Test Token", tm.name.constSlice()); + try std.testing.expectEqualStrings("TEST", tm.symbol.constSlice()); + try std.testing.expectEqualStrings("https://example.com/token.json", tm.uri.constSlice()); + try std.testing.expectEqual(@as(usize, 0), tm.additionalMetadata.len()); + }, + else => try std.testing.expect(false), + } +} + +test "rpc.account_decoder.parse_token_extension: token_metadata single pair" { + const mint = Pubkey{ .data = [_]u8{1} ** 32 }; + + const bytes = buildTokenMetadataBytes( + null, // no update authority + mint, + "NFT", + "NFT", + "https://example.com/nft.json", + &.{.{ .key = "trait_type", .value = "Background" }}, + ); + + const result = UiTokenMetadata.parse(bytes.constSlice()); + try std.testing.expect(result != null); + + switch (result.?) { + .token_metadata => |tm| { + try std.testing.expect(tm.updateAuthority == null); + try std.testing.expectEqual(@as(usize, 1), tm.additionalMetadata.len()); + const pair = tm.additionalMetadata.get(0); + try std.testing.expectEqualStrings("trait_type", pair.key.constSlice()); + try std.testing.expectEqualStrings("Background", pair.value.constSlice()); + }, + else => try std.testing.expect(false), + } +} + +test "rpc.account_decoder.parse_token_extension: token_metadata multiple pairs" { + const mint = Pubkey{ .data = [_]u8{1} ** 32 }; + const authority = Pubkey{ .data = [_]u8{2} ** 32 }; + + const bytes = buildTokenMetadataBytes( + authority, + mint, + "Cool NFT", + "CNFT", + "https://example.com/cool.json", + &.{ + .{ .key = "trait_type", .value = "Background" }, + .{ .key = "value", .value = "Blue" }, + .{ .key = "rarity", .value = "Legendary" }, + }, + ); + + const result = UiTokenMetadata.parse(bytes.constSlice()); + try std.testing.expect(result != null); + + switch (result.?) { + .token_metadata => |tm| { + try std.testing.expectEqual(@as(usize, 3), tm.additionalMetadata.len()); + const meta = tm.additionalMetadata; + try std.testing.expectEqualStrings("trait_type", meta.get(0).key.constSlice()); + try std.testing.expectEqualStrings("Background", meta.get(0).value.constSlice()); + try std.testing.expectEqualStrings("value", meta.get(1).key.constSlice()); + try std.testing.expectEqualStrings("Blue", meta.get(1).value.constSlice()); + try std.testing.expectEqualStrings("rarity", meta.get(2).key.constSlice()); + try std.testing.expectEqualStrings("Legendary", meta.get(2).value.constSlice()); + }, + else => try std.testing.expect(false), + } +} + +test "rpc.account_decoder.parse_token_extension: token_metadata too many pairs returns null" { + const mint = Pubkey{ .data = [_]u8{1} ** 32 }; + + // Build bytes with 33 pairs (exceeds limit of 32) + var buf: std.BoundedArray(u8, 8192) = .{}; + + // update_authority: 32 zero bytes + buf.appendNTimesAssumeCapacity(0, 32); + // mint + buf.appendSliceAssumeCapacity(&mint.data); + // name + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("Test"); + // symbol + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("TEST"); + // uri + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("http"); + // additional_metadata count: 33 + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 33))); + // Add 33 pairs + for (0..33) |i| { + var key_buf: [8]u8 = undefined; + const key_len = std.fmt.bufPrint(&key_buf, "key{d}", .{i}) catch unreachable; + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, @intCast(key_len.len)))); + buf.appendSliceAssumeCapacity(key_len); + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 5))); + buf.appendSliceAssumeCapacity("value"); + } + + const result = UiTokenMetadata.parse(buf.constSlice()); + // Should fail due to too many pairs + try std.testing.expect(result == null); +} + +test "rpc.account_decoder.parse_token_extension: token_metadata key too long returns null" { + const mint = Pubkey{ .data = [_]u8{1} ** 32 }; + + var buf: std.BoundedArray(u8, 4096) = .{}; + + // update_authority: 32 zero bytes + buf.appendNTimesAssumeCapacity(0, 32); + // mint + buf.appendSliceAssumeCapacity(&mint.data); + // name + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("Test"); + // symbol + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("TEST"); + // uri + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("http"); + // additional_metadata count: 1 + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 1))); + // Key with 65 bytes (exceeds 64 limit) + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 65))); + buf.appendNTimesAssumeCapacity('x', 65); + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 5))); + buf.appendSliceAssumeCapacity("value"); + + const result = UiTokenMetadata.parse(buf.constSlice()); + // Should fail due to key too long + try std.testing.expect(result == null); +} + +test "rpc.account_decoder.parse_token_extension: token_metadata value too long returns null" { + const mint = Pubkey{ .data = [_]u8{1} ** 32 }; + + var buf: std.BoundedArray(u8, 4096) = .{}; + + // update_authority: 32 zero bytes + buf.appendNTimesAssumeCapacity(0, 32); + // mint + buf.appendSliceAssumeCapacity(&mint.data); + // name + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("Test"); + // symbol + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("TEST"); + // uri + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 4))); + buf.appendSliceAssumeCapacity("http"); + // additional_metadata count: 1 + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 1))); + // Key (valid) + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 3))); + buf.appendSliceAssumeCapacity("key"); + // Value with 257 bytes (exceeds 256 limit) + buf.appendSliceAssumeCapacity(&std.mem.toBytes(@as(u32, 257))); + buf.appendNTimesAssumeCapacity('v', 257); + + const result = UiTokenMetadata.parse(buf.constSlice()); + try std.testing.expect(result == null); // Should fail due to value too long +} + +test "rpc.account_decoder.parse_token_extension: token_metadata JSON output" { + const mint = Pubkey{ .data = [_]u8{1} ** 32 }; + + const bytes = buildTokenMetadataBytes( + null, + mint, + "Test", + "TST", + "https://x.com", + &.{ + .{ .key = "trait_type", .value = "Background" }, + .{ .key = "value", .value = "Blue" }, + }, + ); + + const result = UiTokenMetadata.parse(bytes.constSlice()); + try std.testing.expect(result != null); + + switch (result.?) { + .token_metadata => |tm| { + var json_buf: [2048]u8 = undefined; + var fbs = std.io.fixedBufferStream(&json_buf); + var jw = std.json.writeStream(fbs.writer(), .{}); + + try jw.write(tm); + + const json_output = fbs.getWritten(); + // Verify JSON contains expected structure + const expected = "\"additionalMetadata\":[[\"trait_type\",\"Background\"]" ++ + ",[\"value\",\"Blue\"]]"; + try std.testing.expect(std.mem.indexOf(u8, json_output, expected) != null); + }, + else => try std.testing.expect(false), + } +} + +test "rpc.account_decoder.parse_token_extension: parseExtensions TLV iteration" { + // Test empty data + { + const empty: []const u8 = &.{}; + const result = parseExtensions(empty); + try std.testing.expectEqual(@as(usize, 0), result.len()); + } + + // Test data with only discriminator (no extensions) + { + const disc_only: []const u8 = &.{0x01}; // discriminator byte + const result = parseExtensions(disc_only); + try std.testing.expectEqual(@as(usize, 0), result.len()); + } + + // Test single extension: MintCloseAuthority (type=3, len=32) + { + var data: [1 + 4 + 32]u8 = undefined; + data[0] = 0x01; // discriminator + std.mem.writeInt(u16, data[1..3], 3, .little); // type = mint_close_authority + std.mem.writeInt(u16, data[3..5], 32, .little); // length + @memset(data[5..37], 0xAA); // pubkey bytes + const result = parseExtensions(&data); + try std.testing.expectEqual(@as(usize, 1), result.len()); + switch (result.get(0)) { + .mint_close_authority => |mca| try std.testing.expect(mca.closeAuthority != null), + else => try std.testing.expect(false), + } + } + + // Test multiple extensions + { + var data: [1 + 4 + 32 + 4 + 1]u8 = undefined; + data[0] = 0x01; // discriminator + // Extension 1: MintCloseAuthority (type=3, len=32) + std.mem.writeInt(u16, data[1..3], 3, .little); + std.mem.writeInt(u16, data[3..5], 32, .little); + @memset(data[5..37], 0xBB); + // Extension 2: DefaultAccountState (type=6, len=1) + std.mem.writeInt(u16, data[37..39], 6, .little); + std.mem.writeInt(u16, data[39..41], 1, .little); + data[41] = 1; // initialized state + const result = parseExtensions(&data); + try std.testing.expectEqual(@as(usize, 2), result.len()); + } + + // Test uninitialized extension type (0) stops parsing + { + var data: [1 + 4 + 4]u8 = undefined; + data[0] = 0x01; + std.mem.writeInt(u16, data[1..3], 0, .little); // uninitialized = stop + std.mem.writeInt(u16, data[3..5], 0, .little); + // More data that should be ignored + std.mem.writeInt(u16, data[5..7], 3, .little); + std.mem.writeInt(u16, data[7..9], 0, .little); + const result = parseExtensions(&data); + try std.testing.expectEqual(@as(usize, 0), result.len()); + } + + // Test malformed TLV (length exceeds data) - graceful degradation + { + var data: [1 + 4]u8 = undefined; + data[0] = 0x01; + std.mem.writeInt(u16, data[1..3], 3, .little); // type + std.mem.writeInt(u16, data[3..5], 100, .little); // length > remaining + const result = parseExtensions(&data); + try std.testing.expectEqual(@as(usize, 0), result.len()); + } + + // Test unknown extension type -> unparseable_extension + { + var data: [1 + 4 + 4]u8 = undefined; + data[0] = 0x01; + std.mem.writeInt(u16, data[1..3], 999, .little); // unknown type + std.mem.writeInt(u16, data[3..5], 4, .little); + @memset(data[5..9], 0x00); + const result = parseExtensions(&data); + try std.testing.expectEqual(@as(usize, 1), result.len()); + try std.testing.expect(result.get(0) == .unparseable_extension); + } +} + +test "rpc.account_decoder.parse_token_extension: simple extensions" { + // DefaultAccountState (type=6, 1 byte) + { + const result = UiDefaultAccountState.parse(&.{1}); // initialized + try std.testing.expect(result != null); + try std.testing.expect(result.?.default_account_state.accountState == .initialized); + + const frozen = UiDefaultAccountState.parse(&.{2}); + try std.testing.expect(frozen.?.default_account_state.accountState == .frozen); + + // Invalid state + try std.testing.expect(UiDefaultAccountState.parse(&.{3}) == null); + // Wrong size + try std.testing.expect(UiDefaultAccountState.parse(&.{ 1, 2 }) == null); + } + + // MemoTransfer (type=8, 1 byte) + { + const enabled = UiMemoTransfer.parse(&.{1}); + try std.testing.expect(enabled.?.memo_transfer.requireIncomingTransferMemos == true); + + const disabled = UiMemoTransfer.parse(&.{0}); + try std.testing.expect(disabled.?.memo_transfer.requireIncomingTransferMemos == false); + } + + // CpiGuard (type=11, 1 byte) + { + const locked = UiCpiGuard.parse(&.{1}); + try std.testing.expect(locked.?.cpi_guard.lockCpi == true); + } + + // TransferHookAccount (type=15, 1 byte) + { + const result = UiTransferHookAccount.parse(&.{1}); + try std.testing.expect(result.?.transfer_hook_account.transferring == true); + } + + // TransferFeeAmount (type=2, 8 bytes) + { + var data: [8]u8 = undefined; + std.mem.writeInt(u64, &data, 12345, .little); + const result = UiTransferFeeAmount.parse(&data); + try std.testing.expectEqual(@as(u64, 12345), result.?.transfer_fee_amount.withheldAmount); + } + + // MintCloseAuthority (type=3, 32 bytes) + { + var pubkey_bytes: [32]u8 = undefined; + @memset(&pubkey_bytes, 0xAA); + const result = UiMintCloseAuthority.parse(&pubkey_bytes); + try std.testing.expect(result.?.mint_close_authority.closeAuthority != null); + + // Zero pubkey = null authority + const zero_result = UiMintCloseAuthority.parse(&([_]u8{0} ** 32)); + try std.testing.expect(zero_result.?.mint_close_authority.closeAuthority == null); + } + + // PermanentDelegate (type=12, 32 bytes) + { + var pubkey_bytes: [32]u8 = undefined; + @memset(&pubkey_bytes, 0xBB); + const result = UiPermanentDelegate.parse(&pubkey_bytes); + try std.testing.expect(result.?.permanent_delegate.delegate != null); + } + + // PausableConfig (type=26, 33 bytes) + { + var data: [33]u8 = undefined; + @memset(data[0..32], 0xCC); + data[32] = 1; // paused = true + const result = UiPausableConfig.parse(&data); + try std.testing.expect(result.?.pausable_config.authority != null); + try std.testing.expect(result.?.pausable_config.paused == true); + } +} + +test "rpc.account_decoder.parse_token_extension: pointer extensions (64 bytes)" { + const authority_bytes: [32]u8 = [_]u8{0xAA} ** 32; + const address_bytes: [32]u8 = [_]u8{0xBB} ** 32; + + // MetadataPointer (type=18) + { + var data: [64]u8 = undefined; + @memcpy(data[0..32], &authority_bytes); + @memcpy(data[32..64], &address_bytes); + const result = UiMetadataPointer.parse(&data); + try std.testing.expect(result.?.metadata_pointer.authority != null); + try std.testing.expect(result.?.metadata_pointer.metadataAddress != null); + } + + // GroupPointer (type=20) + { + var data: [64]u8 = undefined; + @memcpy(data[0..32], &authority_bytes); + @memcpy(data[32..64], &address_bytes); + const result = UiGroupPointer.parse(&data); + try std.testing.expect(result.?.group_pointer.authority != null); + try std.testing.expect(result.?.group_pointer.groupAddress != null); + } + + // GroupMemberPointer (type=22) + { + var data: [64]u8 = undefined; + @memcpy(data[0..32], &authority_bytes); + @memcpy(data[32..64], &address_bytes); + const result = UiGroupMemberPointer.parse(&data); + try std.testing.expect(result.?.group_member_pointer.authority != null); + try std.testing.expect(result.?.group_member_pointer.memberAddress != null); + } + + // TransferHook (type=14) + { + var data: [64]u8 = undefined; + @memcpy(data[0..32], &authority_bytes); + @memcpy(data[32..64], &address_bytes); + const result = UiTransferHook.parse(&data); + try std.testing.expect(result.?.transfer_hook.authority != null); + try std.testing.expect(result.?.transfer_hook.programId != null); + } + + // Wrong size returns null + { + const short: [63]u8 = [_]u8{0} ** 63; + try std.testing.expect(UiMetadataPointer.parse(&short) == null); + } +} + +test "rpc.account_decoder.parse_token_extension: InterestBearingConfig" { + // UiInterestBearingConfig.parse (52 bytes) + { + var data: [52]u8 = undefined; + @memset(data[0..32], 0xAA); // rate_authority + std.mem.writeInt(i64, data[32..40], 1000000, .little); // init_timestamp + std.mem.writeInt(i16, data[40..42], 500, .little); // pre_update_rate + std.mem.writeInt(i64, data[42..50], 2000000, .little); // last_update_ts + std.mem.writeInt(i16, data[50..52], 600, .little); // current_rate + + const result = UiInterestBearingConfig.parse(&data); + try std.testing.expect(result != null); + const config = result.?.interest_bearing_config; + try std.testing.expect(config.rateAuthority != null); + try std.testing.expectEqual(@as(i64, 1000000), config.initializationTimestamp); + try std.testing.expectEqual(@as(i16, 500), config.preUpdateAverageRate); + try std.testing.expectEqual(@as(i64, 2000000), config.lastUpdateTimestamp); + try std.testing.expectEqual(@as(i16, 600), config.currentRate); + } + // Wrong size + { + const short: [51]u8 = [_]u8{0} ** 51; + try std.testing.expect(UiInterestBearingConfig.parse(&short) == null); + } +} + +test "rpc.account_decoder.parse_token_extension: ScaledUiAmountConfig" { + // 56 bytes + var data: [56]u8 = undefined; + @memset(data[0..32], 0xBB); // authority + std.mem.writeInt(u64, data[32..40], @as(u64, @bitCast(@as(f64, 1.5))), .little); // multiplier + std.mem.writeInt(i64, data[40..48], 999999, .little); // new_multiplier_effective_ts + std.mem.writeInt(u64, data[48..56], @as(u64, @bitCast(@as(f64, 2.0))), .little); // new_multiplier + + const result = UiScaledUiAmountConfig.parse(&data); + try std.testing.expect(result != null); + const config = result.?.scaled_ui_amount_config; + try std.testing.expect(config.authority != null); + try std.testing.expectEqual(@as(f64, 1.5), config.multiplier); + try std.testing.expectEqual(@as(i64, 999999), config.newMultiplierEffectiveTimestamp); + try std.testing.expectEqual(@as(f64, 2.0), config.newMultiplier); +} + +test "rpc.account_decoder.parse_token_extension: TransferFeeConfig" { + // 108 bytes + var data: [108]u8 = undefined; + @memset(data[0..32], 0xAA); // config_authority + @memset(data[32..64], 0xBB); // withdraw_authority + std.mem.writeInt(u64, data[64..72], 5000, .little); // withheld_amount + // older_transfer_fee + std.mem.writeInt(u64, data[72..80], 100, .little); // epoch + std.mem.writeInt(u64, data[80..88], 1000000, .little); // max_fee + std.mem.writeInt(u16, data[88..90], 250, .little); // basis_points + // newer_transfer_fee + std.mem.writeInt(u64, data[90..98], 101, .little); // epoch + std.mem.writeInt(u64, data[98..106], 2000000, .little); // max_fee + std.mem.writeInt(u16, data[106..108], 300, .little); // basis_points + + const result = UiTransferFeeConfig.parse(&data); + try std.testing.expect(result != null); + const config = result.?.transfer_fee_config; + try std.testing.expect(config.transferFeeConfigAuthority != null); + try std.testing.expect(config.withdrawWithheldAuthority != null); + try std.testing.expectEqual(@as(u64, 5000), config.withheldAmount); + try std.testing.expectEqual(@as(u64, 100), config.olderTransferFee.epoch); + try std.testing.expectEqual(@as(u16, 250), config.olderTransferFee.transferFeeBasisPoints); + try std.testing.expectEqual(@as(u64, 101), config.newerTransferFee.epoch); + try std.testing.expectEqual(@as(u16, 300), config.newerTransferFee.transferFeeBasisPoints); +} + +test "rpc.account_decoder.parse_token_extension: TokenGroup and TokenGroupMember" { + // TokenGroup (80 bytes) + { + var data: [80]u8 = undefined; + @memset(data[0..32], 0xAA); // update_authority + @memset(data[32..64], 0xBB); // mint + std.mem.writeInt(u64, data[64..72], 10, .little); // size + std.mem.writeInt(u64, data[72..80], 100, .little); // max_size + + const result = UiTokenGroup.parse(&data); + try std.testing.expect(result != null); + const group = result.?.token_group; + try std.testing.expect(group.updateAuthority != null); + try std.testing.expectEqual(@as(u64, 10), group.size); + try std.testing.expectEqual(@as(u64, 100), group.maxSize); + } + + // TokenGroupMember (72 bytes) + { + var data: [72]u8 = undefined; + @memset(data[0..32], 0xCC); // mint + @memset(data[32..64], 0xDD); // group + std.mem.writeInt(u64, data[64..72], 5, .little); // member_number + + const result = UiTokenGroupMember.parse(&data); + try std.testing.expect(result != null); + const member = result.?.token_group_member; + try std.testing.expectEqual(@as(u64, 5), member.memberNumber); + } +} + +test "rpc.account_decoder.parse_token_extension: confidential extensions" { + // ConfidentialTransferMint (65 bytes) + { + var data: [65]u8 = undefined; + @memset(data[0..32], 0xAA); // authority + data[32] = 1; // auto_approve = true + @memset(data[33..65], 0xBB); // auditor_elgamal_pubkey + + const result = UiConfidentialTransferMint.parse(&data); + try std.testing.expect(result != null); + const mint = result.?.confidential_transfer_mint; + try std.testing.expect(mint.authority != null); + try std.testing.expect(mint.autoApproveNewAccounts == true); + try std.testing.expect(mint.auditorElgamalPubkey != null); + } + + // ConfidentialTransferFeeAmount (64 bytes) + { + var data: [64]u8 = undefined; + @memset(&data, 0xCC); + const result = UiConfidentialTransferFeeAmount.parse(&data); + try std.testing.expect(result != null); + } + + // ConfidentialTransferFeeConfig (129 bytes) + { + var data: [129]u8 = undefined; + @memset(data[0..32], 0xAA); // authority + @memset(data[32..64], 0xBB); // elgamal_pubkey + data[64] = 1; // harvest_enabled + @memset(data[65..129], 0xCC); // withheld_amount + + const result = UiConfidentialTransferFeeConfig.parse(&data); + try std.testing.expect(result != null); + const config = result.?.confidential_transfer_fee_config; + try std.testing.expect(config.authority != null); + try std.testing.expect(config.harvestToMintEnabled == true); + } + + // ConfidentialMintBurn (196 bytes) + { + var data: [196]u8 = undefined; + @memset(data[0..64], 0xAA); // confidential_supply + @memset(data[64..100], 0xBB); // decryptable_supply + @memset(data[100..132], 0xCC); // supply_elgamal_pubkey + @memset(data[132..196], 0xDD); // pending_burn + + const result = UiConfidentialMintBurn.parse(&data); + try std.testing.expect(result != null); + } + + // ConfidentialTransferAccount (295 bytes) + { + var data: [295]u8 = undefined; + data[0] = 1; // approved + @memset(data[1..33], 0xAA); // elgamal_pubkey + @memset(data[33..97], 0xBB); // pending_balance_lo + @memset(data[97..161], 0xCC); // pending_balance_hi + @memset(data[161..225], 0xDD); // available_balance + @memset(data[225..261], 0xEE); // decryptable_available_balance + data[261] = 1; // allow_confidential_credits + data[262] = 0; // allow_non_confidential_credits + std.mem.writeInt(u64, data[263..271], 10, .little); + std.mem.writeInt(u64, data[271..279], 20, .little); + std.mem.writeInt(u64, data[279..287], 30, .little); + std.mem.writeInt(u64, data[287..295], 40, .little); + + const result = UiConfidentialTransferAccount.parse(&data); + try std.testing.expect(result != null); + const account = result.?.confidential_transfer_account; + try std.testing.expect(account.approved == true); + try std.testing.expect(account.allowConfidentialCredits == true); + try std.testing.expect(account.allowNonConfidentialCredits == false); + try std.testing.expectEqual(@as(u64, 10), account.pendingBalanceCreditCounter); + } +} + +test "rpc.account_decoder.parse_token_extension: extractFromMint helpers" { + const MINT_LEN = 82; + // InterestBearingConfigData.extractFromMint + { + // Build mint data with interest bearing extension at offset 82+ + var data: [MINT_LEN + 1 + 4 + 52]u8 = undefined; + @memset(data[0..MINT_LEN], 0); // base mint data + data[MINT_LEN] = 0x01; // discriminator + std.mem.writeInt(u16, data[MINT_LEN + 1 ..][0..2], 10, .little); // type=interest_bearing_config + std.mem.writeInt(u16, data[MINT_LEN + 3 ..][0..2], 52, .little); // length + // Extension value (52 bytes) + const ext_start = MINT_LEN + 5; + @memset(data[ext_start..][0..32], 0xAA); // rate_authority + std.mem.writeInt(i64, data[ext_start + 32 ..][0..8], 1234567, .little); + std.mem.writeInt(i16, data[ext_start + 40 ..][0..2], 500, .little); + std.mem.writeInt(i64, data[ext_start + 42 ..][0..8], 7654321, .little); + std.mem.writeInt(i16, data[ext_start + 50 ..][0..2], 600, .little); + + const result = InterestBearingConfigData.extractFromMint(&data); + try std.testing.expect(result != null); + try std.testing.expect(result.?.rate_authority != null); + try std.testing.expectEqual(@as(i64, 1234567), result.?.initialization_timestamp); + try std.testing.expectEqual(@as(i16, 500), result.?.pre_update_average_rate); + try std.testing.expectEqual(@as(i16, 600), result.?.current_rate); + } + + // No extension present + { + var data: [MINT_LEN]u8 = undefined; + @memset(&data, 0); + const result = InterestBearingConfigData.extractFromMint(&data); + try std.testing.expect(result == null); + } + + // ScaledUiAmountConfigData.extractFromMint + { + var data: [MINT_LEN + 1 + 4 + 56]u8 = undefined; + @memset(data[0..MINT_LEN], 0); + data[MINT_LEN] = 0x01; + std.mem.writeInt(u16, data[MINT_LEN + 1 ..][0..2], 25, .little); // type=scaled_ui_amount_config + std.mem.writeInt(u16, data[MINT_LEN + 3 ..][0..2], 56, .little); + const ext_start = MINT_LEN + 5; + @memset(data[ext_start..][0..32], 0xBB); // authority + const mult_bits = @as(u64, @bitCast(@as(f64, 1.25))); + std.mem.writeInt(u64, data[ext_start + 32 ..][0..8], mult_bits, .little); + std.mem.writeInt(i64, data[ext_start + 40 ..][0..8], 999, .little); + const new_mult_bits = @as(u64, @bitCast(@as(f64, 2.5))); + std.mem.writeInt(u64, data[ext_start + 48 ..][0..8], new_mult_bits, .little); + + const result = ScaledUiAmountConfigData.extractFromMint(&data); + try std.testing.expect(result != null); + try std.testing.expectEqual(@as(f64, 1.25), result.?.multiplier); + try std.testing.expectEqual(@as(i64, 999), result.?.new_multiplier_effective_timestamp); + try std.testing.expectEqual(@as(f64, 2.5), result.?.new_multiplier); + } +} + +test "rpc.account_decoder.parse_token_extension: UiExtension jsonStringify" { + var json_buf: [4096]u8 = undefined; + var fbs = std.io.fixedBufferStream(&json_buf); + var jw = std.json.writeStream(fbs.writer(), .{}); + + // Test unit variants + { + const ext: UiExtension = .immutable_owner; + try ext.jsonStringify(&jw); + const output = fbs.getWritten(); + const needle = "\"extension\":\"immutableOwner\""; + try std.testing.expect(std.mem.indexOf(u8, output, needle) != null); + } + + // Reset buffer + fbs.reset(); + jw = std.json.writeStream(fbs.writer(), .{}); + { + const ext: UiExtension = .non_transferable; + try ext.jsonStringify(&jw); + const output = fbs.getWritten(); + const needle = "\"extension\":\"nonTransferable\""; + try std.testing.expect(std.mem.indexOf(u8, output, needle) != null); + } + + // Test extension with state + fbs.reset(); + jw = std.json.writeStream(fbs.writer(), .{}); + { + const ext: UiExtension = .{ .default_account_state = .{ .accountState = .frozen } }; + try ext.jsonStringify(&jw); + const output = fbs.getWritten(); + const ext_needle = "\"extension\":\"defaultAccountState\""; + try std.testing.expect(std.mem.indexOf(u8, output, ext_needle) != null); + const state_needle = "\"accountState\":\"frozen\""; + try std.testing.expect(std.mem.indexOf(u8, output, state_needle) != null); + } + + // Test transfer_fee_config JSON + fbs.reset(); + jw = std.json.writeStream(fbs.writer(), .{}); + { + const ext: UiExtension = .{ .transfer_fee_config = .{ + .transferFeeConfigAuthority = null, + .withdrawWithheldAuthority = null, + .withheldAmount = 1000, + .olderTransferFee = .{ .epoch = 10, .maximumFee = 500, .transferFeeBasisPoints = 100 }, + .newerTransferFee = .{ .epoch = 11, .maximumFee = 600, .transferFeeBasisPoints = 150 }, + } }; + try ext.jsonStringify(&jw); + const output = fbs.getWritten(); + const ext_needle = "\"extension\":\"transferFeeConfig\""; + try std.testing.expect(std.mem.indexOf(u8, output, ext_needle) != null); + const amt_needle = "\"withheldAmount\":1000"; + try std.testing.expect(std.mem.indexOf(u8, output, amt_needle) != null); + const fee_needle = "\"transferFeeBasisPoints\":100"; + try std.testing.expect(std.mem.indexOf(u8, output, fee_needle) != null); + } + + // Test confidential extension with base64 fields + fbs.reset(); + jw = std.json.writeStream(fbs.writer(), .{}); + { + const withheld_bytes = [_]u8{0xAA} ** 64; + const ext: UiExtension = .{ .confidential_transfer_fee_amount = .{ + .withheldAmount = Base64Encoded(64).init(&withheld_bytes), + } }; + try ext.jsonStringify(&jw); + const output = fbs.getWritten(); + const ext_needle = "\"extension\":\"confidentialTransferFeeAmount\""; + try std.testing.expect(std.mem.indexOf(u8, output, ext_needle) != null); + const amt_needle = "\"withheldAmount\":"; + try std.testing.expect(std.mem.indexOf(u8, output, amt_needle) != null); + } +} diff --git a/src/rpc/account_decoder/parse_vote.zig b/src/rpc/account_decoder/parse_vote.zig new file mode 100644 index 0000000000..1aaf70b9d7 --- /dev/null +++ b/src/rpc/account_decoder/parse_vote.zig @@ -0,0 +1,340 @@ +/// Types for parsing a vote account for RPC responses using the `jsonParsed` encoding. +/// [agave]: https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_vote.rs +const std = @import("std"); +const sig = @import("../../sig.zig"); +const base58 = @import("base58"); +const account_decoder = @import("lib.zig"); + +const Allocator = std.mem.Allocator; +const Pubkey = sig.core.Pubkey; +const vote_program = sig.runtime.program.vote; +const ParseError = account_decoder.ParseError; +const JsonString = account_decoder.JsonString; + +const BLS_PUBLIC_KEY_COMPRESSED_SIZE = vote_program.state.BLS_PUBLIC_KEY_COMPRESSED_SIZE; +const BLS_PUBLIC_KEY_BASE58_MAX_SIZE = base58.encodedMaxSize(BLS_PUBLIC_KEY_COMPRESSED_SIZE); + +/// Parses a vote account's data into a `VoteAccountType` for JSON encoding in RPC responses. +/// TODO: somehow enforce arena allocation for all allocations here? +pub fn parseVote( + allocator: Allocator, + // std.io.Reader + reader: anytype, + vote_pubkey: Pubkey, +) ParseError!VoteAccountType { + var vote_state_versions = sig.bincode.read( + allocator, + vote_program.state.VoteStateVersions, + reader, + .{}, + ) catch return ParseError.InvalidAccountData; + defer vote_state_versions.deinit(allocator); + + var vote_state = vote_state_versions.convertToV4(allocator, vote_pubkey) catch + return ParseError.OutOfMemory; + defer vote_state.deinit(allocator); + + const votes = try allocator.alloc( + UiLandedVote, + vote_state.votes.items.len, + ); + for (vote_state.votes.items, 0..) |vote, i| { + votes[i] = UiLandedVote{ + .latency = vote.latency, + .slot = vote.lockout.slot, + .confirmationCount = vote.lockout.confirmation_count, + }; + } + + const auth_voters = try allocator.alloc( + UiAuthorizedVoter, + vote_state.authorized_voters.len(), + ); + + const voter_keys = vote_state.authorized_voters.voters.keys(); + const voter_values = vote_state.authorized_voters.voters.values(); + for (auth_voters, voter_keys, voter_values) |*av, epoch, voter_pubkey| { + av.* = UiAuthorizedVoter{ + .epoch = epoch, + .authorizedVoter = voter_pubkey, + }; + } + + const epoch_credits = try allocator.alloc( + UiEpochCredits, + vote_state.epoch_credits.items.len, + ); + for (epoch_credits, vote_state.epoch_credits.items) |*ec, vote_state_ec| { + ec.* = UiEpochCredits{ + .epoch = vote_state_ec.epoch, + .credits = .{ .value = vote_state_ec.credits }, + .previousCredits = .{ .value = vote_state_ec.prev_credits }, + }; + } + + // Prior voters are not populated in VoteState v4 - AGave returns an empty Vec. + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_vote.rs#L37 + const prior_voters: []const UiPriorVoter = &.{}; + + const ui_vote_state = UiVoteState{ + .nodePubkey = vote_state.node_pubkey, + .authorizedWithdrawer = vote_state.withdrawer, + .commission = vote_state.commission(), + .votes = votes, + .rootSlot = vote_state.root_slot, + .authorizedVoters = auth_voters, + .priorVoters = prior_voters, + .epochCredits = epoch_credits, + .lastTimestamp = UiBlockTimestamp{ + .slot = vote_state.last_timestamp.slot, + .timestamp = vote_state.last_timestamp.timestamp, + }, + // Fields added with vote state v4 via SIMD-0185: + .inflationRewardsCommissionBps = vote_state.inflation_rewards_commission_bps, + .inflationRewardsCollector = vote_state.inflation_rewards_collector, + .blockRevenueCollector = vote_state.block_revenue_collector, + .blockRevenueCommissionBps = vote_state.block_revenue_commission_bps, + .pendingDelegatorRewards = .{ .value = vote_state.pending_delegator_rewards }, + .blsPubkeyCompressed = if (vote_state.bls_pubkey_compressed) |bytes| + JsonString(BLS_PUBLIC_KEY_BASE58_MAX_SIZE).fromBounded( + base58.Table.BITCOIN.encodeArray(BLS_PUBLIC_KEY_COMPRESSED_SIZE, bytes), + ) + else + null, + }; + + return VoteAccountType{ + .vote = ui_vote_state, + }; +} + +/// Wrapper enum matching Agave's VoteAccountType. +/// [agave] https://github.com/anza-xyz/agave/blob/2717084afeeb7baad4342468c27f528ef617a3cf/account-decoder/src/parse_vote.rs#L54 +pub const VoteAccountType = union(enum) { + vote: UiVoteState, + + pub fn jsonStringify(self: VoteAccountType, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + try jw.objectField("type"); + switch (self) { + inline else => |v, tag| { + try jw.write(@tagName(tag)); + try jw.objectField("info"); + try jw.write(v); + }, + } + try jw.endObject(); + } +}; + +/// The main vote state UI representation. +/// [agave] https://github.com/anza-xyz/agave/blob/2717084afeeb7baad4342468c27f528ef617a3cf/account-decoder/src/parse_vote.rs#L61 +pub const UiVoteState = struct { + nodePubkey: Pubkey, + authorizedWithdrawer: Pubkey, + commission: u8, + votes: []const UiLandedVote, + rootSlot: ?u64, + authorizedVoters: []UiAuthorizedVoter, + priorVoters: []const UiPriorVoter, + epochCredits: []const UiEpochCredits, + lastTimestamp: UiBlockTimestamp, + + // Fields added with vote state v4 via SIMD-0185: + inflationRewardsCommissionBps: u16, + inflationRewardsCollector: Pubkey, + blockRevenueCollector: Pubkey, + blockRevenueCommissionBps: u16, + pendingDelegatorRewards: account_decoder.Stringified(u64), + blsPubkeyCompressed: ?JsonString(BLS_PUBLIC_KEY_BASE58_MAX_SIZE), + + pub fn jsonStringify(self: UiVoteState, jw: anytype) @TypeOf(jw.*).Error!void { + try jw.beginObject(); + + try jw.objectField("nodePubkey"); + try jw.write(self.nodePubkey); + + try jw.objectField("authorizedWithdrawer"); + try jw.write(self.authorizedWithdrawer); + + try jw.objectField("commission"); + try jw.write(self.commission); + + try jw.objectField("votes"); + try jw.write(self.votes); + + try jw.objectField("rootSlot"); + try jw.write(self.rootSlot); + + try jw.objectField("authorizedVoters"); + try jw.write(self.authorizedVoters); + + try jw.objectField("priorVoters"); + try jw.write(self.priorVoters); + + try jw.objectField("epochCredits"); + try jw.write(self.epochCredits); + + try jw.objectField("lastTimestamp"); + try jw.write(self.lastTimestamp); + + try jw.objectField("inflationRewardsCommissionBps"); + try jw.write(self.inflationRewardsCommissionBps); + + try jw.objectField("inflationRewardsCollector"); + try jw.write(self.inflationRewardsCollector); + + try jw.objectField("blockRevenueCollector"); + try jw.write(self.blockRevenueCollector); + + try jw.objectField("blockRevenueCommissionBps"); + try jw.write(self.blockRevenueCommissionBps); + + try jw.objectField("pendingDelegatorRewards"); + try jw.write(self.pendingDelegatorRewards); + + try jw.objectField("blsPubkeyCompressed"); + try jw.write(self.blsPubkeyCompressed); + + try jw.endObject(); + } +}; + +/// Flattened vote with latency info. +/// [agave] https://github.com/anza-xyz/agave/blob/2717084afeeb7baad4342468c27f528ef617a3cf/account-decoder/src/parse_vote.rs#L98 +pub const UiLandedVote = struct { + latency: u8, + slot: u64, + confirmationCount: u32, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/2717084afeeb7baad4342468c27f528ef617a3cf/account-decoder/src/parse_vote.rs#L118 +pub const UiAuthorizedVoter = struct { + epoch: u64, + authorizedVoter: Pubkey, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/2717084afeeb7baad4342468c27f528ef617a3cf/account-decoder/src/parse_vote.rs#L125 +pub const UiPriorVoter = struct { + authorizedPubkey: Pubkey, + epochOfLastAuthorizedSwitch: u64, + targetEpoch: u64, +}; + +/// [agave] https://github.com/anza-xyz/agave/blob/2717084afeeb7baad4342468c27f528ef617a3cf/account-decoder/src/parse_vote.rs#L133 +pub const UiEpochCredits = struct { + epoch: u64, + credits: account_decoder.Stringified(u64), + previousCredits: account_decoder.Stringified(u64), +}; + +/// [agave] https://github.com/anza-xyz/solana-sdk/blob/f2d15de6f7a1715ff806f0c39bba8f64bf6a587d/vote-interface/src/state/mod.rs#L148 +pub const UiBlockTimestamp = struct { + slot: u64, + timestamp: i64, +}; + +// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_vote.rs#L140-L199 +test "rpc.account_decoder.parse_vote: parse vote accounts" { + const allocator = std.testing.allocator; + + // Parse default vote state + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_vote.rs#L145-L174 + { + const vote_pubkey = Pubkey{ .data = [_]u8{1} ** 32 }; + + // Create a default VoteStateV4 and serialize as VoteStateVersions.v4 + const vote_state = vote_program.state.VoteStateV4.DEFAULT; + const vote_state_versions = vote_program.state.VoteStateVersions{ .v4 = vote_state }; + + const serialized = try sig.bincode.writeAlloc(allocator, vote_state_versions, .{}); + defer allocator.free(serialized); + + var stream = std.io.fixedBufferStream(serialized); + const result = try parseVote(allocator, stream.reader(), vote_pubkey); + + // Verify the result is a vote type + try std.testing.expect(result == .vote); + + const ui_vote_state = result.vote; + + // Verify default field values match Agave's expectations + // nodePubkey and authorizedWithdrawer should be default (zeroes) + try std.testing.expectEqual(Pubkey.ZEROES, ui_vote_state.nodePubkey); + try std.testing.expectEqual(Pubkey.ZEROES, ui_vote_state.authorizedWithdrawer); + + // Commission should be 0 (inflationRewardsCommissionBps / 100) + try std.testing.expectEqual(@as(u8, 0), ui_vote_state.commission); + + // Votes should be empty + try std.testing.expectEqual(@as(usize, 0), ui_vote_state.votes.len); + + // Root slot should be null + try std.testing.expect(ui_vote_state.rootSlot == null); + + // Authorized voters should be empty (DEFAULT has EMPTY authorized_voters) + try std.testing.expectEqual(@as(usize, 0), ui_vote_state.authorizedVoters.len); + + // Prior voters should be empty (v4 doesn't populate prior_voters) + try std.testing.expectEqual(@as(usize, 0), ui_vote_state.priorVoters.len); + + // Epoch credits should be empty + try std.testing.expectEqual(@as(usize, 0), ui_vote_state.epochCredits.len); + + // Last timestamp should be default (slot=0, timestamp=0) + try std.testing.expectEqual(@as(u64, 0), ui_vote_state.lastTimestamp.slot); + try std.testing.expectEqual(@as(i64, 0), ui_vote_state.lastTimestamp.timestamp); + + // SIMD-0185 fields + try std.testing.expectEqual(@as(u16, 0), ui_vote_state.inflationRewardsCommissionBps); + try std.testing.expectEqual(@as(u16, 10_000), ui_vote_state.blockRevenueCommissionBps); + try std.testing.expectEqual(@as(u64, 0), ui_vote_state.pendingDelegatorRewards.value); + try std.testing.expect(ui_vote_state.blsPubkeyCompressed == null); + } + + // Bad data returns error + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_vote.rs#L176-L177 + { + const vote_pubkey = Pubkey{ .data = [_]u8{1} ** 32 }; + const bad_data = [_]u8{ 0, 0, 0, 0 }; + + var stream = std.io.fixedBufferStream(&bad_data); + const result = parseVote(allocator, stream.reader(), vote_pubkey); + + try std.testing.expectError(ParseError.InvalidAccountData, result); + } + + // UiLandedVote JSON flattening + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/parse_vote.rs#L180-L199 + { + // Verify that UiLandedVote serializes with flattened fields (no nested "lockout" object) + // In Agave, UiLandedVote uses #[serde(flatten)] on the lockout field + const ui_landed_vote = UiLandedVote{ + .latency = 5, + .slot = 12345, + .confirmationCount = 10, + }; + + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + var jw = std.json.writeStream(stream.writer(), .{}); + + try jw.write(ui_landed_vote); + + const json_output = stream.getWritten(); + + // Parse the JSON to verify structure + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_output, .{}); + defer parsed.deinit(); + + const root = parsed.value.object; + + // Verify that the lockout fields are flattened at the top level + try std.testing.expectEqual(@as(i64, 5), root.get("latency").?.integer); + try std.testing.expectEqual(@as(i64, 12345), root.get("slot").?.integer); + try std.testing.expectEqual(@as(i64, 10), root.get("confirmationCount").?.integer); + + // Verify that there is no nested "lockout" field + try std.testing.expect(root.get("lockout") == null); + } +} diff --git a/src/rpc/lib.zig b/src/rpc/lib.zig index dbe76ca405..1ff8c066a6 100644 --- a/src/rpc/lib.zig +++ b/src/rpc/lib.zig @@ -1,6 +1,7 @@ pub const client = @import("client.zig"); pub const http = @import("http.zig"); pub const methods = @import("methods.zig"); +pub const account_decoder = @import("account_decoder/lib.zig"); pub const request = @import("request.zig"); pub const response = @import("response.zig"); pub const server = @import("server/lib.zig"); diff --git a/src/rpc/methods.zig b/src/rpc/methods.zig index 1246c47b05..d712e45492 100644 --- a/src/rpc/methods.zig +++ b/src/rpc/methods.zig @@ -11,6 +11,10 @@ const std = @import("std"); const sig = @import("../sig.zig"); const rpc = @import("lib.zig"); +const base58 = @import("base58"); +const zstd = @import("zstd"); + +const parse_token = sig.rpc.account_decoder.parse_token; const Allocator = std.mem.Allocator; const ParseOptions = std.json.ParseOptions; @@ -18,6 +22,12 @@ const ParseOptions = std.json.ParseOptions; const Pubkey = sig.core.Pubkey; const Signature = sig.core.Signature; const Slot = sig.core.Slot; +const ClientVersion = sig.version.ClientVersion; + +const account_decoder = sig.rpc.account_decoder; + +const MAX_BASE58_INPUT_LEN = 128; +const MAX_BASE58_OUTPUT_LEN = base58.encodedMaxSize(MAX_BASE58_INPUT_LEN); pub fn Result(comptime method: MethodAndParams.Tag) type { return union(enum) { @@ -78,7 +88,7 @@ pub const MethodAndParams = union(enum) { getSlotLeaders: noreturn, getStakeMinimumDelegation: noreturn, getSupply: noreturn, - getTokenAccountBalance: noreturn, + getTokenAccountBalance: GetTokenAccountBalance, getTokenAccountsByDelegate: noreturn, getTokenAccountsByOwner: noreturn, getTokenLargestAccounts: noreturn, @@ -177,17 +187,10 @@ pub const GetAccountInfo = struct { pubkey: Pubkey, config: ?Config = null, - pub const Encoding = enum { - base58, - base64, - @"base64+zstd", - jsonParsed, - }; - pub const Config = struct { commitment: ?common.Commitment = null, minContextSlot: ?u64 = null, - encoding: ?Encoding = null, + encoding: ?common.AccountEncoding = null, dataSlice: ?common.DataSlice = null, }; @@ -204,13 +207,12 @@ pub const GetAccountInfo = struct { space: u64, pub const Data = union(enum) { - encoded: struct { []const u8, Encoding }, - // TODO: this should be a json value/map, test cases can't compare that though - jsonParsed: noreturn, + encoded: struct { []const u8, common.AccountEncoding }, + jsonParsed: account_decoder.ParsedAccount, /// This field is only set when the request object asked for `jsonParsed` encoding, - /// and the server couldn't find a parser, therefore falling back to simply returning - /// the account data in base64 encoding directly as a string. + /// and the server couldn't find a parser, therefore falling back to returning + /// the account data in base64 encoding as an array tuple `["data", "base64"]`. /// /// [Solana documentation REF](https://solana.com/docs/rpc/http/getaccountinfo): /// In the drop-down documentation for the encoding field: @@ -228,7 +230,10 @@ pub const GetAccountInfo = struct { switch (self) { .encoded => |pair| try jw.write(pair), .jsonParsed => |map| try jw.write(map), - .json_parsed_base64_fallback => |str| try jw.write(str), + // Fallback must return array format ["data", "base64"] like testnet + .json_parsed_base64_fallback => |str| { + try jw.write(.{ str, common.AccountEncoding.base64 }); + }, } } @@ -239,7 +244,7 @@ pub const GetAccountInfo = struct { ) std.json.ParseError(@TypeOf(source.*))!Data { return switch (try source.peekNextTokenType()) { .array_begin => .{ .encoded = try std.json.innerParse( - struct { []const u8, Encoding }, + struct { []const u8, common.AccountEncoding }, allocator, source, options, @@ -504,7 +509,20 @@ pub const GetSlot = struct { // TODO: getStakeActivation // TODO: getStakeMinimumDelegation // TODO: getSupply -// TODO: getTokenAccountBalance +pub const GetTokenAccountBalance = struct { + pubkey: Pubkey, + config: ?Config = null, + + pub const Config = struct { + commitment: ?common.Commitment = null, + }; + + pub const Response = struct { + context: common.Context, + value: account_decoder.parse_token.UiTokenAmount, + }; +}; + // TODO: getTokenAccountsByDelegate // TODO: getTokenAccountsByOwner // TODO: getTokenLargestAccounts @@ -662,6 +680,13 @@ pub const common = struct { apiVersion: []const u8, }; + pub const AccountEncoding = enum { + base58, + base64, + @"base64+zstd", + jsonParsed, + }; + // TODO field types pub const RpcContactInfo = struct { /// Pubkey of the node as a base-58 string @@ -706,3 +731,283 @@ pub const SlotHookContext = struct { return if (slot >= min_slot) slot else error.RpcMinContextSlotNotMet; } }; + +pub const AccountHookContext = struct { + slot_tracker: *const sig.replay.trackers.SlotTracker, + account_reader: sig.accounts_db.AccountReader, + + pub fn getAccountInfo( + self: AccountHookContext, + allocator: std.mem.Allocator, + params: GetAccountInfo, + ) !GetAccountInfo.Response { + const config = params.config orelse GetAccountInfo.Config{}; + // [agave] Default commitment is finalized: + // https://github.com/anza-xyz/agave/blob/v3.1.8/rpc/src/rpc.rs#L348 + const commitment = config.commitment orelse .finalized; + // [agave] Default encoding in AGave is `Binary` (legacy base58): + // https://github.com/anza-xyz/agave/blob/v3.1.8/rpc/src/rpc.rs#L545 + // However, `Binary` is deprecated and `Base64` is preferred for performance. + // We default to base64 as it's more efficient and the recommended encoding. + const encoding = config.encoding orelse common.AccountEncoding.base64; + + const slot = self.slot_tracker.getSlotForCommitment(commitment); + if (config.minContextSlot) |min_slot| { + if (slot < min_slot) return error.RpcMinContextSlotNotMet; + } + + // TODO: is this the best way to get the right slot to use? + const ref = self.slot_tracker.get(slot) orelse return error.SlotNotFound; + const slot_reader = self.account_reader.forSlot(&ref.constants.ancestors); + const maybe_account = try slot_reader.get(allocator, params.pubkey); + + if (maybe_account) |account| { + defer account.deinit(allocator); + + const data: GetAccountInfo.Response.Value.Data = if (encoding == .jsonParsed) + try encodeJsonParsed(allocator, params.pubkey, account, slot_reader) + else + try encodeStandard(allocator, account, encoding, config.dataSlice); + + return GetAccountInfo.Response{ + .context = .{ .slot = slot, .apiVersion = ClientVersion.API_VERSION }, + .value = .{ + .data = data, + .executable = account.executable, + .lamports = account.lamports, + .owner = account.owner, + .rentEpoch = account.rent_epoch, + .space = account.data.len(), + }, + }; + } else { + return .{ + .context = .{ .slot = slot, .apiVersion = ClientVersion.API_VERSION }, + .value = null, + }; + } + } + + pub fn getTokenAccountBalance( + self: AccountHookContext, + allocator: std.mem.Allocator, + params: GetTokenAccountBalance, + ) !GetTokenAccountBalance.Response { + const config = params.config orelse GetTokenAccountBalance.Config{}; + const commitment = config.commitment orelse .finalized; + + const slot = self.slot_tracker.getSlotForCommitment(commitment); + + const ref = self.slot_tracker.get(slot) orelse return error.SlotNotFound; + const slot_reader = self.account_reader.forSlot(&ref.constants.ancestors); + const maybe_account = try slot_reader.get(allocator, params.pubkey); + + const account = maybe_account orelse return error.RpcAccountNotFound; + defer account.deinit(allocator); + + // Validate that this is a token account (owned by SPL Token or Token-2022) + const is_token_program = account.owner.equals(&sig.runtime.ids.SPL_TOKEN_PROGRAM_ID) or + account.owner.equals(&sig.runtime.ids.SPL_TOKEN_2022_PROGRAM_ID); + if (!is_token_program) return error.RpcNotATokenAccount; + + // Read account data and unpack the token account + var data_buf: [parse_token.TokenAccount.LEN]u8 = undefined; + var data_iter = account.data.iterator(); + const bytes_read = data_iter.readBytes(&data_buf) catch return error.RpcNotATokenAccount; + if (bytes_read < parse_token.TokenAccount.LEN) + return error.RpcNotATokenAccount; + + const token_account = parse_token.TokenAccount.unpack(&data_buf) catch + return error.RpcNotATokenAccount; + if (token_account.state == .uninitialized) return error.RpcNotATokenAccount; + + // Build SplTokenAdditionalData for mint decimals and extension configs + const is_native_mint = token_account.mint.equals(&sig.runtime.ids.NATIVE_MINT_ID); + const spl_token_data: parse_token.SplTokenAdditionalData = if (is_native_mint) + // Native mint (wrapped SOL): decimals=9, no extensions + // TODO: document agave conformance. + .{ .decimals = 9 } + else + // Look up the mint account for decimals and extension configs + account_decoder.getMintAdditionalData( + allocator, + token_account.mint, + slot_reader, + ) orelse return error.RpcMintNotFound; + + const ui_token_amount = account_decoder.parse_token.UiTokenAmount.init( + token_account.amount, + spl_token_data, + ); + + return .{ + .context = .{ .slot = slot, .apiVersion = ClientVersion.API_VERSION }, + .value = ui_token_amount, + }; + } + + /// Handles jsonParsed encoding with fallback to base64 + fn encodeJsonParsed( + allocator: std.mem.Allocator, + pubkey: sig.core.Pubkey, + account: sig.core.Account, + slot_reader: sig.accounts_db.SlotAccountReader, + ) !GetAccountInfo.Response.Value.Data { + // Build additional data for token accounts, fetch mint and clock for Token-2022 responses. + const additional_data = account_decoder.buildTokenAdditionalData( + allocator, + account, + slot_reader, + ); + + var account_data_iter = account.data.iterator(); + // Try to parse based on owner program + if (try account_decoder.parse_account( + allocator, + pubkey, + account.owner, + account_data_iter.reader(), + account.data.len(), + if (additional_data.spl_token != null) additional_data else null, + )) |parsed| { + return .{ .jsonParsed = parsed }; + } + // Fallback: encode as base64 string when jsonParsed fails. + // [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/lib.rs#L81-L88 + // When parse_account_data_v3 fails, AGave falls back to base64 encoding. + var encoded = try std.ArrayListUnmanaged(u8).initCapacity( + allocator, + // If jsonParsed fails, we fallback to base64 encoding, so we need to allocate + // enough capacity for the encoded string here. + std.base64.standard.Encoder.calcSize(account.data.len()), + ); + errdefer encoded.deinit(allocator); + try encodeAccountData(allocator, account, .base64, null, encoded.writer(allocator)); + return .{ .json_parsed_base64_fallback = try encoded.toOwnedSlice(allocator) }; + } + + fn estimateEncodedSize( + account: sig.core.Account, + encoding: common.AccountEncoding, + data_slice: ?common.DataSlice, + ) usize { + const start, const end = calculateSliceRange(account, data_slice); + const data_len = end - start; + return switch (encoding) { + .base58 => base58.encodedMaxSize(data_len), + .base64 => std.base64.standard.Encoder.calcSize(data_len), + // NOTE: we just use base64 size as a catch-all. + .@"base64+zstd" => std.base64.standard.Encoder.calcSize(data_len), + .jsonParsed => unreachable, // should be handled in encodeJsonParsed + }; + } + + /// Handles base58, base64, base64+zstd encodings + fn encodeStandard( + allocator: std.mem.Allocator, + account: sig.core.Account, + encoding: common.AccountEncoding, + data_slice: ?common.DataSlice, + ) !GetAccountInfo.Response.Value.Data { + const estimated_size = estimateEncodedSize(account, encoding, data_slice); + var encoded_data = try std.ArrayListUnmanaged(u8).initCapacity(allocator, estimated_size); + errdefer encoded_data.deinit(allocator); + try encodeAccountData( + allocator, + account, + encoding, + data_slice, + encoded_data.writer(allocator), + ); + return .{ + .encoded = .{ + try encoded_data.toOwnedSlice(allocator), + encoding, + }, + }; + } + + fn encodeAccountData( + allocator: std.mem.Allocator, + account: sig.core.Account, + encoding: common.AccountEncoding, + data_slice: ?common.DataSlice, + // std.io.Writer + writer: anytype, + ) !void { + const start, const end = calculateSliceRange(account, data_slice); + return switch (encoding) { + .base58 => { + const data_len = end - start; + + if (data_len > MAX_BASE58_INPUT_LEN) { + // [agave] Returns "error: data too large for bs58 encoding" string instead of error: + // https://github.com/anza-xyz/agave/blob/v3.1.8/account-decoder/src/lib.rs#L44-L47 + // We return an error here since returning a fake "error" string would be misleading. + return error.Base58DataTooLarge; + } + + var input_buf: [MAX_BASE58_INPUT_LEN]u8 = undefined; + var output_buf: [MAX_BASE58_OUTPUT_LEN]u8 = undefined; + _ = account.data.read(start, input_buf[0..data_len]); + const encoded_len = base58.Table.BITCOIN.encode(&output_buf, input_buf[0..data_len]); + + try writer.writeAll(output_buf[0..encoded_len]); + }, + .base64 => { + var stream = sig.utils.base64.EncodingStream.init(std.base64.standard.Encoder); + const base64_ctx = stream.writerCtx(writer); + var iter = account.data.iteratorRanged(start, end); + + while (iter.nextFrame()) |frame_slice| { + try base64_ctx.writer().writeAll(frame_slice); + } + try base64_ctx.flush(); + }, + .@"base64+zstd" => { + var stream = sig.utils.base64.EncodingStream.init(std.base64.standard.Encoder); + const base64_ctx = stream.writerCtx(writer); + // TODO: propagate more specifi errors. + const compressor = zstd.Compressor.init(.{}) catch return error.OutOfMemory; + defer compressor.deinit(); + + // TODO: recommOutSize is usually 128KiB. We could stack allocate this or re-use + // buffer set in AccountHookContext instead of allocating it on each call + // since the server is single-threaded. Unfortunately, the zstd lib's doesn't give us a + // comptime-known size to use for stack allocation. Instead of assuming, just allocate for now. + const zstd_out_buf = try allocator.alloc( + u8, + zstd.Compressor.recommOutSize(), + ); + defer allocator.free(zstd_out_buf); + var zstd_ctx = zstd.writerCtx( + base64_ctx.writer(), + &compressor, + zstd_out_buf, + ); + var iter = account.data.iteratorRanged(start, end); + + while (iter.nextFrame()) |frame_slice| { + try zstd_ctx.writer().writeAll(frame_slice); + } + try zstd_ctx.finish(); + try base64_ctx.flush(); + }, + .jsonParsed => unreachable, // handled in encodeJsonParsed + }; + } + + fn calculateSliceRange( + account: sig.core.Account, + data_slice: ?common.DataSlice, + ) struct { u32, u32 } { + const len = account.data.len(); + const slice_start, const slice_end = blk: { + const ds = data_slice orelse break :blk .{ 0, len }; + const start = @min(ds.offset, len); + const end = @min(ds.offset + ds.length, len); + break :blk .{ start, end }; + }; + return .{ slice_start, slice_end }; + } +}; diff --git a/src/rpc/request.zig b/src/rpc/request.zig index 8ffe5b8063..2b05417b92 100644 --- a/src/rpc/request.zig +++ b/src/rpc/request.zig @@ -115,7 +115,7 @@ pub const Request = struct { inline else => |tag| @unionInit(MethodAndParams, @tagName(tag), blk: { const Params = @FieldType(MethodAndParams, @tagName(tag)); if (Params == noreturn) { - return error.MethodNotImplemented; + return diag.initErr(error.MethodNotImplemented, .{ .id = id }); } break :blk jsonParseValuesAsParamsArray( diff --git a/src/rpc/test_serialize.zig b/src/rpc/test_serialize.zig index fa76191939..da5a1f1e25 100644 --- a/src/rpc/test_serialize.zig +++ b/src/rpc/test_serialize.zig @@ -17,6 +17,7 @@ const GetLatestBlockhash = methods.GetLatestBlockhash; const GetLeaderSchedule = methods.GetLeaderSchedule; const GetSignatureStatuses = methods.GetSignatureStatuses; const GetSlot = methods.GetSlot; +const GetTokenAccountBalance = methods.GetTokenAccountBalance; const GetVersion = methods.GetVersion; const GetVoteAccounts = methods.GetVoteAccounts; @@ -255,7 +256,48 @@ test GetSlot { // TODO: test getStakeActivation() // TODO: test getStakeMinimumDelegation() // TODO: test getSupply() -// TODO: test getTokenAccountBalance() + +test GetTokenAccountBalance { + const pubkey: Pubkey = .parse("7fUAJdStEuGbc3sM84cKRL6yYaaSstyLSU4ve5oovLS7"); + + // Request without config + try testRequest( + .getTokenAccountBalance, + .{ .pubkey = pubkey }, + \\{"jsonrpc":"2.0","id":1,"method":"getTokenAccountBalance","params":["7fUAJdStEuGbc3sM84cKRL6yYaaSstyLSU4ve5oovLS7"]} + , + ); + + // Request with commitment config + try testRequest( + .getTokenAccountBalance, + .{ .pubkey = pubkey, .config = .{ .commitment = .finalized } }, + \\{"jsonrpc":"2.0","id":1,"method":"getTokenAccountBalance","params":["7fUAJdStEuGbc3sM84cKRL6yYaaSstyLSU4ve5oovLS7",{"commitment":"finalized"}]} + , + ); + + // Response serialization: construct a UiTokenAmount and verify JSON output. + // UiTokenAmount uses custom jsonStringify (camelCase fields, amount as string), + // so we test serialization directly rather than round-trip deserialization. + const account_decoder = sig.rpc.account_decoder; + const ui_token_amount = account_decoder.parse_token.UiTokenAmount.init( + 9864, + account_decoder.parse_token.SplTokenAdditionalData{ .decimals = 2 }, + ); + const response: GetTokenAccountBalance.Response = .{ + .context = .{ .slot = 1114, .apiVersion = "2.1.6" }, + .value = ui_token_amount, + }; + const actual_json = try std.json.stringifyAlloc(std.testing.allocator, response, .{}); + defer std.testing.allocator.free(actual_json); + try std.testing.expectEqualSlices( + u8, + \\{"context":{"slot":1114,"apiVersion":"2.1.6"},"value":{"uiAmount":9.864e1,"decimals":2,"amount":"9864","uiAmountString":"98.64"}} + , + actual_json, + ); +} + // TODO: test getTokenAccountsByDelegate() // TODO: test getTockenAccountsByOwner() // TODO: test getTokenLargestAccounts() diff --git a/src/runtime/ids.zig b/src/runtime/ids.zig index 8fc6461721..073a19604d 100644 --- a/src/runtime/ids.zig +++ b/src/runtime/ids.zig @@ -22,3 +22,15 @@ pub const FEATURE_PROGRAM_SOURCE_ID: Pubkey = pub const ZK_TOKEN_PROOF_PROGRAM_ID: Pubkey = .parse("ZkTokenProof1111111111111111111111111111111"); pub const INCINERATOR: Pubkey = .parse("1nc1nerator11111111111111111111111111111111"); + +/// SPL Token Program ID +/// NOTE: Defined here solely for use in account decoders. perhaps move it? +pub const SPL_TOKEN_PROGRAM_ID: Pubkey = .parse("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +/// SPL Token 2022 Program ID +/// NOTE: Defined here solely for use in account decoders. perhaps move it? +pub const SPL_TOKEN_2022_PROGRAM_ID: Pubkey = .parse("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); + +/// SPL Token Native Mint (wrapped SOL) - always has decimals=9 +/// NOTE: Defined here solely for use in account decoders. perhaps move it? +pub const NATIVE_MINT_ID: Pubkey = .parse("So11111111111111111111111111111111111111112"); diff --git a/src/runtime/program/vote/state.zig b/src/runtime/program/vote/state.zig index 18fd398fb4..46421c6bcc 100644 --- a/src/runtime/program/vote/state.zig +++ b/src/runtime/program/vote/state.zig @@ -21,6 +21,10 @@ const SlotHashes = sig.runtime.sysvar.SlotHashes; pub const VoteStateV4 = state_v4.VoteStateV4; pub const createTestVoteStateV4 = state_v4.createTestVoteStateV4; +/// Size of a BLS public key in a compressed point representation +/// https://github.com/anza-xyz/solana-sdk/blob/00d056c4ce9def466ad5475533588713feebcb2c/vote-interface/src/state/mod.rs#L33 +pub const BLS_PUBLIC_KEY_COMPRESSED_SIZE: usize = 48; + pub const MAX_PRIOR_VOTERS: usize = 32; pub const MAX_LOCKOUT_HISTORY: usize = 31; pub const INITIAL_LOCKOUT: usize = 2; diff --git a/src/runtime/program/vote/state_v4.zig b/src/runtime/program/vote/state_v4.zig index ec378a39bb..758f53817c 100644 --- a/src/runtime/program/vote/state_v4.zig +++ b/src/runtime/program/vote/state_v4.zig @@ -29,6 +29,7 @@ const MAX_LOCKOUT_HISTORY = state.MAX_LOCKOUT_HISTORY; const MAX_EPOCH_CREDITS_HISTORY = state.MAX_EPOCH_CREDITS_HISTORY; const VOTE_CREDITS_GRACE_SLOTS = state.VOTE_CREDITS_GRACE_SLOTS; const VOTE_CREDITS_MAXIMUM_PER_SLOT = state.VOTE_CREDITS_MAXIMUM_PER_SLOT; +const BLS_PUBLIC_KEY_COMPRESSED_SIZE = state.BLS_PUBLIC_KEY_COMPRESSED_SIZE; /// SIMD-0185: https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0185-vote-account-v4.md pub const VoteStateV4 = struct { @@ -53,7 +54,7 @@ pub const VoteStateV4 = struct { pending_delegator_rewards: u64, /// compressed bls pubkey for alpenglow - bls_pubkey_compressed: ?[48]u8, + bls_pubkey_compressed: ?[BLS_PUBLIC_KEY_COMPRESSED_SIZE]u8, votes: std.ArrayListUnmanaged(LandedVote), root_slot: ?Slot, diff --git a/src/runtime/sysvar/slot_history.zig b/src/runtime/sysvar/slot_history.zig index d5cee158f9..a71d5f1034 100644 --- a/src/runtime/sysvar/slot_history.zig +++ b/src/runtime/sysvar/slot_history.zig @@ -9,8 +9,6 @@ const Pubkey = sig.core.Pubkey; const DynamicArrayBitSet = sig.bloom.bit_set.DynamicArrayBitSet; const BitVecConfig = sig.bloom.bit_vec.BitVecConfig; -pub const MAX_ENTRIES: u64 = 1024 * 1024; // 1 million slots is about 5 days - /// Analogous to [Check](https://github.com/anza-xyz/agave/blob/fc2a8794be2526e9fd6cdbc9b304c055b2d9cc57/sdk/program/src/slot_history.rs#L46) pub const SlotCheckResult = enum { future, too_old, found, not_found }; @@ -25,6 +23,8 @@ pub const SlotHistory = struct { pub const STORAGE_SIZE: u64 = 131_097; + pub const MAX_ENTRIES: u64 = 1024 * 1024; // 1 million slots is about 5 days + /// Agave initialises new slot history with the first slot set. /// This only impacts gensis when the slot history is not fully populated. pub fn init(allocator: Allocator) Allocator.Error!SlotHistory { diff --git a/src/version/version.zig b/src/version/version.zig index 89c75d4774..2ac694397e 100644 --- a/src/version/version.zig +++ b/src/version/version.zig @@ -24,6 +24,16 @@ pub const ClientVersion = struct { .client = .sig, }; + /// [agave] https://github.com/anza-xyz/agave/blob/v3.1.8/version/src/lib.rs#L83 + pub const API_VERSION = std.fmt.comptimePrint( + "{}.{}.{}", + .{ + CURRENT.major, + CURRENT.minor, + CURRENT.patch, + }, + ); + /// Keep up to date with: https://github.com/solana-foundation/solana-validator-client-ids/blob/main/client-ids.csv /// Currently based on: https://github.com/solana-foundation/solana-validator-client-ids/blob/0fff9f8e016972ff55680d011a4f81922c452f72/client-ids.csv const ClientId = enum(u16) {