From ec6c47ea2c1511374c10922526a6dbf1556e530c Mon Sep 17 00:00:00 2001 From: prestonsn Date: Mon, 16 Feb 2026 20:06:21 +0000 Subject: [PATCH 1/2] feat(rpc): implement getAccountInfo with jsonParsed encoding support Implement the getAccountInfo RPC method with full support for all Solana account encodings: base58, base64, base64+zstd, and jsonParsed. - Add AccountHookContext with getAccountInfo method for reading account data - Support dataSlice parameter for partial account reads - Implement streaming zstd compression for base64+zstd encoding - Fall back to base64 encoding when no jsonParsed parser is available Add comprehensive account parsing for the following program types: - **Vote accounts**: Parse vote state with authorized voters, prior voters, epoch credits, and last timestamp - **Stake accounts**: Parse delegation info, stake activation, and lockup - **Nonce accounts**: Parse authority, blockhash, and fee calculator - **Address Lookup Tables**: Parse lookup table state with addresses - **BPF Upgradeable Loader**: Parse program, programData, and buffer accounts - **Sysvars**: Parse clock, rent, epochSchedule, slotHistory, slotHashes, stakeHistory, epochRewards, fees, recentBlockhashes, and lastRestartSlot - **Config accounts**: Parse validator info and stake config - **SPL Token & Token-2022**: Full token account, mint, and multisig parsing with extension support (transfer fees, interest-bearing, metadata, etc.) - Parse all token extensions including TransferFeeConfig, InterestBearingConfig, TokenMetadata, ConfidentialTransferMint, PermanentDelegate, and more - Support interest-bearing token amount calculations with compound interest - Support scaled UI amounts with multiplier switching - Parse TokenMetadata additional_metadata key-value pairs - Move AccountEncoding enum to common module for reuse - Add SPL_TOKEN_PROGRAM_ID and SPL_TOKEN_2022_PROGRAM_ID to runtime/ids.zig - Move MAX_ENTRIES constant into SlotHistory struct - Fix SlotTracker to avoid use-after-free issues with processed/confirmed counters --- src/cmd.zig | 2 +- src/rpc/account_decoder/lib.zig | 590 ++++++ .../parse_account_lookup_table.zig | 204 ++ .../parse_bpf_upgradeable_loader.zig | 356 ++++ src/rpc/account_decoder/parse_config.zig | 310 +++ src/rpc/account_decoder/parse_nonce.zig | 154 ++ src/rpc/account_decoder/parse_stake.zig | 294 +++ src/rpc/account_decoder/parse_sysvar.zig | 654 +++++++ src/rpc/account_decoder/parse_token.zig | 1561 +++++++++++++++ .../account_decoder/parse_token_extension.zig | 1672 +++++++++++++++++ src/rpc/account_decoder/parse_vote.zig | 340 ++++ src/rpc/methods.zig | 224 +++ 12 files changed, 6360 insertions(+), 1 deletion(-) create mode 100644 src/rpc/account_decoder/lib.zig create mode 100644 src/rpc/account_decoder/parse_account_lookup_table.zig create mode 100644 src/rpc/account_decoder/parse_bpf_upgradeable_loader.zig create mode 100644 src/rpc/account_decoder/parse_config.zig create mode 100644 src/rpc/account_decoder/parse_nonce.zig create mode 100644 src/rpc/account_decoder/parse_stake.zig create mode 100644 src/rpc/account_decoder/parse_sysvar.zig create mode 100644 src/rpc/account_decoder/parse_token.zig create mode 100644 src/rpc/account_decoder/parse_token_extension.zig create mode 100644 src/rpc/account_decoder/parse_vote.zig diff --git a/src/cmd.zig b/src/cmd.zig index 7ec11fff71..483ff63ae7 100644 --- a/src/cmd.zig +++ b/src/cmd.zig @@ -1679,7 +1679,7 @@ fn validator( allocator, sig.rpc.methods.AccountHookContext{ .slot_tracker = &replay_service_state.replay_state.slot_tracker, - .account_reader = replay_service_state.replay_state.account_store.reader(), + .account_reader = account_store.reader(), }, ); diff --git a/src/rpc/account_decoder/lib.zig b/src/rpc/account_decoder/lib.zig new file mode 100644 index 0000000000..c9f30cf712 --- /dev/null +++ b/src/rpc/account_decoder/lib.zig @@ -0,0 +1,590 @@ +/// 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"); +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 syvar. +/// 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 .{}; + + // Fetch the mint account + const maybe_mint_account = slot_reader.get(allocator, mint_pubkey) catch return .{}; + const mint_account = maybe_mint_account orelse return .{}; + 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 .{}; + defer allocator.free(mint_data); + _ = mint_iter.readBytes(mint_data) catch return .{}; + + // Parse mint to get decimals + const mint = parse_token.Mint.unpack(mint_data) catch return .{}; + + // 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 .{}; + const clock_account = maybe_clock_account orelse return .{}; + 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 .{}; + + // 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 .{ + .spl_token = .{ + .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..79070510cb --- /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 + 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/methods.zig b/src/rpc/methods.zig index 85656a9e9a..ff5da07f57 100644 --- a/src/rpc/methods.zig +++ b/src/rpc/methods.zig @@ -11,6 +11,8 @@ const std = @import("std"); const sig = @import("../sig.zig"); const rpc = @import("lib.zig"); +const base58 = @import("base58"); +const zstd = @import("zstd"); const Allocator = std.mem.Allocator; const ParseOptions = std.json.ParseOptions; @@ -895,3 +897,225 @@ pub const AccountHookContext = struct { }; } }; + +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, + }; + } + } + + /// 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 }; + } +}; From e6217e2f713628b8f5dde94004d066a4d2295872 Mon Sep 17 00:00:00 2001 From: prestonsn Date: Wed, 18 Feb 2026 21:27:08 -0600 Subject: [PATCH 2/2] feat(rpc): implement getTokenAccountBalance RPC method - Add getTokenAccountBalance endpoint that returns token balance with - UI amount formatting. Handles SPL Token and Token-2022 accounts, - including interest-bearing and scaled UI amount extension configs. --- src/cmd.zig | 7 +- src/rpc/account_codec/lib.zig | 63 +- src/rpc/account_codec/parse_token.zig | 27 +- src/rpc/account_decoder/lib.zig | 590 ------ .../parse_account_lookup_table.zig | 204 -- .../parse_bpf_upgradeable_loader.zig | 356 ---- src/rpc/account_decoder/parse_config.zig | 310 --- src/rpc/account_decoder/parse_nonce.zig | 154 -- src/rpc/account_decoder/parse_stake.zig | 294 --- src/rpc/account_decoder/parse_sysvar.zig | 654 ------- src/rpc/account_decoder/parse_token.zig | 1561 --------------- .../account_decoder/parse_token_extension.zig | 1672 ----------------- src/rpc/account_decoder/parse_vote.zig | 340 ---- src/rpc/methods.zig | 312 +-- src/rpc/test_serialize.zig | 34 +- src/runtime/ids.zig | 4 + 16 files changed, 190 insertions(+), 6392 deletions(-) delete mode 100644 src/rpc/account_decoder/lib.zig delete mode 100644 src/rpc/account_decoder/parse_account_lookup_table.zig delete mode 100644 src/rpc/account_decoder/parse_bpf_upgradeable_loader.zig delete mode 100644 src/rpc/account_decoder/parse_config.zig delete mode 100644 src/rpc/account_decoder/parse_nonce.zig delete mode 100644 src/rpc/account_decoder/parse_stake.zig delete mode 100644 src/rpc/account_decoder/parse_sysvar.zig delete mode 100644 src/rpc/account_decoder/parse_token.zig delete mode 100644 src/rpc/account_decoder/parse_token_extension.zig delete mode 100644 src/rpc/account_decoder/parse_vote.zig diff --git a/src/cmd.zig b/src/cmd.zig index 483ff63ae7..78a3f429f2 100644 --- a/src/cmd.zig +++ b/src/cmd.zig @@ -1665,21 +1665,16 @@ fn validator( }); defer replay_service_state.deinit(allocator); - const account_store = sig.accounts_db.AccountStore{ - .accounts_db_two = &new_db, - }; - try app_base.rpc_hooks.set(allocator, sig.rpc.methods.RpcHookContext{ .slot_tracker = &replay_service_state.replay_state.slot_tracker, .epoch_tracker = &epoch_tracker, - .account_reader = account_store.reader(), }); 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(), + .account_reader = replay_service_state.replay_state.account_store.reader(), }, ); diff --git a/src/rpc/account_codec/lib.zig b/src/rpc/account_codec/lib.zig index 39b2cafb08..157310feec 100644 --- a/src/rpc/account_codec/lib.zig +++ b/src/rpc/account_codec/lib.zig @@ -11,7 +11,7 @@ const parse_config = @import("parse_config.zig"); const parse_nonce = @import("parse_nonce.zig"); const parse_stake = @import("parse_stake.zig"); const parse_sysvar = @import("parse_sysvar.zig"); -const parse_token = @import("parse_token.zig"); +pub const parse_token = @import("parse_token.zig"); const parse_token_extension = @import("parse_token_extension.zig"); const parse_vote = @import("parse_vote.zig"); @@ -412,9 +412,27 @@ pub fn JsonString(comptime max_len: usize) type { self.len += n; } + // Note: only used for unit tests + pub fn eql(self: *const Self, other: *const Self) bool { + return self.len == other.len and + std.mem.eql(u8, self.inner[0..self.len], other.inner[0..other.len]); + } + pub fn jsonStringify(self: Self, jw: anytype) @TypeOf(jw.*).Error!void { try jw.write(self.constSlice()); } + + // Note: only used for unit tests + pub fn jsonParse( + _: std.mem.Allocator, + source: anytype, + _: std.json.ParseOptions, + ) std.json.ParseError(@TypeOf(source.*))!Self { + return switch (try source.next()) { + .string => |str| Self.fromSlice(str), + else => error.UnexpectedToken, + }; + } }; } @@ -668,24 +686,41 @@ pub fn buildTokenAdditionalData( // 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 .{}; - const mint_account = maybe_mint_account orelse return .{}; + 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 .{}; + 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 .{}; + _ = mint_iter.readBytes(mint_data) catch return null; // Parse mint to get decimals - const mint = parse_token.Mint.unpack(mint_data) catch return .{}; + 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 .{}; - const clock_account = maybe_clock_account orelse return .{}; + 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(); @@ -694,7 +729,7 @@ pub fn buildTokenAdditionalData( sig.runtime.sysvar.Clock, clock_iter.reader(), .{}, - ) catch return .{}; + ) catch return null; // Extract extension configs from mint data const InterestCfg = parse_token_extension.InterestBearingConfigData; @@ -703,12 +738,10 @@ pub fn buildTokenAdditionalData( const scaled_config = ScaledCfg.extractFromMint(mint_data); return .{ - .spl_token = .{ - .decimals = mint.decimals, - .unix_timestamp = clock.unix_timestamp, - .interest_bearing_config = interest_config, - .scaled_ui_amount_config = scaled_config, - }, + .decimals = mint.decimals, + .unix_timestamp = clock.unix_timestamp, + .interest_bearing_config = interest_config, + .scaled_ui_amount_config = scaled_config, }; } diff --git a/src/rpc/account_codec/parse_token.zig b/src/rpc/account_codec/parse_token.zig index 5f026c025e..a5956e9977 100644 --- a/src/rpc/account_codec/parse_token.zig +++ b/src/rpc/account_codec/parse_token.zig @@ -404,7 +404,7 @@ pub const UiTokenAmount = struct { /// Create a UiTokenAmount from raw amount and additional data. /// Handles interest-bearing and scaled UI amount calculations if configured. /// Priority: interest-bearing > scaled > simple - fn init(amount: u64, additional_data: SplTokenAdditionalData) UiTokenAmount { + pub fn init(amount: u64, additional_data: SplTokenAdditionalData) UiTokenAmount { const decimals = additional_data.decimals; // Priority 1: Interest-bearing config @@ -466,6 +466,31 @@ pub const UiTokenAmount = struct { try jw.write(self.ui_amount_string); try jw.endObject(); } + + // Note: only used for unit tests. + pub fn jsonParse( + allocator: std.mem.Allocator, + source: anytype, + options: std.json.ParseOptions, + ) std.json.ParseError(@TypeOf(source.*))!UiTokenAmount { + const value = try std.json.Value.jsonParse(allocator, source, options); + const intermediate = try std.json.parseFromValue(struct { + uiAmount: ?f64 = null, + decimals: u8, + amount: []const u8, + uiAmountString: []const u8, + }, allocator, value, options); + return .{ + .ui_amount = intermediate.value.uiAmount, + .decimals = intermediate.value.decimals, + .amount = std.fmt.parseInt( + u64, + intermediate.value.amount, + 10, + ) catch return error.UnexpectedToken, + .ui_amount_string = JsonString(40).fromSlice(intermediate.value.uiAmountString), + }; + } }; /// Format amount with decimal point, trimming trailing zeros. diff --git a/src/rpc/account_decoder/lib.zig b/src/rpc/account_decoder/lib.zig deleted file mode 100644 index c9f30cf712..0000000000 --- a/src/rpc/account_decoder/lib.zig +++ /dev/null @@ -1,590 +0,0 @@ -/// 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"); -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 syvar. -/// 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 .{}; - - // Fetch the mint account - const maybe_mint_account = slot_reader.get(allocator, mint_pubkey) catch return .{}; - const mint_account = maybe_mint_account orelse return .{}; - 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 .{}; - defer allocator.free(mint_data); - _ = mint_iter.readBytes(mint_data) catch return .{}; - - // Parse mint to get decimals - const mint = parse_token.Mint.unpack(mint_data) catch return .{}; - - // 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 .{}; - const clock_account = maybe_clock_account orelse return .{}; - 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 .{}; - - // 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 .{ - .spl_token = .{ - .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 deleted file mode 100644 index f37cececf7..0000000000 --- a/src/rpc/account_decoder/parse_account_lookup_table.zig +++ /dev/null @@ -1,204 +0,0 @@ -/// 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 deleted file mode 100644 index 3d9f99c801..0000000000 --- a/src/rpc/account_decoder/parse_bpf_upgradeable_loader.zig +++ /dev/null @@ -1,356 +0,0 @@ -/// 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 deleted file mode 100644 index 3f74dec0e9..0000000000 --- a/src/rpc/account_decoder/parse_config.zig +++ /dev/null @@ -1,310 +0,0 @@ -/// 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 deleted file mode 100644 index c75f83de9e..0000000000 --- a/src/rpc/account_decoder/parse_nonce.zig +++ /dev/null @@ -1,154 +0,0 @@ -/// 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 deleted file mode 100644 index 52d51da77f..0000000000 --- a/src/rpc/account_decoder/parse_stake.zig +++ /dev/null @@ -1,294 +0,0 @@ -/// 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 deleted file mode 100644 index ef1b7f95f8..0000000000 --- a/src/rpc/account_decoder/parse_sysvar.zig +++ /dev/null @@ -1,654 +0,0 @@ -/// 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 deleted file mode 100644 index 79070510cb..0000000000 --- a/src/rpc/account_decoder/parse_token.zig +++ /dev/null @@ -1,1561 +0,0 @@ -/// 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 - 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 deleted file mode 100644 index d529bc6785..0000000000 --- a/src/rpc/account_decoder/parse_token_extension.zig +++ /dev/null @@ -1,1672 +0,0 @@ -/// 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 deleted file mode 100644 index 1aaf70b9d7..0000000000 --- a/src/rpc/account_decoder/parse_vote.zig +++ /dev/null @@ -1,340 +0,0 @@ -/// 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/methods.zig b/src/rpc/methods.zig index ff5da07f57..39c1832cc9 100644 --- a/src/rpc/methods.zig +++ b/src/rpc/methods.zig @@ -12,7 +12,6 @@ const std = @import("std"); const sig = @import("../sig.zig"); const rpc = @import("lib.zig"); const base58 = @import("base58"); -const zstd = @import("zstd"); const Allocator = std.mem.Allocator; const ParseOptions = std.json.ParseOptions; @@ -83,7 +82,7 @@ pub const MethodAndParams = union(enum) { getSlotLeaders: noreturn, getStakeMinimumDelegation: noreturn, getSupply: noreturn, - getTokenAccountBalance: noreturn, + getTokenAccountBalance: GetTokenAccountBalance, getTokenAccountsByDelegate: noreturn, getTokenAccountsByOwner: noreturn, getTokenLargestAccounts: noreturn, @@ -458,7 +457,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_codec.parse_token.UiTokenAmount, + }; +}; + // TODO: getTokenAccountsByDelegate // TODO: getTokenAccountsByOwner // TODO: getTokenLargestAccounts @@ -651,7 +663,6 @@ pub const common = struct { pub const RpcHookContext = struct { slot_tracker: *const sig.replay.trackers.SlotTracker, epoch_tracker: *const sig.core.EpochTracker, - account_reader: sig.accounts_db.AccountReader, // Limit the length of the `epoch_credits` array for each validator in a `get_vote_accounts` // response. @@ -785,42 +796,6 @@ pub const RpcHookContext = struct { .delinquent = dlinqt, }; } - - pub fn getBalance( - self: RpcHookContext, - allocator: std.mem.Allocator, - params: GetBalance, - ) !GetBalance.Response { - const config = params.config orelse common.CommitmentSlotConfig{}; - // [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; - - const slot = self.slot_tracker.getSlotForCommitment(commitment); - if (config.minContextSlot) |min_slot| { - if (slot < min_slot) return error.RpcMinContextSlotNotMet; - } - - // Get slot reference to access ancestors - const ref = self.slot_tracker.get(slot) orelse return error.SlotNotAvailable; - const slot_reader = self.account_reader.forSlot(&ref.constants.ancestors); - - // Look up account - const maybe_account = try slot_reader.get(allocator, params.pubkey); - - const lamports: u64 = if (maybe_account) |account| blk: { - defer account.deinit(allocator); - break :blk account.lamports; - } else 0; - - return .{ - .context = .{ - .slot = slot, - .apiVersion = ClientVersion.API_VERSION, - }, - .value = lamports, - }; - } }; pub const StaticHookContext = struct { @@ -896,226 +871,95 @@ pub const AccountHookContext = struct { }, }; } -}; -pub const AccountHookContext = struct { - slot_tracker: *const sig.replay.trackers.SlotTracker, - account_reader: sig.accounts_db.AccountReader, - - pub fn getAccountInfo( + pub fn getBalance( self: AccountHookContext, allocator: std.mem.Allocator, - params: GetAccountInfo, - ) !GetAccountInfo.Response { - const config = params.config orelse GetAccountInfo.Config{}; + params: GetBalance, + ) !GetBalance.Response { + const config = params.config orelse common.CommitmentSlotConfig{}; // [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; + // Get slot reference to access ancestors + const ref = self.slot_tracker.get(slot) orelse return error.SlotNotAvailable; const slot_reader = self.account_reader.forSlot(&ref.constants.ancestors); + + // Look up account const maybe_account = try slot_reader.get(allocator, params.pubkey); - if (maybe_account) |account| { + const lamports: u64 = if (maybe_account) |account| blk: { defer account.deinit(allocator); + break :blk account.lamports; + } else 0; - 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, - }; - } - } - - /// 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, - }, + .context = .{ .slot = slot }, + .value = lamports, }; } - fn encodeAccountData( + pub fn getTokenAccountBalance( + self: AccountHookContext, 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; - } + params: GetTokenAccountBalance, + ) !GetTokenAccountBalance.Response { + const config = params.config orelse GetTokenAccountBalance.Config{}; + const commitment = config.commitment orelse .finalized; - 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]); + const slot = self.slot_tracker.getSlotForCommitment(commitment); - 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); + 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); - 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 - }; - } + 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: [account_codec.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 < account_codec.parse_token.TokenAccount.LEN) + return error.RpcNotATokenAccount; + + const token_account = account_codec.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: account_codec.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_codec.getMintAdditionalData( + allocator, + token_account.mint, + slot_reader, + ) orelse return error.RpcMintNotFound; - 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 }; + const ui_token_amount = account_codec.parse_token.UiTokenAmount.init( + token_account.amount, + spl_token_data, + ); + + return .{ + .context = .{ .slot = slot }, + .value = ui_token_amount, }; - return .{ slice_start, slice_end }; } }; diff --git a/src/rpc/test_serialize.zig b/src/rpc/test_serialize.zig index 6ab640f5f1..edd6005a49 100644 --- a/src/rpc/test_serialize.zig +++ b/src/rpc/test_serialize.zig @@ -18,6 +18,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; @@ -310,7 +311,38 @@ 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 deserialization + try testResponse(GetTokenAccountBalance, .{ .result = .{ + .context = .{ .slot = 1114, .apiVersion = "2.1.6" }, + .value = sig.rpc.account_codec.parse_token.UiTokenAmount.init( + 9864, + sig.rpc.account_codec.parse_token.SplTokenAdditionalData{ .decimals = 2 }, + ), + } }, + \\{"jsonrpc":"2.0","result":{"context":{"slot":1114,"apiVersion":"2.1.6"},"value":{"uiAmount":98.64,"decimals":2,"amount":"9864","uiAmountString":"98.64"}},"id":1} + ); +} + // TODO: test getTokenAccountsByDelegate() // TODO: test getTockenAccountsByOwner() // TODO: test getTokenLargestAccounts() diff --git a/src/runtime/ids.zig b/src/runtime/ids.zig index 80689111d6..073a19604d 100644 --- a/src/runtime/ids.zig +++ b/src/runtime/ids.zig @@ -30,3 +30,7 @@ pub const SPL_TOKEN_PROGRAM_ID: Pubkey = .parse("TokenkegQfeZyiNwAJbNbGKPFXCWuBv /// 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");