Organise files more consistently

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Rūdolfs Ošiņš
2022-12-22 16:46:08 +01:00
parent e86accafae
commit caa83db480
130 changed files with 720 additions and 707 deletions
+116
View File
@@ -0,0 +1,116 @@
export interface Host {
host: string;
port: number | null;
}
export class Request {
path: string;
base: string;
protocol: string;
port: number;
constructor(path: string, api: Host) {
this.port = api.port || 8777;
this.base = api.host;
this.path = path.startsWith("/") ? path.slice(1) : path;
this.protocol = api.host === "0.0.0.0" ? "http://" : "https://";
}
async get(
params: Record<string, any> = {},
headers: Record<string, string> = {},
): Promise<any> {
const query = this.formatParams(params);
const search = new URLSearchParams(query).toString();
const urlString = this.createUrl(search);
return await Request.exec(urlString, {
method: "GET",
headers: { ...headers, Accept: "application/json" },
});
}
async post(
params: Record<string, any> = {},
headers: Record<string, string> = {},
): Promise<any> {
const body = this.formatParams(params);
const urlString = this.createUrl();
return await Request.exec(urlString, {
method: "POST",
body: JSON.stringify(body),
headers: { ...headers, "Content-Type": "application/json" },
});
}
async put(
params: Record<string, any> = {},
headers: Record<string, string> = {},
): Promise<any> {
const body = this.formatParams(params);
const urlString = this.createUrl();
return await Request.exec(urlString, {
method: "PUT",
body: JSON.stringify(body),
headers: { ...headers, "Content-Type": "application/json" },
});
}
// Executes a request and returns the response.
static async exec(
urlString: string,
props: Record<string, any>,
): Promise<any> {
let response = null;
try {
response = await fetch(urlString, props);
} catch (err) {
throw new ApiError("API request failed", urlString);
}
if (!response.ok) {
throw new ApiError(response.statusText, urlString);
}
return response.json();
}
// Filters out undefined and null values.
private formatParams(params: Record<string, any>): Record<string, string> {
const query: Record<string, string> = {};
for (const [key, val] of Object.entries(params)) {
if (val !== undefined && val !== null) {
query[key] = val.toString();
}
}
return query;
}
// Creates a URL with an eventual query string and port.
private createUrl(search?: string): string {
const baseUrl = this.path
? `${this.protocol}${this.base}/v1/${this.path}`
: `${this.protocol}${this.base}`;
const url = new URL(search ? `${baseUrl}?${search}` : baseUrl);
url.port = String(this.port);
return String(url);
}
}
export class ApiError extends Error {
url?: string;
constructor(message: string, url?: string) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ApiError);
}
this.name = "ApiError";
this.url = url;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { writable } from "svelte/store";
export type Theme = "dark" | "light";
export const theme = writable<Theme>(loadTheme());
export type CodeFont = "jetbrains" | "system";
export const codeFont = writable<CodeFont>(loadCodeFont());
export function storeCodeFont(codeFont: CodeFont): void {
window.localStorage.setItem("codefont", codeFont);
}
function loadCodeFont(): CodeFont {
const storedCodeFont = window.localStorage.getItem("codefont");
if (storedCodeFont === null) {
return "jetbrains";
} else {
return storedCodeFont as CodeFont;
}
}
export const codeFonts: {
storedName: CodeFont;
fontFamily: string;
displayName: string;
}[] = [
{
storedName: "jetbrains",
fontFamily: "JetBrains Mono",
displayName: "JetBrains Mono",
},
{ storedName: "system", fontFamily: "monospace", displayName: "System" },
];
function loadTheme(): Theme {
const storedTheme = window.localStorage.getItem("theme");
if (storedTheme === null) {
return "dark";
} else {
return storedTheme as Theme;
}
}
export function storeTheme(theme: Theme): void {
window.localStorage.setItem("theme", theme);
}
+125
View File
@@ -0,0 +1,125 @@
// Copyright (c) 2019, Ethereum Name Service
// The random number is a js implementation of the Xorshift PRNG
const randseed = new Array(4); // Xorshift: [x, y, z, w] 32 bit values
function seedrand(seed: string) {
for (let i = 0; i < randseed.length; i++) {
randseed[i] = 0;
}
for (let i = 0; i < seed.length; i++) {
randseed[i % 4] =
(randseed[i % 4] << 5) - randseed[i % 4] + seed.charCodeAt(i);
}
}
function rand(): number {
// Based on Java's String.hashCode(), expanded to 4 32bit values.
const t = randseed[0] ^ (randseed[0] << 11);
randseed[0] = randseed[1];
randseed[1] = randseed[2];
randseed[2] = randseed[3];
randseed[3] = randseed[3] ^ (randseed[3] >> 19) ^ t ^ (t >> 8);
return (randseed[3] >>> 0) / ((1 << 31) >>> 0);
}
function createColor(): string {
// Saturation is the whole color spectrum.
const h = Math.floor(rand() * 360);
// Saturation goes from 40 to 100, it avoids greyish colors.
const s = rand() * 60 + 40 + "%";
// Lightness can be anything from 0 to 100, but probabilities are a bell curve around 50%.
const l = (rand() + rand() + rand() + rand()) * 25 + "%";
return `hsl(${h}, ${s}, ${l})`;
}
function createImageData(size: number): number[] {
const width = size;
const height = size;
const dataWidth = Math.ceil(width / 2);
const mirrorWidth = width - dataWidth;
const data = [];
for (let y = 0; y < height; y++) {
let row = [];
for (let x = 0; x < dataWidth; x++) {
// this makes foreground and background color to have a 43% (1/2.3) probability
// spot color has 13% chance
row[x] = Math.floor(rand() * 2.3);
}
const r = row.slice(0, mirrorWidth);
r.reverse();
row = row.concat(r);
for (let i = 0; i < row.length; i++) {
data.push(row[i]);
}
}
return data;
}
function createCanvas(
imageData: number[],
color: string,
scale: number,
bgcolor: string,
spotcolor: string,
): HTMLCanvasElement {
const c = document.createElement("canvas");
const width = Math.sqrt(imageData.length);
c.width = c.height = width * scale;
const cc = c.getContext("2d");
if (!cc) throw new Error("Can't get 2D context");
cc.fillStyle = bgcolor;
cc.fillRect(0, 0, c.width, c.height);
cc.fillStyle = color;
for (let i = 0; i < imageData.length; i++) {
const row = Math.floor(i / width);
const col = i % width;
// if data is 2, choose spot color, if 1 choose foreground
cc.fillStyle = imageData[i] === 1 ? color : spotcolor;
// if data is 0, leave the background
if (imageData[i]) {
cc.fillRect(col * scale, row * scale, scale, scale);
}
}
return c;
}
export interface Options {
seed: string;
size: number;
scale: number;
color?: string;
bgcolor?: string;
spotcolor?: string;
}
export function createIcon(opts: Options): HTMLCanvasElement {
opts = opts || {};
const size = opts.size || 8;
const scale = opts.scale || 4;
const seed =
opts.seed || Math.floor(Math.random() * Math.pow(10, 16)).toString(16);
seedrand(seed);
const color = opts.color || createColor();
const bgcolor = opts.bgcolor || createColor();
const spotcolor = opts.spotcolor || createColor();
const imageData = createImageData(size);
const canvas = createCanvas(imageData, color, scale, bgcolor, spotcolor);
return canvas;
}
+25
View File
@@ -0,0 +1,25 @@
import LruCache from "lru-cache";
// Creates a function that memoizes its result using an LRU cache.
//
// The cache key is a string created from the arguments using
// `makeKey`.
export function cached<Args extends unknown[], V>(
f: (...args: Args) => Promise<V>,
makeKey: (...args: Args) => string,
options?: LruCache.Options<string, { value: V }>,
): (...args: Args) => Promise<V> {
const cache = new LruCache(options || { max: 500 });
return async function (...args: Args): Promise<V> {
const key = makeKey(...args);
const cached = cache.get(key);
if (cached === undefined) {
const value = await f(...args);
cache.set(key, { value });
return value;
} else {
return cached.value;
}
};
}
+31
View File
@@ -0,0 +1,31 @@
import type { PeerId } from "@app/lib/project";
export interface Author {
peer: PeerId;
urn: string;
profile: {
name: string;
ens: {
name: string;
} | null;
} | null;
}
export interface PeerIdentity {
urn: string;
name: string;
ens: {
name: string;
} | null;
}
export interface PeerInfo {
id: PeerId;
person?: PeerIdentity;
delegate: boolean;
}
// Formats COBs Object Ids
export function formatObjectId(id: string): string {
return id.substring(0, 11);
}
+177
View File
@@ -0,0 +1,177 @@
import type { Stats, Person } from "@app/lib/project";
import type { Diff } from "@app/lib/diff";
import { ApiError } from "@app/lib/api";
import { getDaysPassed } from "@app/lib/utils";
export interface CommitsHistory {
headers: CommitMetadata[];
stats: Stats;
}
export interface CommitMetadata {
header: CommitHeader;
context: CommitContext;
}
export interface Author {
email: string;
name: string;
}
export interface CommitStats {
branches: number;
commits: number;
contributors: number;
}
export interface GroupedCommitsHistory {
headers: CommitGroup[];
stats: Stats;
}
export interface CommitContext {
committer?: {
peer: {
id: string;
person: Person | null;
delegate: boolean;
};
};
}
export interface CommitHeader {
author: Author;
committer: Author;
committerTime: number;
description: string;
sha1: string;
summary: string;
}
// A set of commits grouped by time.
export interface CommitGroup {
date: string;
time: number;
commits: CommitMetadata[];
week: number;
}
export interface WeeklyActivity {
date: string;
time: number;
commits: number[];
week: number;
}
export interface DiffStats {
additions: number;
deletions: number;
}
export interface Commit {
header: CommitHeader;
stats: DiffStats;
diff: Diff;
branches: string[];
context: CommitContext;
}
export function formatGroupTime(timestamp: number): string {
return new Date(timestamp).toLocaleDateString("en-US", {
day: "numeric",
weekday: "long",
month: "long",
year: "numeric",
});
}
export function groupCommits(
commits: { header: CommitHeader; context: CommitContext }[],
): CommitGroup[] {
const groupedCommits: CommitGroup[] = [];
let groupDate: Date | undefined = undefined;
try {
commits = commits.sort((a, b) => {
if (a.header.committerTime > b.header.committerTime) {
return -1;
} else if (a.header.committerTime < b.header.committerTime) {
return 1;
}
return 0;
});
for (const commit of commits) {
const time = commit.header.committerTime * 1000;
const date = new Date(time);
const isNewDay =
!groupedCommits.length ||
!groupDate ||
date.getDate() < groupDate.getDate() ||
date.getMonth() < groupDate.getMonth() ||
date.getFullYear() < groupDate.getFullYear();
if (isNewDay) {
groupedCommits.push({
date: formatGroupTime(time),
time,
commits: [],
week: 0,
});
groupDate = date;
}
groupedCommits[groupedCommits.length - 1].commits.push(commit);
}
return groupedCommits;
} catch (err) {
throw new ApiError(
"Not able to create commit history, please consider updating seed HTTP API.",
);
}
}
export function groupCommitsByWeek(commits: number[]): WeeklyActivity[] {
const groupedCommits: WeeklyActivity[] = [];
let groupDate: Date | undefined = undefined;
if (commits.length === 0) {
return [];
}
commits = commits.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));
// A accumulator that increments by the amount of weeks between weekly commit groups
let weekAccumulator = Math.floor(
getDaysPassed(new Date(commits[0] * 1000), new Date()) / 7,
);
// Loops over all commits and stores them by week with some additional metadata in groupedCommits.
for (const commit of commits) {
const time = commit * 1000;
const date = new Date(time);
const isNewWeek =
!groupedCommits.length ||
!groupDate ||
getDaysPassed(date, groupDate) > 7 ||
date.getFullYear() < groupDate.getFullYear();
if (isNewWeek) {
let daysPassed = 0;
if (groupDate) {
daysPassed = getDaysPassed(date, groupDate);
}
groupedCommits.push({
date: formatGroupTime(time),
time,
commits: [],
week: Math.floor(daysPassed / 7) + weekAccumulator,
});
groupDate = date;
weekAccumulator += Math.floor(daysPassed / 7);
}
groupedCommits[groupedCommits.length - 1].commits.push(commit);
}
return groupedCommits;
}
+36
View File
@@ -0,0 +1,36 @@
import configJson from "@app/config.json";
export interface Config {
walletConnect: { bridge: string };
reactions: string[];
seeds: {
pinned: { host: string; emoji: string }[];
};
projects: {
pinned: {
name: string;
urn: string;
seed: string;
}[];
};
}
function getConfig(): Config {
if (window.VITEST) {
return {
walletConnect: { bridge: "" },
reactions: [],
seeds: {
pinned: [],
},
projects: { pinned: [] },
};
} else if (window.PLAYWRIGHT) {
return window.APP_CONFIG;
} else {
// In dev and production environments we use data from config.json.
return configJson;
}
}
export const config = getConfig();
+98
View File
@@ -0,0 +1,98 @@
export const lineNumberR = (line: LineDiff): string | number => {
switch (line.type) {
case LineDiffType.Addition: {
return line.lineNum;
}
case LineDiffType.Context: {
return line.lineNumNew;
}
case LineDiffType.Deletion: {
return " ";
}
}
};
export const lineNumberL = (line: LineDiff): string | number => {
switch (line.type) {
case LineDiffType.Addition: {
return " ";
}
case LineDiffType.Context: {
return line.lineNumOld;
}
case LineDiffType.Deletion: {
return line.lineNum;
}
}
};
export const lineSign = (line: LineDiff): string => {
switch (line.type) {
case LineDiffType.Addition: {
return "+";
}
case LineDiffType.Context: {
return " ";
}
case LineDiffType.Deletion: {
return "-";
}
}
};
export enum LineDiffType {
Addition = "addition",
Context = "context",
Deletion = "deletion",
}
export interface Addition {
type: LineDiffType.Addition;
line: string;
lineNum: number;
}
export interface Context {
type: LineDiffType.Context;
line: string;
lineNumNew: number;
lineNumOld: number;
}
export interface Deletion {
type: LineDiffType.Deletion;
line: string;
lineNum: number;
}
export type LineDiff = Addition | Deletion | Context;
export interface FileDiff {
path: string;
diff: Changeset;
eof: EofNewLine | null;
}
export interface Changeset {
type: string;
hunks: Hunk[];
}
export interface Hunk {
header: string;
lines: LineDiff[];
}
export interface Diff {
created: FileDiff[];
deleted: FileDiff[];
moved: string[];
copied: string[];
modified: FileDiff[];
}
export enum EofNewLine {
OldMissing = "oldMissing",
NewMissing = "newMissing",
BothMissing = "bothMissing",
}
+8
View File
@@ -0,0 +1,8 @@
import * as FakeTimers from "@sinonjs/fake-timers";
if (typeof window.initializeTestStubs === "function") {
window.e2eTestStubs = {
FakeTimers: FakeTimers,
};
window.initializeTestStubs();
}
+49
View File
@@ -0,0 +1,49 @@
/* eslint-disable @typescript-eslint/naming-convention */
const emojis: { [key: string]: string } = {
100: "💯",
question: "❓",
exclamation: "❗",
sunrise: "🌅",
rainbow: "🌈",
ocean: "🌊",
volcano: "🌋",
seedling: "🌱",
maple_leaf: "🍁",
wood: "🪵",
evergreen_tree: "🌲",
gift: "🎁",
santa: "🎅",
tada: "🎉",
art: "🎨",
dart: "🎯",
bug: "🐛",
wave: "👋",
ok_hand: "👌",
building_construction: "🏗️",
"+1": "👍",
thumbsup: "👍",
"-1": "👎",
thumbsdown: "👎",
clap: "👏",
open_hands: "👐",
ghost: "👻",
alien: "👽",
skull: "💀",
boom: "💥",
poop: "💩",
muscle: "💪",
mage: "🧙‍♀️",
bow: "🙇‍♂️",
see_no_evil: "🙈",
hear_no_evil: "🙉",
speak_no_evil: "🙊",
pray: "🙏",
rocket: "🚀",
construction: "🚧",
rotating_light: "🚨",
no_entry_sign: "🚫",
clown_face: "🤡",
};
export default emojis;
+69
View File
@@ -0,0 +1,69 @@
export interface Err {
type: Failure;
txHash?: string;
message?: string;
}
export enum Failure {
TransactionFailed = 1,
NotAuthenticated = 2,
InsufficientBalance = 3,
}
export class Unreachable extends Error {
constructor(value?: never) {
if (value) {
super("unreachable value reached: " + value);
} else {
super("unreachable code reached");
}
}
}
export class NotFoundError extends Error {
constructor(message?: string) {
if (message) {
super(`not found: ${message}`);
} else {
super(`not found`);
}
}
}
export class MissingReverseRecord extends Error {
constructor(message?: string) {
if (message) {
super(`missing reverse record: ${message}`);
} else {
super(`missing reverse record`);
}
}
}
class AssertionError extends Error {
constructor(message?: string) {
if (message) {
super(`assertion failed: ${message}`);
} else {
super(`assertion failed`);
}
}
}
export function assert(value: unknown, message?: string): asserts value {
if (!value) {
throw new AssertionError(message);
}
}
export function assertEq(
actual: unknown,
expected: unknown,
message?: string,
): void {
if (actual !== expected) {
throw new AssertionError(
`assertion failed: expected '${expected}', got '${actual}': ${message}`,
);
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"ens": ["function owner(bytes32 node) view returns (address)"],
"faucet": [
"function lastWithdrawalByUser(address) view returns (uint256)",
"function maxWithdrawAmount() view returns (uint256)",
"function calculateTimeLock(uint256) view returns (uint256)",
"function withdraw(address, uint256)"
],
"org": ["function owner() view returns (address)"],
"registrar": [
"function rad() view returns (address)",
"function radNode() view returns (bytes32)",
"function minCommitmentAge() view returns (uint256)",
"function registrationFeeRad() view returns (uint256)",
"function commit(bytes32)",
"function commitWithPermit(bytes32, address, uint256, uint256, uint8, bytes32, bytes32)",
"function register(string, address, uint256)",
"function valid(string) pure returns (bool)",
"function available(string) view returns (bool)"
],
"resolver": [
"function multicall(bytes[] calldata data) returns(bytes[] memory results)",
"function setAddr(bytes32 node, address addr)",
"function setText(bytes32 node, string calldata key, string calldata value)"
],
"reverseRegistrar": ["function setName(string) returns (bytes32)"],
"token": [
"function balanceOf(address) view returns (uint256)",
"function approve(address, uint256) returns (bool)",
"function allowance(address, address) view returns (uint256)",
"function DOMAIN_SEPARATOR() view returns (bytes32)",
"function name() pure returns (string)",
"function symbol() pure returns (string)",
"function nonces(address) view returns (uint256)"
],
"vesting": [
"function token() view returns (address)",
"function totalVestingAmount() view returns (uint256)",
"function vestingStartTime() view returns (uint256)",
"function vestingPeriod() view returns (uint256)",
"function cliffPeriod() view returns (uint256)",
"function beneficiary() view returns (address)",
"function interrupted() view returns (bool)",
"function withdrawn() view returns (uint256)",
"function withdrawableBalance() view returns (uint256)",
"function withdrawVested()"
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "goerli",
"chainId": 5,
"registrar": {
"domain": "radicle-goerli.eth",
"address": "0xD88303A92577bFDF5A82FddeF342F3A27A972405"
},
"radToken": {
"address": "0x3EE94D192397aAFAe438C9803825eb1Aa4402e09",
"faucet": "0xc627191d2BB8839eAcbb7191f9500B84d201A066"
},
"reverseRegistrar": {
"address": "0xD5610A08E370051a01fdfe4bB3ddf5270af1aA48"
},
"alchemy": { "key": "1T6h-0rxu7SRzKEtmukIoxaJOXazLDNs" }
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "homestead",
"chainId": 1,
"registrar": {
"domain": "radicle.eth",
"address": "0x37723287Ae6F34866d82EE623401f92Ec9013154"
},
"radToken": {
"address": "0x31c8EAcBFFdD875c74b94b077895Bd78CF1E64A3"
},
"reverseRegistrar": {
"address": "0x084b1c3C81545d370f3634392De611CaaBFf8148"
},
"alchemy": { "key": "cQFlLK8EokIGlJhd_soImwEyUoC7Ec8r" }
}
+80
View File
@@ -0,0 +1,80 @@
import type { TransactionResponse } from "@ethersproject/providers";
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
import type { Wallet } from "@app/lib/wallet";
import type { WalletConnectSigner } from "@app/lib/walletConnectSigner";
import * as ethers from "ethers";
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
import { assert } from "@app/lib/error";
import { toWei } from "@app/lib/utils";
type Signer = (ethers.Signer & TypedDataSigner) | WalletConnectSigner | null;
export async function withdraw(
amount: string,
signer: Signer,
wallet: Wallet,
): Promise<TransactionResponse> {
assert(signer);
assert(wallet.radToken.faucet);
const faucet = new ethers.Contract(
wallet.radToken.faucet,
ethereumContractAbis.faucet,
signer,
);
return faucet.withdraw(wallet.radToken.address, toWei(amount));
}
export async function getMaxWithdrawAmount(
signer: Signer,
wallet: Wallet,
): Promise<ethers.BigNumber> {
assert(signer);
assert(wallet.radToken.faucet);
const faucet = new ethers.Contract(
wallet.radToken.faucet,
ethereumContractAbis.faucet,
signer,
);
return faucet.maxWithdrawAmount();
}
export async function calculateTimeLock(
amount: string,
signer: Signer,
wallet: Wallet,
): Promise<ethers.BigNumber> {
assert(signer);
assert(wallet.radToken.faucet);
const faucet = new ethers.Contract(
wallet.radToken.faucet,
ethereumContractAbis.faucet,
signer,
);
return faucet.calculateTimeLock(toWei(amount));
}
export async function lastWithdrawalByUser(
signer: Signer,
wallet: Wallet,
): Promise<ethers.BigNumber> {
assert(signer);
assert(wallet.radToken.faucet);
const address = signer.getAddress();
const faucet = new ethers.Contract(
wallet.radToken.faucet,
ethereumContractAbis.faucet,
signer,
);
return faucet.lastWithdrawalByUser(address);
}
+103
View File
@@ -0,0 +1,103 @@
import { type Host, Request } from "@app/lib/api";
import type { Author } from "@app/lib/cobs";
export interface TimelineItem {
person: Author;
message: string;
timestamp: number;
}
export interface IIssue {
id: string;
author: Author;
title: string;
state: State;
comment: Comment;
discussion: Thread[];
labels: Label[];
timestamp: number;
}
export type State =
| {
status: "open";
}
| {
status: "closed";
reason: string;
};
export interface Comment<R = null> {
author: Author;
body: string;
reactions: Record<string, number>;
timestamp: number;
replies: R;
}
export type Thread = Comment<Comment[]>;
export type Label = string;
export function groupIssues(issues: Issue[]): {
open: Issue[];
closed: Issue[];
} {
return issues.reduce(
(acc, issue) => {
acc[issue.state.status].push(issue);
return acc;
},
{ open: [] as Issue[], closed: [] as Issue[] },
);
}
export class Issue {
id: string;
author: Author;
title: string;
state: State;
comment: Comment;
discussion: Thread[];
labels: Label[];
timestamp: number;
constructor(issue: IIssue) {
this.id = issue.id;
this.author = issue.author;
this.title = issue.title;
this.state = issue.state;
this.comment = issue.comment;
this.discussion = issue.discussion;
this.labels = issue.labels;
this.timestamp = issue.timestamp;
}
// Counts the amount of comments and replies in a discussion
countComments(): number {
return this.discussion.reduce((acc, comment) => {
if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 forathe comment in this loop.
return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
}, 0);
}
static async getIssues(urn: string, host: Host): Promise<Issue[]> {
const response: IIssue[] = await new Request(
`projects/${urn}/issues`,
host,
).get();
return response.map(issue => new Issue(issue));
}
static async getIssue(
urn: string,
issue: string,
host: Host,
): Promise<Issue> {
const response: IIssue = await new Request(
`projects/${urn}/issues/${issue}`,
host,
).get();
return new Issue(response);
}
}
+206
View File
@@ -0,0 +1,206 @@
import type { Author, PeerInfo } from "@app/lib/cobs";
import type { Comment, Thread } from "@app/lib/issue";
import type { Commit, DiffStats } from "@app/lib/commit";
import type { Diff } from "@app/lib/diff";
import type { Host } from "@app/lib/api";
import type { PeerId, Urn } from "@app/lib/project";
import { Request } from "@app/lib/api";
export interface IPatch {
id: string;
author: Author;
title: string;
state: string;
target: string;
labels: string[];
revisions: Revision[];
timestamp: number;
}
export enum PatchTab {
Timeline = "timeline",
Diff = "diff",
}
export interface Revision {
id: string;
peer: PeerId;
base: string;
oid: string;
comment: Comment;
discussion: Thread[];
reviews: Record<Urn, Review>;
merges: Merge[];
changeset: {
diff: Diff;
commits: Commit[];
stats: DiffStats;
} | null;
timestamp: number;
}
export interface Review {
author: Author;
verdict: Verdict | null;
comment: Thread;
inline: CodeComment[];
timestamp: number;
}
export type Verdict = "accept" | "reject";
export interface CodeComment {
location: CodeLocation;
comment: Comment;
}
export interface CodeLocation {
lines: number;
commit: string;
blob: string;
}
export interface Merge {
peer: PeerInfo;
commit: string;
timestamp: number;
}
export function groupPatches(patches: Patch[]) {
return patches.reduce(
(acc: { [state: string]: Patch[] }, patch) => {
acc[patch.state].push(patch);
return acc;
},
{ proposed: [] as Patch[], draft: [] as Patch[], archived: [] as Patch[] },
);
}
export class Patch implements IPatch {
id: string;
author: Author;
title: string;
state: string;
target: string;
labels: string[];
revisions: Revision[];
timestamp: number;
constructor(patch: IPatch) {
this.id = patch.id;
this.author = patch.author;
this.title = patch.title;
this.state = patch.state;
this.target = patch.target;
this.labels = patch.labels;
this.revisions = patch.revisions;
this.timestamp = patch.timestamp;
}
// Counts the amount of comments and replies in a discussion
countComments(rev: number): number {
return this.revisions[rev].discussion.reduce((acc, comment) => {
if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 for each comment in this loop.
return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
}, 0);
}
createTimeline(rev: number) {
const timeline: TimelineElement[] = [];
const comment: TimelineElement = {
type: TimelineType.Comment,
timestamp: this.revisions[rev].comment.timestamp,
inner: this.revisions[rev].comment,
};
const discussions = this.revisions[rev].discussion.map(
(comment): TimelineElement => {
return {
type: TimelineType.Thread,
timestamp: comment.timestamp,
inner: comment,
};
},
);
const reviews = Object.entries(this.revisions[rev].reviews).map(
([, review]): TimelineElement => {
return {
type: TimelineType.Review,
timestamp: review.timestamp,
inner: review,
};
},
);
const merges = this.revisions[rev].merges.map((merge): TimelineElement => {
return {
type: TimelineType.Merge,
timestamp: merge.timestamp,
inner: merge,
};
});
timeline.push(comment, ...discussions, ...merges, ...reviews);
return timeline.sort((a, b) => a.timestamp - b.timestamp);
}
static async getPatches(urn: string, host: Host): Promise<Patch[]> {
const response: IPatch[] = await new Request(
`projects/${urn}/patches`,
host,
).get();
return response.map(patch => new Patch(patch));
}
static async getPatch(
urn: string,
patch: string,
host: Host,
): Promise<Patch> {
const response: IPatch = await new Request(
`projects/${urn}/patches/${patch}`,
host,
).get();
return new Patch(response);
}
}
export const formatVerdict = (verdict: string | null): string => {
switch (verdict) {
case "accept":
return "approved this revision";
case "reject":
return "rejected this revision";
default:
return "reviewed and left a comment";
}
};
export enum TimelineType {
Comment,
Thread,
Review,
Merge,
}
export type TimelineElement =
| {
type: TimelineType.Thread;
inner: Thread;
timestamp: number;
}
| {
type: TimelineType.Comment;
inner: Comment;
timestamp: number;
}
| {
type: TimelineType.Merge;
inner: Merge;
timestamp: number;
}
| {
type: TimelineType.Review;
inner: Review;
timestamp: number;
};
+267
View File
@@ -0,0 +1,267 @@
import type { EnsProfile } from "@app/lib/registrar";
import type { Seed, InvalidSeed } from "@app/lib/seed";
import type { TransactionResponse } from "@ethersproject/providers";
import type { Wallet } from "@app/lib/wallet";
import * as ethers from "ethers";
import * as cache from "@app/lib/cache";
import * as utils from "@app/lib/utils";
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
import { NotFoundError, MissingReverseRecord } from "@app/lib/error";
import { assert } from "@app/lib/error";
import { cached } from "@app/lib/cache";
import {
isAddress,
resolveEnsProfile,
parseUsername,
AddressType,
identifyAddress,
isFulfilled,
} from "@app/lib/utils";
class Org {
address: string;
owner: string;
name?: string | null;
constructor(address: string, owner: string, name?: string | null) {
assert(ethers.utils.isAddress(address), "address must be valid");
this.address = address.toLowerCase(); // Don't store address checksum.
this.owner = owner;
this.name = name;
}
static async get(addressOrName: string, wallet: Wallet): Promise<Org | null> {
const org = await getOrgContract(addressOrName, wallet);
try {
const [owner, resolved] = await resolveOrgOwner(org);
// If what is resolved is not the same as the input, it's because we
// were given a name.
if (utils.isAddressEqual(addressOrName, resolved)) {
return new Org(resolved, owner, null);
} else {
return new Org(resolved, owner, addressOrName);
}
} catch (e) {
console.error(e);
return null;
}
}
}
export class User {
address: string;
constructor(address: string) {
assert(ethers.utils.isAddress(address), "address must be valid");
this.address = address.toLowerCase(); // Don't store address checksum.
}
async setName(name: string, wallet: Wallet): Promise<TransactionResponse> {
assert(wallet.signer);
const reverseRegistrar = new ethers.Contract(
wallet.reverseRegistrar.address,
ethereumContractAbis.reverseRegistrar,
wallet.signer,
);
return reverseRegistrar.setName(name);
}
}
const getOrgContract = cache.cached(
async (addressOrName: string, wallet: Wallet) => {
return new ethers.Contract(
addressOrName,
ethereumContractAbis.org,
wallet.provider,
);
},
addressOrName => addressOrName,
);
const resolveOrgOwner = cache.cached(
async (org: ethers.Contract) => {
return await Promise.all([org.owner(), org.resolvedAddress]);
},
org => org.address,
);
export interface IProfile {
address: string;
type: AddressType;
ens?: EnsProfile;
org?: Org;
}
export enum ProfileType {
Full,
Minimal,
Project,
}
export class Profile {
profile: IProfile;
constructor(profile: IProfile) {
this.profile = profile;
}
// Get the Ethereum address
get address(): string {
return this.profile.ens?.address ?? this.profile.address;
}
// Get radicle link id.
get id(): string | undefined {
return this.profile.ens?.id;
}
// Get the address type
get type(): AddressType {
return this.profile.type;
}
// Get the org instance
get org(): Org | undefined {
return this.profile.org;
}
// Get the ENS profile
get ens(): EnsProfile | undefined {
return this.profile.ens;
}
get github(): string | undefined {
if (this.profile?.ens?.github) {
return parseUsername(this.profile.ens.github);
} else {
return undefined;
}
}
get twitter(): string | undefined {
if (this.profile?.ens?.twitter) {
return parseUsername(this.profile.ens.twitter);
} else {
return undefined;
}
}
get url(): string | undefined {
if (this.profile?.ens?.url) return this.profile.ens.url;
else return undefined;
}
get name(): string | undefined {
if (this.profile?.ens?.name) return this.profile.ens.name;
else return undefined;
}
get avatar(): string | undefined {
if (this.profile?.ens?.avatar) {
return this.profile.ens.avatar;
} else {
return undefined;
}
}
// We add null here to differentiate between a `undefined` and a invalid / null seed
get seed(): Seed | InvalidSeed | null {
return this.profile?.ens?.seed ?? null;
}
// Get the name, and if not available, the address.
get addressOrName(): string {
return this.name ?? this.address;
}
// Keeping this function private since the desired entrypoint is .get()
// All addresses returned from this function should be lowercase.
private static async lookupProfile(
addressOrName: string,
profileType: ProfileType,
wallet: Wallet,
): Promise<IProfile> {
let type = AddressType.EOA;
let org: Org | null = null;
const ens = await resolveEnsProfile(addressOrName, profileType, wallet);
if (ens) {
if (ens.address) {
type = await identifyAddress(ens.address, wallet);
if (type === AddressType.Org) {
org = await Org.get(ens.address, wallet);
}
return {
address: ens.address.toLowerCase(),
type,
ens: { ...ens, address: ens.address.toLowerCase() },
org: org ?? undefined,
};
}
throw new MissingReverseRecord(`No address set for ${addressOrName}`);
} else if (isAddress(addressOrName)) {
const address = addressOrName.toLowerCase();
type = await identifyAddress(address, wallet);
if (type === AddressType.Org) {
org = await Org.get(address, wallet);
}
try {
return {
address,
type,
org: org ?? undefined,
};
} catch (e: any) {
console.error(e);
return { address, type, org: org ?? undefined };
}
}
throw new NotFoundError(`Not able to resolve profile for ${addressOrName}`);
}
static async getMulti(
addressesOrNames: string[],
wallet: Wallet,
): Promise<Profile[]> {
const profilePromises = addressesOrNames.map(addressOrName =>
this.lookupProfile(addressOrName, ProfileType.Minimal, wallet),
);
const profiles = await Promise.allSettled(profilePromises);
return profiles
.filter(isFulfilled)
.map(profile => new Profile(profile.value));
}
static async get(
addressOrName: string,
profileType: ProfileType,
wallet: Wallet,
): Promise<Profile> {
const profile = await this.lookupProfile(
addressOrName,
profileType,
wallet,
);
return new Profile(profile);
}
}
export const getBalance = cached(
async (address: string, wallet: Wallet) => {
return await wallet.provider.getBalance(address);
},
address => address,
{ max: 1000 },
);
+368
View File
@@ -0,0 +1,368 @@
import type { Commit, CommitHeader, CommitsHistory } from "@app/lib/commit";
import type { Wallet } from "@app/lib/wallet";
import { type Host, Request } from "@app/lib/api";
import { Profile, ProfileType } from "@app/lib/profile";
import { Seed } from "@app/lib/seed";
import { isFulfilled, isOid, isRadicleId } from "@app/lib/utils";
export type Urn = string;
export type PeerId = string;
export type Branches = { [key: string]: string };
export type MaybeBlob = Blob | undefined;
export type MaybeTree = Tree | undefined;
export type Delegate =
| {
type: "indirect";
urn: Urn;
ids: PeerId[];
}
| {
type: "direct";
id: PeerId;
};
// Enumerates the space below the Header component in the projects View component
export enum ProjectContent {
Tree,
History,
Commit,
Issues,
Issue,
Patches,
Patch,
}
export interface ProjectInfo {
head: string | null;
urn: string;
name: string;
description: string;
defaultBranch: string;
delegates: Delegate[];
remotes: PeerId[];
patches?: number;
issues?: number;
}
export interface Tree {
path: string;
info: EntryInfo;
entries: Array<Entry>;
stats: Stats;
}
export interface Stats {
commits: number;
contributors: number;
}
export enum ObjectType {
Blob = "BLOB",
Tree = "TREE",
}
export interface EntryInfo {
name: string;
objectType: ObjectType;
lastCommit: CommitHeader;
}
export interface Entry {
path: string;
info: EntryInfo;
}
export interface Blob {
binary?: boolean;
html?: boolean;
content: string;
path: string;
info: EntryInfo;
}
export interface Remote {
heads: Branches;
}
export interface Person {
name: string;
}
export interface Peer {
id: PeerId;
person?: Person;
delegate: boolean;
}
// We need a SHA1 commit in some places, so we return early if the revision is a SHA and else we look into branches.
export function getOid(revision: string, branches?: Branches): string | null {
if (isOid(revision)) return revision;
if (branches) {
const oid = branches[revision];
if (oid) {
return oid;
}
}
return null;
}
// Parses the path consisting of a revision (eg. branch or commit) and file path into a tuple [revision, file-path]
export function parseRoute(
input: string,
branches: Branches,
): { path?: string; revision?: string } {
const branch = Object.entries(branches).find(([branchName]) =>
input.startsWith(branchName),
);
const commitPath = [input.slice(0, 40), input.slice(41)];
const parsed: { path?: string; revision?: string } = {};
if (branch) {
const [rev, path] = [
input.slice(0, branch[0].length),
input.slice(branch[0].length + 1),
];
parsed.revision = rev;
parsed.path = path ? path : "/";
} else if (isOid(commitPath[0])) {
parsed.revision = commitPath[0];
parsed.path = commitPath[1] ? commitPath[1] : "/";
} else {
parsed.path = input;
}
return parsed;
}
export class Project implements ProjectInfo {
urn: string;
head: string | null;
name: string;
description: string;
defaultBranch: string;
delegates: Delegate[];
remotes: PeerId[];
seed: Seed;
peers: Peer[];
branches: Branches;
profile: Profile | null;
// At the moment we still have seed nodes which won't return neither patches or issues
patches?: number;
issues?: number;
constructor(
urn: string,
info: ProjectInfo,
seed: Seed,
peers: Peer[],
branches: Branches,
profile: Profile | null,
) {
this.urn = urn;
this.head = info.head;
this.name = info.name;
this.description = info.description;
this.defaultBranch = info.defaultBranch;
this.delegates = info.delegates;
this.remotes = info.remotes;
this.seed = seed;
this.peers = peers;
this.branches = branches;
this.patches = info.patches;
this.issues = info.issues;
this.profile = profile;
}
async getRoot(
revision: string | null,
): Promise<{ tree: Tree; commit: string }> {
const head = this.branches[this.defaultBranch];
const commit = revision ? getOid(revision, this.branches) : head;
if (!commit) {
throw new Error(`Revision ${revision} not found`);
}
const tree = await this.getTree(commit, "/");
return { tree, commit };
}
static async getInfo(nameOrUrn: string, host: Host): Promise<ProjectInfo> {
return await new Request(`projects/${nameOrUrn}`, host).get();
}
static async getProjects(
host: Host,
opts?: {
perPage?: number;
page?: number;
},
): Promise<ProjectInfo[]> {
const params: Record<string, any> = {
"per-page": opts?.perPage,
page: opts?.page,
};
return new Request("projects", host).get(params);
}
static async getDelegateProjects(
delegate: string,
host: Host,
opts?: {
perPage?: number;
page?: number;
},
): Promise<ProjectInfo[]> {
const params: Record<string, any> = {
"per-page": opts?.perPage,
page: opts?.page,
};
return new Request(`delegates/${delegate}/projects`, host).get(params);
}
static async getRemote(
urn: string,
peer: string,
host: Host,
): Promise<Remote> {
return new Request(`projects/${urn}/remotes/${peer}`, host).get();
}
static async getRemotes(urn: string, host: Host): Promise<Peer[]> {
return new Request(`projects/${urn}/remotes`, host).get();
}
static async getCommits(
urn: string,
host: Host,
opts?: {
parent?: string | null;
since?: string;
until?: string;
perPage?: number;
page?: number;
verified?: boolean;
},
): Promise<CommitsHistory> {
const params: Record<string, any> = {
parent: opts?.parent,
since: opts?.since,
until: opts?.until,
"per-page": opts?.perPage,
page: opts?.page,
verified: opts?.verified,
};
return new Request(`projects/${urn}/commits`, host).get(params);
}
static async getActivity(
urn: string,
host: Host,
): Promise<{ activity: number[] }> {
return new Request(`projects/${urn}/activity`, host).get();
}
async getCommit(commit: string): Promise<Commit> {
return new Request(
`projects/${this.urn}/commits/${commit}`,
this.seed.api,
).get();
}
async getTree(commit: string, path: string): Promise<Tree> {
if (path === "/") path = "";
return new Request(
`projects/${this.urn}/tree/${commit}/${path}`,
this.seed.api,
).get();
}
async getBlob(commit: string, path: string): Promise<Blob> {
return new Request(
`projects/${this.urn}/blob/${commit}/${path}`,
this.seed.api,
).get();
}
async getReadme(commit: string): Promise<Blob> {
return new Request(
`projects/${this.urn}/readme/${commit}`,
this.seed.api,
).get();
}
static async get(
id: string,
peer: string | null,
profileName: string | null,
seedHost: string | null,
wallet: Wallet,
): Promise<Project> {
const profile = profileName
? await Profile.get(profileName, ProfileType.Project, wallet)
: null;
const [host, port] = seedHost?.includes(":")
? seedHost.split(":")
: [seedHost, "8777"];
const seed = profile
? profile.seed
: host
? await Seed.lookup(host, Number(port))
: null;
if (!profile && !seed) {
throw new Error("Couldn't load project");
}
if (!seed?.valid) {
throw new Error("Couldn't load project: invalid seed");
}
const info = await Project.getInfo(id, seed.api);
const urn = isRadicleId(id) ? id : info.urn;
// Older versions of http-api don't include the URN.
if (!info.urn) info.urn = urn;
const peers: Peer[] = info.delegates
? await Project.getRemotes(urn, seed.api)
: [];
let remote: Remote = {
heads: info.head ? { [info.defaultBranch]: info.head } : {},
};
if (peer) {
try {
remote = await Project.getRemote(urn, peer, seed.api);
} catch {
remote.heads = {};
}
}
return new Project(urn, info, seed, peers, remote.heads, profile);
}
static async getMulti(
projs: { nameOrUrn: Urn; seed: string }[],
): Promise<{ info: ProjectInfo; seed: Host }[]> {
const promises = [];
for (const proj of projs) {
const seed = { host: proj.seed, port: null };
promises.push(
Project.getInfo(proj.nameOrUrn, seed).then(info => {
return { info, seed };
}),
);
}
const results = await Promise.allSettled(promises);
return results.filter(isFulfilled).map(r => r.value);
}
}
+424
View File
@@ -0,0 +1,424 @@
import type { BigNumber } from "ethers";
import type { EnsResolver } from "@ethersproject/providers";
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
import type { Wallet } from "@app/lib/wallet";
import { ethers } from "ethers";
import { writable } from "svelte/store";
import * as cache from "@app/lib/cache";
import * as session from "@app/lib/session";
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
import { Failure } from "@app/lib/error";
import { Seed, InvalidSeed } from "@app/lib/seed";
import { assert } from "@app/lib/error";
import { isFulfilled, unixTime } from "@app/lib/utils";
export interface Registration {
profile: EnsProfile;
resolver: EnsResolver;
}
export interface EnsProfile {
name: string;
id?: string;
owner?: string;
address?: string;
seed?: Seed | InvalidSeed;
url?: string;
avatar?: string;
twitter?: string;
github?: string;
}
export enum State {
Failed = -1,
Connecting,
SigningPermit,
SigningCommit,
Committing,
WaitingToRegister,
SigningRegister,
Registering,
Registered,
}
export type Connection =
| { connection: State.Failed }
| { connection: State.Connecting }
| { connection: State.SigningPermit }
| { connection: State.SigningCommit }
| { connection: State.Committing }
| {
connection: State.WaitingToRegister;
commitmentBlock: number;
minAge: number;
}
| { connection: State.SigningRegister }
| { connection: State.Registering }
| { connection: State.Registered };
export const state = writable<Connection>({ connection: State.Connecting });
window.registrarState = state;
export async function getRegistration(
name: string,
wallet: Wallet,
resolver?: EnsResolver | null,
): Promise<Registration | null> {
name = name.toLowerCase();
if (!resolver) {
resolver = await getResolver(name, wallet);
if (!resolver) {
return null;
}
}
const meta = await Promise.allSettled([
getAddress(resolver),
getText(resolver, "avatar"),
getText(resolver, "url"),
getText(resolver, "eth.radicle.id"),
getText(resolver, "eth.radicle.seed.id"),
getText(resolver, "eth.radicle.seed.host"),
getText(resolver, "eth.radicle.seed.git"),
getText(resolver, "eth.radicle.seed.api"),
getText(resolver, "com.twitter"),
getText(resolver, "com.github"),
]);
const [
address,
avatar,
url,
id,
seedId,
seedHost,
seedGit,
seedApi,
twitter,
github,
] = meta.filter(isFulfilled).map(r => (r.value ? r.value : undefined));
const profile: EnsProfile = {
name,
id,
url,
avatar,
address,
twitter,
github,
};
// If no seed provided profile.seed ends up being undefined
if (seedHost && seedId) {
try {
profile.seed = new Seed({
host: seedHost,
id: seedId,
git: seedGit,
api: seedApi,
});
} catch (e: any) {
console.debug(e, seedHost, seedId);
profile.seed = new InvalidSeed(seedHost, seedId);
}
}
return { resolver, profile };
}
export async function getAvatar(
name: string,
wallet: Wallet,
resolver?: EnsResolver | null,
): Promise<string | null> {
name = name.toLowerCase();
resolver = resolver ?? (await getResolver(name, wallet));
if (!resolver) {
return null;
}
return getText(resolver, "avatar");
}
export async function getSeed(
name: string,
wallet: Wallet,
resolver?: EnsResolver | null,
): Promise<Seed | InvalidSeed | null> {
name = name.toLowerCase();
resolver = resolver ?? (await getResolver(name, wallet));
if (!resolver) {
return null;
}
const [id, host, git, api] = await Promise.all([
getText(resolver, "eth.radicle.seed.id"),
getText(resolver, "eth.radicle.seed.host"),
getText(resolver, "eth.radicle.seed.git"),
getText(resolver, "eth.radicle.seed.api"),
]);
if (!host || !id) {
console.debug("getSeed: No seed host or id provided");
return null;
}
try {
return new Seed({ host, id, git, api });
} catch (e: any) {
console.debug(e, host, id);
return new InvalidSeed(id, host);
}
}
export function registrar(wallet: Wallet): ethers.Contract {
return new ethers.Contract(
wallet.registrar.address,
ethereumContractAbis.registrar,
wallet.provider,
);
}
export async function registrationFee(wallet: Wallet): Promise<BigNumber> {
return await registrar(wallet).registrationFeeRad();
}
export async function registerName(
name: string,
owner: string,
wallet: Wallet,
): Promise<void> {
assert(wallet.signer, "signer is not available");
if (!name) return;
name = name.toLowerCase();
const commitmentJson = window.localStorage.getItem("commitment");
const commitment = commitmentJson && JSON.parse(commitmentJson);
try {
// Try to recover an existing commitment.
if (commitment && commitment.name === name && commitment.owner === owner) {
await register(name, owner, commitment.salt, wallet);
} else {
await commitAndRegister(name, owner, wallet);
}
} catch (e: any) {
throw {
type: e.type || Failure.TransactionFailed,
message: e.message,
txHash: e.txHash,
};
}
}
async function commitAndRegister(
name: string,
owner: string,
wallet: Wallet,
): Promise<void> {
const salt = ethers.utils.randomBytes(32);
const minAge = (await registrar(wallet).minCommitmentAge()).toNumber();
const fee = await registrationFee(wallet);
// Avoids gas spent by the owner, trying to commit to a name and not having
// enough RAD balance
if ((await wallet.token.balanceOf(owner)).lt(fee)) {
throw {
type: Failure.InsufficientBalance,
message: "Not enough RAD funds",
};
}
name = name.toLowerCase();
await commit(name, owner, salt, fee, minAge, wallet);
await register(name, owner, salt, wallet);
}
async function commit(
name: string,
owner: string,
salt: Uint8Array,
fee: BigNumber,
minAge: number,
wallet: Wallet,
): Promise<void> {
assert(wallet.signer, "signer is not available");
const commitment = makeCommitment(name, owner, salt);
const spender = wallet.registrar.address;
const deadline = ethers.BigNumber.from(unixTime()).add(3600); // Expire one hour from now.
const token = wallet.token;
let tx = null;
if (fee.isZero()) {
state.set({ connection: State.SigningCommit });
tx = await registrar(wallet)
.connect(wallet.signer)
.commit(commitment, { gasLimit: 180000 });
} else {
const signature = await permitSignature(
wallet.signer,
token,
spender,
fee,
deadline,
);
state.set({ connection: State.SigningCommit });
tx = await registrar(wallet)
.connect(wallet.signer)
.commitWithPermit(
commitment,
owner,
fee,
deadline,
signature.v,
signature.r,
signature.s,
{ gasLimit: 180000 },
);
}
state.set({ connection: State.Committing });
const receipt = await tx.wait(1);
session.state.updateBalance(fee.mul(-1));
// Save commitment in local storage in case the user refreshes or closes
// the page.
window.localStorage.setItem(
"commitment",
JSON.stringify({
name: name,
owner: owner,
salt: ethers.utils.hexlify(salt),
}),
);
state.set({
connection: State.WaitingToRegister,
commitmentBlock: receipt.blockNumber,
minAge,
});
await tx.wait(minAge + 1);
}
async function permitSignature(
owner: ethers.Signer & TypedDataSigner,
token: ethers.Contract,
spenderAddr: string,
value: ethers.BigNumberish,
deadline: ethers.BigNumberish,
): Promise<ethers.Signature> {
assert(owner.provider, "provider is not available");
state.set({ connection: State.SigningPermit });
const ownerAddr = await owner.getAddress();
const nonce = await token.nonces(ownerAddr);
const chainId = (await owner.provider.getNetwork()).chainId;
const domain = {
name: await token.name(),
chainId,
verifyingContract: token.address,
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const values = {
owner: ownerAddr,
spender: spenderAddr,
value: value,
nonce: nonce,
deadline: deadline,
};
const sig = await owner._signTypedData(domain, types, values);
return ethers.utils.splitSignature(sig);
}
async function register(
name: string,
owner: string,
salt: Uint8Array,
wallet: Wallet,
) {
assert(wallet.signer, "signer is not available");
state.set({ connection: State.SigningRegister });
const tx = await registrar(wallet)
.connect(wallet.signer)
.register(name, owner, ethers.BigNumber.from(salt), { gasLimit: 150000 });
state.set({ connection: State.Registering });
console.debug("Sent", tx);
await tx.wait();
window.localStorage.removeItem("commitment");
state.set({ connection: State.Registered });
}
function makeCommitment(name: string, owner: string, salt: Uint8Array): string {
const bytes = ethers.utils.concat([
ethers.utils.toUtf8Bytes(name),
ethers.utils.getAddress(owner),
ethers.BigNumber.from(salt).toHexString(),
]);
return ethers.utils.keccak256(bytes);
}
export async function getOwner(name: string, wallet: Wallet): Promise<string> {
const ensAddr = wallet.provider.network.ensAddress;
if (!ensAddr) {
throw new Error("ENS address is not defined");
}
const registry = new ethers.Contract(
ensAddr,
ethereumContractAbis.ens,
wallet.provider,
);
const owner = await registry.owner(ethers.utils.namehash(name));
return owner;
}
export const getResolver = cache.cached(
async (name: string, wallet: Wallet) => {
return await wallet.provider.getResolver(name);
},
name => name,
{ max: 1000 },
);
export const getText = cache.cached(
async (resolver: EnsResolver, key: string) => {
return await resolver.getText(key);
},
(resolver, key) => `${resolver.name} ${key}`,
{ max: 1000 },
);
export const getAddress = cache.cached(
async (resolver: EnsResolver) => {
return await resolver.getAddress();
},
resolver => resolver.name,
{ max: 1000 },
);
+64
View File
@@ -0,0 +1,64 @@
import type { EnsResolver } from "@ethersproject/providers";
import type { TransactionResponse } from "@ethersproject/providers";
import type { Wallet } from "@app/lib/wallet";
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
import { assert } from "@app/lib/error";
import { ethers } from "ethers";
export type EnsRecord = { name: string; value: string };
export async function setRecords(
name: string,
records: EnsRecord[],
resolver: EnsResolver,
wallet: Wallet,
): Promise<TransactionResponse> {
assert(wallet.signer, "no signer available");
const resolverContract = new ethers.Contract(
resolver.address,
ethereumContractAbis.resolver,
wallet.signer,
);
const node = ethers.utils.namehash(name);
const calls = [];
const iface = new ethers.utils.Interface(ethereumContractAbis.resolver);
for (const r of records) {
switch (r.name) {
case "address":
calls.push(iface.encodeFunctionData("setAddr", [node, r.value]));
break;
case "url":
case "avatar":
calls.push(
iface.encodeFunctionData("setText", [node, r.name, r.value]),
);
break;
case "github":
case "twitter":
calls.push(
iface.encodeFunctionData("setText", [node, "com." + r.name, r.value]),
);
break;
case "id":
case "seed.id":
case "seed.host":
case "seed.git":
case "seed.api":
calls.push(
iface.encodeFunctionData("setText", [
node,
"eth.radicle." + r.name,
r.value,
]),
);
break;
default:
console.error(`unknown field "${r.name}"`);
}
}
return resolverContract.multicall(calls);
}
+467
View File
@@ -0,0 +1,467 @@
import type { ProjectsParams, Route, ProjectRoute } from "./router/definitions";
import type { Readable } from "svelte/store";
import { get, writable, derived } from "svelte/store";
import { unreachable } from "@app/lib/utils";
// This is only respected by Safari.
const documentTitle = "Radicle Interface";
export const historyStore = writable<Route[]>([{ resource: "home" }]);
export const activeRouteStore: Readable<Route> = derived(
historyStore,
store => {
return store.slice(-1)[0];
},
);
export const base = window.HASH_ROUTING ? "./" : "/";
// Gets triggered when clicking on an anchor hash tag e.g. <a href="#header"/>
// Allows the jump to a anchor hash
window.addEventListener("hashchange", e => {
const route = pathToRoute(e.newURL);
if (route?.resource === "projects" && route.params.hash) {
if (route.params.hash.match(/^L\d+$/)) {
updateProjectRoute({ line: route.params.hash });
} else {
updateProjectRoute({ hash: route.params.hash });
}
}
});
// Replaces history on any user interaction with forward and backwards buttons
// with the current window.history.state
window.addEventListener("popstate", e => {
if (e.state) replace(e.state);
});
export function createProjectRoute(
activeRoute: ProjectRoute,
projectRouteParams: Partial<ProjectsParams>,
): ProjectRoute {
return {
resource: "projects",
params: {
...activeRoute.params,
line: undefined,
hash: undefined,
...projectRouteParams,
},
};
}
export function projectLinkHref(
projectRouteParams: Partial<ProjectsParams>,
): string | undefined {
const activeRoute = get(activeRouteStore);
if (activeRoute.resource === "projects") {
return routeToPath(createProjectRoute(activeRoute, projectRouteParams));
} else {
throw new Error(
"Don't use project specific navigation outside of project views",
);
}
}
export function updateProjectRoute(
projectRouteParams: Partial<ProjectsParams>,
opts: { replace: boolean } = { replace: false },
) {
const activeRoute = get(activeRouteStore);
if (activeRoute.resource === "projects") {
const updatedRoute = createProjectRoute(activeRoute, projectRouteParams);
if (opts.replace) {
replace(updatedRoute);
} else {
push(updatedRoute);
}
} else {
throw new Error(
"Don't use project specific navigation outside of project views",
);
}
}
export const push = (newRoute: Route): void => {
const history = get(historyStore);
// Limit history to a maximum of 10 steps. We shouldn't be doing more than
// one subsequent pop() anyway.
historyStore.set([...history, newRoute].slice(-10));
const path = window.HASH_ROUTING
? "#" + routeToPath(newRoute)
: routeToPath(newRoute);
window.history.pushState(newRoute, documentTitle, path);
};
export const pop = (): void => {
const history = get(historyStore);
const newRoute = history.pop();
if (newRoute) {
historyStore.set(history);
window.history.back();
}
};
export function replace(newRoute: Route): void {
historyStore.set([newRoute]);
const path = window.HASH_ROUTING
? "#" + routeToPath(newRoute)
: routeToPath(newRoute);
window.history.replaceState(newRoute, documentTitle, path);
}
export const initialize = () => {
const { pathname, search, hash } = window.location;
const url = pathname + search + hash;
const route = pathToRoute(url);
if (route) {
replace(route);
} else {
replace({ resource: "404", params: { url } });
}
};
function pathToRoute(path: string): Route | null {
// This matches e.g. an empty string
if (!path) {
return null;
}
const url = new URL(path, window.origin);
const segments = window.HASH_ROUTING
? url.hash.substring(2).split("#")[0].split("/") // Try to remove any additional hashes at the end of the URL.
: url.pathname.substring(1).split("/");
const resource = segments.shift();
switch (resource) {
case "registrations": {
const nameOrDomain = segments.shift();
const view = segments.shift();
const owner = url.searchParams.get("owner");
const retry = url.searchParams.get("retry");
if (nameOrDomain) {
if (view === "checkNameAvailability" || view === "register") {
return {
resource: "registrations",
params: {
view: {
resource: view,
params: { nameOrDomain, owner },
},
},
};
} else if (!view) {
return {
resource: "registrations",
params: {
view: {
resource: "view",
params: { nameOrDomain, retry: retry === "true" },
},
},
};
}
}
return {
resource: "registrations",
params: { view: { resource: "validateName" } },
};
}
case "faucet": {
const view = segments.shift();
if (view === "withdraw") {
return {
resource: "faucet",
params: {
view: {
resource: "withdraw",
params: { amount: url.searchParams.get("amount") },
},
},
};
}
return { resource: "faucet", params: { view: { resource: "form" } } };
}
case "vesting": {
const contract = segments.shift();
if (contract) {
return {
resource: "vesting",
params: { view: { resource: "view", params: { contract } } },
};
}
return { resource: "vesting", params: { view: { resource: "form" } } };
}
case "seeds": {
const host = segments.shift();
if (host) {
const urn = segments.shift();
if (urn) {
if (segments.length === 0) {
return {
resource: "projects",
params: {
view: { resource: "tree" },
urn,
peer: undefined,
profile: undefined,
seed: host,
},
};
}
const params = resolveProjectRoute(url, urn, segments);
if (params) {
return {
resource: "projects",
params: {
...params,
search: url.search,
seed: host,
urn,
},
};
}
return null;
}
return { resource: "seeds", params: { host } };
}
return null;
}
case "":
return { resource: "home" };
default: {
if (resource) {
const urn = segments.shift();
if (urn) {
if (segments.length === 0) {
return {
resource: "projects",
params: {
view: { resource: "tree" },
urn,
peer: undefined,
profile: resource,
seed: undefined,
},
};
} else {
const params = resolveProjectRoute(url, urn, segments);
if (params) {
return {
resource: "projects",
params: {
...params,
urn,
search: url.search,
profile: resource,
},
};
}
}
return null;
}
return { resource: "profile", params: { addressOrName: resource } };
}
return { resource: "home" };
}
}
}
export function routeToPath(route: Route) {
if (route.resource === "home") {
return "/";
} else if (route.resource === "faucet") {
if (route.params.view.resource === "form") {
return "/faucet";
} else if (route.params.view.resource === "withdraw") {
return `/faucet/withdraw?amount=${route.params.view.params.amount}`;
}
} else if (route.resource === "vesting") {
if (route.params.view.resource === "form") {
return "/vesting";
} else if (route.params.view.resource === "view") {
return `/vesting/${route.params.view.params.contract}`;
}
} else if (route.resource === "seeds") {
return `/seeds/${route.params.host}`;
} else if (route.resource === "projects") {
let hostPrefix;
if (route.params.profile) {
hostPrefix = `/${route.params.profile}`;
} else {
hostPrefix = `/seeds/${route.params.seed}`;
}
const content = `/${route.params.view.resource}`;
let peer = "";
if (route.params.peer) {
peer = `/remotes/${route.params.peer}`;
}
let suffix = "";
if (!route.params.route) {
if (route.params.revision) {
suffix = `/${route.params.revision}`;
}
if (route.params.path && route.params.path !== "/") {
suffix += `/${route.params.path}`;
}
if (route.params.line) {
suffix += `#${route.params.line}`;
} else if (route.params.hash) {
suffix += `#${route.params.hash}`;
}
if (route.params.search) {
suffix += `${route.params.search}`;
}
} else {
suffix = `/${route.params.route}`;
if (route.params.search) {
suffix += `${route.params.search}`;
}
if (route.params.line) {
suffix += `#${route.params.line}`;
} else if (route.params.hash) {
suffix += `#${route.params.hash}`;
}
}
if (route.params.view.resource === "tree") {
return `${hostPrefix}/${route.params.urn}${peer}/tree${suffix}`;
} else if (route.params.view.resource === "commits") {
return `${hostPrefix}/${route.params.urn}${peer}/commits${suffix}`;
} else if (route.params.view.resource === "history") {
return `${hostPrefix}/${route.params.urn}${peer}/history${suffix}`;
} else if (route.params.view.resource === "patches") {
return `${hostPrefix}/${route.params.urn}${peer}/patches${suffix}`;
} else if (route.params.view.resource === "patch") {
return `${hostPrefix}/${route.params.urn}${peer}/patches/${route.params.view.params.patch}`;
} else if (route.params.view.resource === "issues") {
return `${hostPrefix}/${route.params.urn}${peer}/issues${suffix}`;
} else if (route.params.view.resource === "issue") {
return `${hostPrefix}/${route.params.urn}${peer}/issues/${route.params.view.params.issue}`;
} else {
return `${hostPrefix}/${route.params.urn}${peer}${content}`;
}
} else if (route.resource === "registrations") {
if (route.params.view.resource === "validateName") {
return `/registrations`;
} else if (route.params.view.resource === "view") {
return `/registrations/${route.params.view.params.nameOrDomain}?retry=${route.params.view.params.retry}`;
} else if (
route.params.view.resource === "checkNameAvailability" ||
route.params.view.resource === "register"
) {
if (route.params.view.params.owner) {
return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}?owner=${route.params.view.params.owner}`;
}
return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}`;
}
} else if (route.resource === "profile") {
return `/${route.params.addressOrName}`;
} else if (route.resource === "404") {
return route.params.url;
} else {
unreachable(route);
}
}
function resolveProjectRoute(
url: URL,
urn: string,
segments: string[],
): ProjectsParams | null {
let content = segments.shift();
let peer;
if (content === "remotes") {
peer = segments.shift();
content = segments.shift();
}
if (content === "tree") {
const line = url.href.match(/#L\d+$/)?.pop();
const hash = url.href.match(/#{1}[^#.]+$/)?.pop();
return {
view: { resource: "tree" },
urn,
peer,
path: undefined,
revision: undefined,
line: line?.substring(1),
hash: hash?.substring(1),
route: segments.join("/"),
};
} else if (content === "history") {
return {
view: { resource: "history" },
urn,
peer,
path: undefined,
revision: undefined,
route: segments.join("/"),
};
} else if (content === "commits") {
return {
view: { resource: "commits" },
urn,
peer,
path: undefined,
revision: undefined,
route: segments.join("/"),
};
} else if (content === "patches") {
const patch = segments.shift();
if (patch) {
return {
view: { resource: "patch", params: { patch } },
urn,
peer,
path: undefined,
revision: undefined,
};
} else {
return {
view: { resource: "patches" },
urn,
peer,
path: undefined,
revision: undefined,
};
}
} else if (content === "issues") {
const issue = segments.shift();
if (issue) {
return {
view: { resource: "issue", params: { issue } },
urn,
peer,
path: undefined,
revision: undefined,
};
} else {
return {
view: { resource: "issues" },
urn,
peer,
path: undefined,
revision: undefined,
};
}
}
return null;
}
export const testExports = { pathToRoute };
+71
View File
@@ -0,0 +1,71 @@
import type { VestingInfo } from "@app/lib/vesting";
export type Route =
| FaucetRoute
| ProjectRoute
| RegistrationRoute
| VestingRoute
| { resource: "home" }
| { resource: "404"; params: { url: string } }
| { resource: "profile"; params: { addressOrName: string } }
| { resource: "seeds"; params: { host: string } };
export interface ProjectsParams {
urn: string;
view:
| { resource: "tree" }
| { resource: "commits" }
| { resource: "history" }
| { resource: "issue"; params: { issue: string } }
| { resource: "issues" }
| { resource: "patch"; params: { patch: string } }
| { resource: "patches" };
hash?: string;
line?: string;
path?: string;
peer?: string;
profile?: string;
revision?: string;
route?: string;
search?: string;
seed?: string;
}
export interface VestingParams {
view:
| { resource: "form" }
| { resource: "view"; params: { contract: string; info?: VestingInfo } };
}
export interface FaucetParams {
view:
| { resource: "form" }
| { resource: "withdraw"; params: { amount: string | null } };
}
export interface RegistrationParams {
view:
| {
resource: "validateName";
}
| {
resource: "checkNameAvailability";
params: { nameOrDomain: string; owner: string | null };
}
| {
resource: "register";
params: { nameOrDomain: string; owner: string | null };
}
| {
resource: "view";
params: { nameOrDomain: string; retry: boolean };
};
}
export type ProjectRoute = { resource: "projects"; params: ProjectsParams };
export type FaucetRoute = { resource: "faucet"; params: FaucetParams };
export type VestingRoute = { resource: "vesting"; params: VestingParams };
export type RegistrationRoute = {
resource: "registrations";
params: RegistrationParams;
};
+166
View File
@@ -0,0 +1,166 @@
import type { Host } from "@app/lib/api";
import * as proj from "@app/lib/project";
import { Request } from "@app/lib/api";
import { assert } from "@app/lib/error";
import { getSeedEmoji } from "@app/lib/utils";
import { isDomain } from "@app/lib/utils";
export interface Stats {
projects: { count: number };
users: { count: number };
}
export class InvalidSeed {
valid = false as const;
host?: string;
id?: string;
constructor(host?: string, id?: string) {
this.host = host;
this.id = id;
}
}
export const defaultHttpApiPort = 8777;
export const defaultLinkPort = 8776;
export const defaultGitPort = 443;
export class Seed {
valid = true as const;
api: { host: string; port: number | null };
git: { host: string; port: number | null };
link: { host: string; id: string; port: number };
version?: string;
emoji: string;
constructor(seed: {
host: string;
id: string;
git?: string | null;
api?: string | null;
version?: string | null;
}) {
assert(isDomain(seed.host), `invalid seed host: ${seed.host}`);
assert(/^[a-z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
let api = null;
let git = null;
let apiPort: number | null = defaultHttpApiPort;
let gitPort: number | null = defaultGitPort;
if (seed.api) {
try {
const url = new URL(seed.api);
api = url.hostname;
if (url.port) {
apiPort = Number(url.port);
} else if (url.protocol === "http:" && url.port === "") {
apiPort = 80;
}
if (url.protocol === "https:" && url.port === "") {
apiPort = 443;
} else {
apiPort = null;
}
} catch {
api = seed.api;
}
assert(isDomain(api), `invalid seed api host ${api}`);
}
if (seed.git) {
try {
const url = new URL(seed.git);
git = url.hostname;
gitPort = url.port ? Number(url.port) : null;
} catch {
git = seed.git;
}
assert(isDomain(git), `invalid seed git host ${git}`);
}
this.emoji = getSeedEmoji(seed.host);
// The `git` and `api` keys being more specific take
// precedence over the `host`, if available.
api = api ?? seed.host;
git = git ?? seed.host;
this.api = { host: api, port: apiPort };
this.git = { host: git, port: gitPort };
this.link = { host: seed.host, id: seed.id, port: defaultLinkPort };
if (seed.version) {
this.version = seed.version;
}
}
get id(): string {
return this.link.id;
}
get host(): string {
return this.api.host;
}
async getPeer(): Promise<{ id: string }> {
return Seed.getPeer(this.api);
}
async getProject(urn: string): Promise<proj.ProjectInfo> {
return proj.Project.getInfo(urn, this.api);
}
async getProjects(perPage: number, id?: string): Promise<proj.ProjectInfo[]> {
const result = id
? await proj.Project.getDelegateProjects(id, this.api, { perPage })
: await proj.Project.getProjects(this.api, { perPage });
return result.map((project: proj.ProjectInfo) => ({
...project,
id: project.urn,
}));
}
async getStats(): Promise<{
projects: { count: number };
users: { count: number };
}> {
return new Request("/stats", this.api).get();
}
static async getPeer(host: Host): Promise<{ id: string }> {
return new Request("/peer", host).get();
}
static async getInfo(host: Host): Promise<{ version: string }> {
return new Request("/", host).get();
}
static async lookup(
hostname: string,
port: number = defaultHttpApiPort,
): Promise<Seed> {
const host = { host: hostname, port };
const [info, peer] = await Promise.all([
Seed.getInfo(host),
Seed.getPeer(host),
]);
return new Seed({
host: hostname,
id: peer.id,
version: info.version,
api: `https://${host.host}:${host.port}`,
});
}
static async lookupMulti(hostnames: string[]): Promise<Seed[]> {
return await Promise.all(hostnames.map(h => Seed.lookup(h)));
}
}
+399
View File
@@ -0,0 +1,399 @@
import type { BigNumber } from "ethers";
import type { Readable } from "svelte/store";
import type {
TransactionReceipt,
TransactionResponse,
} from "@ethersproject/providers";
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
import type { WalletConnectSigner } from "@app/lib/walletConnectSigner";
import * as ethers from "ethers";
import { get, writable, derived } from "svelte/store";
import { Unreachable, assert, assertEq } from "@app/lib/error";
import { Wallet, getWallet } from "@app/lib/wallet";
export enum Connection {
Disconnected,
Connecting,
Connected,
}
export type TxState =
| { state: "signing" }
| { state: "pending"; hash: string }
| { state: "success"; hash: string; blockHash: string; blockNumber: number }
| {
state: "fail";
hash: string;
blockHash: string;
blockNumber: number;
error: string;
}
| null;
export type Signer = (ethers.Signer & TypedDataSigner) | WalletConnectSigner;
// Defines the type of signer we are using in the current session.
// Allows us to guard certain functionality for a specific signer.
enum SignerType {
WalletConnect,
MetaMask,
}
export type State =
| { connection: Connection.Disconnected }
| { connection: Connection.Connecting }
| { connection: Connection.Connected; session: Session };
export interface Session {
address: string;
signer: Signer | null;
signerType: SignerType;
tokenBalance: BigNumber | null; // `null` means it isn't loaded yet.
tx: TxState;
}
export interface Store extends Readable<State> {
connectMetamask(wallet: Wallet): Promise<void>;
connectWalletConnect(wallet: Wallet): Promise<void>;
updateBalance(n: BigNumber): void;
refreshBalance(wallet: Wallet): Promise<void>;
setTxSigning(): void;
setTxPending(tx: TransactionResponse): void;
setTxConfirmed(tx: TransactionReceipt): void;
setChangedAccount(address: string, signer: Signer): void;
}
export const loadState = (initial: State): Store => {
const store = writable<State>(initial);
return {
subscribe: store.subscribe,
connectMetamask: async (wallet: Wallet) => {
assert(wallet.metamask.signer);
// We use wallet.metamask.signer here, because wallet.signer is still null on page reload.
const signer = wallet.metamask.signer;
// Re-connect using previous session.
if (wallet.metamask.connected) {
const metamask = wallet.metamask.session;
const tokenBalance: BigNumber = await wallet.token.balanceOf(
metamask.address,
);
const session = {
address: metamask.address,
signer,
signerType: SignerType.MetaMask,
tokenBalance,
tx: null,
};
store.set({ connection: Connection.Connected, session });
wallet.setSigner(signer);
return;
}
const state = get(store);
assertEq(
state.connection,
Connection.Disconnected || Connection.Connecting,
);
store.set({ connection: Connection.Connecting });
await window.ethereum.request({ method: "eth_requestAccounts" });
const address = await signer.getAddress();
wallet.setSigner(signer);
try {
// Closes the wallet modal.
// TODO: We should move this into the session store.
wallet.walletConnect.state.set({ state: "close" });
const tokenBalance: BigNumber = await wallet.token.balanceOf(address);
const session = {
address,
signer,
signerType: SignerType.MetaMask,
tokenBalance,
tx: null,
};
store.set({
connection: Connection.Connected,
session,
});
saveMetamaskSession(session);
} catch (e) {
console.error(e);
}
},
connectWalletConnect: async (wallet: Wallet) => {
store.set({ connection: Connection.Connecting });
// We fetch the walletConnect signer here, because wallet.signer is still null on page reload.
const signer = wallet.getWalletConnectSigner();
try {
await wallet.walletConnect.client.connect();
console.debug("WalletConnect: connected.");
const address = await signer.getAddress();
const tokenBalance: BigNumber = await wallet.token.balanceOf(address);
const session = {
address,
signer,
signerType: SignerType.WalletConnect,
tokenBalance,
tx: null,
};
const network = ethers.providers.getNetwork(
signer.walletConnect.chainId,
);
// Instead of killing the WalletConnect session, we force the UI to change network
if (network.chainId !== wallet.network.chainId) {
wallet.changeNetwork(network.chainId);
}
wallet.walletConnect.client.on(
"session_update",
async (
error,
{
params: [{ accounts, chainId }],
}: { params: [{ accounts: [string]; chainId: number }] },
) => {
if (error) {
throw error;
}
try {
// We update wallet to reflect the new signer address.
const signer = wallet.getWalletConnectSigner();
changeAccounts(accounts[0], signer);
// Check the current chainId, and request Metamask to change, or reload the window to get the correct chain.
if (chainId !== wallet.network.chainId) {
if (session.signerType === SignerType.MetaMask) {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: ethers.utils.hexValue(chainId) }],
});
} else {
window.location.reload();
}
}
} catch (e) {
console.error(e);
}
},
);
store.set({ connection: Connection.Connected, session });
} catch (e: any) {
console.debug("WalletConnect: connection failed.");
store.set({ connection: Connection.Disconnected });
// There seems to be no way to detect this "error" caused by the user
// closing the modal dialog, besides matching on the message string.
// Welcome to the wonderful ghetto that is WalletConnect.
//
// Since it's not really an error, we don't throw if this is what happened.
if (e.message !== "User close QRCode Modal") {
throw e;
}
}
},
updateBalance: (n: BigNumber) => {
store.update((s: State) => {
assert(s.connection === Connection.Connected);
if (s.session.tokenBalance) {
// If the token balance is loaded, we can update it, otherwise
// we let it finish loading.
s.session.tokenBalance = s.session.tokenBalance.add(n);
saveMetamaskSession(s.session);
}
return s;
});
},
refreshBalance: async (wallet: Wallet) => {
const state = get(store);
assert(state.connection === Connection.Connected);
const addr = state.session.address;
try {
const tokenBalance: BigNumber = await wallet.token.balanceOf(addr);
state.session.tokenBalance = tokenBalance;
store.set(state);
} catch (e) {
console.error(e);
}
},
setTxSigning: () => {
store.update(s => {
switch (s.connection) {
case Connection.Connected:
s.session.tx = { state: "signing" };
return s;
default:
throw new Unreachable();
}
});
},
setTxPending: (tx: TransactionResponse) => {
store.update(s => {
switch (s.connection) {
case Connection.Connected:
assert(s.session.tx !== null);
assert(s.session.tx.state === "signing");
s.session.tx = { state: "pending", hash: tx.hash };
return s;
default:
throw new Unreachable();
}
});
},
setTxConfirmed: (tx: TransactionReceipt) => {
store.update(s => {
switch (s.connection) {
case Connection.Connected:
assert(s.session.tx !== null);
assert(s.session.tx.state === "pending");
if (tx.status === 1) {
s.session.tx = {
state: "success",
hash: s.session.tx.hash,
blockHash: tx.blockHash,
blockNumber: tx.blockNumber,
};
} else {
s.session.tx = {
state: "fail",
hash: s.session.tx.hash,
blockHash: tx.blockHash,
blockNumber: tx.blockNumber,
error: "Failed",
};
}
return s;
default:
throw new Unreachable();
}
});
},
setChangedAccount: (address: string, signer: Signer) => {
store.update(s => {
switch (s.connection) {
case Connection.Connected:
// In case of locking Metamask the accountsChanged event returns undefined.
// To prevent out of sync state, the wallet gets disconnected.
if (address === undefined) {
disconnectMetamask();
return s;
} else {
s.session.address = address;
s.session.signer = signer;
// We only save the session to localStorage if we use a MetaMask signer
// WalletConnect does their own session persistance.
if (s.session.signerType === SignerType.MetaMask) {
saveMetamaskSession(s.session);
}
}
return s;
default:
return s;
}
});
},
};
};
// Initializes the session state on page load or hard refresh.
export const state = loadState({ connection: Connection.Disconnected });
export const session = derived(state, s => {
if (s.connection === Connection.Connected) {
return s.session;
}
return null;
});
window.ethereum?.on("chainChanged", () => {
// We disconnect the wallet to avoid out of sync state.
disconnectMetamask();
});
// Updates state when user changes accounts
window.ethereum?.on("accountsChanged", async ([address]: string) => {
const s = get(session);
// Only allow user to change accounts with Metamask if they are connected with Metamask.
if (s?.signerType !== SignerType.MetaMask) {
return;
} else if (s.signer) {
changeAccounts(address, s.signer);
}
});
export async function changeAccounts(
address: string,
signer: Signer,
): Promise<void> {
const wallet = await getWallet();
state.setChangedAccount(address, signer);
state.refreshBalance(wallet);
}
export async function approveSpender(
spender: string,
amount: BigNumber,
wallet: Wallet,
): Promise<void> {
assert(wallet.signer);
const signer = wallet.signer;
const addr = await signer.getAddress();
const allowance = await wallet.token.allowance(addr, spender);
if (allowance < amount) {
const tx = await wallet.token.connect(signer).approve(spender, amount);
await tx.wait();
}
}
export function disconnectMetamask(): void {
window.localStorage.removeItem("metamask");
window.location.reload();
}
export function disconnectWallet(wallet: Wallet): void {
if (wallet.walletConnect.client.connected) {
wallet.walletConnect.client.killSession();
}
disconnectMetamask();
}
function saveMetamaskSession(session: Session): void {
window.localStorage.setItem(
"metamask",
JSON.stringify({
address: session.address,
tokenBalance: null,
tx: null,
wallet: null,
}),
);
}
+159
View File
@@ -0,0 +1,159 @@
import type { Root } from "@wooorm/starry-night";
import type { ElementContent } from "hast";
import { createStarryNight, common } from "@wooorm/starry-night";
import sourceTsx from "@wooorm/starry-night/lang/source.tsx";
import sourceSvelte from "@wooorm/starry-night/lang/source.svelte.js";
import sourceSolidity from "@wooorm/starry-night/lang/source.solidity.js";
import sourceToml from "@wooorm/starry-night/lang/source.toml";
import sourceErlang from "@wooorm/starry-night/lang/source.erlang.js";
import sourceDockerfile from "@wooorm/starry-night/lang/source.dockerfile";
import sourceAsciiDoc from "@wooorm/starry-night/lang/text.html.asciidoc";
export type MaybeHighlighted = Root | undefined;
export const grammars = [
...common,
sourceSvelte,
sourceSolidity,
sourceTsx,
sourceErlang,
sourceDockerfile,
sourceAsciiDoc,
sourceToml,
// A grammar that doesn't do any parsing, but needed for files without a known filetype.
{
extensions: [""],
names: ["raw-format"],
patterns: [],
scopeName: "text.raw",
},
];
let starryNight: Awaited<ReturnType<typeof createStarryNight>>;
export async function highlight(
content: string,
grammar: string,
): Promise<MaybeHighlighted> {
if (starryNight === undefined) {
starryNight = await createStarryNight(grammars);
}
const scope = starryNight.flagToScope(grammar);
return starryNight.highlight(content, scope ?? "text.raw");
}
export function lineNumbersGutter(tree: Root) {
const replacement: ElementContent[] = [];
const search = /\r?\n|\r/g;
let index = -1;
let start = 0;
let startTextRemainder = "";
let lineNumber = 0;
while (++index < tree.children.length) {
const child = tree.children[index];
if (child.type === "text") {
let textStart = 0;
let match = search.exec(child.value);
while (match) {
// Nodes in this line.
const line = tree.children.slice(start, index) as ElementContent[];
// Prepend text from a partial matched earlier text.
if (startTextRemainder) {
line.unshift({ type: "text", value: startTextRemainder });
startTextRemainder = "";
}
// Append text from this text.
if (match.index > textStart) {
line.push({
type: "text",
value: child.value.slice(textStart, match.index),
});
}
// Add a line, and the eol.
lineNumber += 1;
replacement.push(createLine(line, lineNumber), {
type: "text",
value: match[0],
});
start = index + 1;
textStart = match.index + match[0].length;
match = search.exec(child.value);
}
// If we matched, make sure to not drop the text after the last line ending.
if (start === index + 1) {
startTextRemainder = child.value.slice(textStart);
}
}
}
const line = tree.children.slice(start) as ElementContent[];
// Prepend text from a partial matched earlier text.
if (startTextRemainder) {
line.unshift({ type: "text", value: startTextRemainder });
startTextRemainder = "";
}
if (line.length > 0) {
lineNumber += 1;
replacement.push(createLine(line, lineNumber));
}
// Replace children with new array.
tree.children = replacement;
return tree;
}
function createLine(children: ElementContent[], line: number): ElementContent {
return {
type: "element",
tagName: "tr",
properties: {
class: "line",
id: "L" + line,
},
children: [
{
type: "element",
tagName: "td",
properties: {
className: "line-number",
},
children: [
{
type: "element",
tagName: "a",
properties: { href: "#L" + line },
children: [{ type: "text", value: line.toString() }],
},
],
},
{
type: "element",
tagName: "td",
properties: {
className: "line-content",
},
children: [
{
type: "element",
tagName: "pre",
properties: {
className: "content",
},
children,
},
],
},
],
};
}
+571
View File
@@ -0,0 +1,571 @@
import type { EnsProfile } from "@app/lib/registrar";
import type { Wallet } from "@app/lib/wallet";
import type { marked } from "marked";
import katex from "katex";
import md5 from "md5";
import twemojiModule from "twemoji";
import { BigNumber, ethers } from "ethers";
import { parseUnits } from "@ethersproject/units";
import * as cache from "@app/lib/cache";
import emojis from "@app/lib/emojis";
import { ProfileType } from "@app/lib/profile";
import { assert } from "@app/lib/error";
import { base } from "@app/lib/router";
import { config } from "@app/lib/config";
import { getAddress, getResolver } from "@app/lib/registrar";
import { getAvatar, getSeed, getRegistration } from "@app/lib/registrar";
export enum AddressType {
Contract,
Org,
EOA,
}
export interface Token {
name: string;
symbol: string;
logo: string;
decimals: number;
balance: BigNumber;
}
export enum Status {
Signing,
Pending,
Success,
Failed,
}
export type State =
| { status: Status.Signing }
| { status: Status.Pending }
| { status: Status.Success }
| { status: Status.Failed; error: string };
export async function isReverseRecordSet(
address: string,
domain: string,
wallet: Wallet,
): Promise<boolean> {
const name = await lookupAddress(address, wallet);
return name === domain;
}
export async function toClipboard(text: string): Promise<void> {
return navigator.clipboard.writeText(text);
}
export function setOpenGraphMetaTag(
data: { prop: string; content: string; attr?: string }[],
): void {
const elements = Array.from<HTMLElement>(document.querySelectorAll(`meta`));
elements.forEach((element: any) => {
const foundElement = data.find(data => {
return data.prop === element.getAttribute(data.attr || "property");
});
if (foundElement) element.content = foundElement.content;
});
}
export function toWei(amount: string): BigNumber {
return parseUnits(amount);
}
export function isAddressEqual(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
export function formatSeedAddress(
id: string,
host: string,
port: number,
): string {
return `${id}@${host}:${port}`;
}
export function formatSeedHost(host: string): string {
if (isLocal(host)) {
return "radicle.local";
} else {
return host;
}
}
export function formatLocationHash(hash: string | null): number | null {
if (hash && hash.match(/^#L[0-9]+$/)) return parseInt(hash.slice(2));
return null;
}
export function formatSeedId(id: string): string {
return id.substring(0, 6) + "…" + id.substring(id.length - 6, id.length);
}
export function formatRadicleUrn(id: string): string {
assert(isRadicleId(id));
return id.substring(0, 14) + "…" + id.substring(id.length - 6, id.length);
}
export function formatBalance(n: BigNumber, decimals?: number): string {
return ethers.utils.commify(
parseFloat(ethers.utils.formatUnits(n, decimals)).toFixed(2),
);
}
// Returns a checksummed, shortened, without 0x prefix Ethereum address
export function formatAddress(input: string): string {
const addr = ethers.utils.getAddress(input).replace(/^0x/, "");
return (
addr.substring(0, 4) + " – " + addr.substring(addr.length - 4, addr.length)
);
}
export function formatCommit(oid: string): string {
return oid.substring(0, 7);
}
export function formatProfile(input: string, wallet: Wallet): string {
if (isAddress(input)) {
return ethers.utils.getAddress(input);
} else {
return parseEnsLabel(input, wallet);
}
}
export function capitalize(s: string): string {
if (s === "") return s;
return s[0].toUpperCase() + s.substring(1);
}
// Takes a domain name, eg. 'cloudhead.radicle.eth' and returns the label, eg. 'cloudhead'.
export function parseEnsLabel(name: string, wallet: Wallet): string {
const domain = wallet.registrar.domain.replace(".", "\\.");
const label = name.replace(new RegExp(`\\.${domain}$`), "");
return label;
}
// Get the mime type of an image, given a file path.
// Returns `null` if unknown.
export function getImageMime(path: string): string | null {
const mimes: Record<string, string> = {
apng: "image/apng",
png: "image/png",
svg: "image/svg+xml",
gif: "image/gif",
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
};
const ext = path.split(".").pop();
if (ext) {
if (mimes[ext]) {
return mimes[ext];
}
}
return null;
}
// Takes a path, eg. "../images/image.png", and a base from where to start resolving, e.g. "static/images/index.html".
// Returns the resolved path.
export function canonicalize(
path: string,
base: string,
origin = document.location.origin,
): string {
path = path.replace(/^\//, ""); // Remove leading slash
const finalPath = base
.split("/")
.slice(0, -1) // Remove file name.
.concat([path]) // Add image file path.
.join("/");
// URL is used to resolve relative paths, eg. `../../assets/image.png`.
const url = new URL(finalPath, origin);
const pathname = url.pathname.replace(/^\//, "");
return pathname;
}
// Takes a URL, eg. "https://twitter.com/cloudhead", and return "cloudhead".
// Returns the original string if it was unable to extract the username.
export function parseUsername(input: string): string {
const parts = input.split("/");
return parts[parts.length - 1];
}
// Return the current unix time.
export function unixTime(): number {
return Math.floor(Date.now() / 1000);
}
export const formatTimestamp = (
timestamp: number,
current = new Date().getTime(),
): string => {
const units: Record<string, number> = {
year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
// Multiplying timestamp with 1000 to convert from seconds to milliseconds
timestamp = timestamp * 1000;
const rtf = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
style: "long",
});
const elapsed = current - timestamp;
if (elapsed > units["year"]) {
return new Date(timestamp).toUTCString(); // If it's more than a year we return early showing a Datetime string
} else if (elapsed < 0) {
return "now"; // If elapsed is a negative number we are dealing with an item from the future, and we return "now"
}
for (const u in units) {
if (elapsed > units[u] || u === "second") {
// We convert the division result to a negative number to get "XX [unit] ago"
return rtf.format(
Math.round(elapsed / units[u]) * -1,
u as Intl.RelativeTimeFormatUnit,
);
}
}
return new Date(timestamp).toUTCString();
};
// Check whether the input is a Radicle ID.
export function isRadicleId(input: string): boolean {
return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);
}
// Check whether the input is a Radicle Peer ID.
export function isPeerId(input: string): boolean {
return /^h[a-zA-Z0-9]+$/.test(input);
}
// Check whether the input is a SHA1 commit.
export function isOid(input: string): boolean {
return /^[a-fA-F0-9]{40}$/.test(input);
}
// Check whether the input is a URL.
export function isUrl(input: string): boolean {
return /^https?:\/\//.test(input);
}
export function isENSName(input: string, wallet: Wallet): boolean {
const domain = wallet.registrar.domain.replace(".", "\\.");
const regEx = new RegExp(`^[a-zA-Z0-9]+.(${domain}|eth)$`);
return regEx.test(input);
}
// Check whether the input is an checksummed or all lowercase Ethereum address.
export function isAddress(input: string): boolean {
return ethers.utils.isAddress(input);
}
export function isFulfilled<T>(
input: PromiseSettledResult<T>,
): input is PromiseFulfilledResult<T> {
return input.status === "fulfilled";
}
// Get the explorer link of an address, eg. Etherscan.
export function explorerLink(addr: string, wallet: Wallet): string {
if (wallet.network.name === "goerli") {
return `https://goerli.etherscan.io/address/${addr}`;
}
return `https://etherscan.io/address/${addr}`;
}
// Format a name.
export function formatName(input: string, wallet: Wallet): string {
return parseEnsLabel(input, wallet);
}
// Parse a Radicle Id (URN).
export function parseRadicleId(urn: string): string {
return urn.replace(/^rad:[a-z]+:/, "");
}
// Get amount of days passed between two dates without including the end date
export function getDaysPassed(from: Date, to: Date): number {
return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
}
export function parseEmoji(input: string): string {
if (input in emojis) {
return emojis[input];
}
return input;
}
export function scrollIntoView(id: string) {
const lineElement = document.getElementById(id);
if (lineElement) lineElement.scrollIntoView();
}
export function getSeedEmoji(seedHost: string): string {
const seed = config.seeds.pinned.find(s => s.host === seedHost);
if (seed) {
return seed.emoji;
} else if (isLocal(seedHost)) {
return "🏠";
} else {
return "🌱";
}
}
// Identify an address by checking whether it's a contract or an externally-owned address.
export async function identifyAddress(
address: string,
wallet: Wallet,
): Promise<AddressType> {
const code = await getCode(address, wallet);
const bytes = ethers.utils.arrayify(code);
if (bytes.length > 0) {
return AddressType.Contract;
}
return AddressType.EOA;
}
// Resolves an ENS profile or return null
export async function resolveEnsProfile(
addressOrName: string,
profileType: ProfileType,
wallet: Wallet,
): Promise<EnsProfile | null> {
const name = ethers.utils.isAddress(addressOrName)
? await lookupAddress(addressOrName, wallet)
: addressOrName;
if (name) {
const resolver = await getResolver(name, wallet);
if (!resolver) {
return null;
}
if (profileType === ProfileType.Full) {
const registration = await getRegistration(name, wallet, resolver);
if (registration) {
return registration.profile;
}
} else {
const promises: [Promise<any>] = [getAvatar(name, wallet, resolver)];
if (addressOrName === name) {
promises.push(getAddress(resolver));
} else {
promises.push(Promise.resolve(addressOrName));
}
if (profileType === ProfileType.Project) {
promises.push(getSeed(name, wallet, resolver));
} else if (profileType === ProfileType.Minimal) {
promises.push(Promise.resolve(null));
}
const project = await Promise.allSettled(promises);
const [avatar, address, seed] =
// Just checking for r.value equal null and casting to undefined,
// since resolver functions return null.
project.filter(isFulfilled).map(r => (r.value ? r.value : null));
return {
name,
avatar,
address,
seed,
};
}
}
return null;
}
// Get token balances for an address.
export async function getTokens(
address: string,
wallet: Wallet,
): Promise<Array<Token>> {
const userBalances = await getRpcMethod(
"alchemy_getTokenBalances",
[address, "DEFAULT_TOKENS"],
wallet,
);
const balances = userBalances.tokenBalances
.filter((token: any) => {
// alchemy_getTokenBalances sometimes returns 0x and this does not work well with ethers.BigNumber
if (token.tokenBalance !== "0x") {
if (!BigNumber.from(token.tokenBalance).isZero()) {
return token;
}
}
})
.map(async (token: any) => {
const tokenMetaData = await getRpcMethod(
"alchemy_getTokenMetadata",
[token.contractAddress],
wallet,
);
return { ...tokenMetaData, balance: BigNumber.from(token.tokenBalance) };
});
return Promise.all(balances);
}
export const getRpcMethod = cache.cached(
async (method: string, props: string[], wallet: Wallet) => {
return await wallet.provider.send(method, props);
},
(method, props) => JSON.stringify([method, props]),
{ ttl: 2 * 60 * 1000, max: 1000 },
);
// Check whether the given path has a markdown file extension.
export function isMarkdownPath(path: string): boolean {
return /\.(md|mkd|markdown)$/i.test(path);
}
// Check whether the given input string is a domain, eg. `alt-clients.radicle.xyz.
// Also accepts in dev env 0.0.0.0 as domain
export function isDomain(input: string): boolean {
return (
(/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
(!import.meta.env.PROD && /^0.0.0.0$/.test(input))
);
}
// Check whether the given address is a local host address.
export function isLocal(addr: string): boolean {
return addr === "127.0.0.1" || addr === "0.0.0.0";
}
// Get the gravatar URL of an email.
export function gravatarURL(email: string): string {
const address = email.trim().toLowerCase();
const hash = md5(address);
return `https://www.gravatar.com/avatar/${hash}`;
}
export const getCode = cache.cached(
async (address: string, wallet: Wallet) => {
return await wallet.provider.getCode(address);
},
address => address,
{ max: 1000 },
);
export const lookupAddress = cache.cached(
async (address: string, wallet: Wallet) => {
return await wallet.provider.lookupAddress(address);
},
address => address,
{ max: 1000 },
);
export const unreachable = (value: never): never => {
throw new Error(`Unreachable code: ${value}`);
};
const emojisMarkedExtension = {
name: "emoji",
level: "inline",
start: (src: string) => src.indexOf(":"),
tokenizer(src: string) {
const match = src.match(/^:([\w+-]+):/);
if (match) {
return {
type: "emoji",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer: (token: marked.Tokens.Generic) =>
`<span>${parseEmoji(token.text)}</span>`,
};
const katexMarkedExtension = {
name: "katex",
level: "inline",
start: (src: string) => src.indexOf("$"),
tokenizer(src: string) {
const match = src.match(/^\$+([^$\n]+?)\$+/);
if (match) {
return {
type: "katex",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer: (token: marked.Tokens.Generic) =>
katex.renderToString(token.text, {
throwOnError: false,
}),
};
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
// e.g. <a name="test"/> -> <a name="test"></a>
const anchorMarkedExtension = {
name: "sanitizedAnchor",
level: "block",
start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
tokenizer(src: string) {
const match = src.match(/^<a name="([\w]+)"\/>/);
if (match) {
return {
type: "sanitizedAnchor",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer: (token: marked.Tokens.Generic) => {
return `<a name="${token.text}"></a>`;
},
};
// Overwrites the rendering of heading tokens.
// Since there are possible non ASCII characters in headings,
// we escape them by replacing them with dashes and,
// trim eventual dashes on each side of the string.
export const renderer = {
heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6) {
const escapedText = text
.toLowerCase()
.replace(/[^\w]+/g, "-")
.replace(/^-|-$/g, "");
return `<h${level} id="${escapedText}">${text}</h${level}>`;
},
};
export function twemoji(node: HTMLElement) {
twemojiModule.parse(node, {
base,
folder: "twemoji",
ext: ".svg",
className: `txt-emoji`,
});
}
export const markdownExtensions = [
emojisMarkedExtension,
katexMarkedExtension,
anchorMarkedExtension,
];
+86
View File
@@ -0,0 +1,86 @@
import type { Wallet } from "@app/lib/wallet";
import { ethers } from "ethers";
import { writable } from "svelte/store";
import * as session from "@app/lib/session";
import * as utils from "@app/lib/utils";
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
import { assert } from "@app/lib/error";
export interface VestingInfo {
token: string;
symbol: string;
beneficiary: string;
totalVesting: string;
withdrawableBalance: string;
withdrawn: string;
cliffPeriod: string;
vestingStartTime: string;
vestingPeriod: string;
}
export const state = writable<
"idle" | "loading" | "withdrawingSign" | "withdrawing" | "withdrawn"
>("idle");
export async function withdrawVested(
address: string,
wallet: Wallet,
): Promise<void> {
assert(wallet.signer);
const contract = new ethers.Contract(
address,
ethereumContractAbis.vesting,
wallet.provider,
);
const signer = wallet.signer;
state.set("withdrawingSign");
const tx = await contract.connect(signer).withdrawVested();
state.set("withdrawing");
await tx.wait();
session.state.refreshBalance(wallet);
state.set("withdrawn");
}
export async function getInfo(
address: string,
wallet: Wallet,
): Promise<VestingInfo> {
const contract = new ethers.Contract(
address,
ethereumContractAbis.vesting,
wallet.provider,
);
const token = await contract.token();
const beneficiary = await contract.beneficiary();
const withdrawable = await contract.withdrawableBalance();
const withdrawn = await contract.withdrawn();
const total = await contract.totalVestingAmount();
const vestingStartTime = await contract.vestingStartTime();
const vestingPeriod = await contract.vestingPeriod();
const cliffPeriod = await contract.cliffPeriod();
const tokenContract = new ethers.Contract(
token,
ethereumContractAbis.token,
wallet.provider,
);
const symbol = await tokenContract.symbol();
return {
token,
symbol,
beneficiary,
totalVesting: utils.formatBalance(total),
withdrawableBalance: utils.formatBalance(withdrawable),
withdrawn: utils.formatBalance(withdrawn),
vestingStartTime,
vestingPeriod,
cliffPeriod,
};
}
+262
View File
@@ -0,0 +1,262 @@
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
import type { Writable } from "svelte/store";
import WalletConnect from "@walletconnect/client";
import { ethers } from "ethers";
import { get, writable } from "svelte/store";
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
import goerli from "@app/lib/ethereum/networks/goerli.json";
import homestead from "@app/lib/ethereum/networks/homestead.json";
import { WalletConnectSigner } from "@app/lib/walletConnectSigner";
import { capitalize } from "@app/lib/utils";
import { config } from "@app/lib/config";
interface NetworkConfig {
name: string;
chainId: number;
registrar: {
domain: string;
address: string;
};
radToken: {
address: string;
faucet?: string;
};
reverseRegistrar: {
address: string;
};
alchemy: { key: string };
}
export type WalletConnectState =
| { state: "close" }
| { state: "open"; uri: string; onClose: any };
export class Wallet {
network: { name: string; chainId: number };
registrar: { address: string; domain: string };
radToken: { address: string; faucet?: string };
reverseRegistrar: { address: string };
provider: ethers.providers.JsonRpcProvider;
signer: (ethers.Signer & TypedDataSigner) | WalletConnectSigner | null;
walletConnect: {
client: WalletConnect;
bridge: string;
signer: WalletConnectSigner;
state: Writable<WalletConnectState>;
};
metamask:
| {
connected: true;
signer: ethers.Signer & TypedDataSigner;
session: { address: string };
}
| {
connected: false;
signer: (ethers.Signer & TypedDataSigner) | null;
};
token: ethers.Contract;
constructor(
network: NetworkConfig,
provider: ethers.providers.JsonRpcProvider,
metamaskSigner: (ethers.Signer & TypedDataSigner) | null,
) {
const walletConnectState = writable<WalletConnectState>({ state: "close" });
const wc = Wallet.initializeWalletConnect(
config.walletConnect.bridge,
walletConnectState,
provider,
);
const metamaskSession = window.localStorage.getItem("metamask");
const metamask = metamaskSession ? JSON.parse(metamaskSession) : null;
this.network = network;
this.metamask =
metamask && metamaskSigner
? {
connected: true,
session: { address: metamask["address"] },
signer: metamaskSigner,
}
: {
connected: false,
signer: metamaskSigner,
};
this.walletConnect = {
bridge: config.walletConnect.bridge,
client: wc.connector,
signer: wc.signer,
state: walletConnectState,
};
this.registrar = network.registrar;
this.radToken = network.radToken;
this.reverseRegistrar = network.reverseRegistrar;
this.provider = provider;
this.signer = null;
this.token = new ethers.Contract(
this.radToken.address,
ethereumContractAbis.token,
this.provider,
);
}
changeNetwork(chainId: number): void {
this.network = ethers.providers.getNetwork(chainId);
}
setSigner(
signer: (ethers.Signer & TypedDataSigner) | WalletConnectSigner,
): void {
this.signer = signer;
}
getWalletConnectSigner(): WalletConnectSigner {
if (this.walletConnect.client.connected) {
this.setSigner(this.walletConnect.signer);
return this.walletConnect.signer;
}
const wc = Wallet.initializeWalletConnect(
this.walletConnect.bridge,
this.walletConnect.state,
this.provider,
);
this.walletConnect.client = wc.connector;
this.walletConnect.signer = wc.signer;
this.setSigner(wc.signer);
return wc.signer;
}
static initializeWalletConnect(
bridge: string,
state: Writable<WalletConnectState>,
provider: ethers.providers.JsonRpcProvider,
): {
connector: WalletConnect;
signer: WalletConnectSigner;
} {
const walletConnect = new WalletConnect({
bridge,
qrcodeModal: {
open: (uri: string, onClose) => {
state.set({ state: "open", uri, onClose });
},
close: () => {
// We handle the "close" event through the "disconnect" handler.
},
},
});
walletConnect.on("modal_closed", () => {
state.set({ state: "close" });
});
walletConnect.on("disconnect", () => {
const wcs = get(state);
if (wcs.state === "open") {
wcs.onClose();
}
});
// Behold, we set this private class variable here because WalletConnect doesn't
// give us any other way to set it :'(
//
// The default is to use the favicon, which doesn't work, given that it is
// designed for browsers and not mobile apps which often show a much bigger
// icon, resulting in a blurry image.
(walletConnect as any)._clientMeta.icons = [
`${window.location.protocol}//${window.location.host}/logo.png`,
];
const walletConnectSigner = new WalletConnectSigner(
walletConnect,
provider,
);
return {
connector: walletConnect,
signer: walletConnectSigner,
};
}
}
export function isMetamaskInstalled(): boolean {
const { ethereum } = window;
return Boolean(ethereum && ethereum.isMetaMask);
}
function getProvider(
networkConfig: NetworkConfig,
metamask: ethers.providers.JsonRpcProvider | null,
): ethers.providers.JsonRpcProvider {
if (metamask) {
return metamask;
} else if (
import.meta.env.PROD &&
window.location.host !== "localhost:4173"
) {
return new ethers.providers.AlchemyWebSocketProvider(
networkConfig.name,
networkConfig.alchemy.key,
);
}
// Run the production smoke test with the ethers provider,
// because we block requests from localhost on Alchemy,
// which in turn throws an exception.
else if (import.meta.env.DEV || window.location.host === "localhost:4173") {
// The ethers defaultProvider doesn't include a `send` method, which breaks the `utils.getTokens` fn.
// Since Metamask nor WalletConnect provide an `alchemy_getTokenBalances` nor `alchemy_getTokenMetadata` endpoint,
// we can rely on not using `config.provider.send`.
return ethers.providers.getDefaultProvider() as ethers.providers.JsonRpcProvider;
} else {
throw new Error("No Web3 provider available.");
}
}
// Checks if the promise metamask.ready returns the network, else timesout after 4 seconds.
function checkMetaMask(
metamask: ethers.providers.Web3Provider,
): Promise<ethers.providers.Network | null> {
return new Promise(resolve => {
setTimeout(() => {
resolve(null);
}, 4000);
metamask.ready.then(network => resolve(network));
});
}
export async function getWallet(): Promise<Wallet> {
const metamask = isMetamaskInstalled()
? new ethers.providers.Web3Provider(window.ethereum)
: null;
const metamaskSigner = metamask?.getSigner() || null;
let selectedNetwork: NetworkConfig;
if (metamask) {
const ready = await checkMetaMask(metamask);
if (ready) {
if (ready.name === "homestead") {
selectedNetwork = homestead;
} else if (ready.name === "goerli") {
selectedNetwork = goerli;
} else {
throw new Error(
`${capitalize(
ready.name,
)} is not supported. Connect to Homestead or Goerli instead.`,
);
}
} else {
throw new Error("Metamask was not ready after 4 seconds.");
}
} else {
// Fall back to homestead.
selectedNetwork = homestead;
}
const provider = getProvider(selectedNetwork, metamask);
const cfg = new Wallet(selectedNetwork, provider, metamaskSigner);
return cfg;
}
+158
View File
@@ -0,0 +1,158 @@
import type WalletConnect from "@walletconnect/client";
import type { Deferrable } from "@ethersproject/properties";
import type {
TransactionRequest,
TransactionResponse,
} from "@ethersproject/abstract-provider";
import type {
TypedDataDomain,
TypedDataField,
} from "@ethersproject/abstract-signer";
import * as ethers from "ethers";
import * as ethersBytes from "@ethersproject/bytes";
import { _TypedDataEncoder } from "ethers/lib/utils";
import { resolveProperties } from "@ethersproject/properties";
export class WalletConnectSigner extends ethers.Signer {
public walletConnect: WalletConnect;
public readonly provider: ethers.providers.JsonRpcProvider;
constructor(
walletConnect: WalletConnect,
provider: ethers.providers.JsonRpcProvider,
) {
super();
this.provider = provider;
this.walletConnect = walletConnect;
}
async getAddress(): Promise<string> {
const accountAddress = this.walletConnect.accounts[0];
if (!accountAddress) {
throw new Error(
"The connected wallet has no accounts or there is a connection problem",
);
}
return ethers.utils.getAddress(accountAddress);
}
async _signTypedData(
domain: TypedDataDomain,
types: Record<string, Array<TypedDataField>>,
value: Record<string, any>,
): Promise<string> {
// Populate any ENS names (in-place)
const populated = await _TypedDataEncoder.resolveNames(
domain,
types,
value,
async (name: string) => {
const address = await this.provider.resolveName(name);
if (address === null) {
throw Error("resolver or addr is not configured for ENS name");
}
return address;
},
);
const address = await this.getAddress();
const signature = await this.walletConnect.signTypedData([
address.toLowerCase(),
JSON.stringify(
_TypedDataEncoder.getPayload(populated.domain, types, populated.value),
),
]);
return signature;
}
async signMessage(message: ethers.Bytes | string): Promise<string> {
const prefix = ethers.utils.toUtf8Bytes(
`\x19Ethereum Signed Message:\n${message.length}`,
);
const data =
typeof message === "string" ? ethers.utils.toUtf8Bytes(message) : message;
const msg = ethers.utils.concat([prefix, data]);
const address = await this.getAddress();
const keccakMessage = ethers.utils.keccak256(msg);
const signature = await this.walletConnect.signMessage([
address,
keccakMessage,
]);
return signature;
}
async sendTransaction(
transaction: Deferrable<TransactionRequest>,
): Promise<TransactionResponse> {
const tx = await resolveProperties(transaction);
const from = tx.from || (await this.getAddress());
const txHash = await this.walletConnect.sendTransaction({
from,
to: tx.to,
value: maybeBigNumberToString(tx.value),
data: bytesLikeToString(tx.data),
});
return <TransactionResponse>{
hash: txHash,
nonce: tx.nonce,
gasLimit: tx.gasLimit,
gasPrice: tx.gasPrice,
data: bytesLikeToString(tx.data) || "",
value: tx.value,
chainId: tx.chainId,
confirmations: 0,
from: from,
wait: (confirmations?: number) => {
return this.provider?.waitForTransaction(txHash, confirmations);
},
};
}
async signTransaction(
transaction: Deferrable<TransactionRequest>,
): Promise<string> {
const tx = await resolveProperties(transaction);
const from = tx.from || (await this.getAddress());
const nonce = await this.provider.getTransactionCount(from);
const signedTx = await this.walletConnect.signTransaction({
from,
to: tx.to,
value: maybeBigNumberToString(tx.value || 0),
gasLimit: maybeBigNumberToString(tx.gasLimit || 200 * 1000),
gasPrice: maybeBigNumberToString(tx.gasPrice || 0),
nonce,
data: bytesLikeToString(tx.data),
});
return signedTx;
}
connect(): ethers.Signer {
throw new Error("WalletConnectSigner.connect should never be called");
}
}
function maybeBigNumberToString(
bn: ethers.BigNumberish | undefined,
): string | undefined {
if (bn === undefined) {
return undefined;
} else {
return ethers.BigNumber.from(bn).toString();
}
}
function bytesLikeToString(
bytes: ethersBytes.BytesLike | undefined,
): string | undefined {
if (bytes === undefined) {
return undefined;
} else {
return ethersBytes.hexlify(bytes);
}
}