Skip to content

Commit

Permalink
Feature request: Add ability to manage Icrc1 tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
aligatorr89 committed Jan 10, 2025
1 parent 2c77cc5 commit 0b38f6b
Show file tree
Hide file tree
Showing 7 changed files with 350 additions and 21 deletions.
71 changes: 66 additions & 5 deletions src/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
IcrcLedgerCanister,
IcrcTransferError,
IcrcAccount,
IcrcMetadataResponseEntries,
} from "@dfinity/ledger-icrc";
import { PostId } from "./types";
import { Value } from "@dfinity/ledger-icrc/dist/candid/icrc_ledger";
import { Icrc1Canister, PostId } from "./types";

export type Backend = {
query: <T>(
Expand Down Expand Up @@ -100,6 +102,8 @@ export type Backend = {
amount: number,
fee: number,
) => Promise<string | number>;

icrc_metadata: (canisterId: string) => Promise<Icrc1Canister | null>;
};

export const ApiGenerator = (
Expand All @@ -118,6 +122,18 @@ export const ApiGenerator = (
console.error(err);
});

const icrc_canisters: Map<string, IcrcLedgerCanister> = new Map();
const getIcrcCanister = (canisterId: string) => {
const canisterAgent = icrc_canisters.get(canisterId);
if (canisterAgent) {
return canisterAgent;
}
return IcrcLedgerCanister.create({
canisterId: Principal.from(canisterId),
agent,
});
};

const query_raw = async (
canisterId = CANISTER_ID,
methodName: string,
Expand Down Expand Up @@ -483,16 +499,61 @@ export const ApiGenerator = (
token: Principal,
account: IcrcAccount,
): Promise<bigint> => {
const canister = IcrcLedgerCanister.create({
canisterId: Principal.from(token),
agent,
});
const canister = getIcrcCanister(token.toString());
return await canister.balance({
certified: false,
owner: account.owner,
subaccount: account.subaccount,
});
},
icrc_metadata: async (canisterId: string) => {
try {
const canister = getIcrcCanister(canisterId);
// console.log(canister.metadata)
const meta = await canister.metadata({
certified: false,
});

const m = new Map<IcrcMetadataResponseEntries, Value>(
meta as any,
);

return {
decimals: new Number(
(
m.get(
IcrcMetadataResponseEntries.DECIMALS,
) as unknown as { Nat: number }
).Nat,
).valueOf(),
fee: new Number(
(
m.get(
IcrcMetadataResponseEntries.FEE,
) as unknown as { Nat: number }
).Nat,
).valueOf(),
logo: (
m.get(IcrcMetadataResponseEntries.LOGO) as unknown as {
Text: string;
}
).Text,
name: (
m.get(IcrcMetadataResponseEntries.NAME) as unknown as {
Text: string;
}
).Text,
symbol: (
m.get(
IcrcMetadataResponseEntries.SYMBOL,
) as unknown as { Text: string }
).Text,
} as Icrc1Canister;
} catch (error) {
console.error(error);
return null;
}
},
};
};

Expand Down
226 changes: 226 additions & 0 deletions src/frontend/src/icrc1-tokens-wallet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import * as React from "react";
import { ButtonWithLoading, CopyToClipboard } from "./common";
import { Principal } from "@dfinity/principal";
import { Icrc1Canister } from "./types";
import { bucket_image_url } from "./util";
import { Repost } from "./icons";

