mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-14 11:00:34 +08:00
Organise files more consistently
Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
+116
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as FakeTimers from "@sinonjs/fake-timers";
|
||||
|
||||
if (typeof window.initializeTestStubs === "function") {
|
||||
window.e2eTestStubs = {
|
||||
FakeTimers: FakeTimers,
|
||||
};
|
||||
window.initializeTestStubs();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()"
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "homestead",
|
||||
"chainId": 1,
|
||||
"registrar": {
|
||||
"domain": "radicle.eth",
|
||||
"address": "0x37723287Ae6F34866d82EE623401f92Ec9013154"
|
||||
},
|
||||
"radToken": {
|
||||
"address": "0x31c8EAcBFFdD875c74b94b077895Bd78CF1E64A3"
|
||||
},
|
||||
"reverseRegistrar": {
|
||||
"address": "0x084b1c3C81545d370f3634392De611CaaBFf8148"
|
||||
},
|
||||
"alchemy": { "key": "cQFlLK8EokIGlJhd_soImwEyUoC7Ec8r" }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user