Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
195 changes: 174 additions & 21 deletions e2e/test2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,29 +87,13 @@ test.describe("Regular users flow", () => {

await page.getByRole("button", { name: "POST" }).click();
await page.locator("textarea").fill("Hello world!");
const imagePath = resolve(
__dirname,
"..",
"src",
"frontend",
"assets",
"apple-touch-icon.png",
);
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByTestId("file-picker").click(),
]);
await fileChooser.setFiles([imagePath]);
await page.getByRole("button", { name: "SUBMIT" }).click();
await page.waitForURL(/#\/post\//);
await waitForUILoading(page);

await expect(
page.locator("article", { hasText: /Hello world/ }),
).toBeVisible();
await expect(
page.getByRole("img", { name: "512x512, 2kb" }),
).toBeVisible();

await page.getByTestId("post-info-toggle").click();
const editButton = page.locator("button[title=Edit]");
Expand All @@ -133,15 +117,12 @@ test.describe("Regular users flow", () => {
await expect(
article.getByText(/Edit:.*this is a post-scriptum/),
).toBeVisible();
await expect(
page.getByRole("img", { name: "512x512, 2kb" }),
).toBeVisible();
});

test("Wallet", async () => {
await page.getByTestId("toggle-user-section").click();

await expect(page.getByTestId("credits-balance")).toHaveText("976");
await expect(page.getByTestId("credits-balance")).toHaveText("996");

await handleDialog(
page,
Expand All @@ -152,7 +133,7 @@ test.describe("Regular users flow", () => {
},
);
await waitForUILoading(page);
await expect(page.getByTestId("credits-balance")).toHaveText("2,976");
await expect(page.getByTestId("credits-balance")).toHaveText("2,996");

const icpBalance = parseFloat(
await page.getByTestId("icp-balance").textContent(),
Expand Down Expand Up @@ -331,4 +312,176 @@ test.describe("Regular users flow", () => {
"WONDERLAND",
);
});

test("Upload image", async () => {
// Navigate to home to clear any overlays from previous test
await page.goto("/");
await waitForUILoading(page);

// Sign out bob and sign back in as alice
await page.getByTestId("toggle-user-section").click();
await page.getByRole("link", { name: /.*SIGN OUT.*/ }).click();
await waitForUILoading(page);
await page.getByRole("button", { name: "SIGN IN" }).click();
await page.getByRole("button", { name: "SEED PHRASE" }).click();
await page
.getByPlaceholder("Enter your seed phrase...")
.fill(mkPwd("alice"));
await page.getByRole("button", { name: "CONTINUE" }).click();
await waitForUILoading(page);

// Read credits balance before upload
await page.getByTestId("toggle-user-section").click();
const balanceBefore = parseInt(
(await page.getByTestId("credits-balance").textContent())!.replace(
/,/g,
"",
),
);
await page.getByTestId("toggle-user-section").click();

const imagePath = resolve(
__dirname,
"..",
"src",
"frontend",
"assets",
"apple-touch-icon.png",
);
await page.getByRole("button", { name: "POST" }).click();
await page.locator("textarea").fill("Post with image");
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByTestId("file-picker").click(),
]);
await fileChooser.setFiles([imagePath]);
await page.getByRole("button", { name: "SUBMIT" }).click();
await page.waitForURL(/#\/post\//);
await waitForUILoading(page);

await expect(
page.locator("article", { hasText: /Post with image/ }),
).toBeVisible();
await expect(
page.getByRole("img", { name: "512x512, 2kb" }),
).toBeVisible();

// Verify credits charged: post_cost (2) + blob_cost (20) = 22
await page.goto("/");
await waitForUILoading(page);
await page.getByTestId("toggle-user-section").click();
const balanceAfter = parseInt(
(await page.getByTestId("credits-balance").textContent())!.replace(
/,/g,
"",
),
);
expect(balanceBefore - balanceAfter).toBe(22);
});

test("Blob space reclamation", async () => {
const largeImage = resolve(
__dirname,
"..",
"src",
"frontend",
"assets",
"logo.png",
);
const smallImage = resolve(
__dirname,
"..",
"src",
"frontend",
"assets",
"apple-touch-icon.png",
);

const getImageOffset = async (altText: string): Promise<number> => {
const img = page.getByRole("img", { name: altText });
await expect(img).toBeVisible();
const src = await img.getAttribute("src");
const match = src!.match(/offset=(\d+)/);
return parseInt(match![1]);
};

const uploadPost = async (
text: string,
imagePath: string,
): Promise<void> => {
await page.getByRole("button", { name: "POST" }).click();
await page.locator("textarea").fill(text);
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByTestId("file-picker").click(),
]);
await fileChooser.setFiles([imagePath]);
await page.getByRole("button", { name: "SUBMIT" }).click();
await page.waitForURL(/#\/post\//);
await waitForUILoading(page);
};

// Read initial credits balance
await page.goto("/");
await waitForUILoading(page);
await page.getByTestId("toggle-user-section").click();
const initialBalance = parseInt(
(await page.getByTestId("credits-balance").textContent())!.replace(
/,/g,
"",
),
);
await page.getByTestId("toggle-user-section").click();

// Create post 1 with large image
await uploadPost("Large image post", largeImage);
const offset1 = await getImageOffset("200x200, 6kb");
const post1Url = page.url();

// Create post 2 with small image
await page.goto("/");
await waitForUILoading(page);
await uploadPost("Small image post", smallImage);
const offset2 = await getImageOffset("512x512, 2kb");

// Post 2 should be after post 1 in storage
expect(offset2).toBeGreaterThan(offset1);

// Delete post 1
await page.goto(post1Url);
await waitForUILoading(page);
await page
.locator(".post_box", { hasText: /Large image post/ })
.getByTestId("post-info-toggle")
.click();
await handleDialog(page, "confirm the post deletion", "", async () => {
await page.locator("button[title='Delete post']").click();
});
await waitForUILoading(page);

// Wait for the async free call to complete on the bucket
await page.waitForTimeout(3000);

// Create post 3 with large image (same size as post 1)
await page.goto("/");
await waitForUILoading(page);
await uploadPost("Reclaimed space post", largeImage);
const offset3 = await getImageOffset("200x200, 6kb");

// Post 3 should have reclaimed the space freed by post 1
expect(offset3).toBe(offset1);
expect(offset3).toBeLessThan(offset2);

// Verify total credits charged: 3 uploads (3*22=66) + 1 deletion (2) = 68
await page.goto("/");
await waitForUILoading(page);
await page.getByTestId("toggle-user-section").click();
const finalBalance = parseInt(
(await page.getByTestId("credits-balance").textContent())!.replace(
/,/g,
"",
),
);
expect(initialBalance - finalBalance).toBe(68);
});
});
106 changes: 106 additions & 0 deletions src/backend/env/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use self::invoices::{ICPInvoice, USER_ICP_SUBACCOUNT};
use self::post::{archive_cold_posts, Extension, Post, PostId};
use self::post_iterators::{IteratorMerger, MergeStrategy};
use self::proposals::{Payload, ReleaseInfo, Status};
use self::storage::Storage;
use self::token::{account, TransferArgs};
use self::user::{Filters, Mode, Notification, Predicate, UserFilter};
use crate::assets::export_token_supply;
Expand Down Expand Up @@ -125,6 +126,22 @@ pub struct Stats {
volume_week: Token,
}

