Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add route support to vesting feature
Sebastian Martinez committed 3 years ago
commit cdef3b5d5ea34e53a854fdca772c02bc6109675a
parent 6c363e9313efef8035fbacf7de3d515a7954d234
9 files changed +315 -204
modified src/App.svelte
@@ -16,7 +16,7 @@
  import Projects from "@app/base/projects/View.svelte";
  import Registrations from "@app/base/registrations/Routes.svelte";
  import Seeds from "@app/base/seeds/Routes.svelte";
-
  import Vesting from "@app/base/vesting/Index.svelte";
+
  import Vesting from "@app/base/vesting/Routes.svelte";

  initialize();

@@ -105,7 +105,7 @@
          session={$session}
          activeRoute={$activeRouteStore} />
      {:else if $activeRouteStore.resource === "vesting"}
-
        <Vesting {wallet} session={$session} />
+
        <Vesting {wallet} session={$session} activeRoute={$activeRouteStore} />
      {:else if $activeRouteStore.resource === "projects"}
        <Projects {wallet} activeRoute={$activeRouteStore} />
      {:else if $activeRouteStore.resource === "profile"}
added src/base/vesting/Form.svelte
@@ -0,0 +1,91 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/wallet";
+

+
  import * as utils from "@app/utils";
+
  import * as router from "@app/router";
+
  import Button from "@app/Button.svelte";
+
  import TextInput from "@app/TextInput.svelte";
+
  import { state, getInfo } from "./vesting";
+

+
  export let wallet: Wallet;
+

+
  let contractAddress = "";
+

+
  $: valid = utils.isAddress(contractAddress);
+
  $: validationMessage =
+
    contractAddress !== "" && !valid
+
      ? "Please enter a valid Ethereum address."
+
      : "";
+

+
  const loadContract = async () => {
+
    state.set("loading");
+
    try {
+
      const info = await getInfo(contractAddress, wallet);
+
      router.push({
+
        resource: "vesting",
+
        params: {
+
          view: {
+
            resource: "view",
+
            params: { contract: contractAddress, info },
+
          },
+
        },
+
      });
+
    } catch (error) {
+
      validationMessage =
+
        "Couldn't load contract, check dev console for details.";
+
      console.error(error);
+
    }
+
    state.set("idle");
+
  };
+
</script>
+

+
<style>
+
  main {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 38rem;
+
  }
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
+
  }
+
  .form {
+
    display: flex;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Vesting</title>
+
</svelte:head>
+

+
<main>
+
  <div class="title">
+
    Your Radicle <span class="txt-bold">vesting contract</span>
+
  </div>
+

+
  <div class="form">
+
    <TextInput
+
      autofocus
+
      placeholder="Enter vesting contract address"
+
      {valid}
+
      {validationMessage}
+
      loading={$state === "loading"}
+
      disabled={$state === "loading"}
+
      on:submit={loadContract}
+
      bind:value={contractAddress} />
+

+
    <Button
+
      on:click={loadContract}
+
      variant="primary"
+
      waiting={$state === "loading"}
+
      disabled={!valid || $state === "loading"}>
+
      Load
+
    </Button>
+
  </div>
+
</main>
deleted src/base/vesting/Index.svelte
@@ -1,192 +0,0 @@
-
<script lang="ts">
-
  import type { Session } from "@app/session";
-
  import type { Wallet } from "@app/wallet";
-
  import type { VestingInfo } from "./vesting";
-

-
  import * as utils from "@app/utils";
-
  import Address from "@app/Address.svelte";
-
  import Button from "@app/Button.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import TextInput from "@app/TextInput.svelte";
-
  import { state, getInfo, withdrawVested } from "./vesting";
-

-
  export let wallet: Wallet;
-
  export let session: Session | null;
-

-
  let contractAddress = "";
-
  let info: VestingInfo | null = null;
-
  let validationMessage: string | undefined = undefined;
-
  let valid: boolean = false;
-

-
  async function loadContract(wallet: Wallet) {
-
    if (!valid) {
-
      return;
-
    }
-

-
    state.set("loading");
-
    try {
-
      info = await getInfo(contractAddress, wallet);
-
    } catch (error) {
-
      validationMessage =
-
        "Couldn't load contract, check dev console for details.";
-
      console.error(error);
-
    }
-
    state.set("idle");
-
  }
-

-
  $: isBeneficiary =
-
    info && session && utils.isAddressEqual(info.beneficiary, session.address);
-

-
  function validate(address: string) {
-
    if (address === "") {
-
      return { valid: false };
-
    }
-

-
    if (!utils.isAddress(address)) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a valid Ethereum address.",
-
      };
-
    }
