DEV Community

Alexander
Alexander

Posted on • Updated on

Tutorial: Connecting A Token With Multiple Ledgers Using ic-event-hub

This tutorial is dedicated to Rust smart-contract (canister) development on Internet Computer (Dfinity) platform. Completing it, you’ll know how to use ic-event-hub library APIs in order to perform efficient cross-canister integrations.

Before digging into this tutorial it is recommended to learn the basics of smart-contract development for the Internet Computer. Here are some good starting points: official website, dedicated to canister development on the IC; IC developer forum, where one could find an answer to almost any technical question.

Complete code of this tutorial is here:

Motivation

In the previous article about ic-event-hub "Introduction To ic-event-hub Library", we imagined the following scenario:

  • you've built a token canister that accepts transfer transactions from users and re-transmits the info about these transactions to any other canister that will ask for it;
  • there are many ledger canisters (developed by third parties who want to integrate them with your token), each of which is specialized on specific transaction type:
    • one of them only keeps track of transactions made by US citizens;
    • another one only keeps track of transactions made to/from charity organizations;
    • a bunch of personal ledgers, which keep track of transactions of a particular person or organization;
    • and each month there is at least one more ledger appears with some new specific requirements;
  • besides all of that you want to make your token as efficient as possible:
    • if the token experiences high load (e.g. 100+ tx/block), you don't want to send 100+ messages to each ledger;
    • since messages you send to ledgers are small (only contain some generic transfer data: from, to, amount, timestamp), you want to pack these messages into a single batch and send them all at once - this way you could save a lot of cycles.

Let's now implement it! Let's build a simple token canister that allows multiple ledgers to listen for various events happening in this token. And also let's build a couple of such ledgers.

Implementation

Project layout

project/
    token/
        src/
            actor.rs
            common/
                mod.rs
                currency_token.rs
                guards.rs
                types.rs
        build.sh
        can.did
        cargo.toml

    ledger-1/
        src/
            actor.rs
            common/
                mod.rs
                ledger.rs
                types.rs
        build.sh
        can.did
        cargo.toml

    ledger-2/
        src/
            actor.rs
            common/
                mod.rs
                ledger.rs
                types.rs
        build.sh
        can.did
        cargo.toml
Enter fullscreen mode Exit fullscreen mode

DFX configuration

// project/dfx.json

{
  "canisters": {
    "token": {
      "build": "./token/build.sh",
      "candid": "./token/can.did",
      "wasm": "./token/target/wasm32-unknown-unknown/release/token-opt.wasm",
      "type": "custom"
    },
    "ledger-1": {
      "build": "./ledger-1/build.sh",
      "candid": "./ledger-1/can.did",
      "wasm": "./ledger-1/target/wasm32-unknown-unknown/release/ledger-1-opt.wasm",
      "type": "custom"
    },
    "ledger-2": {
      "build": "./ledger-2/build.sh",
      "candid": "./ledger-2/can.did",
      "wasm": "./ledger-2/target/wasm32-unknown-unknown/release/ledger-2-opt.wasm",
      "type": "custom"
    }
  },
  "dfx": "0.9.2",
  "networks": {
    "local": {
      "bind": "127.0.0.1:8000",
      "type": "ephemeral"
    }
  },
  "version": 1
}
Enter fullscreen mode Exit fullscreen mode

Token canister

This canister provides basic token functionality: minting, transferring and burning. Each time such a functionality is used, a corresponding event is emitted.

Dependencies

# project/token/cargo.toml

[package]
name = "token"
version = "0.1.0"
edition = "2018"

