diff --git a/docs/book/src/forc/plugins/forc_client/index.md b/docs/book/src/forc/plugins/forc_client/index.md index 0f87979d3c4..9a55351bfb8 100644 --- a/docs/book/src/forc/plugins/forc_client/index.md +++ b/docs/book/src/forc/plugins/forc_client/index.md @@ -246,6 +246,37 @@ The proxy contract includes both its own storage slots and preserves the storage - Proxy contracts work with both regular and [chunked contracts](#large-contracts) (contracts over 100kB) - The implementation uses the SRC-14 standard for maximum compatibility with the Fuel ecosystem +### Multi-Network Proxy Support + +For projects that need to deploy across multiple networks (testnet, mainnet, devnet, local), you can configure network-specific proxy addresses using the `addresses` table instead of a single `address` field: + +```TOML +[project] +name = "test_contract" +authors = ["Fuel Labs "] +entry = "main.sw" +license = "Apache-2.0" +implicit-std = false + +[proxy] +enabled = true + +[proxy.addresses] +testnet = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" +mainnet = "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" +devnet = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" +local = "0x567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456" +``` + +When using network-specific proxy addresses: +- `forc-deploy` automatically determines the current network and uses the appropriate proxy address +- If no proxy address is configured for the current network, a new proxy contract will be deployed +- The proxy address for the current network is automatically updated in `Forc.toml` after deployment + +**Note:** The `address` and `addresses` fields are mutually exclusive. Use `address` for single-network deployments or `addresses` for multi-network setups. + +This approach eliminates the need to manually update proxy addresses when switching between networks, making multi-network deployment workflows much more robust. + ## Large Contracts For contracts over the maximum contract size limit (currently `100kB`) defined by the network, `forc-deploy` will split the contract into chunks and deploy the contract with multiple transactions using the Rust SDK's [loader contract](https://github.com/FuelLabs/fuels-rs/blob/master/docs/src/deploying/large_contracts.md) functionality. Chunks that have already been deployed will be reused on subsequent deployments. diff --git a/forc-pkg/src/manifest/mod.rs b/forc-pkg/src/manifest/mod.rs index 6b97149f3e4..0b8003783a5 100644 --- a/forc-pkg/src/manifest/mod.rs +++ b/forc-pkg/src/manifest/mod.rs @@ -330,7 +330,43 @@ pub struct Proxy { /// Points to the proxy contract to be updated with the new contract id. /// If there is a value for this field, forc will try to update the proxy contract's storage /// field such that it points to current contract's deployed instance. + /// + /// This field provides backward compatibility for single-network deployments. + /// For multi-network deployments, use the `addresses` field instead. pub address: Option, + /// Network-specific proxy addresses for multi-network deployments. + /// Keys should be network names (e.g., "testnet", "mainnet", "devnet", "local"). + /// This field is mutually exclusive with the `address` field. + pub addresses: Option>, +} + +impl Proxy { + /// Validate the proxy configuration. + /// + /// Ensures that `address` and `addresses` fields are mutually exclusive. + pub fn validate(&self) -> anyhow::Result<()> { + if self.address.is_some() && self.addresses.is_some() { + bail!("Proxy configuration cannot have both `address` and `addresses` fields. Use `address` for single-network deployments or `addresses` for multi-network deployments."); + } + Ok(()) + } + + /// Returns the proxy address for a specific network. + /// + /// If `addresses` is configured, looks up the network-specific address. + /// Otherwise, falls back to the single `address` field for backward compatibility. + pub fn address_for_network(&self, network: &str) -> Option<&String> { + if let Some(addresses) = &self.addresses { + addresses.get(network) + } else { + self.address.as_ref() + } + } + + /// Returns true if the proxy has any addresses configured (either single or multi-network). + pub fn has_address(&self) -> bool { + self.address.is_some() || self.addresses.is_some() + } } impl DependencyDetails { @@ -705,6 +741,7 @@ impl PackageManifest { /// 2. The validity of the details provided. Makes sure that there are no mismatching detail /// declarations (to prevent mixing details specific to certain types). /// 3. The dependencies listed does not have an alias ("package" field) that is the same as package name. + /// 4. The proxy configuration is valid (mutually exclusive address/addresses fields). pub fn validate(&self) -> Result<()> { validate_project_name(&self.project.name)?; if let Some(ref org) = self.project.organization { @@ -725,6 +762,9 @@ impl PackageManifest { )) } } + if let Some(ref proxy) = self.proxy { + proxy.validate()?; + } Ok(()) } diff --git a/forc-plugins/forc-client/src/op/deploy.rs b/forc-plugins/forc-client/src/op/deploy.rs index 1c7dbc8d163..5ea55bc7ce3 100644 --- a/forc-plugins/forc-client/src/op/deploy.rs +++ b/forc-plugins/forc-client/src/op/deploy.rs @@ -3,7 +3,7 @@ use crate::{ constants::TX_SUBMIT_TIMEOUT_MS, util::{ account::ForcClientAccount, - pkg::{built_pkgs, create_proxy_contract, update_proxy_address_in_manifest}, + pkg::{built_pkgs, create_proxy_contract, update_proxy_address_in_manifest_for_network}, target::Target, tx::{ check_and_create_wallet_at_default_path, prompt_forc_wallet_password, select_account, @@ -45,6 +45,34 @@ use std::{ }; use sway_core::{asm_generation::ProgramABI, language::parsed::TreeType, BuildTarget}; +/// Resolves the proxy address for the current network context. +/// +/// Uses the network name from chain info to look up network-specific proxy addresses, +/// falling back to the single address field for backward compatibility. +fn resolve_proxy_address_for_network( + proxy: &forc_pkg::manifest::Proxy, + chain_info: &fuels::types::chain_info::ChainInfo, +) -> Option { + if !proxy.enabled { + return None; + } + + let target = Target::from_str(&chain_info.name).unwrap_or_default(); + let network_name = match target { + Target::Testnet => "testnet", + Target::Mainnet => "mainnet", + Target::Devnet => "devnet", + Target::Local => "local", + }; + + // First try network-specific addresses + if let Some(address) = proxy.address_for_network(network_name) { + Some(address.clone()) + } else { + None + } +} + /// Default maximum contract size allowed for a single contract. If the target /// contract size is bigger than this amount, forc-deploy will automatically /// starts dividing the contract and deploy them in chunks automatically. @@ -553,45 +581,45 @@ pub async fn deploy_contracts( deploy_pkg(command, pkg, salt, &provider, &account).await? }; + // Get chain info to resolve network-specific proxy addresses + let chain_info = provider.chain_info().await?; let proxy_id = match &pkg.descriptor.manifest_file.proxy { - Some(forc_pkg::manifest::Proxy { - enabled: true, - address: Some(proxy_addr), - }) => { - // Make a call into the contract to update impl contract address to 'deployed_contract'. - - // Create a contract instance for the proxy contract using default proxy contract abi and - // specified address. - let proxy_contract = - ContractId::from_str(proxy_addr).map_err(|e| anyhow::anyhow!(e))?; - - update_proxy_contract_target(&account, proxy_contract, deployed_contract_id) + Some(proxy) if proxy.enabled => { + if let Some(proxy_addr) = resolve_proxy_address_for_network(proxy, &chain_info) { + // Make a call into the contract to update impl contract address to 'deployed_contract'. + + // Create a contract instance for the proxy contract using default proxy contract abi and + // specified address. + let proxy_contract = + ContractId::from_str(&proxy_addr).map_err(|e| anyhow::anyhow!(e))?; + + update_proxy_contract_target(&account, proxy_contract, deployed_contract_id) + .await?; + Some(proxy_contract) + } else { + // No proxy address configured for this network, deploy a new one + let pkg_name = &pkg.descriptor.name; + let pkg_storage_slots = &pkg.storage_slots; + // Deploy a new proxy contract. + let deployed_proxy_contract = deploy_new_proxy( + command, + pkg_name, + pkg_storage_slots, + &deployed_contract_id, + &provider, + &account, + ) .await?; - Some(proxy_contract) - } - Some(forc_pkg::manifest::Proxy { - enabled: true, - address: None, - }) => { - let pkg_name = &pkg.descriptor.name; - let pkg_storage_slots = &pkg.storage_slots; - // Deploy a new proxy contract. - let deployed_proxy_contract = deploy_new_proxy( - command, - pkg_name, - pkg_storage_slots, - &deployed_contract_id, - &provider, - &account, - ) - .await?; - // Update manifest file such that the proxy address field points to the new proxy contract. - update_proxy_address_in_manifest( - &format!("0x{deployed_proxy_contract}"), - &pkg.descriptor.manifest_file, - )?; - Some(deployed_proxy_contract) + // Update manifest file such that the proxy address field points to the new proxy contract. + // This will now use network-aware updating logic + update_proxy_address_in_manifest_for_network( + &format!("0x{}", deployed_proxy_contract), + &pkg.descriptor.manifest_file, + &chain_info, + )?; + Some(deployed_proxy_contract) + } } // Proxy not enabled. _ => None, @@ -621,12 +649,9 @@ async fn confirm_transaction_details( .map(|pkg| { tx_count += 1; let proxy_text = match &pkg.descriptor.manifest_file.proxy { - Some(forc_pkg::manifest::Proxy { - enabled: true, - address, - }) => { + Some(proxy) if proxy.enabled => { tx_count += 1; - if address.is_some() { + if proxy.has_address() { " + update proxy" } else { " + deploy proxy" diff --git a/forc-plugins/forc-client/src/util/pkg.rs b/forc-plugins/forc-client/src/util/pkg.rs index aad97d0e151..bf14109fb95 100644 --- a/forc-plugins/forc-client/src/util/pkg.rs +++ b/forc-plugins/forc-client/src/util/pkg.rs @@ -7,6 +7,9 @@ use std::fs::File; use std::io::{Read, Write}; use std::path::PathBuf; use std::{collections::HashMap, path::Path, sync::Arc}; +use fuels::types::chain_info::ChainInfo; +use crate::util::target::Target; +use std::str::FromStr; /// The name of the folder that forc generated proxy contract project will reside at. pub const GENERATED_CONTRACT_FOLDER_NAME: &str = ".generated_contracts"; @@ -38,6 +41,68 @@ pub(crate) fn update_proxy_address_in_manifest( Ok(()) } +/// Updates the given package manifest file with network-specific proxy address. +/// This function determines the network from the chain info and updates the appropriate +/// network entry in the proxy.addresses table. +pub(crate) fn update_proxy_address_in_manifest_for_network( + address: &str, + manifest: &PackageManifestFile, + chain_info: &ChainInfo, +) -> Result<()> { + let mut toml = String::new(); + let mut file = File::open(manifest.path())?; + file.read_to_string(&mut toml)?; + let mut manifest_toml = toml.parse::()?; + + if manifest.proxy().is_some() { + // Determine network name from chain info + let target = Target::from_str(&chain_info.name).unwrap_or_default(); + let network_name = match target { + Target::Testnet => "testnet", + Target::Mainnet => "mainnet", + Target::Devnet => "devnet", + Target::Local => "local", + }; + + // Check if we're using the new addresses format or need to convert + if manifest.proxy().unwrap().addresses.is_some() { + // Update network-specific address in addresses table + if !manifest_toml["proxy"]["addresses"].is_table() { + manifest_toml["proxy"]["addresses"] = toml_edit::table(); + } + manifest_toml["proxy"]["addresses"][network_name] = toml_edit::value(address); + } else if manifest.proxy().unwrap().address.is_some() { + // Migration from single address to network-specific addresses + // Move current address to network-specific and add new one + + // Remove the old single address field + if manifest_toml["proxy"]["address"].is_value() { + manifest_toml["proxy"]["address"] = toml_edit::Item::None; + } + + // Create addresses table and add the new address for this network + manifest_toml["proxy"]["addresses"] = toml_edit::table(); + manifest_toml["proxy"]["addresses"][network_name] = toml_edit::value(address); + + // Optionally preserve the old address under a generic network name + // This is commented out to avoid confusion, but could be enabled if desired + // manifest_toml["proxy"]["addresses"]["previous"] = toml_edit::value(current_address); + } else { + // No address configured yet, create addresses table with this network + manifest_toml["proxy"]["addresses"] = toml_edit::table(); + manifest_toml["proxy"]["addresses"][network_name] = toml_edit::value(address); + } + + let mut file = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(manifest.path())?; + file.write_all(manifest_toml.to_string().as_bytes())?; + } + + Ok(()) +} + /// Creates a proxy contract project at the given path, adds a forc.toml and source file. pub(crate) fn create_proxy_contract(pkg_name: &str) -> Result { // Create the proxy contract folder. diff --git a/forc-plugins/forc-client/tests/deploy.rs b/forc-plugins/forc-client/tests/deploy.rs index 09a45c63f7b..a63fa0f883c 100644 --- a/forc-plugins/forc-client/tests/deploy.rs +++ b/forc-plugins/forc-client/tests/deploy.rs @@ -136,6 +136,17 @@ fn patch_manifest_file_with_proxy_table(manifest_dir: &Path, proxy: Proxy) -> an proxy_table.remove("address"); } + if let Some(addresses) = proxy.addresses { + let addresses_table = proxy_table.entry("addresses").or_insert(Item::Table(Table::new())); + let addresses_table = addresses_table.as_table_mut().unwrap(); + + for (network, address) in addresses { + addresses_table.insert(&network, value(address)); + } + } else { + proxy_table.remove("addresses"); + } + fs::write(&toml_path, doc.to_string())?; Ok(()) } @@ -441,6 +452,7 @@ async fn test_deploy_fresh_proxy() { let proxy = Proxy { enabled: true, address: None, + addresses: None, }; patch_manifest_file_with_proxy_table(tmp_dir.path(), proxy).unwrap(); @@ -494,6 +506,7 @@ async fn test_proxy_contract_re_routes_call() { let proxy = Proxy { enabled: true, address: None, + addresses: None, }; patch_manifest_file_with_proxy_table(tmp_dir.path(), proxy).unwrap(); @@ -630,6 +643,7 @@ async fn test_non_owner_fails_to_set_target() { let proxy = Proxy { enabled: true, address: None, + addresses: None, }; patch_manifest_file_with_proxy_table(tmp_dir.path(), proxy).unwrap(); @@ -814,6 +828,7 @@ async fn chunked_deploy_with_proxy_re_routes_call() { let proxy = Proxy { enabled: true, address: None, + addresses: None, }; patch_manifest_file_with_proxy_table(tmp_dir.path(), proxy).unwrap(); @@ -1338,3 +1353,183 @@ async fn offset_shifted_abi_works() { node.kill().unwrap() } + +#[tokio::test] +async fn test_deploy_proxy_with_network_specific_addresses() { + let (mut node, port) = run_node(); + let tmp_dir = tempdir().unwrap(); + let project_dir = test_data_path().join("standalone_contract"); + copy_dir(&project_dir, tmp_dir.path()).unwrap(); + patch_manifest_file_with_path_std(tmp_dir.path()).unwrap(); + + // Configure network-specific proxy addresses - but don't include address for "local" network + // so it will deploy a new proxy and add it to the manifest + let mut addresses = std::collections::BTreeMap::new(); + addresses.insert("testnet".to_string(), "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321".to_string()); + + let proxy = Proxy { + enabled: true, + address: None, + addresses: Some(addresses), + }; + patch_manifest_file_with_proxy_table(tmp_dir.path(), proxy).unwrap(); + + let pkg = Pkg { + path: Some(tmp_dir.path().display().to_string()), + ..Default::default() + }; + + let node_url = format!("http://127.0.0.1:{}/v1/graphql", port); + let target = NodeTarget { + node_url: Some(node_url), + target: None, + testnet: false, + mainnet: false, + devnet: false, + }; + let cmd = cmd::Deploy { + pkg, + salt: Some(vec![format!("{}", Salt::default())]), + node: target, + default_signer: true, + ..Default::default() + }; + + // This should deploy a new proxy since no "local" network address exists + let contract_ids = deploy(cmd).await.unwrap(); + node.kill().unwrap(); + + // Verify proxy was deployed + let deployed_contract = expect_deployed_contract(contract_ids.into_iter().next().unwrap()); + assert!(deployed_contract.proxy.is_some()); + + // Verify the manifest was updated with network-specific address for local network + let updated_toml = fs::read_to_string(tmp_dir.path().join("Forc.toml")).unwrap(); + assert!(updated_toml.contains("[proxy.addresses]")); + assert!(updated_toml.contains("local")); + assert!(updated_toml.contains("testnet")); // Original address should still be there +} + +#[tokio::test] +async fn test_deploy_fresh_proxy_with_network_addresses() { + let (mut node, port) = run_node(); + let tmp_dir = tempdir().unwrap(); + let project_dir = test_data_path().join("standalone_contract"); + copy_dir(&project_dir, tmp_dir.path()).unwrap(); + patch_manifest_file_with_path_std(tmp_dir.path()).unwrap(); + + // Configure proxy without any addresses - should deploy fresh proxy + let proxy = Proxy { + enabled: true, + address: None, + addresses: None, + }; + patch_manifest_file_with_proxy_table(tmp_dir.path(), proxy).unwrap(); + + let pkg = Pkg { + path: Some(tmp_dir.path().display().to_string()), + ..Default::default() + }; + + let node_url = format!("http://127.0.0.1:{}/v1/graphql", port); + let target = NodeTarget { + node_url: Some(node_url), + target: None, + testnet: false, + mainnet: false, + devnet: false, + }; + let cmd = cmd::Deploy { + pkg, + salt: Some(vec![format!("{}", Salt::default())]), + node: target, + default_signer: true, + ..Default::default() + }; + + // This should deploy a fresh proxy and update the manifest with network-specific format + let contract_ids = deploy(cmd).await.unwrap(); + node.kill().unwrap(); + + let deployed_contract = expect_deployed_contract(contract_ids.into_iter().next().unwrap()); + assert!(deployed_contract.proxy.is_some()); + + // Verify the manifest was updated to network-specific format + let updated_toml = fs::read_to_string(tmp_dir.path().join("Forc.toml")).unwrap(); + assert!(updated_toml.contains("[proxy.addresses]")); + assert!(updated_toml.contains("local")); +} + +#[test] +fn test_proxy_validation_mutually_exclusive_fields() { + // Test that having both address and addresses fields fails validation + let mut addresses = std::collections::BTreeMap::new(); + addresses.insert("testnet".to_string(), "0x1234".to_string()); + + let proxy = Proxy { + enabled: true, + address: Some("0x5678".to_string()), + addresses: Some(addresses), + }; + + // This should fail validation + assert!(proxy.validate().is_err()); +} + +#[test] +fn test_proxy_address_for_network() { + // Test network-specific address resolution + let mut addresses = std::collections::BTreeMap::new(); + addresses.insert("testnet".to_string(), "0x1111".to_string()); + addresses.insert("mainnet".to_string(), "0x2222".to_string()); + + let proxy = Proxy { + enabled: true, + address: None, + addresses: Some(addresses), + }; + + assert_eq!(proxy.address_for_network("testnet"), Some(&"0x1111".to_string())); + assert_eq!(proxy.address_for_network("mainnet"), Some(&"0x2222".to_string())); + assert_eq!(proxy.address_for_network("devnet"), None); +} + +#[test] +fn test_proxy_address_fallback_to_single_address() { + // Test fallback to single address field when addresses is not configured + let proxy = Proxy { + enabled: true, + address: Some("0x3333".to_string()), + addresses: None, + }; + + assert_eq!(proxy.address_for_network("testnet"), Some(&"0x3333".to_string())); + assert_eq!(proxy.address_for_network("any_network"), Some(&"0x3333".to_string())); +} + +#[test] +fn test_proxy_has_address_detection() { + // Test has_address() method + let proxy_with_single = Proxy { + enabled: true, + address: Some("0x1234".to_string()), + addresses: None, + }; + assert!(proxy_with_single.has_address()); + + let mut addresses = std::collections::BTreeMap::new(); + addresses.insert("testnet".to_string(), "0x5678".to_string()); + let proxy_with_addresses = Proxy { + enabled: true, + address: None, + addresses: Some(addresses), + }; + assert!(proxy_with_addresses.has_address()); + + let proxy_without_address = Proxy { + enabled: true, + address: None, + addresses: None, + }; + assert!(!proxy_without_address.has_address()); +}