diff --git a/contract_manager/package.json b/contract_manager/package.json index 1b23ca5a47..8a09c12c25 100644 --- a/contract_manager/package.json +++ b/contract_manager/package.json @@ -199,6 +199,16 @@ "types": "./dist/cjs/core/contracts/wormhole.d.ts" } }, + "./core/pythnet-programs": { + "import": { + "default": "./dist/esm/core/pythnet-programs.mjs", + "types": "./dist/esm/core/pythnet-programs.d.ts" + }, + "require": { + "default": "./dist/cjs/core/pythnet-programs.cjs", + "types": "./dist/cjs/core/pythnet-programs.d.ts" + } + }, "./core/token": { "import": { "default": "./dist/esm/core/token.mjs", diff --git a/contract_manager/scripts/PYTHNET_AUTHORITIES.md b/contract_manager/scripts/PYTHNET_AUTHORITIES.md new file mode 100644 index 0000000000..bdafcf0223 --- /dev/null +++ b/contract_manager/scripts/PYTHNET_AUTHORITIES.md @@ -0,0 +1,81 @@ +# Pythnet Authority Audit Tool + +Lists the BPF upgrade authorities for known Pyth programs deployed on Pythnet. + +## Usage + +```bash +pnpm --filter @pythnetwork/contract-manager exec ts-node scripts/list_pythnet_authorities.ts +``` + +### CLI Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--rpc ` | `https://pythnet.rpcpool.com` | Pythnet RPC endpoint | +| `--out ` | `pythnet-authorities.json` | Output JSON file path | +| `--programs ` | _(built-in list)_ | Custom program list JSON | + +### Custom Program List + +Supply a JSON file with `--programs` to override the built-in list: + +```json +[ + { + "name": "My Program", + "program_id": "", + "source": "description of where this ID comes from", + "is_validator_builtin": false + } +] +``` + +## Program Registry + +The built-in program list is sourced from: +- **[Pyth documentation](https://docs.pyth.network/price-feeds/core/contract-addresses/pythnet)** for program IDs +- **Existing repo constants** (`@pythnetwork/xc-admin-common`) for `REMOTE_EXECUTOR_ADDRESS` and `MESSAGE_BUFFER_PROGRAM_ID` + +Current programs: +| Name | Program ID | +|------|-----------| +| Oracle Program | `FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH` | +| Remote Executor | `exe6S3AxPVNmy46L4Nj6HrnnAVQUhwyYzMSNcnRn3qq` | +| Message Buffer | `7Vbmv1jt4vyuqBZcpYPpnVhrqVe5e6ZPb6JxDcffRHUM` | + +## Validator-Built-in Investigation + +The pythnet validator tree ([pyth-network/pythnet@pyth-v1.14.29/programs](https://github.com/pyth-network/pythnet/tree/pyth-v1.14.29/programs)) was investigated for Pyth oracle programs baked into the validator as builtin/native programs. + +**Finding: No Pyth oracle programs are baked into the validator as builtins.** + +The validator tree contains standard Solana system programs (vote, stake, system, etc.) but the Pyth oracle, Remote Executor, and Message Buffer programs are deployed as regular BPF upgradeable programs with ProgramData accounts. This means all three have inspectable upgrade authorities via the BPF Upgradeable Loader. + +## Output Schema + +The JSON output follows this schema (fields `state_authorities` and `validators` are placeholders for future PRs): + +```json +{ + "rpc": "https://pythnet.rpcpool.com", + "generated_at": "2024-01-01T00:00:00.000Z", + "programs": [ + { + "name": "Oracle Program", + "program_id": "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH", + "source": "docs.pyth.network/price-feeds/core/contract-addresses/pythnet", + "upgrade_authority": "", + "programdata_address": "", + "last_deploy_slot": 12345678, + "notes": "", + "state_authorities": [] + } + ], + "validators": [] +} +``` + +## Extending + +To add a new program, edit `contract_manager/src/core/pythnet-programs.ts` and add an entry to the `PYTHNET_PROGRAMS` array. If the program is a validator builtin (no ProgramData account), set `isValidatorBuiltin: true`. diff --git a/contract_manager/scripts/__tests__/list_pythnet_authorities.test.ts b/contract_manager/scripts/__tests__/list_pythnet_authorities.test.ts new file mode 100644 index 0000000000..8f0c94f5a8 --- /dev/null +++ b/contract_manager/scripts/__tests__/list_pythnet_authorities.test.ts @@ -0,0 +1,130 @@ +/** + * Fixture-based test for list_pythnet_authorities JSON output schema. + * + * Run: pnpm --filter @pythnetwork/contract-manager exec tsx scripts/__tests__/list_pythnet_authorities.test.ts + * + * Live RPC test (optional): TEST_LIVE_RPC=1 pnpm --filter @pythnetwork/contract-manager exec tsx scripts/__tests__/list_pythnet_authorities.test.ts + */ +/* eslint-disable no-console */ +import assert from "node:assert"; +import { PublicKey } from "@solana/web3.js"; +import { + decodeProgramAccount, + decodeProgramDataAccount, +} from "../../../governance/xc_admin/packages/xc_admin_common/src/bpf_upgradable_loader"; +import { PYTHNET_PROGRAMS } from "../../src/core/pythnet-programs"; + +// -- Fixture: a recorded Program account (type=2) pointing to a programdata address -- +// Type 2 (Program) = 02 00 00 00 in LE, followed by 32-byte programdata pubkey +const FIXTURE_PROGRAMDATA_PUBKEY = new PublicKey( + "BJ3jrUzddfuSrZHXSCxMUUQsjKEyLmuuyZebPtaxyPBJ", +); +const programAccountData = Buffer.alloc(36); +programAccountData.writeUInt32LE(2, 0); +FIXTURE_PROGRAMDATA_PUBKEY.toBuffer().copy(programAccountData, 4); + +// -- Fixture: a recorded ProgramData account (type=3) with upgrade authority -- +const FIXTURE_AUTHORITY = new PublicKey( + "3HGMrhJx7GhZHZSgxVikvRG4iz2gmqiH8WPqMYdykeyN", +); +const FIXTURE_SLOT = 123456789; +const programDataWithAuthority = Buffer.alloc(45); +programDataWithAuthority.writeUInt32LE(3, 0); +programDataWithAuthority.writeBigUInt64LE(BigInt(FIXTURE_SLOT), 4); +programDataWithAuthority[12] = 1; // Some(authority) +FIXTURE_AUTHORITY.toBuffer().copy(programDataWithAuthority, 13); + +// -- Fixture: ProgramData with no authority (immutable) -- +const programDataNoAuthority = Buffer.alloc(45); +programDataNoAuthority.writeUInt32LE(3, 0); +programDataNoAuthority.writeBigUInt64LE(BigInt(999), 4); +programDataNoAuthority[12] = 0; // None + +function testDecodeProgramAccount() { + const result = decodeProgramAccount(programAccountData); + assert.ok(result.equals(FIXTURE_PROGRAMDATA_PUBKEY), "decoded programdata address should match fixture"); + console.log(" PASS: decodeProgramAccount"); +} + +function testDecodeProgramDataWithAuthority() { + const result = decodeProgramDataAccount(programDataWithAuthority); + assert.strictEqual(result.slot, FIXTURE_SLOT, "slot should match"); + assert.ok(result.upgradeAuthority !== null, "authority should not be null"); + assert.ok( + result.upgradeAuthority!.equals(FIXTURE_AUTHORITY), + "authority should match fixture", + ); + console.log(" PASS: decodeProgramDataAccount (with authority)"); +} + +function testDecodeProgramDataNoAuthority() { + const result = decodeProgramDataAccount(programDataNoAuthority); + assert.strictEqual(result.slot, 999, "slot should be 999"); + assert.strictEqual(result.upgradeAuthority, null, "authority should be null for immutable program"); + console.log(" PASS: decodeProgramDataAccount (no authority / immutable)"); +} + +function testInvalidAccountType() { + const badData = Buffer.alloc(36); + badData.writeUInt32LE(99, 0); + assert.throws(() => decodeProgramAccount(badData), /Expected Program account type/); + console.log(" PASS: decodeProgramAccount rejects invalid type"); +} + +function testProgramRegistry() { + assert.ok(PYTHNET_PROGRAMS.length >= 3, "should have at least 3 programs"); + for (const p of PYTHNET_PROGRAMS) { + assert.ok(p.name, "program must have a name"); + assert.ok(p.programId instanceof PublicKey, "programId must be a PublicKey"); + assert.ok(p.source, "program must have a source"); + } + console.log(" PASS: PYTHNET_PROGRAMS registry is valid"); +} + +function testOutputSchema() { + // Verify the expected JSON schema shape + const sampleOutput = { + rpc: "https://pythnet.rpcpool.com", + generated_at: new Date().toISOString(), + programs: [ + { + name: "Test", + program_id: "11111111111111111111111111111111", + source: "test", + upgrade_authority: FIXTURE_AUTHORITY.toBase58(), + programdata_address: FIXTURE_PROGRAMDATA_PUBKEY.toBase58(), + last_deploy_slot: FIXTURE_SLOT, + notes: "", + state_authorities: [], + }, + ], + validators: [], + }; + + assert.ok(typeof sampleOutput.rpc === "string"); + assert.ok(typeof sampleOutput.generated_at === "string"); + assert.ok(Array.isArray(sampleOutput.programs)); + assert.ok(Array.isArray(sampleOutput.validators)); + const prog = sampleOutput.programs[0]; + assert.ok(typeof prog.name === "string"); + assert.ok(typeof prog.program_id === "string"); + assert.ok(typeof prog.source === "string"); + assert.ok(Array.isArray(prog.state_authorities)); + console.log(" PASS: JSON output schema is valid"); +} + +async function main() { + console.log("Running fixture-based tests...\n"); + + testDecodeProgramAccount(); + testDecodeProgramDataWithAuthority(); + testDecodeProgramDataNoAuthority(); + testInvalidAccountType(); + testProgramRegistry(); + testOutputSchema(); + + console.log("\nAll fixture tests passed!"); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises, unicorn/prefer-top-level-await +main(); diff --git a/contract_manager/scripts/list_pythnet_authorities.ts b/contract_manager/scripts/list_pythnet_authorities.ts new file mode 100644 index 0000000000..9eb348d14e --- /dev/null +++ b/contract_manager/scripts/list_pythnet_authorities.ts @@ -0,0 +1,194 @@ +/* eslint-disable no-console */ +import { Connection, PublicKey } from "@solana/web3.js"; +import { + BPF_UPGRADABLE_LOADER, + decodeProgramAccount, + decodeProgramDataAccount, +} from "@pythnetwork/xc-admin-common"; +import * as fs from "fs"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +import { PYTHNET_PROGRAMS } from "../src/core/pythnet-programs"; +import type { PythnetProgram } from "../src/core/pythnet-programs"; + +interface ProgramAuthorityEntry { + name: string; + program_id: string; + source: string; + upgrade_authority: string | null; + programdata_address: string | null; + last_deploy_slot: number | null; + notes: string; + state_authorities: never[]; +} + +interface AuthorityReport { + rpc: string; + generated_at: string; + programs: ProgramAuthorityEntry[]; + validators: never[]; +} + +const parser = yargs(hideBin(process.argv)) + .usage("Usage: $0") + .options({ + rpc: { + default: "https://pythnet.rpcpool.com", + desc: "Pythnet RPC endpoint URL", + type: "string", + }, + out: { + default: "pythnet-authorities.json", + desc: "Output JSON file path", + type: "string", + }, + programs: { + desc: "Optional path to a JSON file with custom program list", + type: "string", + }, + }); + +async function fetchUpgradeAuthority( + connection: Connection, + program: PythnetProgram, +): Promise { + const entry: ProgramAuthorityEntry = { + name: program.name, + program_id: program.programId.toBase58(), + source: program.source, + upgrade_authority: null, + programdata_address: null, + last_deploy_slot: null, + notes: "", + state_authorities: [], + }; + + if (program.isValidatorBuiltin) { + entry.notes = "validator builtin — upgraded via validator release"; + return entry; + } + + // Fetch the program account to get the programdata address + const programAccountInfo = await connection.getAccountInfo(program.programId); + if (!programAccountInfo) { + entry.notes = "program account not found"; + return entry; + } + + if (!programAccountInfo.owner.equals(BPF_UPGRADABLE_LOADER)) { + entry.notes = `unexpected owner: ${programAccountInfo.owner.toBase58()}`; + return entry; + } + + const programdataAddress = decodeProgramAccount( + programAccountInfo.data as Buffer, + ); + entry.programdata_address = programdataAddress.toBase58(); + + // Fetch the ProgramData account to get upgrade authority + const programdataAccountInfo = + await connection.getAccountInfo(programdataAddress); + if (!programdataAccountInfo) { + entry.notes = "programdata account not found"; + return entry; + } + + const programData = decodeProgramDataAccount( + programdataAccountInfo.data as Buffer, + ); + entry.last_deploy_slot = programData.slot; + entry.upgrade_authority = programData.upgradeAuthority + ? programData.upgradeAuthority.toBase58() + : null; + + if (!programData.upgradeAuthority) { + entry.notes = "program is immutable (no upgrade authority)"; + } + + return entry; +} + +async function loadCustomPrograms(path: string): Promise { + const content = fs.readFileSync(path, "utf-8"); + const data = JSON.parse(content) as Array<{ + name: string; + program_id: string; + source: string; + is_validator_builtin?: boolean; + }>; + return data.map((p) => ({ + name: p.name, + programId: new PublicKey(p.program_id), + source: p.source, + isValidatorBuiltin: p.is_validator_builtin ?? false, + })); +} + +async function main() { + const argv = await parser.argv; + const rpcUrl = argv.rpc; + const connection = new Connection(rpcUrl, "confirmed"); + + const programs = argv.programs + ? await loadCustomPrograms(argv.programs) + : PYTHNET_PROGRAMS; + + console.log(`Connecting to ${rpcUrl}...`); + console.log(`Querying ${programs.length} programs...\n`); + + const entries: ProgramAuthorityEntry[] = []; + + for (const program of programs) { + try { + const entry = await fetchUpgradeAuthority(connection, program); + entries.push(entry); + console.log(`✓ ${program.name}: authority=${entry.upgrade_authority ?? "null"}`); + } catch (error) { + console.error(`✗ ${program.name}: ${error}`); + entries.push({ + name: program.name, + program_id: program.programId.toBase58(), + source: program.source, + upgrade_authority: null, + programdata_address: null, + last_deploy_slot: null, + notes: `error: ${error instanceof Error ? error.message : String(error)}`, + state_authorities: [], + }); + } + } + + const report: AuthorityReport = { + rpc: rpcUrl, + generated_at: new Date().toISOString(), + programs: entries, + validators: [], + }; + + // Write JSON output + fs.writeFileSync(argv.out, JSON.stringify(report, null, 2) + "\n"); + console.log(`\nJSON output written to ${argv.out}`); + + // Print human-readable table + console.log("\n--- Pythnet BPF Upgrade Authorities ---\n"); + console.table( + entries.map((e) => ({ + Program: e.name, + "Program ID": e.program_id, + "Upgrade Authority": e.upgrade_authority ?? "(none)", + "Last Deploy Slot": e.last_deploy_slot ?? "-", + Notes: e.notes || "-", + })), + ); + + // Exit non-zero if all programs had errors + const allFailed = entries.every((e) => e.notes.startsWith("error:")); + if (allFailed) { + console.error("\nAll program queries failed. Check the RPC endpoint."); + process.exit(1); + } +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises, unicorn/prefer-top-level-await +main(); diff --git a/contract_manager/src/core/pythnet-programs.ts b/contract_manager/src/core/pythnet-programs.ts new file mode 100644 index 0000000000..070aaa21f7 --- /dev/null +++ b/contract_manager/src/core/pythnet-programs.ts @@ -0,0 +1,47 @@ +import { PublicKey } from "@solana/web3.js"; +import { + MESSAGE_BUFFER_PROGRAM_ID, + REMOTE_EXECUTOR_ADDRESS, +} from "@pythnetwork/xc-admin-common"; + +/** + * Registry of known Pyth programs deployed on Pythnet. + * Sources are documented per entry; see https://docs.pyth.network/price-feeds/core/contract-addresses/pythnet + */ + +export interface PythnetProgram { + name: string; + programId: PublicKey; + source: string; + isValidatorBuiltin: boolean; +} + +// Oracle Program ID for pythnet cluster +// Source: @pythnetwork/client getPythProgramKeyForCluster("pythnet") +const ORACLE_PROGRAM_ID = new PublicKey( + "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH", +); + +export const PYTHNET_PROGRAMS: PythnetProgram[] = [ + { + name: "Oracle Program", + // Source: docs.pyth.network/price-feeds/core/contract-addresses/pythnet + programId: ORACLE_PROGRAM_ID, + source: "docs.pyth.network/price-feeds/core/contract-addresses/pythnet", + isValidatorBuiltin: false, + }, + { + name: "Remote Executor", + // Source: @pythnetwork/xc-admin-common REMOTE_EXECUTOR_ADDRESS + programId: REMOTE_EXECUTOR_ADDRESS, + source: "docs.pyth.network/price-feeds/core/contract-addresses/pythnet", + isValidatorBuiltin: false, + }, + { + name: "Message Buffer", + // Source: @pythnetwork/xc-admin-common MESSAGE_BUFFER_PROGRAM_ID + programId: MESSAGE_BUFFER_PROGRAM_ID, + source: "docs.pyth.network/price-feeds/core/contract-addresses/pythnet", + isValidatorBuiltin: false, + }, +]; diff --git a/governance/xc_admin/packages/xc_admin_common/src/bpf_upgradable_loader.ts b/governance/xc_admin/packages/xc_admin_common/src/bpf_upgradable_loader.ts index d40a8eef1e..bbc3e70414 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/bpf_upgradable_loader.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/bpf_upgradable_loader.ts @@ -7,3 +7,47 @@ export const PROGRAM_AUTHORITY_ESCROW = new PublicKey( export const BPF_UPGRADABLE_LOADER = new PublicKey( "BPFLoaderUpgradeab1e11111111111111111111111", ); + +/** + * Decoded ProgramData account from BPFLoaderUpgradeable. + * Layout: 4-byte type (u32=3) | 8-byte slot (u64 LE) | 1-byte Option tag | 32-byte pubkey + */ +export interface ProgramDataAccountInfo { + slot: number; + upgradeAuthority: PublicKey | null; +} + +/** + * Decode the programdata_address from a BPF upgradeable Program account. + * Layout: 4-byte type (u32=2) | 32-byte programdata address + */ +export function decodeProgramAccount(data: Buffer): PublicKey { + const accountType = data.readUInt32LE(0); + if (accountType !== 2) { + throw new Error( + `Expected Program account type (2), got ${accountType}`, + ); + } + return new PublicKey(data.subarray(4, 36)); +} + +/** + * Decode a ProgramData account to extract upgrade authority and deploy slot. + * Layout: 4-byte type (u32=3) | 8-byte slot (u64 LE) | 1-byte Option tag | 32-byte pubkey + */ +export function decodeProgramDataAccount( + data: Buffer, +): ProgramDataAccountInfo { + const accountType = data.readUInt32LE(0); + if (accountType !== 3) { + throw new Error( + `Expected ProgramData account type (3), got ${accountType}`, + ); + } + // slot is a u64 LE at offset 4; use Number (safe for realistic slot values) + const slot = Number(data.readBigUInt64LE(4)); + const optionTag = data[12]; + const upgradeAuthority = + optionTag === 1 ? new PublicKey(data.subarray(13, 45)) : null; + return { slot, upgradeAuthority }; +}