From 25a4c91567d512fe28b2d1696a005788fb4f8f82 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Wed, 16 Sep 2026 17:23:09 -0400 Subject: [PATCH] feat(solana): support token icons and descriptions on wrapped tokens Token-2022 has no icon or description fields; both are served from the off-chain JSON document that its `uri` field points at. That field was initialized to an empty string, so wrapped tokens could never carry either. Adds `uri` to `PartialTokenMetadata` and passes it through to the metadata initialize CPI. It is deliberately excluded from the mint's PDA hash: that hash is recomputed from onchain metadata to authenticate a mint as a wrapped token, so extending the preimage would move every already-deployed mint to an address the program no longer derives. `wrap_token` keeps its exact wire format, taking a `PartialTokenMetadataV1` argument whose layout is byte-identical to the previous type, and delegates with an empty uri. `wrap_token_v2` accepts the uri. Both derive the same mint. Also fixes `create_mock_wrapped_mint`, which left `update_authority` and `mint` at their defaults rather than mirroring a real wrapped mint. Co-authored-by: Cursor --- scripts/src/internal/sol/bridge.idl.ts | 190 ++++++- solana/programs/bridge/idl.json | 190 ++++++- .../programs/bridge/src/common/constants.rs | 6 + .../bridge/src/common/internal/metadata.rs | 169 ++++++- solana/programs/bridge/src/errors.rs | 3 + solana/programs/bridge/src/lib.rs | 29 +- .../instructions/bridge_wrapped_token.rs | 4 + .../buffered/bridge_wrapped_token.rs | 3 + .../solana_to_base/instructions/wrap_token.rs | 470 ++++++++++++------ .../bridge/src/solana_to_base/internal/mod.rs | 1 + .../src/solana_to_base/internal/wrap_token.rs | 205 ++++++++ solana/programs/bridge/src/test_utils/mod.rs | 8 +- 12 files changed, 1104 insertions(+), 174 deletions(-) create mode 100644 solana/programs/bridge/src/solana_to_base/internal/wrap_token.rs diff --git a/scripts/src/internal/sol/bridge.idl.ts b/scripts/src/internal/sol/bridge.idl.ts index d89184fb..a1f0765d 100644 --- a/scripts/src/internal/sol/bridge.idl.ts +++ b/scripts/src/internal/sol/bridge.idl.ts @@ -2488,10 +2488,12 @@ export const IDL = { { "name": "wrap_token", "docs": [ - "Creates a wrapped version of a Base token.", - "This function creates a new SPL mint account on Solana that represents the Base token,", - "enabling users to bridge the token between the two chains. It will also trigger a message", - "to Base to register the wrapped token in the Base Bridge contract.", + "Creates a wrapped version of a Base token, without an off-chain metadata uri.", + "", + "DEPRECATED: use `wrap_token_v2`, which additionally accepts the uri that serves the token's", + "icon and description. This instruction is retained only so clients built before that field", + "existed keep working. It derives the same mint as `wrap_token_v2`, but leaves the uri empty", + "permanently, since a wrapped token's metadata can never be changed after it is created.", "", "# Arguments", "* `ctx` - The transaction context", @@ -2569,6 +2571,114 @@ export const IDL = { ] } ], + "args": [ + { + "name": "outgoing_message_salt", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "decimals", + "type": "u8" + }, + { + "name": "partial_token_metadata", + "type": { + "defined": { + "name": "PartialTokenMetadataV1" + } + } + } + ] + }, + { + "name": "wrap_token_v2", + "docs": [ + "Creates a wrapped version of a Base token.", + "This function creates a new SPL mint account on Solana that represents the Base token,", + "enabling users to bridge the token between the two chains. It will also trigger a message", + "to Base to register the wrapped token in the Base Bridge contract.", + "", + "# Arguments", + "* `ctx` - The transaction context", + "* `outgoing_message_salt` - The salt for the outgoing message account", + "* `decimals` - Number of decimal places for the token", + "* `partial_token_metadata` - Token name, symbol, off-chain metadata uri, remote Base token address, and scaler exponent" + ], + "discriminator": [ + 156, + 173, + 188, + 64, + 139, + 131, + 187, + 136 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The account that pays for the transaction and all account creation costs.", + "Must be mutable to deduct lamports for mint creation, metadata storage, and gas fees." + ], + "writable": true, + "signer": true + }, + { + "name": "gas_fee_receiver", + "docs": [ + "The account that receives payment for the gas costs of registering the token on Base." + ], + "writable": true + }, + { + "name": "mint", + "docs": [ + "The new SPL Token-2022 mint being created for the wrapped token.", + "- Uses PDA with token metadata hash and decimals for deterministic address", + "- Mint authority set to itself (mint account) for controlled minting", + "- Includes metadata pointer extension to store token information onchain" + ], + "writable": true + }, + { + "name": "bridge", + "docs": [ + "The main bridge state account that tracks cross-chain operations.", + "Used to increment the nonce counter and manage EIP-1559 gas pricing.", + "Must be mutable to update the nonce after creating the outgoing message." + ], + "writable": true + }, + { + "name": "outgoing_message", + "docs": [ + "The outgoing message account that stores the cross-chain call to register", + "the wrapped token on the Base blockchain. Contains the encoded function call", + "with token address, local mint address, and scaling parameters." + ], + "writable": true + }, + { + "name": "token_program", + "docs": [ + "SPL Token-2022 program for creating the mint with metadata extensions.", + "Required for initializing tokens with advanced features like metadata pointers." + ] + }, + { + "name": "system_program", + "docs": [ + "System program required for creating new accounts and transferring lamports.", + "Used internally by Anchor for account initialization and rent payments." + ] + } + ], "args": [ { "name": "outgoing_message_salt", @@ -2850,6 +2960,11 @@ export const IDL = { "name": "MintIsNotWrappedTokenPda", "msg": "Mint is not a valid wrapped token PDA" }, + { + "code": 12706, + "name": "UriTooLong", + "msg": "Token metadata uri is too long" + }, { "code": 12800, "name": "InvalidThreshold", @@ -3756,6 +3871,16 @@ export const IDL = { ], "type": "string" }, + { + "name": "uri", + "docs": [ + "URI pointing to off-chain JSON metadata holding the token's image and description.", + "Token-2022 has no dedicated fields for either, so both are served from this document.", + "", + "NOTE: Deliberately excluded from [`PartialTokenMetadata::hash`]. See that method." + ], + "type": "string" + }, { "name": "remote_token", "docs": [ @@ -3784,6 +3909,52 @@ export const IDL = { ] } }, + { + "name": "PartialTokenMetadataV1", + "docs": [ + "Deprecated wire format for the `wrap_token` instruction, retained byte-for-byte so clients built", + "before `uri` existed keep working. Converts to [`PartialTokenMetadata`] with an empty `uri`,", + "which cannot be filled in afterwards. New integrations should use `wrap_token_v2`." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "name", + "docs": [ + "The human-readable name of the token (e.g., \"Wrapped Bitcoin\")" + ], + "type": "string" + }, + { + "name": "symbol", + "docs": [ + "The symbol/ticker of the token (e.g., \"WBTC\")" + ], + "type": "string" + }, + { + "name": "remote_token", + "docs": [ + "The 20-byte address of the corresponding token contract on Base (EVM address bytes)." + ], + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "scaler_exponent", + "docs": [ + "The scaling exponent used to convert between token amounts on different chains." + ], + "type": "u8" + } + ] + } + }, { "name": "PartnerOracleConfig", "type": { @@ -4096,6 +4267,17 @@ export const IDL = { "type": "u8", "value": "16" }, + { + "name": "MAX_URI_LEN", + "docs": [ + "Upper bound on a wrapped token's off-chain metadata uri. Matches the limit Metaplex applies to", + "its own uri field. The uri is written once when the token is wrapped and can never be changed,", + "so it is bounded to keep the mint small enough that unpacking its metadata during later bridge", + "operations stays well within the compute budget." + ], + "type": "u8", + "value": "200" + }, { "name": "NATIVE_SOL_PUBKEY", "type": "pubkey", diff --git a/solana/programs/bridge/idl.json b/solana/programs/bridge/idl.json index ed1af10b..9609c193 100644 --- a/solana/programs/bridge/idl.json +++ b/solana/programs/bridge/idl.json @@ -2485,10 +2485,12 @@ { "name": "wrap_token", "docs": [ - "Creates a wrapped version of a Base token.", - "This function creates a new SPL mint account on Solana that represents the Base token,", - "enabling users to bridge the token between the two chains. It will also trigger a message", - "to Base to register the wrapped token in the Base Bridge contract.", + "Creates a wrapped version of a Base token, without an off-chain metadata uri.", + "", + "DEPRECATED: use `wrap_token_v2`, which additionally accepts the uri that serves the token's", + "icon and description. This instruction is retained only so clients built before that field", + "existed keep working. It derives the same mint as `wrap_token_v2`, but leaves the uri empty", + "permanently, since a wrapped token's metadata can never be changed after it is created.", "", "# Arguments", "* `ctx` - The transaction context", @@ -2566,6 +2568,114 @@ ] } ], + "args": [ + { + "name": "outgoing_message_salt", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "decimals", + "type": "u8" + }, + { + "name": "partial_token_metadata", + "type": { + "defined": { + "name": "PartialTokenMetadataV1" + } + } + } + ] + }, + { + "name": "wrap_token_v2", + "docs": [ + "Creates a wrapped version of a Base token.", + "This function creates a new SPL mint account on Solana that represents the Base token,", + "enabling users to bridge the token between the two chains. It will also trigger a message", + "to Base to register the wrapped token in the Base Bridge contract.", + "", + "# Arguments", + "* `ctx` - The transaction context", + "* `outgoing_message_salt` - The salt for the outgoing message account", + "* `decimals` - Number of decimal places for the token", + "* `partial_token_metadata` - Token name, symbol, off-chain metadata uri, remote Base token address, and scaler exponent" + ], + "discriminator": [ + 156, + 173, + 188, + 64, + 139, + 131, + 187, + 136 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The account that pays for the transaction and all account creation costs.", + "Must be mutable to deduct lamports for mint creation, metadata storage, and gas fees." + ], + "writable": true, + "signer": true + }, + { + "name": "gas_fee_receiver", + "docs": [ + "The account that receives payment for the gas costs of registering the token on Base." + ], + "writable": true + }, + { + "name": "mint", + "docs": [ + "The new SPL Token-2022 mint being created for the wrapped token.", + "- Uses PDA with token metadata hash and decimals for deterministic address", + "- Mint authority set to itself (mint account) for controlled minting", + "- Includes metadata pointer extension to store token information onchain" + ], + "writable": true + }, + { + "name": "bridge", + "docs": [ + "The main bridge state account that tracks cross-chain operations.", + "Used to increment the nonce counter and manage EIP-1559 gas pricing.", + "Must be mutable to update the nonce after creating the outgoing message." + ], + "writable": true + }, + { + "name": "outgoing_message", + "docs": [ + "The outgoing message account that stores the cross-chain call to register", + "the wrapped token on the Base blockchain. Contains the encoded function call", + "with token address, local mint address, and scaling parameters." + ], + "writable": true + }, + { + "name": "token_program", + "docs": [ + "SPL Token-2022 program for creating the mint with metadata extensions.", + "Required for initializing tokens with advanced features like metadata pointers." + ] + }, + { + "name": "system_program", + "docs": [ + "System program required for creating new accounts and transferring lamports.", + "Used internally by Anchor for account initialization and rent payments." + ] + } + ], "args": [ { "name": "outgoing_message_salt", @@ -2847,6 +2957,11 @@ "name": "MintIsNotWrappedTokenPda", "msg": "Mint is not a valid wrapped token PDA" }, + { + "code": 12706, + "name": "UriTooLong", + "msg": "Token metadata uri is too long" + }, { "code": 12800, "name": "InvalidThreshold", @@ -3753,6 +3868,16 @@ ], "type": "string" }, + { + "name": "uri", + "docs": [ + "URI pointing to off-chain JSON metadata holding the token's image and description.", + "Token-2022 has no dedicated fields for either, so both are served from this document.", + "", + "NOTE: Deliberately excluded from [`PartialTokenMetadata::hash`]. See that method." + ], + "type": "string" + }, { "name": "remote_token", "docs": [ @@ -3781,6 +3906,52 @@ ] } }, + { + "name": "PartialTokenMetadataV1", + "docs": [ + "Deprecated wire format for the `wrap_token` instruction, retained byte-for-byte so clients built", + "before `uri` existed keep working. Converts to [`PartialTokenMetadata`] with an empty `uri`,", + "which cannot be filled in afterwards. New integrations should use `wrap_token_v2`." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "name", + "docs": [ + "The human-readable name of the token (e.g., \"Wrapped Bitcoin\")" + ], + "type": "string" + }, + { + "name": "symbol", + "docs": [ + "The symbol/ticker of the token (e.g., \"WBTC\")" + ], + "type": "string" + }, + { + "name": "remote_token", + "docs": [ + "The 20-byte address of the corresponding token contract on Base (EVM address bytes)." + ], + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "scaler_exponent", + "docs": [ + "The scaling exponent used to convert between token amounts on different chains." + ], + "type": "u8" + } + ] + } + }, { "name": "PartnerOracleConfig", "type": { @@ -4093,6 +4264,17 @@ "type": "u8", "value": "16" }, + { + "name": "MAX_URI_LEN", + "docs": [ + "Upper bound on a wrapped token's off-chain metadata uri. Matches the limit Metaplex applies to", + "its own uri field. The uri is written once when the token is wrapped and can never be changed,", + "so it is bounded to keep the mint small enough that unpacking its metadata during later bridge", + "operations stays well within the compute budget." + ], + "type": "u8", + "value": "200" + }, { "name": "NATIVE_SOL_PUBKEY", "type": "pubkey", diff --git a/solana/programs/bridge/src/common/constants.rs b/solana/programs/bridge/src/common/constants.rs index dc744bb8..d598b304 100644 --- a/solana/programs/bridge/src/common/constants.rs +++ b/solana/programs/bridge/src/common/constants.rs @@ -14,3 +14,9 @@ pub const WRAPPED_TOKEN_SEED: &[u8] = b"wrapped_token"; pub const MAX_PARTNER_VALIDATOR_THRESHOLD: u8 = 5; #[constant] pub const MAX_SIGNER_COUNT: u8 = 16; +/// Upper bound on a wrapped token's off-chain metadata uri. Matches the limit Metaplex applies to +/// its own uri field. The uri is written once when the token is wrapped and can never be changed, +/// so it is bounded to keep the mint small enough that unpacking its metadata during later bridge +/// operations stays well within the compute budget. +#[constant] +pub const MAX_URI_LEN: u8 = 200; diff --git a/solana/programs/bridge/src/common/internal/metadata.rs b/solana/programs/bridge/src/common/internal/metadata.rs index 156330f5..35c607ca 100644 --- a/solana/programs/bridge/src/common/internal/metadata.rs +++ b/solana/programs/bridge/src/common/internal/metadata.rs @@ -18,7 +18,7 @@ use anchor_spl::{ /// The metadata is stored using the SPL Token-2022 metadata interface's /// `additional_metadata` key/value field and can be used to reconstruct the relationship /// between tokens on both sides of the bridge. -#[derive(Debug, Clone, AnchorDeserialize, AnchorSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, AnchorDeserialize, AnchorSerialize)] pub struct PartialTokenMetadata { /// The human-readable name of the token (e.g., "Wrapped Bitcoin") pub name: String, @@ -26,6 +26,12 @@ pub struct PartialTokenMetadata { /// The symbol/ticker of the token (e.g., "WBTC") pub symbol: String, + /// URI pointing to off-chain JSON metadata holding the token's image and description. + /// Token-2022 has no dedicated fields for either, so both are served from this document. + /// + /// NOTE: Deliberately excluded from [`PartialTokenMetadata::hash`]. See that method. + pub uri: String, + /// The 20-byte address of the corresponding token contract on Base (EVM address bytes). /// This allows the bridge to identify which Base token this Solana token represents. pub remote_token: [u8; 20], @@ -39,6 +45,49 @@ pub struct PartialTokenMetadata { pub scaler_exponent: u8, } +/// Deprecated wire format for the `wrap_token` instruction, retained byte-for-byte so clients built +/// before `uri` existed keep working. Converts to [`PartialTokenMetadata`] with an empty `uri`, +/// which cannot be filled in afterwards. New integrations should use `wrap_token_v2`. +#[derive(Debug, Clone, PartialEq, Eq, AnchorDeserialize, AnchorSerialize)] +pub struct PartialTokenMetadataV1 { + /// The human-readable name of the token (e.g., "Wrapped Bitcoin") + pub name: String, + + /// The symbol/ticker of the token (e.g., "WBTC") + pub symbol: String, + + /// The 20-byte address of the corresponding token contract on Base (EVM address bytes). + pub remote_token: [u8; 20], + + /// The scaling exponent used to convert between token amounts on different chains. + pub scaler_exponent: u8, +} + +impl PartialTokenMetadataV1 { + /// Equal to [`PartialTokenMetadata::hash`] for the same token, so both wrap instructions + /// derive the same mint. + pub fn hash(&self) -> [u8; 32] { + metadata_hash( + &self.name, + &self.symbol, + &self.remote_token, + self.scaler_exponent, + ) + } +} + +impl From for PartialTokenMetadata { + fn from(value: PartialTokenMetadataV1) -> Self { + Self { + name: value.name, + symbol: value.symbol, + uri: String::new(), + remote_token: value.remote_token, + scaler_exponent: value.scaler_exponent, + } + } +} + /// Key used in `additional_metadata` for the Base (EVM) token address bytes, hex-encoded. pub const REMOTE_TOKEN_METADATA_KEY: &str = "remote_token"; /// Key used in `additional_metadata` for the decimal scaling exponent. @@ -49,6 +98,7 @@ impl From<&PartialTokenMetadata> for TokenMetadata { TokenMetadata { name: value.name.clone(), symbol: value.symbol.clone(), + uri: value.uri.clone(), additional_metadata: vec![ ( REMOTE_TOKEN_METADATA_KEY.to_string(), @@ -112,6 +162,7 @@ impl TryFrom for PartialTokenMetadata { Ok(PartialTokenMetadata { name: metadata.name, symbol: metadata.symbol, + uri: metadata.uri, remote_token, scaler_exponent, }) @@ -145,21 +196,43 @@ impl TryFrom<&AccountInfo<'_>> for PartialTokenMetadata { } impl PartialTokenMetadata { - /// Computes a keccak256 hash of the metadata fields as: - /// `keccak(len(name) || name || len(symbol) || symbol || remote_token || scaler_exponent_le)`, - /// where `scaler_exponent_le` is the little-endian byte representation. + /// See [`metadata_hash`]. pub fn hash(&self) -> [u8; 32] { - let mut data = Vec::new(); - data.extend_from_slice(&self.name.len().to_le_bytes()); - data.extend_from_slice(self.name.as_bytes()); - data.extend_from_slice(&self.symbol.len().to_le_bytes()); - data.extend_from_slice(self.symbol.as_bytes()); - data.extend_from_slice(self.remote_token.as_ref()); - data.extend_from_slice(&self.scaler_exponent.to_le_bytes()); - keccak::hash(&data).0 + metadata_hash( + &self.name, + &self.symbol, + &self.remote_token, + self.scaler_exponent, + ) } } +/// Computes a keccak256 hash of the metadata fields as: +/// `keccak(len(name) || name || len(symbol) || symbol || remote_token || scaler_exponent_le)`, +/// where `scaler_exponent_le` is the little-endian byte representation. +/// +/// IMPORTANT: a token's `uri` is excluded from the preimage and must stay excluded. This hash seeds +/// the wrapped mint PDA and is recomputed from onchain metadata to authenticate a mint as a wrapped +/// token, so extending the preimage moves every already-deployed mint to an address the program no +/// longer derives. Those mints would stop bridging in both directions, and would start passing the +/// `MintIsWrappedToken` guard in `bridge_spl` that routes them away from the burn path. Excluding it +/// is also what lets `wrap_token` and `wrap_token_v2` derive the same mint for a given token. +fn metadata_hash( + name: &str, + symbol: &str, + remote_token: &[u8; 20], + scaler_exponent: u8, +) -> [u8; 32] { + let mut data = Vec::new(); + data.extend_from_slice(&name.len().to_le_bytes()); + data.extend_from_slice(name.as_bytes()); + data.extend_from_slice(&symbol.len().to_le_bytes()); + data.extend_from_slice(symbol.as_bytes()); + data.extend_from_slice(remote_token.as_ref()); + data.extend_from_slice(&scaler_exponent.to_le_bytes()); + keccak::hash(&data).0 +} + /// Reads and returns Token-2022 `TokenMetadata` and `decimals` from a mint account. /// /// Fails if the account is not owned by the Token-2022 program or if the metadata @@ -177,3 +250,75 @@ fn mint_info_to_token_metadata(mint: &AccountInfo<'_>) -> Result<(TokenMetadata, let decimals = mint_with_extension.base.decimals; Ok((token_metadata, decimals)) } + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + const URI: &str = "https://example.com/weth.json"; + + /// Metadata and decimals of the wrapped ETH mint live on Solana mainnet, which was deployed + /// before `uri` was a supported field and therefore stores an empty one. + const DEPLOYED_DECIMALS: u8 = 9; + const DEPLOYED_MINT: Pubkey = pubkey!("2ZCFyWM6WthDLBo41zJsMQmjJ4Kvb6yumvrbLpVh9LMX"); + const DEPLOYED_BRIDGE: Pubkey = pubkey!("HNCne2FkVaNghhjKXapxJzPaBvAKDG1Ge3gqhZyfVWLM"); + + fn deployed_wrapped_eth(uri: &str) -> PartialTokenMetadata { + PartialTokenMetadata { + name: "Wrapped ETH".to_string(), + symbol: "wETH".to_string(), + uri: uri.to_string(), + remote_token: hex!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), + scaler_exponent: 9, + } + } + + /// Keeps `uri` out of the hash preimage: an already-deployed mint has to keep deriving to the + /// address it actually occupies, or it stops bridging in both directions. + #[test] + fn deployed_mint_still_derives() { + let (mint, _) = Pubkey::find_program_address( + &[ + WRAPPED_TOKEN_SEED, + DEPLOYED_DECIMALS.to_le_bytes().as_ref(), + deployed_wrapped_eth("").hash().as_ref(), + ], + &DEPLOYED_BRIDGE, + ); + + assert_eq!(mint, DEPLOYED_MINT); + } + + /// `wrap_token` and `wrap_token_v2` have to seed the same mint for a given token, otherwise the + /// deprecated path would create a second, separate asset. + #[test] + fn v1_hashes_identically_and_converts_to_an_empty_uri() { + let current = deployed_wrapped_eth(URI); + let v1 = PartialTokenMetadataV1 { + name: current.name.clone(), + symbol: current.symbol.clone(), + remote_token: current.remote_token, + scaler_exponent: current.scaler_exponent, + }; + + assert_eq!(v1.hash(), current.hash()); + assert_eq!(PartialTokenMetadata::from(v1), deployed_wrapped_eth("")); + } + + /// An empty uri is what every mint deployed before this field stores, so both must round-trip. + #[test] + fn round_trips_through_token_metadata() { + for uri in ["", URI] { + let expected = deployed_wrapped_eth(uri); + let token_metadata = TokenMetadata::from(&expected); + + assert_eq!(token_metadata.uri, uri); + assert_eq!( + PartialTokenMetadata::try_from(token_metadata).unwrap(), + expected + ); + } + } +} diff --git a/solana/programs/bridge/src/errors.rs b/solana/programs/bridge/src/errors.rs index d1c98e34..796ae543 100644 --- a/solana/programs/bridge/src/errors.rs +++ b/solana/programs/bridge/src/errors.rs @@ -115,6 +115,9 @@ pub enum BridgeError { #[msg("Mint is not a valid wrapped token PDA")] MintIsNotWrappedTokenPda, + #[msg("Token metadata uri is too long")] + UriTooLong, + // Bridge Configuration (6800-6899) #[msg("Threshold must be <= number of signers")] InvalidThreshold = 6800, diff --git a/solana/programs/bridge/src/lib.rs b/solana/programs/bridge/src/lib.rs index 70cb385a..4bade6fd 100644 --- a/solana/programs/bridge/src/lib.rs +++ b/solana/programs/bridge/src/lib.rs @@ -183,6 +183,27 @@ pub mod bridge { // Solana -> Base + /// Creates a wrapped version of a Base token, without an off-chain metadata uri. + /// + /// DEPRECATED: use `wrap_token_v2`, which additionally accepts the uri that serves the token's + /// icon and description. This instruction is retained only so clients built before that field + /// existed keep working. It derives the same mint as `wrap_token_v2`, but leaves the uri empty + /// permanently, since a wrapped token's metadata can never be changed after it is created. + /// + /// # Arguments + /// * `ctx` - The transaction context + /// * `outgoing_message_salt` - The salt for the outgoing message account + /// * `decimals` - Number of decimal places for the token + /// * `partial_token_metadata` - Token name, symbol, remote Base token address, and scaler exponent + pub fn wrap_token( + ctx: Context, + outgoing_message_salt: [u8; 32], + decimals: u8, + partial_token_metadata: PartialTokenMetadataV1, + ) -> Result<()> { + wrap_token_handler(ctx, outgoing_message_salt, decimals, partial_token_metadata) + } + /// Creates a wrapped version of a Base token. /// This function creates a new SPL mint account on Solana that represents the Base token, /// enabling users to bridge the token between the two chains. It will also trigger a message @@ -192,14 +213,14 @@ pub mod bridge { /// * `ctx` - The transaction context /// * `outgoing_message_salt` - The salt for the outgoing message account /// * `decimals` - Number of decimal places for the token - /// * `partial_token_metadata` - Token name, symbol, remote Base token address, and scaler exponent - pub fn wrap_token( - ctx: Context, + /// * `partial_token_metadata` - Token name, symbol, off-chain metadata uri, remote Base token address, and scaler exponent + pub fn wrap_token_v2( + ctx: Context, outgoing_message_salt: [u8; 32], decimals: u8, partial_token_metadata: PartialTokenMetadata, ) -> Result<()> { - wrap_token_handler(ctx, outgoing_message_salt, decimals, partial_token_metadata) + wrap_token_v2_handler(ctx, outgoing_message_salt, decimals, partial_token_metadata) } /// Initiates a cross-chain function call from Solana to Base. diff --git a/solana/programs/bridge/src/solana_to_base/instructions/bridge_wrapped_token.rs b/solana/programs/bridge/src/solana_to_base/instructions/bridge_wrapped_token.rs index 944fdce6..59ee6c0f 100644 --- a/solana/programs/bridge/src/solana_to_base/instructions/bridge_wrapped_token.rs +++ b/solana/programs/bridge/src/solana_to_base/instructions/bridge_wrapped_token.rs @@ -144,6 +144,7 @@ mod tests { let partial_token_metadata = PartialTokenMetadata { name: "Test Token".to_string(), symbol: "TEST".to_string(), + uri: "https://example.com/test-token.json".to_string(), remote_token: [1u8; 20], scaler_exponent: 0, }; @@ -261,6 +262,7 @@ mod tests { let partial_token_metadata = PartialTokenMetadata { name: "Test Token".to_string(), symbol: "TEST".to_string(), + uri: "https://example.com/test-token.json".to_string(), remote_token: [2u8; 20], // Different remote token scaler_exponent: 0, }; @@ -376,6 +378,7 @@ mod tests { let partial_token_metadata = PartialTokenMetadata { name: "Test Token".to_string(), symbol: "TEST".to_string(), + uri: "https://example.com/test-token.json".to_string(), remote_token: [1u8; 20], scaler_exponent: 0, }; @@ -478,6 +481,7 @@ mod tests { let partial_token_metadata = PartialTokenMetadata { name: "Test Token".to_string(), symbol: "TEST".to_string(), + uri: "https://example.com/test-token.json".to_string(), remote_token: [1u8; 20], scaler_exponent: 0, }; diff --git a/solana/programs/bridge/src/solana_to_base/instructions/buffered/bridge_wrapped_token.rs b/solana/programs/bridge/src/solana_to_base/instructions/buffered/bridge_wrapped_token.rs index 39a2e81b..0c75d22a 100644 --- a/solana/programs/bridge/src/solana_to_base/instructions/buffered/bridge_wrapped_token.rs +++ b/solana/programs/bridge/src/solana_to_base/instructions/buffered/bridge_wrapped_token.rs @@ -172,6 +172,7 @@ mod tests { let partial_token_metadata = PartialTokenMetadata { name: "Test Token".to_string(), symbol: "TEST".to_string(), + uri: "https://example.com/test-token.json".to_string(), remote_token: [1u8; 20], scaler_exponent: 0, }; @@ -361,6 +362,7 @@ mod tests { let partial_token_metadata = PartialTokenMetadata { name: "Test Token".to_string(), symbol: "TEST".to_string(), + uri: "https://example.com/test-token.json".to_string(), remote_token: [1u8; 20], scaler_exponent: 0, }; @@ -496,6 +498,7 @@ mod tests { let partial_token_metadata = PartialTokenMetadata { name: "Test Token".to_string(), symbol: "TEST".to_string(), + uri: "https://example.com/test-token.json".to_string(), remote_token: [1u8; 20], scaler_exponent: 0, }; diff --git a/solana/programs/bridge/src/solana_to_base/instructions/wrap_token.rs b/solana/programs/bridge/src/solana_to_base/instructions/wrap_token.rs index 04c5168a..baa0a808 100644 --- a/solana/programs/bridge/src/solana_to_base/instructions/wrap_token.rs +++ b/solana/programs/bridge/src/solana_to_base/instructions/wrap_token.rs @@ -1,29 +1,78 @@ -use alloy_primitives::{Address, FixedBytes, U256}; -use alloy_sol_types::SolValue; use anchor_lang::prelude::*; -use anchor_lang::solana_program::rent::{ - DEFAULT_EXEMPTION_THRESHOLD, DEFAULT_LAMPORTS_PER_BYTE_YEAR, -}; -use anchor_lang::system_program::{transfer, Transfer}; -use anchor_spl::token_2022::spl_token_2022::extension::{ExtensionType, Length}; -use anchor_spl::token_interface::spl_pod::bytemuck::pod_get_packed_len; -use anchor_spl::token_interface::{ - spl_token_metadata_interface::state::{Field, TokenMetadata}, - token_metadata_initialize, token_metadata_update_field, Mint, Token2022, - TokenMetadataInitialize, TokenMetadataUpdateField, -}; -use spl_type_length_value::variable_len_pack::VariableLenPack; +use anchor_spl::token_interface::{Mint, Token2022}; use crate::common::DISCRIMINATOR_LEN; -use crate::common::{bridge::Bridge, PartialTokenMetadata, BRIDGE_SEED, WRAPPED_TOKEN_SEED}; -use crate::solana_to_base::{pay_for_gas, Call, CallType, OutgoingMessage, OUTGOING_MESSAGE_SEED}; -use crate::solana_to_base::{REMOTE_TOKEN_METADATA_KEY, SCALER_EXPONENT_METADATA_KEY}; +use crate::common::{ + bridge::Bridge, PartialTokenMetadata, PartialTokenMetadataV1, BRIDGE_SEED, WRAPPED_TOKEN_SEED, +}; +use crate::solana_to_base::{ + internal::wrap_token::{wrap_token_internal, REGISTER_REMOTE_TOKEN_DATA_LEN}, + Call, OutgoingMessage, OUTGOING_MESSAGE_SEED, +}; use crate::BridgeError; -use crate::ID; -const REGISTER_REMOTE_TOKEN_DATA_LEN: usize = { - 32 + 32 + 32 // abi.encode(address, bytes32, uint8) = 96 bytes -}; +/// Accounts struct for the deprecated wrap token instruction. Identical to [`WrapTokenV2`] except +/// that its metadata argument predates the `uri` field, so its instruction data stays byte-for-byte +/// compatible with clients built before that field existed. +#[derive(Accounts)] +#[instruction(outgoing_message_salt: [u8; 32], decimals: u8, metadata: PartialTokenMetadataV1)] +pub struct WrapToken<'info> { + /// The account that pays for the transaction and all account creation costs. + /// Must be mutable to deduct lamports for mint creation, metadata storage, and gas fees. + #[account(mut)] + pub payer: Signer<'info>, + + /// The account that receives payment for the gas costs of registering the token on Base. + /// CHECK: This account is validated to be the same as bridge.gas_config.gas_fee_receiver + #[account(mut, address = bridge.gas_config.gas_fee_receiver @ BridgeError::IncorrectGasFeeReceiver)] + pub gas_fee_receiver: AccountInfo<'info>, + + /// The new SPL Token-2022 mint being created for the wrapped token. + /// - Uses PDA with token metadata hash and decimals for deterministic address + /// - Mint authority set to itself (mint account) for controlled minting + /// - Includes metadata pointer extension to store token information onchain + #[account( + init, + payer = payer, + // NOTE: Suboptimal to compute the seeds here but it allows to use `init`. + seeds = [ + WRAPPED_TOKEN_SEED, + decimals.to_le_bytes().as_ref(), + metadata.hash().as_ref(), + ], + bump, + mint::decimals = decimals, + mint::authority = mint, + extensions::metadata_pointer::metadata_address = mint, + )] + pub mint: InterfaceAccount<'info, Mint>, + + /// The main bridge state account that tracks cross-chain operations. + /// Used to increment the nonce counter and manage EIP-1559 gas pricing. + /// Must be mutable to update the nonce after creating the outgoing message. + #[account(mut, seeds = [BRIDGE_SEED], bump)] + pub bridge: Account<'info, Bridge>, + + /// The outgoing message account that stores the cross-chain call to register + /// the wrapped token on the Base blockchain. Contains the encoded function call + /// with token address, local mint address, and scaling parameters. + #[account( + init, + payer = payer, + seeds = [OUTGOING_MESSAGE_SEED, outgoing_message_salt.as_ref()], + bump, + space = DISCRIMINATOR_LEN + OutgoingMessage::space::(REGISTER_REMOTE_TOKEN_DATA_LEN), + )] + pub outgoing_message: Account<'info, OutgoingMessage>, + + /// SPL Token-2022 program for creating the mint with metadata extensions. + /// Required for initializing tokens with advanced features like metadata pointers. + pub token_program: Program<'info, Token2022>, + + /// System program required for creating new accounts and transferring lamports. + /// Used internally by Anchor for account initialization and rent payments. + pub system_program: Program<'info, System>, +} /// Accounts struct for the wrap token instruction that creates a wrapped representation /// of a Base token on Solana. This instruction initializes a new SPL token @@ -31,7 +80,7 @@ const REGISTER_REMOTE_TOKEN_DATA_LEN: usize = { /// token transfers. The wrapped token maintains metadata linking it to its Base counterpart. #[derive(Accounts)] #[instruction(outgoing_message_salt: [u8; 32], decimals: u8, metadata: PartialTokenMetadata)] -pub struct WrapToken<'info> { +pub struct WrapTokenV2<'info> { /// The account that pays for the transaction and all account creation costs. /// Must be mutable to deduct lamports for mint creation, metadata storage, and gas fees. #[account(mut)] @@ -93,144 +142,267 @@ pub fn wrap_token_handler( ctx: Context, _outgoing_message_salt: [u8; 32], decimals: u8, - partial_token_metadata: PartialTokenMetadata, + partial_token_metadata: PartialTokenMetadataV1, ) -> Result<()> { - // Check if bridge is paused - require!(!ctx.accounts.bridge.paused, BridgeError::BridgePaused); - - initialize_metadata(&ctx, decimals, &partial_token_metadata)?; - - register_remote_token( - ctx, - &partial_token_metadata.remote_token, - partial_token_metadata.scaler_exponent, - )?; - - Ok(()) + wrap_token_internal( + &ctx.accounts.payer, + &ctx.accounts.gas_fee_receiver, + &ctx.accounts.mint, + &mut ctx.accounts.bridge, + &mut ctx.accounts.outgoing_message, + &ctx.accounts.token_program, + &ctx.accounts.system_program, + ctx.bumps.mint, + decimals, + partial_token_metadata.into(), + ) } -fn initialize_metadata( - ctx: &Context, +pub fn wrap_token_v2_handler( + ctx: Context, + _outgoing_message_salt: [u8; 32], decimals: u8, - partial_token_metadata: &PartialTokenMetadata, + partial_token_metadata: PartialTokenMetadata, ) -> Result<()> { - let token_metadata = TokenMetadata::from(partial_token_metadata); - - // Calculate lamports required for the additional metadata - let token_metadata_size = add_type_and_length_to_len(token_metadata.get_packed_len().unwrap()); - let lamports = token_metadata_size as u64 - * DEFAULT_LAMPORTS_PER_BYTE_YEAR - * DEFAULT_EXEMPTION_THRESHOLD as u64; - - // Transfer additional lamports to mint account (because we're increasing its size to store the metadata) - transfer( - CpiContext::new( - ctx.accounts.system_program.to_account_info(), - Transfer { - from: ctx.accounts.payer.to_account_info(), - to: ctx.accounts.mint.to_account_info(), - }, - ), - lamports, - )?; - - let decimals_bytes = decimals.to_le_bytes(); - let metadata_hash = partial_token_metadata.hash(); - - let seeds = &[ - WRAPPED_TOKEN_SEED, - &decimals_bytes, - &metadata_hash, - &[ctx.bumps.mint], - ]; - - // Initialize token metadata (name, symbol, etc.) - token_metadata_initialize( - CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - TokenMetadataInitialize { - program_id: ctx.accounts.token_program.to_account_info(), - mint: ctx.accounts.mint.to_account_info(), - metadata: ctx.accounts.mint.to_account_info(), - mint_authority: ctx.accounts.mint.to_account_info(), - update_authority: ctx.accounts.mint.to_account_info(), - }, - &[seeds], - ), - token_metadata.name, - token_metadata.symbol, - Default::default(), - )?; - - // Set the remote token metadata key (remote token address) - token_metadata_update_field( - CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - TokenMetadataUpdateField { - program_id: ctx.accounts.token_program.to_account_info(), - metadata: ctx.accounts.mint.to_account_info(), - update_authority: ctx.accounts.mint.to_account_info(), - }, - &[seeds], - ), - Field::Key(REMOTE_TOKEN_METADATA_KEY.to_string()), - hex::encode(partial_token_metadata.remote_token), - )?; - - // Set the scaler exponent metadata key - token_metadata_update_field( - CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - TokenMetadataUpdateField { - program_id: ctx.accounts.token_program.to_account_info(), - metadata: ctx.accounts.mint.to_account_info(), - update_authority: ctx.accounts.mint.to_account_info(), - }, - &[seeds], - ), - Field::Key(SCALER_EXPONENT_METADATA_KEY.to_string()), - partial_token_metadata.scaler_exponent.to_string(), - )?; - - Ok(()) + wrap_token_internal( + &ctx.accounts.payer, + &ctx.accounts.gas_fee_receiver, + &ctx.accounts.mint, + &mut ctx.accounts.bridge, + &mut ctx.accounts.outgoing_message, + &ctx.accounts.token_program, + &ctx.accounts.system_program, + ctx.bumps.mint, + decimals, + partial_token_metadata, + ) } -fn register_remote_token( - ctx: Context, - remote_token: &[u8; 20], - scaler_exponent: u8, -) -> Result<()> { - let address = Address::from(remote_token); - let local_token = FixedBytes::from(ctx.accounts.mint.key().to_bytes()); - let scaler_exponent = U256::from(scaler_exponent); - - let call = Call { - ty: CallType::Call, - to: [0; 20], - value: 0, - data: (address, local_token, scaler_exponent).abi_encode(), +#[cfg(test)] +mod tests { + use super::*; + + use anchor_lang::{solana_program::instruction::Instruction, system_program, InstructionData}; + use anchor_spl::token_2022::spl_token_2022::{ + extension::{BaseStateWithExtensions, PodStateWithExtensions}, + pod::PodMint, }; + use anchor_spl::token_interface::spl_token_metadata_interface::state::TokenMetadata; + use litesvm::LiteSVM; + use solana_keypair::Keypair; + use solana_message::Message; + use solana_signer::Signer; + use solana_transaction::Transaction; - let message = OutgoingMessage::new_call(ctx.accounts.bridge.nonce, ID, call); + use crate::common::MAX_URI_LEN; + use crate::{ + accounts, + instruction::{WrapToken as WrapTokenIx, WrapTokenV2 as WrapTokenV2Ix}, + test_utils::{ + create_outgoing_message, setup_bridge, SetupBridgeResult, TEST_GAS_FEE_RECEIVER, + }, + ID, + }; - pay_for_gas( - &ctx.accounts.system_program, - &ctx.accounts.payer, - &ctx.accounts.gas_fee_receiver, - &mut ctx.accounts.bridge, - )?; + const DECIMALS: u8 = 6; + const URI: &str = "https://example.com/werc20.json"; - *ctx.accounts.outgoing_message = message; - ctx.accounts.bridge.nonce += 1; + fn metadata_v1() -> PartialTokenMetadataV1 { + PartialTokenMetadataV1 { + name: "Wrapped ERC20".to_string(), + symbol: "wERC20".to_string(), + remote_token: [1u8; 20], + scaler_exponent: 9, + } + } - Ok(()) -} + fn metadata(uri: &str) -> PartialTokenMetadata { + PartialTokenMetadata { + uri: uri.to_string(), + ..metadata_v1().into() + } + } + + fn wrapped_mint(metadata_hash: [u8; 32]) -> Pubkey { + Pubkey::find_program_address( + &[ + WRAPPED_TOKEN_SEED, + DECIMALS.to_le_bytes().as_ref(), + metadata_hash.as_ref(), + ], + &ID, + ) + .0 + } + + fn account_metas( + payer: Pubkey, + mint: Pubkey, + bridge: Pubkey, + outgoing: Pubkey, + ) -> Vec { + accounts::WrapTokenV2 { + payer, + gas_fee_receiver: TEST_GAS_FEE_RECEIVER, + mint, + bridge, + outgoing_message: outgoing, + token_program: anchor_spl::token_2022::ID, + system_program: system_program::ID, + } + .to_account_metas(None) + } + + fn send( + svm: &mut LiteSVM, + payer: &Keypair, + ix: Instruction, + ) -> std::result::Result<(), String> { + let tx = Transaction::new( + &[payer], + Message::new(&[ix], Some(&payer.pubkey())), + svm.latest_blockhash(), + ); + + svm.send_transaction(tx) + .map(|_| ()) + .map_err(|err| format!("{:?}", err)) + } + + fn mint_uri(svm: &LiteSVM, mint: Pubkey) -> String { + let account = svm.get_account(&mint).unwrap(); + PodStateWithExtensions::::unpack(&account.data) + .unwrap() + .get_variable_len_extension::() + .unwrap() + .uri + } + + /// Byte-for-byte instruction data as a client built before `uri` existed would emit it. Written + /// out literally rather than serialized from a type, so a change to either the discriminator or + /// the argument layout of `wrap_token` fails here instead of silently breaking those clients. + fn legacy_instruction_data( + outgoing_message_salt: [u8; 32], + metadata: &PartialTokenMetadataV1, + ) -> Vec { + let mut data = vec![203, 83, 204, 83, 225, 109, 44, 6]; + data.extend_from_slice(&outgoing_message_salt); + data.push(DECIMALS); + data.extend_from_slice(&(metadata.name.len() as u32).to_le_bytes()); + data.extend_from_slice(metadata.name.as_bytes()); + data.extend_from_slice(&(metadata.symbol.len() as u32).to_le_bytes()); + data.extend_from_slice(metadata.symbol.as_bytes()); + data.extend_from_slice(&metadata.remote_token); + data.push(metadata.scaler_exponent); + data + } + + #[test] + fn test_wrap_token_legacy_wire_format_is_unchanged() { + let SetupBridgeResult { + mut svm, + payer, + bridge_pda, + .. + } = setup_bridge(); + + let (outgoing_message_salt, outgoing_message) = create_outgoing_message(); + let metadata = metadata_v1(); + let mint = wrapped_mint(metadata.hash()); + + let expected = legacy_instruction_data(outgoing_message_salt, &metadata); + let actual = WrapTokenIx { + outgoing_message_salt, + decimals: DECIMALS, + partial_token_metadata: metadata.clone(), + } + .data(); + assert_eq!( + actual, expected, + "wrap_token instruction data changed shape" + ); + + let ix = Instruction { + program_id: ID, + accounts: account_metas(payer.pubkey(), mint, bridge_pda, outgoing_message), + data: expected, + }; + + send(&mut svm, &payer, ix).expect("Failed to send legacy wrap_token transaction"); + + // The token is wrapped, permanently without a uri. + assert_eq!(mint_uri(&svm, mint), ""); + } + + #[test] + fn test_wrap_token_v2_stores_uri_on_mint() { + let SetupBridgeResult { + mut svm, + payer, + bridge_pda, + .. + } = setup_bridge(); + + let (outgoing_message_salt, outgoing_message) = create_outgoing_message(); + let partial_token_metadata = metadata(URI); + let mint = wrapped_mint(partial_token_metadata.hash()); + + let ix = Instruction { + program_id: ID, + accounts: account_metas(payer.pubkey(), mint, bridge_pda, outgoing_message), + data: WrapTokenV2Ix { + outgoing_message_salt, + decimals: DECIMALS, + partial_token_metadata: partial_token_metadata.clone(), + } + .data(), + }; + + // Succeeding also proves the mint was funded for the extra bytes the uri occupies. + send(&mut svm, &payer, ix).expect("Failed to send wrap_token_v2 transaction"); + + let account = svm.get_account(&mint).unwrap(); + let token_metadata = PodStateWithExtensions::::unpack(&account.data) + .unwrap() + .get_variable_len_extension::() + .unwrap(); + + assert_eq!(token_metadata.uri, URI); + assert_eq!( + PartialTokenMetadata::try_from(token_metadata).unwrap(), + partial_token_metadata + ); + } + + #[test] + fn test_wrap_token_v2_rejects_oversized_uri() { + let SetupBridgeResult { + mut svm, + payer, + bridge_pda, + .. + } = setup_bridge(); + + let (outgoing_message_salt, outgoing_message) = create_outgoing_message(); + let partial_token_metadata = metadata(&"u".repeat(MAX_URI_LEN as usize + 1)); + let mint = wrapped_mint(partial_token_metadata.hash()); + + let ix = Instruction { + program_id: ID, + accounts: account_metas(payer.pubkey(), mint, bridge_pda, outgoing_message), + data: WrapTokenV2Ix { + outgoing_message_salt, + decimals: DECIMALS, + partial_token_metadata, + } + .data(), + }; -/// Helper function to calculate exactly how many bytes a value will take up, -/// given the value's length -/// Copied from https://github.com/solana-program/token-2022/blob/4f292ccb95529b5fea7c305c4c8bf7ea1037175a/program/src/extension/mod.rs#L136 -const fn add_type_and_length_to_len(value_len: usize) -> usize { - value_len - .saturating_add(std::mem::size_of::()) - .saturating_add(pod_get_packed_len::()) + let error = send(&mut svm, &payer, ix).expect_err("Expected oversized uri to be rejected"); + assert!( + error.contains("UriTooLong"), + "Expected UriTooLong error, got: {}", + error + ); + } } diff --git a/solana/programs/bridge/src/solana_to_base/internal/mod.rs b/solana/programs/bridge/src/solana_to_base/internal/mod.rs index c8fe89e2..8dbaf0f5 100644 --- a/solana/programs/bridge/src/solana_to_base/internal/mod.rs +++ b/solana/programs/bridge/src/solana_to_base/internal/mod.rs @@ -2,3 +2,4 @@ pub mod bridge_call; pub mod bridge_sol; pub mod bridge_spl; pub mod bridge_wrapped_token; +pub mod wrap_token; diff --git a/solana/programs/bridge/src/solana_to_base/internal/wrap_token.rs b/solana/programs/bridge/src/solana_to_base/internal/wrap_token.rs new file mode 100644 index 00000000..989370d4 --- /dev/null +++ b/solana/programs/bridge/src/solana_to_base/internal/wrap_token.rs @@ -0,0 +1,205 @@ +use alloy_primitives::{Address, FixedBytes, U256}; +use alloy_sol_types::SolValue; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::rent::{ + DEFAULT_EXEMPTION_THRESHOLD, DEFAULT_LAMPORTS_PER_BYTE_YEAR, +}; +use anchor_lang::system_program::{transfer, Transfer}; +use anchor_spl::token_2022::spl_token_2022::extension::{ExtensionType, Length}; +use anchor_spl::token_interface::spl_pod::bytemuck::pod_get_packed_len; +use anchor_spl::token_interface::{ + spl_token_metadata_interface::state::{Field, TokenMetadata}, + token_metadata_initialize, token_metadata_update_field, Mint, Token2022, + TokenMetadataInitialize, TokenMetadataUpdateField, +}; +use spl_type_length_value::variable_len_pack::VariableLenPack; + +use crate::common::{bridge::Bridge, PartialTokenMetadata, MAX_URI_LEN, WRAPPED_TOKEN_SEED}; +use crate::solana_to_base::{ + pay_for_gas, Call, CallType, OutgoingMessage, REMOTE_TOKEN_METADATA_KEY, + SCALER_EXPONENT_METADATA_KEY, +}; +use crate::BridgeError; +use crate::ID; + +pub const REGISTER_REMOTE_TOKEN_DATA_LEN: usize = { + 32 + 32 + 32 // abi.encode(address, bytes32, uint8) = 96 bytes +}; + +/// Creates the wrapped mint's metadata and messages Base to register the token. +/// +/// Shared by `wrap_token` and `wrap_token_v2`, which differ only in whether the caller can supply a +/// `uri`. Both derive the same mint for a given token, so a token wrapped through either +/// instruction behaves identically afterwards. +#[allow(clippy::too_many_arguments)] +pub fn wrap_token_internal<'info>( + payer: &Signer<'info>, + gas_fee_receiver: &AccountInfo<'info>, + mint: &InterfaceAccount<'info, Mint>, + bridge: &mut Account<'info, Bridge>, + outgoing_message: &mut Account<'info, OutgoingMessage>, + token_program: &Program<'info, Token2022>, + system_program: &Program<'info, System>, + mint_bump: u8, + decimals: u8, + partial_token_metadata: PartialTokenMetadata, +) -> Result<()> { + // Check if bridge is paused + require!(!bridge.paused, BridgeError::BridgePaused); + + require!( + partial_token_metadata.uri.len() <= MAX_URI_LEN as usize, + BridgeError::UriTooLong + ); + + initialize_metadata( + payer, + mint, + token_program, + system_program, + mint_bump, + decimals, + &partial_token_metadata, + )?; + + register_remote_token( + payer, + gas_fee_receiver, + mint, + bridge, + outgoing_message, + system_program, + &partial_token_metadata, + )?; + + Ok(()) +} + +fn initialize_metadata<'info>( + payer: &Signer<'info>, + mint: &InterfaceAccount<'info, Mint>, + token_program: &Program<'info, Token2022>, + system_program: &Program<'info, System>, + mint_bump: u8, + decimals: u8, + partial_token_metadata: &PartialTokenMetadata, +) -> Result<()> { + let token_metadata = TokenMetadata::from(partial_token_metadata); + + // Calculate lamports required for the additional metadata + let token_metadata_size = add_type_and_length_to_len(token_metadata.get_packed_len().unwrap()); + let lamports = token_metadata_size as u64 + * DEFAULT_LAMPORTS_PER_BYTE_YEAR + * DEFAULT_EXEMPTION_THRESHOLD as u64; + + // Transfer additional lamports to mint account (because we're increasing its size to store the metadata) + transfer( + CpiContext::new( + system_program.to_account_info(), + Transfer { + from: payer.to_account_info(), + to: mint.to_account_info(), + }, + ), + lamports, + )?; + + let decimals_bytes = decimals.to_le_bytes(); + let metadata_hash = partial_token_metadata.hash(); + + let seeds = &[ + WRAPPED_TOKEN_SEED, + &decimals_bytes, + &metadata_hash, + &[mint_bump], + ]; + + // Initialize token metadata (name, symbol, etc.) + token_metadata_initialize( + CpiContext::new_with_signer( + token_program.to_account_info(), + TokenMetadataInitialize { + program_id: token_program.to_account_info(), + mint: mint.to_account_info(), + metadata: mint.to_account_info(), + mint_authority: mint.to_account_info(), + update_authority: mint.to_account_info(), + }, + &[seeds], + ), + token_metadata.name, + token_metadata.symbol, + token_metadata.uri, + )?; + + // Set the remote token metadata key (remote token address) + token_metadata_update_field( + CpiContext::new_with_signer( + token_program.to_account_info(), + TokenMetadataUpdateField { + program_id: token_program.to_account_info(), + metadata: mint.to_account_info(), + update_authority: mint.to_account_info(), + }, + &[seeds], + ), + Field::Key(REMOTE_TOKEN_METADATA_KEY.to_string()), + hex::encode(partial_token_metadata.remote_token), + )?; + + // Set the scaler exponent metadata key + token_metadata_update_field( + CpiContext::new_with_signer( + token_program.to_account_info(), + TokenMetadataUpdateField { + program_id: token_program.to_account_info(), + metadata: mint.to_account_info(), + update_authority: mint.to_account_info(), + }, + &[seeds], + ), + Field::Key(SCALER_EXPONENT_METADATA_KEY.to_string()), + partial_token_metadata.scaler_exponent.to_string(), + )?; + + Ok(()) +} + +fn register_remote_token<'info>( + payer: &Signer<'info>, + gas_fee_receiver: &AccountInfo<'info>, + mint: &InterfaceAccount<'info, Mint>, + bridge: &mut Account<'info, Bridge>, + outgoing_message: &mut Account<'info, OutgoingMessage>, + system_program: &Program<'info, System>, + partial_token_metadata: &PartialTokenMetadata, +) -> Result<()> { + let address = Address::from(&partial_token_metadata.remote_token); + let local_token = FixedBytes::from(mint.key().to_bytes()); + let scaler_exponent = U256::from(partial_token_metadata.scaler_exponent); + + let call = Call { + ty: CallType::Call, + to: [0; 20], + value: 0, + data: (address, local_token, scaler_exponent).abi_encode(), + }; + + let message = OutgoingMessage::new_call(bridge.nonce, ID, call); + + pay_for_gas(system_program, payer, gas_fee_receiver, bridge)?; + + **outgoing_message = message; + bridge.nonce += 1; + + Ok(()) +} + +/// Helper function to calculate exactly how many bytes a value will take up, +/// given the value's length +/// Copied from https://github.com/solana-program/token-2022/blob/4f292ccb95529b5fea7c305c4c8bf7ea1037175a/program/src/extension/mod.rs#L136 +const fn add_type_and_length_to_len(value_len: usize) -> usize { + value_len + .saturating_add(std::mem::size_of::()) + .saturating_add(pod_get_packed_len::()) +} diff --git a/solana/programs/bridge/src/test_utils/mod.rs b/solana/programs/bridge/src/test_utils/mod.rs index ec002c0c..d0edf162 100644 --- a/solana/programs/bridge/src/test_utils/mod.rs +++ b/solana/programs/bridge/src/test_utils/mod.rs @@ -370,7 +370,13 @@ pub fn create_mock_wrapped_mint( ExtensionType::try_calculate_account_len::(&[ExtensionType::MetadataPointer]) .unwrap(); - let token_metadata = TokenMetadata::from(partial_token_metadata); + // A mint wrapped by this program is its own metadata update authority and metadata mint, which + // `TokenMetadata::from` leaves at their defaults. + let token_metadata = TokenMetadata { + update_authority: Some(wrapped_mint).try_into().unwrap(), + mint: wrapped_mint, + ..TokenMetadata::from(partial_token_metadata) + }; account_size += token_metadata.tlv_size_of().unwrap(); let mut mint_data = vec![0u8; account_size];