export const Icrc1TokensWallet = () => {
const [user] = React.useState(window.user);
const USER_CANISTERS_KEY = `user:${user?.id}_canisters`;
const USER_BALANCES_KEY = `user:${user?.id}_canister_balances`;
const [icrc1Canisters, setIcrc1Canisters] = React.useState<
Array<[string, Icrc1Canister]>
>([]);
const [canisterBalances, setCanisterBalances] = React.useState<{
[key: string]: string;
}>({});

const getCanistersLocal = () => {
return (
(JSON.parse(
localStorage.getItem(USER_CANISTERS_KEY) || (null as any),
) as unknown as Array<[string, Icrc1Canister]>) || []
);
};

const getBalancesLocal = () => {
return (
(JSON.parse(
localStorage.getItem(USER_BALANCES_KEY) || (null as any),
) as unknown as { [key: string]: string }) || {}
);
};

const loadIcrc1Canisters = async () => {
const canisters = getCanistersLocal();
setIcrc1Canisters(canisters);

loadIcrc1CanisterBalances();
};

const loadIcrc1CanisterBalances = async (
forCanisterId?: string,
forceRefresh = false,
) => {
const balances: { [key: string]: string } = getBalancesLocal();
const canisters = getCanistersLocal();
if (user && (forceRefresh || Object.keys(balances).length === 0)) {
await Promise.allSettled(
canisters
.filter(
([canisterId]) =>
!forCanisterId || forCanisterId === canisterId,
)
.map(([canisterId]) =>
window.api
.account_balance(Principal.from(canisterId), {
owner: Principal.from(user.principal),
})
.then(
(balance) =>
(balances[canisterId] =
new Number(balance).toString() || "0"),
)
.catch(() => (balances[canisterId] = "NaN")),
),
);
localStorage.setItem(USER_BALANCES_KEY, JSON.stringify(balances));
}
setCanisterBalances(balances);
};

React.useEffect(() => {
loadIcrc1Canisters();
}, []);

const addIcrc1CanisterPrompt = async () => {
const canisterId = prompt(`Icrc1 canister id`) || "";
if (!canisterId) {
return;
}
try {
Principal.fromText(canisterId);

const meta = await window.api.icrc_metadata(canisterId);
if (!meta) {
throw new Error("Could not find Icrc1 canister data");
}

const canisters = getCanistersLocal();
const existingCanister = canisters.find(
([id]) => id === canisterId,
);
if (existingCanister) {
return alert(
`Token ${existingCanister[1].symbol} was already added`,
);
}

canisters.push([canisterId, meta]);

localStorage.setItem(USER_CANISTERS_KEY, JSON.stringify(canisters));

setIcrc1Canisters(canisters);

await loadIcrc1CanisterBalances(canisterId, true);
} catch (error: any) {
alert(error?.message || "Failed to add token to your wallet");
}
};

const icrcTransferPrompts = async (
canisterId: string,
info: Icrc1Canister,
) => {
try {
const toPrincipal = Principal.fromText(
prompt(`Principal to send ${info.symbol}`) || "",
);
if (!toPrincipal) {
return;
}

const amount: number =
+(prompt(`Amount ${info.symbol} to send`) as any) || 0;

if (toPrincipal && amount) {
await window.api.icrc_transfer(
Principal.fromText(canisterId),
toPrincipal,
amount as number,
info.fee,
);
await loadIcrc1CanisterBalances(canisterId, true);
}
} catch (e: any) {
alert(e.message);
}
};

return (
<>
<div style={{ marginBottom: 4 }}>
<ButtonWithLoading
onClick={addIcrc1CanisterPrompt}
label={"Add token"}
></ButtonWithLoading>
<ButtonWithLoading
onClick={() => loadIcrc1CanisterBalances(undefined, true)}
label={"Refresh balances"}
></ButtonWithLoading>
</div>
{icrc1Canisters.length > 0 && (
<table className="icrc1-canisters">
<tbody>
{icrc1Canisters.map(([canisterId, info]) => (
<tr key={canisterId}>
<td className="monospace">{info.symbol}</td>
<td>
<img
style={{
height: 32,
width: 32,
verticalAlign: "middle",
}}
src={
info.logo_params
? bucket_image_url(
...info.logo_params,
)
: info.logo
}
/>
</td>
<td className="hide-mobile">
<a
href={`https://dashboard.internetcomputer.org/canister/${canisterId}`}
target="_blank"
>
{canisterId}
</a>
</td>

<td
style={{ textAlign: "right", width: "99%" }}
>
<ButtonWithLoading
classNameArg="send"
onClick={() =>
loadIcrc1CanisterBalances(
canisterId,
true,
)
}
label={<Repost></Repost>}
></ButtonWithLoading>
</td>
<td>
<ButtonWithLoading
classNameArg="send"
onClick={() =>
icrcTransferPrompts(
canisterId,
info,
)
}
label={"Send"}
></ButtonWithLoading>
</td>
<td>
<span className="monospace">{`${(+canisterBalances[canisterId] / Math.pow(10, info.decimals))?.toFixed(info.decimals)}`}</span>
</td>
<td>
<CopyToClipboard
value={user.principal}
displayMap={() => `Receive`}
></CopyToClipboard>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
};
17 changes: 1 addition & 16 deletions src/frontend/src/post.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ import {
} from "./icons";
import { ProposalView } from "./proposals";
import { Feature, Post, PostId, Realm, UserId } from "./types";
import { MAINNET_MODE } from "./env";
import { UserLink, UserList, populateUserNameCache } from "./user_resolve";
import { bucket_image_url } from "./util";

export const PostView = ({
id,
Expand Down Expand Up @@ -1091,21 +1091,6 @@ export const filesToUrls = (files: { [id: string]: [number, number] }) =>
{} as { [id: string]: string },
);

function bucket_image_url(bucket_id: string, offset: number, len: number) {
// Fall back to the mainnet if the local config doesn't contain the bucket.
let fallback_to_mainnet = !window.backendCache.stats?.buckets?.find(
([id, _y]) => id == bucket_id,
);
let host =
MAINNET_MODE || fallback_to_mainnet
? `https://${bucket_id}.raw.icp0.io`
: `http://127.0.0.1:8080`;
return (
`${host}/image?offset=${offset}&len=${len}` +
(MAINNET_MODE ? "" : `&canisterId=${bucket_id}`)
);
}

const FeatureView = ({ id }: { id: PostId }) => {
const [feature, setFeature] = React.useState<Feature>();
const [vp, setVP] = React.useState(0);
Expand Down
23 changes: 23 additions & 0 deletions src/frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,10 @@ label svg {
.prime h1 {
font-size: 200%;
}

.hide-mobile {
display: none;
}
}

.bottom_spaced {
Expand Down Expand Up @@ -1167,3 +1171,22 @@ div.thumbnails {
margin-top: 0.4em;
padding: 0.1em 0 0.1em 0;
}

.icrc1-canisters {
display: table;
width: 100%;
margin: 0;
}

.icrc1-canisters td {
padding: 0 0.25rem 0.125rem 0;
white-space: nowrap;
}
.icrc1-canisters td:last-of-type {
padding: 0;
}

.icrc1-canisters button.send {
padding-top: 0.4em;
padding-bottom: 0.4em;
}
Loading

0 comments on commit 0b38f6b

Please sign in to comment.