Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(backend): Support Solana SPL as a custom token type #4462

Merged
merged 51 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
dd4dca7
Add comments
bitdivine Jan 23, 2025
94dd476
test
bitdivine Jan 27, 2025
b1ea448
++
bitdivine Jan 27, 2025
ad0c112
validators
bitdivine Jan 27, 2025
89f422f
Verify that inputs look valid
bitdivine Jan 27, 2025
3fe988b
Simplify by removing the Serde code
bitdivine Jan 27, 2025
4de6dce
Merge remote-tracking branch 'origin/main' into spl-token-backend
bitdivine Jan 27, 2025
08d84e3
doc
bitdivine Jan 27, 2025
1310bb8
Revert "Simplify by removing the Serde code"
bitdivine Jan 27, 2025
e9b59db
++
bitdivine Jan 27, 2025
a50f0dc
++
bitdivine Jan 27, 2025
4b86a9c
🤖 Apply bindings changes
github-actions[bot] Jan 27, 2025
3b6c913
++
bitdivine Jan 27, 2025
8c13163
++
bitdivine Jan 27, 2025
744a794
Merge remote-tracking branch 'origin/spl-token-backend' into spl-toke…
bitdivine Jan 27, 2025
ad2dcbe
++
bitdivine Jan 27, 2025
83fa60d
++
bitdivine Jan 27, 2025
57c5fe6
++
bitdivine Jan 27, 2025
52e8d4b
Enable test
bitdivine Jan 27, 2025
4715763
Merge remote-tracking branch 'origin/main' into spl-token-backend
bitdivine Jan 28, 2025
8587d7a
++
bitdivine Jan 28, 2025
7f1c6c5
++
bitdivine Jan 28, 2025
c3c8ea2
simplify
bitdivine Jan 28, 2025
6d2e145
simplify
bitdivine Jan 28, 2025
f88e88c
Sanity check ICRC tokens
bitdivine Jan 28, 2025
d085f19
++
bitdivine Jan 28, 2025
083bbac
Test ICRC1 validation
bitdivine Jan 28, 2025
4a9b8bc
++
bitdivine Jan 28, 2025
4b78833
++
bitdivine Jan 28, 2025
3079356
++
bitdivine Jan 28, 2025
875ae1d
Merge remote-tracking branch 'origin/main' into spl-token-backend
bitdivine Jan 28, 2025
addc38c
Make the token legal
bitdivine Jan 28, 2025
f915e45
test
bitdivine Jan 28, 2025
6a0cb1e
test
bitdivine Jan 28, 2025
ca9b2dc
test
bitdivine Jan 28, 2025
f854100
Merge remote-tracking branch 'origin/main' into spl-token-backend
bitdivine Jan 28, 2025
15011a8
++
bitdivine Jan 28, 2025
ddb68f5
Add sol devnet
bitdivine Jan 29, 2025
fc6c06d
devnet
bitdivine Jan 29, 2025
d3eb5e8
Clumsy solution to variant specialisation
bitdivine Jan 29, 2025
0a60f03
🤖 Apply formatting changes
github-actions[bot] Jan 29, 2025
cb3a152
lint
bitdivine Jan 29, 2025
a9b4b49
🤖 Apply formatting changes
github-actions[bot] Jan 29, 2025
ba06e28
adjust TS tests
AntonioVentilii Jan 29, 2025
e82a9dc
Merge branch 'main' into spl-token-backend
bitdivine Jan 29, 2025
da773e9
Add a sol devnet token type
bitdivine Jan 30, 2025
85cf22a
++
bitdivine Jan 30, 2025
4e5c95a
Deep variant
bitdivine Jan 30, 2025
31b1a8e
Revert "Deep variant"
bitdivine Jan 30, 2025
1d1e4e4
Merge remote-tracking branch 'origin/main' into spl-token-backend
bitdivine Jan 30, 2025
fde7b19
🤖 Apply bindings changes
github-actions[bot] Jan 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bs58 is used to verify that the token ID is a valid base58 encoded 32 byte value.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion src/backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use shared::types::user_profile::{
ListUsersResponse, OisyUser, UserProfile,
};
use shared::types::{
Arg, Config, Guards, InitArg, Migration, MigrationProgress, MigrationReport, Stats,
Arg, Config, Guards, InitArg, Migration, MigrationProgress, MigrationReport, Stats, Validate,
};
use signer::{btc_principal_to_p2wpkh_address, AllowSigningError};
use std::cell::RefCell;
Expand Down Expand Up @@ -98,6 +98,12 @@ fn mutate_state<R>(f: impl FnOnce(&mut State) -> R) -> R {
STATE.with(|cell| f(&mut cell.borrow_mut()))
}