[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"

[dependencies]
ic-cdk = "0.4.0"
ic-cdk-macros = "0.4.0"
candid = "0.7.12"
serde = "1.0"
ic-event-hub = "0.3.0"
ic-event-hub-macros = "0.3.0"
Enter fullscreen mode Exit fullscreen mode

Buildscript

# project/token/build.sh

#!/usr/bin/env bash

SCRIPT=$(readlink -f "$0")
SCRIPTPATH=$(dirname "$SCRIPT")
cd "$SCRIPTPATH" || exit

cargo build --target wasm32-unknown-unknown --release --package token && \
 ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/token.wasm -o ./target/wasm32-unknown-unknown/release/token-opt.wasm
Enter fullscreen mode Exit fullscreen mode

Token internal logic

As it was said earlier, our token would have three separate functionalities: mint, transfer and burn. Also we want to provide a function to get current token balance of some account (we use user principals as account identifiers) as well as to get total token supply value.

We also want our token to have some associated information: token name, token symbol (ticker) and token decimals (how many cents there are in one token).

Also we want to control the access to the minting functionality, to allow to invoke it only for a selected set of principals (controllers).

These requirements left us with the following token state structure:

// project/token/src/common/currency_token.rs

#[derive(CandidType, Deserialize)]
pub struct CurrencyToken {
    pub balances: HashMap<Principal, u64>,
    pub total_supply: u64,
    pub info: TokenInfo,
    pub controllers: Controllers,
}

pub type Controllers = Vec<Principal>;
Enter fullscreen mode Exit fullscreen mode

Let's define the implementation of this structure that will add all the functions we need:

// project/token/src/common/currency_token.rs

impl CurrencyToken {
    pub fn mint(&mut self, to: Principal, qty: u64) -> Result<(), Error> {
        if qty == 0 {
            return Err(Error::ZeroQuantity);
        }

        let prev_balance = self.balance_of(&to);
        let new_balance = prev_balance + qty;

        self.total_supply += qty;
        self.balances.insert(to, new_balance);

        Ok(())
    }

    pub fn transfer(&mut self, from: Principal, to: Principal, qty: u64) -> Result<(), Error> {
        if qty == 0 {
            return Err(Error::ZeroQuantity);
        }

        let prev_from_balance = self.balance_of(&from);
        let prev_to_balance = self.balance_of(&to);

        if prev_from_balance < qty {
            return Err(Error::InsufficientBalance);
        }

        let new_from_balance = prev_from_balance - qty;
        let new_to_balance = prev_to_balance + qty;

        if new_from_balance == 0 {
            self.balances.remove(&from);
        } else {
            self.balances.insert(from, new_from_balance);
        }

        self.balances.insert(to, new_to_balance);

        Ok(())
    }

    pub fn burn(&mut self, from: Principal, qty: u64) -> Result<(), Error> {
        if qty == 0 {
            return Err(Error::ZeroQuantity);
        }

        let prev_balance = self.balance_of(&from);

        if prev_balance < qty {
            return Err(Error::InsufficientBalance);
        }

        let new_balance = prev_balance - qty;

        if new_balance == 0 {
            self.balances.remove(&from);
        } else {
            self.balances.insert(from, new_balance);
        }

        self.total_supply -= qty;

        Ok(())
    }

    pub fn balance_of(&self, account_owner: &Principal) -> u64 {
        match self.balances.get(account_owner) {
            None => 0,
            Some(b) => *b,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There is nothing unusual in these functions. In fact they're pretty much copying the same functions from ERC-20 standard.

Each of these functions may return an Error, which type is defined as follows:

// project/token/src/common/types.rs

#[derive(Debug)]
pub enum Error {
    InsufficientBalance,
    ZeroQuantity,
    AccessDenied,
    ForbiddenOperation,
}
Enter fullscreen mode Exit fullscreen mode

Actor definition

Now, when we have our basic token functionality ready, let's wrap it into a canister. The state definition and initialization is as simple as follows:

// project/token/src/actor.rs

static mut STATE: Option<CurrencyToken> = None;

pub fn get_state() -> &'static mut CurrencyToken {
    unsafe { STATE.as_mut().unwrap() }
}

#[init]
fn init(controller: Principal, info: TokenInfo) {
    let token = CurrencyToken {
        balances: HashMap::new(),
        total_supply: 0,
        info,
        controllers: vec![controller],
    };

    unsafe {
        STATE = Some(token);
    }
}
Enter fullscreen mode Exit fullscreen mode

As you might notice, we pass a controller and token's info into an init() function. This token info is defined like this:

// project/token/src/common/types.rs

#[derive(Clone, CandidType, Deserialize)]
pub struct TokenInfo {
    pub name: String,
    pub symbol: String,
    pub decimals: u8,
}
Enter fullscreen mode Exit fullscreen mode

The next thing we want to do is to set up the ic-event-hub library. In order to do that, we need to invoke these three macros:

// project/token/src/actor.rs

implement_event_emitter!(10 * 1_000_000_000, 100 * 1024);
implement_subscribe!();
implement_unsubscribe!();
Enter fullscreen mode Exit fullscreen mode

The first one initializes the event emitter's state. You can find more information about it in this tutorial. Other two macros are enabling event listeners to subscribe for events, by adding a couple of new update functions (subscribe() and unsubscribe()).

In order to fully enable ic-event-hub we also have to call send_events() function inside the heartbeat system function like this:

// project/token/src/actor.rs

#[heartbeat]
pub fn tick() {
    send_events();
}
Enter fullscreen mode Exit fullscreen mode

This expression will check the ic-event-hub's state for ready event batches and then it will send these batches to their recipients.

Now, let's define all the update functions of our canister:

// project/token/src/actor.rs

#[update(guard = "controller_guard")]
fn mint(to: Principal, qty: u64) {
    get_state().mint(to, qty).expect("Minting failed");

    emit(MintEvent {
        to,
        amount: qty,
        timestamp: time(),
    });
}

#[update]
fn transfer(to: Principal, qty: u64) {
    let from = caller();

    get_state()
        .transfer(from, to, qty)
        .expect("Transfer failed");

    emit(TransferEvent {
        from,
        to,
        amount: qty,
        timestamp: time(),
    });
}

#[update]
fn burn(qty: u64) {
    let from = caller();

    get_state().burn(from, qty).expect("Burning failed");

    emit(BurnEvent {
        from,
        amount: qty,
        timestamp: time(),
    });
}
Enter fullscreen mode Exit fullscreen mode

In each of these methods, we're just calling an associated function defined in the state and emit an associated event. There are three types of events:

// project/token/src/common/types.rs

#[derive(Event)]
pub struct TransferEvent {
    #[topic]
    pub from: Principal,
    #[topic]
    pub to: Principal,
    pub amount: u64,
    pub timestamp: u64,
}

#[derive(Event)]
pub struct MintEvent {
    #[topic]
    pub to: Principal,
    pub amount: u64,
    pub timestamp: u64,
}

#[derive(Event)]
pub struct BurnEvent {
    #[topic]
    pub from: Principal,
    pub amount: u64,
    pub timestamp: u64,
}
Enter fullscreen mode Exit fullscreen mode

By the way, the controllers_guard() function is defined this way:

// project/token/src/common/guards.rs

pub fn controller_guard() -> Result<(), String> {
    if get_state().controllers.contains(&caller()) {
        Ok(())
    } else {
        Err(String::from("The caller is not a controller"))
    }
}
Enter fullscreen mode Exit fullscreen mode

The only thing our token misses are some basic query methods:

// project/token/src/actor.rs

#[query]
fn get_balance_of(account_owner: Principal) -> u64 {
    get_state().balance_of(&account_owner)
}

#[query]
fn get_total_supply() -> u64 {
    get_state().total_supply
}

#[query]
fn get_info() -> TokenInfo {
    get_state().info.clone()
}
Enter fullscreen mode Exit fullscreen mode

This is it. The complete code for our token canister should look like this:

// project/common/src/actor.rs

// ----------------- MAIN LOGIC ------------------

#[update(guard = "controller_guard")]
fn mint(to: Principal, qty: u64) {
    get_state().mint(to, qty).expect("Minting failed");

    emit(MintEvent {
        to,
        amount: qty,
        timestamp: time(),
    });
}

#[update]
fn transfer(to: Principal, qty: u64) {
    let from = caller();

    get_state()
        .transfer(from, to, qty)
        .expect("Transfer failed");

    emit(TransferEvent {
        from,
        to,
        amount: qty,
        timestamp: time(),
    });
}

#[update]
fn burn(qty: u64) {
    let from = caller();

    get_state().burn(from, qty).expect("Burning failed");

    emit(BurnEvent {
        from,
        amount: qty,
        timestamp: time(),
    });
}

#[query]
fn get_balance_of(account_owner: Principal) -> u64 {
    get_state().balance_of(&account_owner)
}

#[query]
fn get_total_supply() -> u64 {
    get_state().total_supply
}

#[query]
fn get_info() -> TokenInfo {
    get_state().info.clone()
}

// ------------------- EVENT HUB ------------------

implement_event_emitter!(10 * 1_000_000_000, 100 * 1024);
implement_subscribe!();
implement_unsubscribe!();

#[heartbeat]
pub fn tick() {
    send_events();
}

// ------------------ STATE ----------------------

static mut STATE: Option<CurrencyToken> = None;

pub fn get_state() -> &'static mut CurrencyToken {
    unsafe { STATE.as_mut().unwrap() }
}

#[init]
fn init(controller: Principal, info: TokenInfo) {
    let token = CurrencyToken {
        balances: HashMap::new(),
        total_supply: 0,
        info,
        controllers: vec![controller],
    };

    unsafe {
        STATE = Some(token);
    }
}
Enter fullscreen mode Exit fullscreen mode

Candid interface

// project/token/can.did

type TokenInfo = record {
    name : text;
    symbol : text;
    decimals : nat8;
};

service : (principal, TokenInfo) -> {
    "mint" : (principal, nat64) -> ();
    "transfer" : (principal, nat64) -> ();
    "burn" : (nat64) -> ();
    "get_balance_of" : (principal) -> (nat64) query;
    "get_total_supply" : () -> (nat64) query;
    "get_info" : () -> (TokenInfo) query;
}
Enter fullscreen mode Exit fullscreen mode

Let's move to ledgers now.

Ledger canisters

We're going to implement two ledgers. Their implementation would be almost the same, but the first one will listen for all possible events emitted by the token canister, while the second one will listen only for events related to the particular principal we're gonna provide it at the canister initialization phase.

Dependencies

# project/ledger-1/cargo.toml

[package]
name = "ledger-1"
version = "0.1.0"
edition = "2018"

[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"

[dependencies]
ic-cdk = "0.4.0"
ic-cdk-macros = "0.4.0"
candid = "0.7.12"
serde = "1.0"
ic-cron = "0.6.1"
ic-event-hub = "0.3.0"
ic-event-hub-macros = "0.3.0"
Enter fullscreen mode Exit fullscreen mode

Buildscript

# project/ledger-1/build.sh

#!/usr/bin/env bash

SCRIPT=$(readlink -f "$0")
SCRIPTPATH=$(dirname "$SCRIPT")
cd "$SCRIPTPATH" || exit

cargo build --target wasm32-unknown-unknown --release --package ledger-1 && \
 ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/ledger_1.wasm -o ./target/wasm32-unknown-unknown/release/ledger-1-opt.wasm
Enter fullscreen mode Exit fullscreen mode

Ledger internal logic

Ledger is a pretty straightforward thing - it is just a log of historical records in a chronological order. So its state and internals are trivial:

// project/ledger-1/src/common/ledger.rs

#[derive(CandidType, Deserialize)]
pub struct Ledger {
    pub token: Principal,
    pub entries: Vec<Entry>,
}

impl Ledger {
    pub fn add_entry(&mut self, entry: Entry) {
        self.entries.push(entry);
    }

    pub fn get_entries(&self) -> Vec<Entry> {
        self.entries.clone()
    }
}
Enter fullscreen mode Exit fullscreen mode

We have a principal of the token we're going to listen to and the log of historical entries each of which is defined like this:

// project/ledger-1/src/common/types.rs

#[derive(Clone, CandidType, Deserialize)]
pub enum Entry {
    Mint(MintEvent),
    Transfer(TransferEvent),
    Burn(BurnEvent),
}

#[derive(Clone, Event, CandidType, Deserialize)]
pub struct TransferEvent {
    #[topic]
    pub from: Principal,
    #[topic]
    pub to: Principal,
    pub amount: u64,
    pub timestamp: u64,
}

#[derive(Clone, Event, CandidType, Deserialize)]
pub struct MintEvent {
    #[topic]
    pub to: Principal,
    pub amount: u64,
    pub timestamp: u64,
}

#[derive(Clone, Event, CandidType, Deserialize)]
pub struct BurnEvent {
    #[topic]
    pub from: Principal,
    pub amount: u64,
    pub timestamp: u64,
}
Enter fullscreen mode Exit fullscreen mode

Notice, events are defined the same way they are in the token's codebase.

Actor definition

Let's define canister's state and init() function:

// project/ledger-1/src/actor.rs

static mut STATE: Option<Ledger> = None;

pub fn get_state() -> &'static mut Ledger {
    unsafe { STATE.as_mut().unwrap() }
}

implement_cron!();

#[init]
fn init(token_principal: Principal) {
    let token = Ledger {
        token: token_principal,
        entries: Vec::new(),
    };

    unsafe {
        STATE = Some(token);
    }

    cron_enqueue(
        token_principal,
        SchedulingOptions {
            delay_nano: 0,
            interval_nano: 0,
            iterations: Exact(1),
        },
    )
    .expect("Enqueue failed");
}

#[heartbeat]
pub fn tick() {
    for task in cron_ready_tasks() {
        let token = task
            .get_payload::<Principal>()
            .expect("Payload deserialization failed");

        spawn(async move {
            token
                .subscribe(SubscribeRequest {
                    callbacks: vec![CallbackInfo {
                        filter: EventFilter::empty(),
                        method_name: String::from("events_callback"),
                    }],
                })
                .await
                .expect("Subscribe failed");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

As you might know, it is impossible to make calls to external canisters inside init() function (here is a thread about this), but we can workaround this restriction, by using ic-cron library.
All we have to do is to enqueue a cron task and then process it in the first ever heartbeat of this canister. This is exactly what we do here. Processing the enqueued task we're calling the token canister in order to subscribe to all events it emits.

Let's now define an events_callback() function that will be used by ic-event-hub as a callback for event processing:

// project/ledger-1/src/actor.rs

#[update(guard = "token_guard")]
pub fn events_callback(events: Vec<Event>) {
    for event in events {
        match event.get_name().as_str() {
            "MintEvent" => {
                let mint_event = MintEvent::from_event(event);
                get_state().add_entry(Entry::Mint(mint_event));
            }
            "TransferEvent" => {
                let transfer_event = TransferEvent::from_event(event);
                get_state().add_entry(Entry::Transfer(transfer_event));
            }
            "BurnEvent" => {
                let burn_event = BurnEvent::from_event(event);
               get_state().add_entry(Entry::Burn(burn_event));
            }
            _ => trap("Unknown event"),
        }
    }
}

fn token_guard() -> Result<(), String> {
    if caller() != get_state().token {
        Err(String::from("Can only be called by the token canister"))
    } else {
        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

This function simply wraps any received event into an enum we defined earlier and then stores them into the state. We're guarding this function in order to prevent malicious canisters (or users) from messing with the history.

Let's also define a query function that will let us get all events stored in this ledger:

// project/ledger-1/src/actor.rs

#[query]
fn get_events() -> Vec<Entry> {
    get_state().get_entries()
}
Enter fullscreen mode Exit fullscreen mode

This is it. Ledger canister #1 is ready. Don't forget to define the .did file:

type Entry = variant {
    Mint : MintEvent;
    Transfer : TransferEvent;
    Burn : BurnEvent;
};

type MintEvent = record {
    to : principal;
    amount : nat64;
    timestamp : nat64;
};

type TransferEvent = record {
    from : principal;
    to : principal;
    amount : nat64;
    timestamp : nat64;
};

type BurnEvent = record {
    from : principal;
    amount : nat64;
    timestamp : nat64;
};

service : (principal) -> {
    "get_events" : () -> (vec Entry) query;
}
Enter fullscreen mode Exit fullscreen mode

Ledger #2

The second ledger is defined almost the same way as the previous one. It differs in a way the subscribe() call is made, because we want it to only listen for events related to a particular principal:

// project/ledger-2/src/actor.rs

#[heartbeat]
pub fn tick() {
    for _ in cron_ready_tasks() {
        spawn(async move {
            let state = get_state();

            state
                .token
                .subscribe(SubscribeRequest {
                    callbacks: vec![
                        CallbackInfo {
                            filter: MintEventFilter {
                                to: Some(state.track),
                            }
                            .to_event_filter(),
                            method_name: String::from("mint_callback"),
                        },
                        CallbackInfo {
                            filter: TransferEventFilter {
                                from: Some(state.track),
                                to: None,
                            }
                            .to_event_filter(),
                            method_name: String::from("transfer_callback"),
                        },
                        CallbackInfo {
                            filter: TransferEventFilter {
                                from: None,
                                to: Some(state.track),
                            }
                            .to_event_filter(),
                            method_name: String::from("transfer_callback"),
                        },
                        CallbackInfo {
                            filter: BurnEventFilter {
                                from: Some(state.track),
                            }
                            .to_event_filter(),
                            method_name: String::from("burn_callback"),
                        },
                    ],
                })
                .await
                .expect("Subscribe failed");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Subscribing for each event type we set an event filter that defines the only account identifier we're interested in. This account identifier (principal) should somehow appear in this ledger, so we just pass it as an argument into init() function and save it to the state:

// project/ledger-2/src/actor.rs

#[init]
fn init(token_principal: Principal, track_principal: Principal) {
    let token = Ledger {
        token: token_principal,
        track: track_principal,
        entries: Vec::new(),
    };

    unsafe {
        STATE = Some(token);
    }

    cron_enqueue(
        (),
        SchedulingOptions {
            delay_nano: 0,
            interval_nano: 0,
            iterations: Exact(1),
        },
    )
    .expect("Enqueue failed");
}
Enter fullscreen mode Exit fullscreen mode

And since we modify the signature of the init() function, we're also gonna need to update the candid interface:

// project/ledger-2/can.did

...

service : (principal, principal) -> {
    "get_events" : () -> (vec Entry) query;
}
Enter fullscreen mode Exit fullscreen mode

Everything else stays the same.

Battle testing

Let's now try to deploy and use our token and see what will happen.

Start the development network in a separate terminal window:

$ dfx start --clean
Enter fullscreen mode Exit fullscreen mode

Check your principal:

$ dfx identity get-principal
<your principal>
Enter fullscreen mode Exit fullscreen mode

Deploy canisters:

$ dfx deploy token --argument '(principal "<your principal>", record { name = "Test"; symbol = "TST"; decimals = 8 : nat8 })'

$ dfx deploy ledger-1 --argument '(principal "<token canister id>")'

$ dfx deploy ledger-2 --argument '(principal "<token canister id>", principal "aaaaa-aa")'
Enter fullscreen mode Exit fullscreen mode

We're using "aaaaa-aa" principal as an account identifier for ledger-2 to listen to as an example, but you can use any other principal here.

Mint some tokens to yourself:

$ dfx canister call token mint '(principal "<your principal>", 1000)'

()
Enter fullscreen mode Exit fullscreen mode

Transfer some tokens to "aaaaa-aa":

$ dfx canister call token transfer '(principal "aaaaa-aa", 200)'

()
Enter fullscreen mode Exit fullscreen mode

Burn the rest:

$ dfx canister call token burn '(800)'

()
Enter fullscreen mode Exit fullscreen mode

Let's now check the first ledger's state:

$ dfx canister call ledger-1 get_events '()'

(
  vec {
    variant {
      Mint = record {
        to = principal "<your principal>";
        timestamp = 1_647_816_085_333_883_997 : nat64;
        amount = 1_000 : nat64;
      }
    };
    variant {
      Transfer = record {
        to = principal "aaaaa-aa";
        from = principal "<your principal>";
        timestamp = 1_647_816_164_528_431_504 : nat64;
        amount = 200 : nat64;
      }
    };
    variant {
      Burn = record {
        from = principal "<your principal>";
        timestamp = 1_647_816_211_161_122_783 : nat64;
        amount = 800 : nat64;
      }
    };
  },
)
Enter fullscreen mode Exit fullscreen mode

All three transactions are there, good. Now let's check the second ledger:

$ dfx canister call ledger-2 get_events '()'

(
  vec {
    variant {
      Transfer = record {
        to = principal "aaaaa-aa";
        from = principal "<your principal>";
        timestamp = 1_647_816_164_528_431_504 : nat64;
        amount = 200 : nat64;
      }
    };
  },
)
Enter fullscreen mode Exit fullscreen mode

As was expected, it only contains a single transaction that is related to "aaaaa-aa" account id.

Looks like everything works fine. Woohoo!

The complete source code for this tutorial can be found here:

Thanks for reading!

Oldest comments (0)