diff --git a/.github/workflows/deploy-program.yml b/.github/workflows/deploy-program.yml index 99f9483..379cdbe 100644 --- a/.github/workflows/deploy-program.yml +++ b/.github/workflows/deploy-program.yml @@ -7,6 +7,16 @@ on: description: Program directory containing Cargo.toml required: true type: string + environment: + description: Optional GitHub deployment environment; empty leaves the job without an environment + required: false + default: "" + type: string + expect-program-id: + description: Optional expected program ID (Base58 or 64-character hex); must match PROGRAM_KEY + required: false + default: "" + type: string cargo-features: description: Optional Cargo features to enable when building the program required: false @@ -33,10 +43,15 @@ on: default: false type: boolean idl-size: - description: Initial IDL account size; zero uses the arch-kit default + description: Minimum IDL account size; zero uses the arch-kit default required: false default: 0 type: number + allow-idl-resize: + description: Opt into clearing, growing, and republishing a populated canonical IDL + required: false + default: false + type: boolean secrets: PROGRAM_KEY: description: Program private key as hex or SDK-compatible JSON @@ -53,7 +68,7 @@ permissions: contents: read env: - ARCH_KIT_VERSION: 0.1.3 + ARCH_KIT_VERSION: 0.1.4 ANZA_VERSION: v3.1.9 CARGO_TERM_COLOR: always @@ -61,6 +76,7 @@ jobs: deploy: name: Build and Deploy runs-on: ubuntu-24.04 + environment: ${{ inputs.environment }} outputs: program-id: ${{ steps.keys.outputs.program-id }} @@ -218,6 +234,8 @@ jobs: BITCOIN_NETWORK: ${{ inputs.bitcoin-network }} FUND_AUTHORITY: ${{ inputs.fund-authority }} IDL_SIZE: ${{ inputs.idl-size }} + ALLOW_IDL_RESIZE: ${{ inputs.allow-idl-resize }} + EXPECT_PROGRAM_ID: ${{ inputs.expect-program-id }} ELF_PATH: ${{ steps.program.outputs.elf-path }} IDL_PATH: ${{ steps.idl.outputs.idl-path }} PROGRAM_KEY_PATH: ${{ steps.keys.outputs.program-key }} @@ -240,6 +258,12 @@ jobs: if (( IDL_SIZE > 0 )); then args+=(--idl-size "${IDL_SIZE}") fi + if [[ "${ALLOW_IDL_RESIZE}" == "true" ]]; then + args+=(--allow-idl-resize) + fi + if [[ -n "${EXPECT_PROGRAM_ID}" ]]; then + args+=(--expect-program-id "${EXPECT_PROGRAM_ID}") + fi arch-kit "${args[@]}" - name: Remove deployment keys diff --git a/Cargo.lock b/Cargo.lock index da6b422..44517b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ dependencies = [ [[package]] name = "arch-kit" -version = "0.1.3" +version = "0.1.4" dependencies = [ "apl-associated-token-account", "apl-token", diff --git a/Cargo.toml b/Cargo.toml index d1d90e7..606b626 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "arch-kit" -version = "0.1.3" +version = "0.1.4" edition = "2024" rust-version = "1.85" description = "Command-line toolkit for developing, deploying, and interacting with Arch Network programs" diff --git a/README.md b/README.md index 8518b68..6511bf9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ with the reusable workflow: ```yaml jobs: deploy: - uses: Arch-Network/arch-kit/.github/workflows/deploy-program.yml@v0.1.0 + uses: Arch-Network/arch-kit/.github/workflows/deploy-program.yml@v0.1.4 with: program-path: program bitcoin-network: testnet @@ -51,6 +51,19 @@ jobs: AUTHORITY_KEY: ${{ secrets.AUTHORITY_KEY }} ``` +To select a GitHub deployment environment, add `environment` under `with`: + +```yaml + with: + program-path: program + environment: production +``` + +The input defaults to an empty string, so omitting it runs without an +environment. When set, the deployment job uses that environment's protection +rules and secrets. Environment secrets take precedence over caller-provided +secrets with the same name; see [GitHub's reusable workflow documentation](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow). + When `idl-path` is omitted, the workflow builds the Satellite IDL from the program. Programs with manually maintained IDLs can bypass that step: @@ -66,6 +79,16 @@ the program and canonical IDL. Faucet funding is disabled unless explicitly enabled. Reusing the same two key secrets updates the existing deployment; changing the program key creates a new deployment. +Set `expect-program-id` to the intended program ID (Base58 or 64-character hex) +to check that `PROGRAM_KEY` derives that ID before faucet funding or deployment. +It defaults to an empty string, which skips this check. + +Set `allow-idl-resize: true` to opt into growing an existing populated canonical +IDL when the new IDL no longer fits, or to reserve more space with `idl-size`. +The input defaults to `false`, preserving the existing fixed-capacity behavior. +Growth also supports Satellite 0.31.5/0.31.6 handlers: the CLI backs up the IDL, +then clears, grows, and restores it within each resize transaction. + ## Program development Programs using Satellite require Rust nightly during the IDL-building step. @@ -350,17 +373,30 @@ byte array. Useful deployment options: +- `--expect-program-id ` checks that `--program-key` derives the expected + program ID before faucet funding or deployment. Accepts Base58 or 64-character + hex; omitted by default. - `--generate-if-missing` securely creates missing program or authority keys. - `--fund-authority` requests faucet funding before deployment; it is rejected on mainnet. - `--idl ` publishes or upgrades an IDL after deployment. -- `--idl-size ` sets the initial IDL account size and requires `--idl`. +- `--idl-size ` sets the minimum IDL account size and requires `--idl`. + Growing a populated account also requires `--allow-idl-resize`. +- `--allow-idl-resize` opts into growing a populated canonical IDL and requires + `--idl`. It is disabled by default. IDL accounts default to at least 10,000 bytes, including the 44-byte header. -Reserve enough capacity for future upgrades because a populated IDL account -cannot be grown. The target program must include compatible canonical Satellite -IDL handlers. If IDL publication fails, the deployed program remains deployed -and its program ID is included in the error. +Populated accounts retain their capacity unless `--allow-idl-resize` is supplied. +With the flag, growth accommodates the new compressed IDL and any larger +`--idl-size`, including when the IDL contents are unchanged. Accounts are never +shrunk. The target program must include canonical Satellite IDL handlers. +For compatibility with older handlers, the CLI copies the current IDL into a +backup buffer, verifies the copy, then clears, grows, and restores the canonical +IDL within each resize transaction. A failed transaction leaves the published +IDL intact. The temporary resize buffers are closed after the final growth +step, returning their rent to the authority. The CLI verifies the restored IDL +before publishing an update. If IDL publication fails, the deployed program +remains deployed and its program ID is included in the error. ## Publishing arch-kit diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index ba9e755..55e8a60 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -15,6 +15,7 @@ use crate::{ error::{CliError, Result}, idl, keys::{load_or_generate_key, pubkey_hex}, + token::parse_pubkey, }; #[derive(Debug, clap::Args)] @@ -27,6 +28,10 @@ pub(crate) struct Args { #[arg(long, value_name = "PATH")] pub(crate) program_key: PathBuf, + /// Require --program-key to derive this program ID (Base58 or 64-character hex). + #[arg(long, value_name = "PUBKEY")] + pub(crate) expect_program_id: Option, + /// Existing deployment and IDL authority keypair. #[arg(long, value_name = "PATH")] pub(crate) authority: PathBuf, @@ -43,12 +48,23 @@ pub(crate) struct Args { #[arg(long, value_name = "PATH")] pub(crate) idl: Option, - /// Minimum initial canonical IDL account size in bytes, including its 44-byte header. + /// Minimum canonical IDL account size in bytes, including its 44-byte header. + /// Growing a populated account also requires --allow-idl-resize. #[arg(long, requires = "idl", value_name = "BYTES")] pub(crate) idl_size: Option, + + /// Allow clearing, growing, and republishing a populated canonical IDL. + #[arg(long, requires = "idl")] + pub(crate) allow_idl_resize: bool, } pub(crate) fn run(config: &Config, args: Args) -> Result<()> { + let expected_program = args + .expect_program_id + .as_deref() + .map(|value| parse_pubkey(value, "--expect-program-id")) + .transpose()?; + ensure_file(&args.elf, "program ELF")?; // Read now so invalid permissions or I/O fail before optional faucet use. std::fs::read(&args.elf).map_err(|source| CliError::ReadInput { @@ -63,6 +79,13 @@ pub(crate) fn run(config: &Config, args: Args) -> Result<()> { config.network, args.generate_if_missing, )?; + if let Some(expected) = expected_program + && expected != program_pubkey + { + return Err(CliError::InvalidArgument(format!( + "program ID mismatch: expected {expected} from --expect-program-id, but --program-key derives {program_pubkey}" + ))); + } let (authority_keypair, authority_pubkey, generated_authority_key) = load_or_generate_key( &args.authority, "authority key", @@ -140,6 +163,7 @@ pub(crate) fn run(config: &Config, args: Args) -> Result<()> { authority_pubkey, authority_keypair, prepared, + args.allow_idl_resize, ) { return Err(CliError::IdlAfterDeployment { @@ -273,6 +297,9 @@ mod tests { "program.idl.json", "--idl-size", "20000", + "--allow-idl-resize", + "--expect-program-id", + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", ]) .unwrap(); @@ -282,6 +309,11 @@ mod tests { assert!(args.fund_authority); assert!(!args.generate_if_missing); assert_eq!(args.idl_size, Some(20_000)); + assert!(args.allow_idl_resize); + assert_eq!( + args.expect_program_id.as_deref(), + Some("1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f") + ); assert_eq!(args.idl, Some(PathBuf::from("program.idl.json"))); } @@ -303,6 +335,46 @@ mod tests { assert!(parsed.is_err()); } + #[test] + fn idl_resize_defaults_to_false() { + let cli = Cli::try_parse_from([ + "arch-kit", + "deploy", + "--elf", + "program.so", + "--program-key", + "program.json", + "--authority", + "authority.json", + "--idl", + "program.idl.json", + ]) + .unwrap(); + + let Command::Deploy(args) = cli.command else { + panic!("expected deploy command"); + }; + assert!(!args.allow_idl_resize); + assert!(args.expect_program_id.is_none()); + } + + #[test] + fn rejects_idl_resize_without_idl() { + let parsed = Cli::try_parse_from([ + "arch-kit", + "deploy", + "--elf", + "program.so", + "--program-key", + "program.json", + "--authority", + "authority.json", + "--allow-idl-resize", + ]); + + assert!(parsed.is_err()); + } + #[test] fn accepts_generate_if_missing() { let cli = Cli::try_parse_from([ diff --git a/src/commands/deploy_tests.rs b/src/commands/deploy_tests.rs index e15bd37..1e68c14 100644 --- a/src/commands/deploy_tests.rs +++ b/src/commands/deploy_tests.rs @@ -1,11 +1,4 @@ //! Account preparation against a local JSON-RPC server; no chain or real keys. -use std::{ - io::{BufRead, BufReader, Read, Write}, - net::TcpListener, - thread, - time::{Duration, Instant}, -}; - use arch_sdk::{AccountInfo, RuntimeTransaction, arch_program::hash::Hash}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use serde_json::{Value, json}; @@ -51,66 +44,10 @@ fn assignment_replies(status: Value, rollback: Value) -> Vec<(&'static str, Valu ] } -// A finite response script makes extra RPC calls or uploads fail the test. fn run_rpc(replies: Vec<(&'static str, Value)>) -> (Result<()>, Vec) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let endpoint = format!("http://{}", listener.local_addr().unwrap()); - let server = thread::spawn(move || { - let mut requests = Vec::new(); - let mut submitted = Value::Null; - for (method, mut response) in replies { - let deadline = Instant::now() + Duration::from_secs(10); - let (mut stream, _) = loop { - match listener.accept() { - Ok(connection) => break connection, - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!(Instant::now() < deadline, "missing RPC call {method}"); - thread::sleep(Duration::from_millis(5)); - } - Err(error) => panic!("RPC accept: {error}"), - } - }; - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - let mut length = 0; - loop { - let mut line = String::new(); - assert!(reader.read_line(&mut line).unwrap() > 0); - if line == "\r\n" { - break; - } - if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { - length = value.trim().parse().unwrap(); - } - } - let mut body = vec![0; length]; - reader.read_exact(&mut body).unwrap(); - let request: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(request["method"], method); - if method == "send_transaction" { - submitted = request["params"].clone(); - } - if method == "get_processed_transaction" { - response["result"]["runtime_transaction"] = submitted.clone(); - } - response["jsonrpc"] = json!("2.0"); - response["id"] = request["id"].clone(); - let body = response.to_string(); - write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); - requests.push(request); - } - requests - }); - let config = Config { - arch_node_url: endpoint, - network: bitcoin::Network::Bitcoin, - ..Config::localnet() - }; - let result = prepare_program_account(&config, keypair(1), keypair(2)); - (result, server.join().unwrap()) + crate::test_rpc::run(replies, |config| { + prepare_program_account(config, keypair(1), keypair(2)) + }) } #[test] @@ -215,3 +152,86 @@ fn program_cannot_also_pay_deployment_fees() { let result = prepare_program_account(&Config::localnet(), keypair(1), keypair(1)); assert!(result.unwrap_err().to_string().contains("different keys")); } + +fn deployment_args(expect_program_id: Option) -> (tempfile::TempDir, Args) { + let directory = tempfile::tempdir().unwrap(); + let elf = directory.path().join("program.so"); + let program_key = directory.path().join("program.key"); + let authority = directory.path().join("authority.key"); + std::fs::write(&elf, b"test ELF; deployment must not be reached").unwrap(); + std::fs::write(&program_key, hex::encode(keypair(1).secret_bytes())).unwrap(); + std::fs::write(&authority, hex::encode(keypair(2).secret_bytes())).unwrap(); + let args = Args { + elf, + program_key, + expect_program_id, + authority, + generate_if_missing: false, + fund_authority: false, + idl: None, + idl_size: None, + allow_idl_resize: false, + }; + (directory, args) +} + +#[test] +fn expected_program_id_mismatch_stops_before_authority_setup_or_funding() { + let actual = Pubkey::from_slice(&keypair(1).x_only_public_key().0.serialize()); + let expected = Pubkey::from_slice(&keypair(2).x_only_public_key().0.serialize()); + for value in [expected.to_string(), pubkey_hex(&expected)] { + let (directory, mut args) = deployment_args(Some(value)); + args.authority = directory.path().join("missing-authority.key"); + args.generate_if_missing = true; + args.fund_authority = true; + let authority_path = args.authority.clone(); + let (result, requests) = crate::test_rpc::run(vec![], |config| run(config, args)); + let error = result.unwrap_err().to_string(); + assert!(error.contains("program ID mismatch")); + assert!(error.contains(&expected.to_string())); + assert!(error.contains(&actual.to_string())); + assert!(!authority_path.exists()); + assert!(requests.is_empty()); + } +} + +#[test] +fn matching_or_omitted_expected_program_id_reaches_account_preparation() { + let program = Pubkey::from_slice(&keypair(1).x_only_public_key().0.serialize()); + for value in [ + None, + Some(program.to_string()), + Some(pubkey_hex(&program)), + Some(pubkey_hex(&program).to_uppercase()), + ] { + let (_directory, args) = deployment_args(value); + let (result, requests) = crate::test_rpc::run( + vec![( + "read_account_info", + json!({"error": {"code": -32603, "message": "stop before deployment"}}), + )], + |config| run(config, args), + ); + assert!(matches!(result, Err(CliError::ArchRpc(_)))); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["params"], json!(program)); + } +} + +#[test] +fn malformed_expected_program_id_fails_before_loading_files() { + for value in [ + "".to_string(), + "not-a-public-key".to_string(), + "ab".repeat(31), + "ab".repeat(33), + ] { + let (directory, mut args) = deployment_args(Some(value)); + args.elf = directory.path().join("missing.so"); + let (result, requests) = crate::test_rpc::run(vec![], |config| run(config, args)); + let error = result.unwrap_err(); + assert!(matches!(error, CliError::InvalidArgument(_))); + assert!(error.to_string().contains("--expect-program-id")); + assert!(requests.is_empty()); + } +} diff --git a/src/idl.rs b/src/idl.rs index 2036466..d21e5aa 100644 --- a/src/idl.rs +++ b/src/idl.rs @@ -34,6 +34,7 @@ const TAG_CREATE: u8 = 0; const TAG_CREATE_BUFFER: u8 = 1; const TAG_WRITE: u8 = 2; const TAG_SET_BUFFER: u8 = 3; +const TAG_CLOSE: u8 = 5; const TAG_RESIZE: u8 = 6; #[derive(Debug)] @@ -118,6 +119,7 @@ pub(crate) fn publish( authority: Pubkey, authority_keypair: Keypair, prepared: PreparedIdl, + allow_resize: bool, ) -> Result<()> { let client = ArchRpcClient::new(config); let (_, idl_address) = derive_idl_addresses(&program)?; @@ -134,13 +136,13 @@ pub(crate) fn publish( let outcome = match client.read_account_info(idl_address) { Ok(existing) => upgrade_or_skip( &client, - config, program, idl_address, authority, authority_keypair, existing, &prepared, + allow_resize, )?, Err(ArchError::NotFound(_)) => { initialize( @@ -217,7 +219,7 @@ fn initialize( vec![authority_keypair], )?; - grow_empty_account( + grow_account( client, program, idl_address, @@ -225,6 +227,7 @@ fn initialize( authority_keypair, prepared.initial_capacity.min(IDL_RESIZE_INCREMENT), prepared.initial_capacity, + None, )?; write_chunks( client, @@ -239,44 +242,128 @@ fn initialize( #[allow(clippy::too_many_arguments)] fn upgrade_or_skip( client: &ArchRpcClient, - config: &Config, program: Pubkey, idl_address: Pubkey, authority: Pubkey, authority_keypair: Keypair, existing: AccountInfo, prepared: &PreparedIdl, + allow_resize: bool, ) -> Result { let payload_range = validate_idl_account(&existing, program, Some(authority))?; - let capacity = existing.data.len(); + let mut capacity = existing.data.len(); if let Some(requested) = prepared.requested_capacity && requested > capacity + && !allow_resize { return Err(CliError::Idl(format!( - "--idl-size requests {requested} bytes, but the populated canonical IDL account has fixed capacity {capacity}; publish initially with a larger size" + "--idl-size requests {requested} bytes, but the populated canonical IDL account has fixed capacity {capacity}; use --allow-idl-resize to opt into clearing, growing, and republishing it" ))); } let existing_json = decompress_json(&existing.data[payload_range])?; - if existing_json == prepared.expected { - return Ok(PublishOutcome::Unchanged { capacity }); - } - + let unchanged = existing_json == prepared.expected; let required = required_space(prepared.compressed.len())?; - if required > capacity { + if !unchanged && required > capacity && !allow_resize { return Err(CliError::Idl(format!( - "new compressed IDL requires {required} account bytes, but the populated canonical IDL account has fixed capacity {capacity}; redeploy the IDL account with a larger --idl-size" + "new compressed IDL requires {required} account bytes, but the populated canonical IDL account has fixed capacity {capacity}; use --allow-idl-resize to opt into clearing, growing, and republishing it" ))); } - let (buffer_keypair, buffer, _) = generate_new_keypair(config.network); - println!(" Upgrade buffer: {buffer}"); + // An unchanged IDL only needs growth when explicitly requested by --idl-size. + let target_capacity = capacity + .max(prepared.requested_capacity.unwrap_or(0)) + .max(if unchanged { 0 } else { required }); + if target_capacity > capacity { + println!(" Growing canonical IDL from {capacity} to {target_capacity} bytes..."); + let buffers = prepare_resize_buffers( + client, + program, + idl_address, + authority, + authority_keypair, + &existing, + )?; + grow_account( + client, + program, + idl_address, + authority, + authority_keypair, + capacity, + target_capacity, + Some(buffers), + )?; + + let resized = client.read_account_info(idl_address)?; + let payload_range = validate_idl_account(&resized, program, Some(authority))?; + capacity = resized.data.len(); + if capacity < target_capacity { + return Err(CliError::Idl(format!( + "canonical IDL account has {capacity} bytes after resize; expected at least {target_capacity}" + ))); + } + if decompress_json(&resized.data[payload_range])? != existing_json { + return Err(CliError::Idl( + "canonical IDL contents changed during resize".to_string(), + )); + } + if unchanged { + return Ok(PublishOutcome::Upgraded { capacity }); + } + } + if unchanged { + return Ok(PublishOutcome::Unchanged { capacity }); + } + + let buffer = create_buffer( + client, + program, + authority, + authority_keypair, + required, + None, + )?; + write_chunks( + client, + program, + buffer, + authority, + authority_keypair, + &prepared.compressed, + )?; + send( + client, + "IDL set upgrade buffer", + vec![set_buffer_instruction( + program, + buffer, + idl_address, + authority, + )], + authority, + vec![authority_keypair], + )?; + + Ok(PublishOutcome::Upgraded { capacity }) +} + +fn create_buffer( + client: &ArchRpcClient, + program: Pubkey, + authority: Pubkey, + authority_keypair: Keypair, + capacity: usize, + copy_from: Option, +) -> Result { + let (buffer_keypair, buffer, _) = generate_new_keypair(client.config.network); + println!(" IDL buffer: {buffer}"); let create_account = system_instruction::create_account( &authority, &buffer, - minimum_rent(required), - to_u64(required, "IDL buffer size")?, + minimum_rent(capacity), + to_u64(capacity, "IDL buffer size")?, &program, ); let create_buffer = Instruction { @@ -287,44 +374,88 @@ fn upgrade_or_skip( ], data: unit_ix_data(TAG_CREATE_BUFFER), }; + let mut instructions = vec![create_account, create_buffer]; + if let Some(source) = copy_from { + instructions.push(set_buffer_instruction(program, source, buffer, authority)); + } send( client, - "IDL create upgrade buffer", - vec![create_account, create_buffer], + "IDL create buffer", + instructions, authority, vec![authority_keypair, buffer_keypair], )?; + Ok(buffer) +} - write_chunks( +fn prepare_resize_buffers( + client: &ArchRpcClient, + program: Pubkey, + idl_address: Pubkey, + authority: Pubkey, + authority_keypair: Keypair, + existing: &AccountInfo, +) -> Result<(Pubkey, Pubkey)> { + let range = validate_idl_account(existing, program, Some(authority))?; + // SetBuffer can copy between any two IDL accounts with the same authority. + // Snapshot the canonical IDL on-chain before temporarily clearing it. + let backup = create_buffer( client, program, - buffer, authority, authority_keypair, - &prepared.compressed, + range.end, + Some(idl_address), + )?; + let saved = client.read_account_info(backup)?; + let saved_range = validate_idl_account(&saved, program, Some(authority))?; + if saved.data[saved_range] != existing.data[range] { + return Err(CliError::Idl( + "IDL resize backup differs from the existing canonical IDL".to_string(), + )); + } + let empty = create_buffer( + client, + program, + authority, + authority_keypair, + IDL_HEADER_LEN, + None, )?; - let set_buffer = Instruction { + Ok((empty, backup)) +} + +fn set_buffer_instruction( + program: Pubkey, + buffer: Pubkey, + target: Pubkey, + authority: Pubkey, +) -> Instruction { + Instruction { program_id: program, accounts: vec![ AccountMeta::new(buffer, false), - AccountMeta::new(idl_address, false), + AccountMeta::new(target, false), AccountMeta::new_readonly(authority, true), ], data: unit_ix_data(TAG_SET_BUFFER), - }; - send( - client, - "IDL set upgrade buffer", - vec![set_buffer], - authority, - vec![authority_keypair], - )?; + } +} - Ok(PublishOutcome::Upgraded { capacity }) +fn close_buffer_instruction(program: Pubkey, buffer: Pubkey, authority: Pubkey) -> Instruction { + Instruction { + program_id: program, + accounts: vec![ + AccountMeta::new(buffer, false), + AccountMeta::new_readonly(authority, true), + AccountMeta::new(authority, false), + ], + data: unit_ix_data(TAG_CLOSE), + } } #[allow(clippy::too_many_arguments)] -fn grow_empty_account( +fn grow_account( client: &ArchRpcClient, program: Pubkey, idl_address: Pubkey, @@ -332,6 +463,7 @@ fn grow_empty_account( authority_keypair: Keypair, initial_space: usize, target_space: usize, + buffers: Option<(Pubkey, Pubkey)>, ) -> Result<()> { let mut current_space = initial_space; while current_space < target_space { @@ -347,10 +479,29 @@ fn grow_empty_account( let next_space = current_space .checked_add((target_space - current_space).min(IDL_RESIZE_INCREMENT)) .ok_or_else(|| CliError::InvalidIdl("IDL resize overflow".to_string()))?; + let instructions = if let Some((empty, backup)) = buffers { + // Legacy Satellite handlers only resize empty IDLs. Restore the + // snapshot in the same transaction so every committed state has + // the original IDL, even if a later resize or upload fails. + let mut instructions = vec![ + set_buffer_instruction(program, empty, idl_address, authority), + resize, + set_buffer_instruction(program, backup, idl_address, authority), + ]; + if next_space == target_space { + instructions.extend([ + close_buffer_instruction(program, empty, authority), + close_buffer_instruction(program, backup, authority), + ]); + } + instructions + } else { + vec![resize] + }; send( client, format!("IDL resize {next_space}/{target_space}"), - vec![resize], + instructions, authority, vec![authority_keypair], )?; @@ -405,10 +556,13 @@ fn send( let transaction = build_and_sign_transaction(message, signers, client.config.network)?; let transaction_id = client.send_transaction(transaction)?; let processed = client.wait_for_processed_transaction(&transaction_id)?; - if processed.status != Status::Processed { + if processed.status != Status::Processed || !processed.rollback_status.is_applied() { return Err(CliError::TransactionFailed { action, - status: format!("{:?}", processed.status), + status: format!( + "{:?}, rollback={:?}", + processed.status, processed.rollback_status + ), }); } println!(" {action}: {transaction_id}"); @@ -552,6 +706,10 @@ fn unit_ix_data(tag: u8) -> Vec { data } +#[cfg(test)] +#[path = "idl_tests.rs"] +mod publication_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/idl_tests.rs b/src/idl_tests.rs new file mode 100644 index 0000000..fe90ae7 --- /dev/null +++ b/src/idl_tests.rs @@ -0,0 +1,416 @@ +//! IDL publication against a scripted local RPC server; no chain or real keys. +use arch_sdk::RuntimeTransaction; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use serde_json::json; + +use super::*; + +fn program() -> Pubkey { + Pubkey::from([3; 32]) +} + +fn authority_keypair() -> Keypair { + Keypair::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[2; 32]).unwrap()) +} + +fn authority() -> Pubkey { + Pubkey::from_slice(&authority_keypair().x_only_public_key().0.serialize()) +} + +fn prepared(requested: Option) -> PreparedIdl { + prepare_bytes(br#"{"instructions":[]}"#, program(), requested).unwrap() +} + +fn updated() -> PreparedIdl { + let raw = serde_json::to_vec(&json!({ + "instructions": [], + "docs": (0..500).collect::>(), + })) + .unwrap(); + prepare_bytes(&raw, program(), None).unwrap() +} + +fn account(prepared: &PreparedIdl, capacity: usize) -> AccountInfo { + let mut data = vec![0; capacity]; + data[..8].copy_from_slice(&IDL_ACCOUNT_DISCRIMINATOR); + data[8..40].copy_from_slice(authority().as_ref()); + data[40..44].copy_from_slice(&(prepared.compressed.len() as u32).to_le_bytes()); + data[44..44 + prepared.compressed.len()].copy_from_slice(&prepared.compressed); + AccountInfo { + lamports: minimum_rent(capacity), + owner: program(), + data, + utxo: format!("{}:0", "00".repeat(32)), + is_executable: false, + } +} + +fn read_reply(account: &AccountInfo) -> (&'static str, Value) { + ("read_account_info", json!({"result": account})) +} + +fn transaction_replies(status: Value, rollback: Value) -> Vec<(&'static str, Value)> { + vec![ + ( + "get_best_finalized_block_hash", + json!({"result": "00".repeat(32)}), + ), + ("send_transaction", json!({"result": "11".repeat(32)})), + ( + "get_processed_transaction", + json!({"result": { + "status": status, "rollback_status": rollback, + "bitcoin_txid": null, "logs": [], "inner_instructions_list": [], + }}), + ), + ] +} + +fn successful_transaction() -> Vec<(&'static str, Value)> { + transaction_replies( + json!({"type": "processed"}), + json!({"type": "notRolledback"}), + ) +} + +fn resize_setup(existing: &AccountInfo) -> Vec<(&'static str, Value)> { + let mut backup = existing.clone(); + let range = validate_idl_account(existing, program(), Some(authority())).unwrap(); + backup.data.truncate(range.end); + let mut replies = successful_transaction(); + replies.push(read_reply(&backup)); + replies.extend(successful_transaction()); + replies +} + +fn created_buffer(transaction: &RuntimeTransaction) -> Pubkey { + let index = transaction.message.instructions[1].accounts[0]; + transaction.message.account_keys[index as usize] +} + +fn run_publish( + replies: Vec<(&'static str, Value)>, + prepared: PreparedIdl, + allow_resize: bool, +) -> (Result<()>, Vec) { + let (result, requests) = crate::test_rpc::run(replies, |config| { + publish( + config, + program(), + authority(), + authority_keypair(), + prepared, + allow_resize, + ) + }); + let transactions = requests + .into_iter() + .filter(|request| request["method"] == "send_transaction") + .map(|request| serde_json::from_value(request["params"].clone()).unwrap()) + .collect(); + (result, transactions) +} + +#[test] +fn growth_requires_opt_in_for_both_requested_size_and_larger_payload() { + let old = prepared(None); + let existing = account(&old, required_space(old.compressed.len()).unwrap()); + for new in [prepared(Some(20_000)), updated()] { + let (result, transactions) = run_publish(vec![read_reply(&existing)], new, false); + assert!( + result + .unwrap_err() + .to_string() + .contains("--allow-idl-resize") + ); + assert!(transactions.is_empty()); + } +} + +#[test] +fn initial_idl_can_still_grow_while_empty_with_either_flag_value() { + for allow_resize in [false, true] { + let new = prepared(Some(20_000)); + let mut replies = vec![( + "read_account_info", + json!({"error": {"code": 404, "message": "Account not found"}}), + )]; + replies.extend(successful_transaction()); + replies.extend(successful_transaction()); + for _ in new.compressed.chunks(MAX_WRITE_SIZE) { + replies.extend(successful_transaction()); + } + replies.push(read_reply(&account(&new, 20_000))); + let (result, transactions) = run_publish(replies, new, allow_resize); + result.unwrap(); + assert_eq!(transactions[0].message.instructions[0].data[8], TAG_CREATE); + assert_eq!( + transactions[1].message.instructions[0].data, + resize_ix_data(20_000) + ); + } +} + +#[test] +fn resize_opt_in_does_not_bypass_account_validation() { + let existing = account(&prepared(None), 1_000); + let mut wrong_owner = existing.clone(); + wrong_owner.owner = Pubkey::system_program(); + let mut wrong_authority = existing.clone(); + wrong_authority.data[8..40].fill(0); + let mut wrong_discriminator = existing; + wrong_discriminator.data[..8].fill(0); + for invalid in [wrong_owner, wrong_authority, wrong_discriminator] { + let (result, transactions) = + run_publish(vec![read_reply(&invalid)], prepared(Some(20_000)), true); + assert!(matches!(result, Err(CliError::Idl(_)))); + assert!(transactions.is_empty()); + } +} + +#[test] +fn unchanged_idl_does_not_grow_to_the_initial_default_or_shrink() { + let existing = account(&prepared(None), 1_000); + for allow_resize in [false, true] { + for requested in [None, Some(500)] { + let (result, transactions) = run_publish( + vec![read_reply(&existing), read_reply(&existing)], + prepared(requested), + allow_resize, + ); + result.unwrap(); + assert!(transactions.is_empty()); + } + } +} + +#[test] +fn explicit_growth_resizes_an_unchanged_idl_in_multiple_steps() { + let new = prepared(Some(21_001)); + let existing = account(&new, 1_000); + let resized = account(&new, 21_001); + let mut replies = vec![read_reply(&existing)]; + replies.extend(resize_setup(&existing)); + for _ in 0..3 { + replies.extend(successful_transaction()); + } + replies.extend([read_reply(&resized), read_reply(&resized)]); + let (result, transactions) = run_publish(replies, new, true); + result.unwrap(); + assert_eq!(transactions.len(), 5); + + let (_, idl_address) = derive_idl_addresses(&program()).unwrap(); + let backup = created_buffer(&transactions[0]); + let empty = created_buffer(&transactions[1]); + let mut accounts = std::collections::BTreeMap::from([ + (idl_address, existing.data.clone()), + (backup, existing.data.clone()), + (empty, vec![0; IDL_HEADER_LEN]), + ]); + let snapshot = &transactions[0].message.instructions[2]; + assert_eq!(snapshot.data, unit_ix_data(TAG_SET_BUFFER)); + assert_eq!( + transactions[0].message.account_keys[snapshot.accounts[0] as usize], + idl_address + ); + assert_eq!( + transactions[0].message.account_keys[snapshot.accounts[1] as usize], + backup + ); + for (index, transaction) in transactions[2..].iter().enumerate() { + let mut instructions = vec![ + set_buffer_instruction(program(), empty, idl_address, authority()), + Instruction { + program_id: program(), + accounts: vec![ + AccountMeta::new(idl_address, false), + AccountMeta::new(authority(), true), + AccountMeta::new_readonly(Pubkey::system_program(), false), + ], + data: resize_ix_data(21_001), + }, + set_buffer_instruction(program(), backup, idl_address, authority()), + ]; + if index == 2 { + instructions.extend([ + close_buffer_instruction(program(), empty, authority()), + close_buffer_instruction(program(), backup, authority()), + ]); + } + let expected = ArchMessage::new(&instructions, Some(authority()), Hash::from([0; 32])); + assert_eq!(transaction.message, expected); + transaction.verify_sigs(bitcoin::Network::Bitcoin).unwrap(); + + // Replay the submitted instructions using legacy Satellite's data_len + // rules: Resize must see an empty IDL, and SetBuffer copies its payload. + for instruction in &transaction.message.instructions { + let first = transaction.message.account_keys[instruction.accounts[0] as usize]; + match instruction.data[8] { + TAG_SET_BUFFER => { + let source = &accounts[&first]; + let len = u32::from_le_bytes(source[40..44].try_into().unwrap()) as usize; + let payload = source[44..44 + len].to_vec(); + let target = transaction.message.account_keys[instruction.accounts[1] as usize]; + let data = accounts.get_mut(&target).unwrap(); + data[40..44].copy_from_slice(&(len as u32).to_le_bytes()); + data[44..44 + len].copy_from_slice(&payload); + } + TAG_RESIZE => { + let data = accounts.get_mut(&first).unwrap(); + assert_eq!( + &data[40..44], + &[0; 4], + "legacy handler rejects populated IDLs" + ); + let target = + u64::from_le_bytes(instruction.data[9..17].try_into().unwrap()) as usize; + data.resize(data.len() + (target - data.len()).min(10_000), 0); + } + TAG_CLOSE => { + assert_eq!( + transaction.message.account_keys[instruction.accounts[2] as usize], + authority() + ); + accounts.remove(&first).unwrap(); + } + tag => panic!("unexpected IDL instruction {tag}"), + } + } + // Every transaction boundary retains the original published bytes. + assert_eq!( + &accounts[&idl_address][..existing.data.len()], + &existing.data + ); + assert_eq!( + accounts[&idl_address].len(), + (1_000 + (index + 1) * 10_000).min(21_001) + ); + } + assert_eq!(accounts.len(), 1, "temporary resize buffers must be closed"); +} + +#[test] +fn larger_payload_grows_before_uploading_and_swapping_the_upgrade_buffer() { + let old = prepared(None); + let new = updated(); + let required = required_space(new.compressed.len()).unwrap(); + let existing = account(&old, required_space(old.compressed.len()).unwrap()); + assert!(required > existing.data.len()); + let mut replies = vec![read_reply(&existing)]; + replies.extend(resize_setup(&existing)); + replies.extend(successful_transaction()); + replies.push(read_reply(&account(&old, required))); + replies.extend(successful_transaction()); + let chunks = new.compressed.chunks(MAX_WRITE_SIZE).count(); + for _ in 0..chunks { + replies.extend(successful_transaction()); + } + replies.extend(successful_transaction()); + replies.push(read_reply(&account(&new, required))); + + let (result, transactions) = run_publish(replies, new, true); + result.unwrap(); + assert_eq!(transactions.len(), chunks + 5); + assert_eq!( + transactions[2].message.instructions[1].data, + resize_ix_data(required as u64) + ); + assert_eq!( + transactions[3].message.instructions[1].data, + unit_ix_data(TAG_CREATE_BUFFER) + ); + for transaction in &transactions[4..4 + chunks] { + assert_eq!(transaction.message.instructions[0].data[8], TAG_WRITE); + } + assert_eq!( + transactions.last().unwrap().message.instructions[0].data, + unit_ix_data(TAG_SET_BUFFER) + ); +} + +#[test] +fn upgrades_that_fit_keep_existing_capacity_with_either_flag_value() { + for allow_resize in [false, true] { + let new = updated(); + let mut replies = vec![read_reply(&account(&prepared(None), 10_000))]; + replies.extend(successful_transaction()); + for _ in new.compressed.chunks(MAX_WRITE_SIZE) { + replies.extend(successful_transaction()); + } + replies.extend(successful_transaction()); + replies.push(read_reply(&account(&new, 10_000))); + let (result, transactions) = run_publish(replies, new, allow_resize); + result.unwrap(); + assert_eq!( + transactions[0].message.instructions[1].data, + unit_ix_data(TAG_CREATE_BUFFER) + ); + } +} + +#[test] +fn rejected_or_rolled_back_resize_stops_before_uploading_an_upgrade() { + for (status, rollback) in [ + ( + json!({"type": "failed", "message": "IdlAccountNotEmpty"}), + json!({"type": "notRolledback"}), + ), + ( + json!({"type": "processed"}), + json!({"type": "rolledback", "message": "Reverted"}), + ), + ] { + let old = prepared(None); + let existing = account(&old, required_space(old.compressed.len()).unwrap()); + let mut replies = vec![read_reply(&existing)]; + replies.extend(resize_setup(&existing)); + replies.extend(transaction_replies(status, rollback)); + let (result, transactions) = run_publish(replies, updated(), true); + assert!(matches!(result, Err(CliError::TransactionFailed { .. }))); + assert_eq!(transactions.len(), 3); + } +} + +#[test] +fn resize_must_grow_the_account_and_preserve_the_existing_idl() { + let old = prepared(None); + let existing = account(&old, required_space(old.compressed.len()).unwrap()); + let target = required_space(updated().compressed.len()).unwrap(); + for (resized, error) in [ + (existing.clone(), "after resize"), + ( + account(&updated(), target), + "contents changed during resize", + ), + ] { + let mut replies = vec![read_reply(&existing)]; + replies.extend(resize_setup(&existing)); + replies.extend(successful_transaction()); + replies.push(read_reply(&resized)); + let (result, transactions) = run_publish(replies, updated(), true); + assert!(result.unwrap_err().to_string().contains(error)); + assert_eq!(transactions.len(), 3); + } +} + +#[test] +fn invalid_backup_stops_before_clearing_the_canonical_idl() { + let existing = account(&prepared(None), 1_000); + let mut replies = vec![read_reply(&existing)]; + replies.extend(successful_transaction()); + replies.push(read_reply(&account(&updated(), 2_000))); + let (result, transactions) = run_publish(replies, prepared(Some(20_000)), true); + assert!(result.unwrap_err().to_string().contains("backup differs")); + assert_eq!(transactions.len(), 1); + // The only SetBuffer so far copies FROM the canonical IDL into the backup. + let copy = &transactions[0].message.instructions[2]; + let (_, canonical) = derive_idl_addresses(&program()).unwrap(); + assert_eq!( + transactions[0].message.account_keys[copy.accounts[0] as usize], + canonical + ); + assert_ne!( + transactions[0].message.account_keys[copy.accounts[1] as usize], + canonical + ); +} diff --git a/src/main.rs b/src/main.rs index 5d5f0c7..53bee3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,9 @@ mod transaction; mod utils; mod vanity; +#[cfg(test)] +mod test_rpc; + use clap::Parser; use crate::{ diff --git a/src/test_rpc.rs b/src/test_rpc.rs new file mode 100644 index 0000000..a4ccb09 --- /dev/null +++ b/src/test_rpc.rs @@ -0,0 +1,75 @@ +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::TcpListener, + thread, + time::{Duration, Instant}, +}; + +use arch_sdk::Config; +use serde_json::{Value, json}; + +// A finite response script makes extra RPC calls or uploads fail the test. +pub(crate) fn run( + replies: Vec<(&'static str, Value)>, + action: impl FnOnce(&Config) -> T, +) -> (T, Vec) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + let mut requests = Vec::new(); + let mut submitted = Value::Null; + for (method, mut response) in replies { + let deadline = Instant::now() + Duration::from_secs(10); + let (mut stream, _) = loop { + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "missing RPC call {method}"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("RPC accept: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut length = 0; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + let request: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(request["method"], method); + if method == "send_transaction" { + submitted = request["params"].clone(); + } + if method == "get_processed_transaction" { + response["result"]["runtime_transaction"] = submitted.clone(); + } + response["jsonrpc"] = json!("2.0"); + response["id"] = request["id"].clone(); + let body = response.to_string(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); + requests.push(request); + } + requests + }); + let config = Config { + arch_node_url: endpoint, + network: bitcoin::Network::Bitcoin, + ..Config::localnet() + }; + let result = action(&config); + (result, server.join().unwrap()) +}