fn validate_or_trap<V: Validate>(v: &V) {
v.validate()
.map_err(|err| format!("{err:?}"))
.unwrap_or_else(|err| ic_cdk::trap(&err));
}

/// Reads the internal canister configuration, normally set at canister install or upgrade.
///
/// # Panics
Expand Down Expand Up @@ -309,6 +315,7 @@ pub fn list_user_tokens() -> Vec<UserToken> {
#[update(guard = "may_write_user_data")]
#[allow(clippy::needless_pass_by_value)]
pub fn set_custom_token(token: CustomToken) {
validate_or_trap(&token);
let stored_principal = StoredPrincipal(ic_cdk::caller());

let find = |t: &CustomToken| -> bool {
Expand All @@ -320,6 +327,9 @@ pub fn set_custom_token(token: CustomToken) {

#[update(guard = "may_write_user_data")]
pub fn set_many_custom_tokens(tokens: Vec<CustomToken>) {
for token in &tokens {
validate_or_trap(token);
}
let stored_principal = StoredPrincipal(ic_cdk::caller());

mutate_state(|s| {
Expand Down
1 change: 1 addition & 0 deletions src/shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.0.2"
edition = "2021"

[dependencies]
bs58 = "0.5.1"
candid = { workspace = true }
getrandom = { workspace = true }
ic-canister-sig-creation = { workspace = true }
Expand Down
76 changes: 74 additions & 2 deletions src/shared/src/impls.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::types::custom_token::{CustomToken, CustomTokenId, Token};
use crate::types::custom_token::{
CustomToken, CustomTokenId, IcrcToken, SplToken, SplTokenId, Token,
};
use crate::types::dapp::{AddDappSettingsError, DappCarouselSettings, DappSettings};
use crate::types::settings::Settings;
use crate::types::token::UserToken;
Expand All @@ -7,7 +9,7 @@ use crate::types::user_profile::{
};
use crate::types::{
ApiEnabled, Config, CredentialType, InitArg, Migration, MigrationProgress, MigrationReport,
Timestamp, TokenVersion, Version,
Timestamp, TokenVersion, Validate, Version,
};
use candid::Principal;
use ic_canister_sig_creation::{extract_raw_root_pk_from_der, IC_ROOT_PK_DER};
Expand All @@ -20,6 +22,9 @@ impl From<&Token> for CustomTokenId {
fn from(token: &Token) -> Self {
match token {
Token::Icrc(token) => CustomTokenId::Icrc(token.ledger_id),
Token::Spl(SplToken { token_address, .. }) => {
CustomTokenId::SolMainnet(token_address.clone())
}
}
}
}
Expand Down Expand Up @@ -331,3 +336,70 @@ fn next_matches_strum_iter() {
"Once completed, it should stay completed"
);
}

impl SplTokenId {
pub const MAX_LENGTH: usize = 44;
pub const MIN_LENGTH: usize = 32;
}

impl Validate for SplTokenId {
fn validate(&self) -> Result<(), candid::Error> {
if self.0.len() < 32 {
return Err(candid::Error::msg(
"Minimum valid Solana address length is 32",
));
}
if self.0.len() > 44 {
return Err(candid::Error::msg(
"Maximum valid Solana address length is 44",
));
}
let parsed_maybe = bs58::decode(&self.0).into_vec();
if let Ok(bytes) = parsed_maybe {
if bytes.len() != 32 {
return Err(candid::Error::msg(
"Invalid Solana address: not 32 bytes when decoded",
));
}
} else {
return Err(candid::Error::msg("Invalid Solana address: not base58"));
}
Ok(())
}
}

impl Validate for CustomTokenId {
AntonioVentilii marked this conversation as resolved.
Show resolved Hide resolved
fn validate(&self) -> Result<(), candid::Error> {
match self {
CustomTokenId::Icrc(_) => Ok(()), // This is a principal. In principle we could check the exact type of principal.
CustomTokenId::SolMainnet(token_address) => token_address.validate(),
}
}
}

impl Validate for CustomToken {
fn validate(&self) -> Result<(), candid::Error> {
self.token.validate()
}
}

impl Validate for Token {
fn validate(&self) -> Result<(), candid::Error> {
match self {
Token::Icrc(token) => token.validate(),
Token::Spl(token) => token.validate(),
}
}
}

impl Validate for SplToken {
fn validate(&self) -> Result<(), candid::Error> {
self.token_address.validate()
}
}

impl Validate for IcrcToken {
fn validate(&self) -> Result<(), candid::Error> {
Ok(())
}
}
68 changes: 68 additions & 0 deletions src/shared/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ use strum_macros::{EnumCount as EnumCountMacro, EnumIter};

pub type Timestamp = u64;

#[cfg(test)]
mod tests;

pub trait Validate {
/// Verifies that an object is semantically valid.
///
/// # Errors
/// - If the object is invalid.
fn validate(&self) -> Result<(), candid::Error>;
/// Returns the object if it is semantically valid.
///
/// # Errors
/// - If the object is invalid.
fn validated(self) -> Result<Self, candid::Error>
where
Self: Sized,
{
self.validate().map(|()| self)
}
}

#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug, Ord, PartialOrd)]
pub enum CredentialType {
ProofOfUniqueness,
Expand Down Expand Up @@ -142,31 +163,78 @@ pub mod token {
pub mod custom_token {
use crate::types::Version;
use candid::{CandidType, Deserialize, Principal};
use serde::{de, Deserializer};

pub type LedgerId = Principal;
pub type IndexId = Principal;

/// An ICRC-1 compliant token on the Internet Computer.
#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)]
pub struct IcrcToken {
pub ledger_id: LedgerId,
pub index_id: Option<IndexId>,
}

/// A Solana token
#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)]
pub struct SplToken {
pub token_address: SplTokenId,
pub symbol: Option<String>,
pub decimals: Option<u8>,
}

/// A network-specific unique Solana token identifier.
#[derive(CandidType, Clone, Eq, PartialEq, Deserialize, Debug)]
#[serde(remote = "Self")]
pub struct SplTokenId(pub String);

/// Basic verification of the Solana address.
///
/// # References
/// - <https://solana.com/docs/more/exchange#basic-verification>
///
impl<'de> Deserialize<'de> for SplTokenId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let unchecked = SplTokenId::deserialize(deserializer)?;
if unchecked.0.len() > 32 {
return Err(de::Error::custom(
"Minimum valid Solana address length is 32",
));
}
if unchecked.0.len() > 44 {
return Err(de::Error::custom(
"Maximum valid Solana address length is 44",
));
}
Ok(unchecked)
}
}

/// A variant describing any token
#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)]
pub enum Token {
Icrc(IcrcToken),
Spl(SplToken),
}

/// User preferences for any token
#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)]
pub struct CustomToken {
pub token: Token,
pub enabled: bool,
pub version: Option<Version>,
}

/// A cross-chain token identifier.
#[derive(CandidType, Deserialize, Clone, Eq, PartialEq)]
pub enum CustomTokenId {
/// An ICRC-1 compliant token on the Internet Computer mainnet.
Icrc(LedgerId),
/// A Solana token on the Solana mainnet.
SolMainnet(SplTokenId),
AntonioVentilii marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/shared/src/types/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Tests for the types module.

mod custom_token {
//! Tests for the custom_token module.
use candid::{Decode, Encode};

use crate::types::custom_token::*;
use crate::types::Validate;

const SPL_TEST_VECTORS: [(&str, bool); 4] = [
("", false),
("1", false),
("11111111111111111111111111111111", true),
(
"11111111111111111111111111111111111111111111111111111111111111111111111111111111",
false,
),
];

#[test]
fn spl_token_id_parsing_validation_works() {
for (input, expected) in SPL_TEST_VECTORS.iter() {
let input = SplTokenId(input.to_string());
let result = input.validate();
assert_eq!(*expected, result.is_ok());
}
}
#[ignore] // This does NOT currently work for candid
#[test]
fn spl_token_validation_works_for_candid() {
for (input, expected) in SPL_TEST_VECTORS.iter() {
let spl_token_id = SplTokenId(input.to_string());

let candid = Encode!(&spl_token_id).unwrap();
let result: Result<SplTokenId, _> = Decode!(&candid, SplTokenId);
assert_eq!(*expected, result.is_ok());
}
}
}
Loading