#[derive(Serialize)]
pub struct TokenStats {
circulating_supply: Token,
holders: usize,
held_by_users: Token,
nakamoto_coefficient: usize,
fees_burned: Token,
volume_day: Token,
volume_week: Token,
e8s_for_one_xdr: u64,
e8s_revenue_per_1k: u64,
active_users_vp: u64,
last_weekly_chores: u64,
balances: Vec<(Account, Token, Option<UserId>, bool)>,
}

#[derive(Default, Serialize, Deserialize)]
pub struct TagIndex {
pub subscribers: usize,
Expand Down Expand Up @@ -2600,6 +2617,90 @@ impl State {
}
}

pub fn token_stats(&self, now: Time) -> TokenStats {
let mut held_by_users: Token = 0;
let mut active_balances: Vec<(UserId, Token)> = Vec::new();
let mut entries: Vec<(Account, Token, Option<UserId>, bool)> = Vec::new();
for (acc, balance) in &self.balances {
let user = self.principal_to_user(acc.owner).or_else(|| {
self.cold_wallets
.get(&acc.owner)
.and_then(|id| self.users.get(id))
});
let (user_id, active) = match user {
Some(u) => {
held_by_users += balance;
let active = u.active_within(CONFIG.voting_power_activity_weeks, WEEK, now);
if active {
active_balances.push((u.id, *balance));
}
(Some(u.id), active)
}
None => (None, false),
};
entries.push((acc.clone(), *balance, user_id, active));
}
entries.sort_unstable_by(|a, b| b.1.cmp(&a.1));
entries.truncate(30);

// Nakamoto coefficient: group by active user, count top holders
// needed to reach proposal_approval_threshold%.
let mut user_totals: BTreeMap<UserId, Token> = BTreeMap::new();
for (uid, bal) in active_balances {
*user_totals.entry(uid).or_default() += bal;
}
let mut sorted_totals: Vec<Token> = user_totals.into_values().collect();
sorted_totals.sort_unstable_by(|a, b| b.cmp(a));
let total_active: Token = sorted_totals.iter().sum();
let mut cumulative: Token = 0;
let mut nakamoto_coefficient = 0;
for bal in &sorted_totals {
cumulative += bal;
nakamoto_coefficient += 1;
if total_active > 0
&& cumulative * 100 / total_active >= CONFIG.proposal_approval_threshold as u64
{
break;
}
}

let last_week_txs = self
.memory
.ledger
.iter()
.rev()
.take_while(|(_, tx)| tx.timestamp + WEEK >= now)
.collect::<Vec<_>>();
let volume_day = last_week_txs
.iter()
.take_while(|(_, tx)| tx.timestamp + DAY >= now)
.map(|(_, tx)| tx.amount)
.sum();
let volume_week = last_week_txs.into_iter().map(|(_, tx)| tx.amount).sum();

TokenStats {
circulating_supply: self.balances.values().sum(),
holders: self.balances.len(),
held_by_users,
nakamoto_coefficient,
fees_burned: self.token_fees_burned,
volume_day,
volume_week,
e8s_for_one_xdr: self.e8s_for_one_xdr,
e8s_revenue_per_1k: self.last_revenues.iter().sum::<u64>()
/ self.last_revenues.len().max(1) as u64,
active_users_vp: self
.users
.values()
.filter(|u| u.active_within(CONFIG.voting_power_activity_weeks, WEEK, now))
.map(|u| u.total_balance())
.sum::<Token>()
/ token::base(),
last_weekly_chores: self.timers.last_weekly,
balances: entries,
}
}

pub fn vote_on_poll(
&mut self,
principal: Principal,
Expand Down Expand Up @@ -2722,12 +2823,17 @@ impl State {
_ => {}
};

let files = post.files.clone();
Post::mutate(self, &post_id, |post| {
post.delete(versions.clone());
Ok(())
})
.expect("couldn't delete post");

if !files.is_empty() {
ic_cdk::spawn(Storage::free_blobs(files));
}

Ok(())
}

Expand Down
Loading