-

-
    return { valid: true };
-
  }
-

-
  $: ({ valid, validationMessage } = validate(contractAddress));
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    height: 100%;
-
    justify-content: center;
-
    padding-bottom: 24vh;
-
    padding-top: 5rem;
-
    width: 38rem;
-
  }
-
  .title {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-medium);
-
  }
-
  .form {
-
    display: flex;
-
    gap: 1rem;
-
  }
-
  table {
-
    table-layout: fixed;
-
    border-collapse: separate;
-
    border-spacing: 2rem 0;
-
  }
-
  td {
-
    text-align: left;
-
    text-overflow: ellipsis;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Vesting</title>
-
</svelte:head>
-

-
{#if info}
-
  <Modal>
-
    <span slot="title">
-
      {contractAddress}
-
    </span>
-

-
    <span slot="body">
-
      {#if $state === "withdrawn"}
-
        Tokens successfully withdrawn to {utils.formatAddress(
-
          info.beneficiary,
-
        )}.
-
      {:else}
-
        <table>
-
          <tr>
-
            <td class="txt-highlight">Beneficiary</td>
-
            <td>
-
              <Address {wallet} address={info.beneficiary} compact resolve />
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Allocation</td>
-
            <td>
-
              {info.totalVesting}
-
              <span class="txt-bold">{info.symbol}</span>
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Withdrawn</td>
-
            <td>
-
              {info.withdrawn}
-
              <span class="txt-bold">{info.symbol}</span>
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Withdrawable</td>
-
            <td>
-
              {info.withdrawableBalance}
-
              <span class="txt-bold">{info.symbol}</span>
-
            </td>
-
          </tr>
-
        </table>
-
      {/if}
-
    </span>
-

-
    <span slot="actions">
-
      {#if isBeneficiary}
-
        {#if $state === "withdrawingSign"}
-
          <Button disabled waiting={true} variant="primary">
-
            Waiting for signature…
-
          </Button>
-
        {:else if $state === "withdrawing"}
-
          <Button disabled waiting={true} variant="primary">
-
            Withdrawing…
-
          </Button>
-
        {:else if $state === "idle"}
-
          <Button
-
            on:click={() => withdrawVested(contractAddress, wallet)}
-
            variant="primary">
-
            Withdraw
-
          </Button>
-
        {/if}
-
      {/if}
-
      <Button
-
        on:click={() => {
-
          info = null;
-
          state.set("idle");
-
        }}
-
        variant="primary">
-
        Back
-
      </Button>
-
    </span>
-
  </Modal>
-
{:else}
-
  <main>
-
    <div class="title">
-
      Your Radicle <span class="txt-bold">vesting contract</span>
-
    </div>
-

-
    <div class="form">
-
      <TextInput
-
        autofocus
-
        placeholder="Enter vesting contract address"
-
        {valid}
-
        {validationMessage}
-
        loading={$state === "loading"}
-
        disabled={$state === "loading"}
-
        on:submit={() => {
-
          loadContract(wallet);
-
        }}
-
        bind:value={contractAddress} />
-

-
      <Button
-
        on:click={() => loadContract(wallet)}
-
        variant="primary"
-
        waiting={$state === "loading"}
-
        disabled={!valid || $state === "loading"}>
-
        Load
-
      </Button>
-
    </div>
-
  </main>
-
{/if}
added src/base/vesting/Routes.svelte
@@ -0,0 +1,22 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/wallet";
+
  import type { VestingRoute } from "@app/router/definitions";
+
  import type { Session } from "@app/session";
+

+
  import Form from "@app/base/vesting/Form.svelte";
+
  import View from "@app/base/vesting/View.svelte";
+

+
  export let activeRoute: VestingRoute;
+
  export let wallet: Wallet;
+
  export let session: Session | null;
+
</script>
+

+
{#if activeRoute.params.view.resource === "form"}
+
  <Form {wallet} />
+
{:else if activeRoute.params.view.resource === "view"}
+
  <View
+
    {wallet}
+
    {session}
+
    info={activeRoute.params.view.params.info}
+
    contractAddress={activeRoute.params.view.params.contract} />
+
{/if}
added src/base/vesting/View.svelte
@@ -0,0 +1,160 @@
+
<script lang="ts">
+
  import type { Session } from "@app/session";
+
  import type { Wallet } from "@app/wallet";
+
  import type { VestingInfo } from "./vesting";
+

+
  import * as router from "@app/router";
+
  import * as utils from "@app/utils";
+
  import Address from "@app/Address.svelte";
+
  import Button from "@app/Button.svelte";
+
  import Modal from "@app/Modal.svelte";
+
  import { state, getInfo, withdrawVested } from "./vesting";
+
  import { onMount } from "svelte";
+
  import ErrorModal from "@app/ErrorModal.svelte";
+
  import Loading from "@app/Loading.svelte";
+

+
  export let contractAddress: string;
+
  export let info: VestingInfo | null = null;
+
  export let session: Session | null;
+
  export let wallet: Wallet;
+

+
  let error: Error | undefined = undefined;
+

+
  onMount(async () => {
+
    if (!info) {
+
      state.set("loading");
+
      try {
+
        info = await getInfo(contractAddress, wallet);
+
      } catch (e) {
+
        error = e as Error;
+
      }
+
    }
+
    state.set("idle");
+
  });
+

+
  const parseVestingPeriods = (input: string[]): string => {
+
    const total = input
+
      .map(s => parseInt(s))
+
      .reduce((prev, curr) => prev + curr, 0);
+
    return new Date(total * 1000).toDateString();
+
  };
+
</script>
+

+
<style>
+
  table {
+
    table-layout: fixed;
+
    border-collapse: separate;
+
    border-spacing: 2rem 0;
+
  }
+
  td {
+
    text-align: left;
+
    text-overflow: ellipsis;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Vesting</title>
+
</svelte:head>
+

+
{#if error}
+
  <ErrorModal
+
    title="Failed to obtain contract information"
+
    message={error.message}
+
    on:close={() => router.pop()} />
+
{:else if $state === "loading"}
+
  <Loading center />
+
{:else if info}
+
  {@const isBeneficiary =
+
    session && utils.isAddressEqual(info.beneficiary, session.address)}
+
  <Modal>
+
    <span slot="title">
+
      {contractAddress}
+
    </span>
+

+
    <span slot="body">
+
      {#if $state === "withdrawn"}
+
        Tokens successfully withdrawn to {utils.formatAddress(
+
          info.beneficiary,
+
        )}.
+
      {:else}
+
        <table>
+
          <tr>
+
            <td class="txt-highlight">Beneficiary</td>
+
            <td>
+
              <Address {wallet} address={info.beneficiary} compact resolve />
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Allocation</td>
+
            <td>
+
              {info.totalVesting}
+
              <span class="txt-bold">{info.symbol}</span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Withdrawn</td>
+
            <td>
+
              {info.withdrawn}
+
              <span class="txt-bold">{info.symbol}</span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Withdrawable</td>
+
            <td>
+
              {info.withdrawableBalance}
+
              <span class="txt-bold">{info.symbol}</span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Start Time</td>
+
            <td>
+
              <span class="txt-bold">
+
                {parseVestingPeriods([info.vestingStartTime])}
+
              </span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Cliff Period End</td>
+
            <td>
+
              <span class="txt-bold">
+
                {parseVestingPeriods([info.vestingStartTime, info.cliffPeriod])}
+
              </span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Vesting Period End</td>
+
            <td>
+
              <span class="txt-bold">
+
                {parseVestingPeriods([
+
                  info.vestingStartTime,
+
                  info.vestingPeriod,
+
                ])}
+
              </span>
+
            </td>
+
          </tr>
+
        </table>
+
      {/if}
+
    </span>
+

+
    <span slot="actions">
+
      {#if isBeneficiary}
+
        {#if $state === "withdrawingSign"}
+
          <Button disabled waiting={true} variant="primary">
+
            Waiting for signature…
+
          </Button>
+
        {:else if $state === "withdrawing"}
+
          <Button disabled waiting={true} variant="primary">
+
            Withdrawing…
+
          </Button>
+
        {:else if $state === "idle"}
+
          <Button
+
            on:click={() => withdrawVested(contractAddress, wallet)}
+
            variant="primary">
+
            Withdraw
+
          </Button>
+
        {/if}
+
      {/if}
+
      <Button on:click={() => router.pop()} variant="primary">Back</Button>
+
    </span>
+
  </Modal>
+
{/if}
modified src/base/vesting/vesting.ts
@@ -15,6 +15,9 @@ export interface VestingInfo {
  totalVesting: string;
  withdrawableBalance: string;
  withdrawn: string;
+
  cliffPeriod: string;
+
  vestingStartTime: string;
+
  vestingPeriod: string;
}

export const state = writable<
@@ -58,6 +61,9 @@ export async function getInfo(
  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,
@@ -67,11 +73,14 @@ export async function getInfo(
  const symbol = await tokenContract.symbol();

  return {
-
    token: token,
-
    symbol: symbol,
-
    beneficiary: beneficiary,
+
    token,
+
    symbol,
+
    beneficiary,
    totalVesting: utils.formatBalance(total),
    withdrawableBalance: utils.formatBalance(withdrawable),
    withdrawn: utils.formatBalance(withdrawn),
+
    vestingStartTime,
+
    vestingPeriod,
+
    cliffPeriod,
  };
}
modified src/router/definitions.ts
@@ -1,12 +1,14 @@
+
import type { VestingInfo } from "@app/base/vesting/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 } }
-
  | { resource: "vesting" };
+
  | { resource: "seeds"; params: { host: string } };

export interface ProjectsParams {
  urn: string;
@@ -29,6 +31,12 @@ export interface ProjectsParams {
  seed?: string;
}

+
export interface VestingParams {
+
  view:
+
    | { resource: "form" }
+
    | { resource: "view"; params: { contract: string; info?: VestingInfo } };
+
}
+

export interface FaucetParams {
  view:
    | { resource: "form" }
@@ -56,6 +64,7 @@ export interface RegistrationParams {

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;
modified src/router/index.ts
@@ -189,8 +189,16 @@ function pathToRoute(path: string): Route | null {
      }
      return { resource: "faucet", params: { view: { resource: "form" } } };
    }
-
    case "vesting":
-
      return { resource: "vesting" };
+
    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) {
@@ -276,7 +284,11 @@ export function routeToPath(route: Route) {
      return `/faucet/withdraw?amount=${route.params.view.params.amount}`;
    }
  } else if (route.resource === "vesting") {
-
    return "/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") {
modified tests/unit/router.test.ts
@@ -9,7 +9,7 @@ describe("routeToPath", () => {
  test.each([
    { input: { resource: "home" }, output: "/", description: "Home Route" },
    {
-
      input: { resource: "vesting" },
+
      input: { resource: "vesting", params: { view: { resource: "form" } } },
      output: "/vesting",
      description: "Vesting Route",
    },
@@ -124,7 +124,7 @@ describe("pathToRoute", () => {
    { input: "/", output: { resource: "home" }, description: "Home Route" },
    {
      input: "/vesting",
-
      output: { resource: "vesting" },
+
      output: { resource: "vesting", params: { view: { resource: "form" } } },
      description: "Vesting Route",
    },
    {