Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Make route organization RESTful
Alexis Sellier committed 5 years ago
commit b61c336dcac36e0a5459f4f6fb2dc74ca9449aed
parent 73b00be0af0441c10320eefa915d1e545f4c28fd
33 files changed +1116 -1093
modified src/App.svelte
@@ -4,11 +4,11 @@
  import { getConfig } from '@app/config';
  import { session } from '@app/session';

-
  import Home from '@app/base/home/Home.svelte';
-
  import Vesting from '@app/base/vesting/Vesting.svelte';
-
  import Register from '@app/base/register/Routes.svelte';
+
  import Home from '@app/base/home/Index.svelte';
+
  import Vesting from '@app/base/vesting/Index.svelte';
+
  import Registrations from '@app/base/registrations/Routes.svelte';
  import Orgs from '@app/base/orgs/Routes.svelte';
-
  import Resolve from '@app/base/resolve/Routes.svelte';
+
  import Resolver from '@app/base/resolver/Routes.svelte';
  import Header from '@app/Header.svelte';

  function handleKeydown(event: KeyboardEvent) {
@@ -43,9 +43,9 @@
        <Route path="vesting">
          <Vesting {config} session={$session} />
        </Route>
-
        <Register {config} session={$session} />
+
        <Registrations {config} session={$session} />
        <Orgs {config} />
-
        <Resolve {config} />
+
        <Resolver {config} />
      </Router>
    </div>
  </div>
modified src/Error.svelte
@@ -24,7 +24,9 @@
  </span>

  <span slot="body">
-
    <strong>Error:</strong> {body}
+
    <slot>
+
      <strong>Error:</strong> {body}
+
    </slot>
  </span>

  <span slot="actions">
modified src/Header.svelte
@@ -88,7 +88,7 @@
  <div class="left">
    <a use:link href="/"><Logo /></a>
    <div class="nav">
-
      <a use:link href="/register/">Register</a>
+
      <a use:link href="/registrations">Register</a>
      <a use:link href="/vesting/">Vesting</a>
      {#if config.network.name === 'ropsten'}
        <a use:link href="/orgs/">Orgs</a>
deleted src/base/home/Home.svelte
@@ -1,17 +0,0 @@
-
<script lang="typescript">
-
  import { navigate } from 'svelte-routing';
-

-
  let input: string = "";
-
  const search = () => {
-
    navigate(`/resolve?${
-
      new URLSearchParams({ q: input })
-
    }`);
-
  };
-
</script>
-

-
<main>
-
  <input size="40" type="text" bind:value={input} placeholder="Enter a name, address or domain..." />
-
  <button class="primary" on:click={search} disabled={!input}>
-
    Search
-
  </button>
-
</main>
added src/base/home/Index.svelte
@@ -0,0 +1,17 @@
+
<script lang="typescript">
+
  import { navigate } from 'svelte-routing';
+

+
  let input: string = "";
+
  const search = () => {
+
    navigate(`/resolver/query?${
+
      new URLSearchParams({ q: input })
+
    }`);
+
  };
+
</script>
+

+
<main>
+
  <input size="40" type="text" bind:value={input} placeholder="Enter a name, address or domain..." />
+
  <button class="primary" on:click={search} disabled={!input}>
+
    Search
+
  </button>
+
</main>
added src/base/orgs/Index.svelte
@@ -0,0 +1,23 @@
+
<script lang="typescript">
+
  import type { SvelteComponent } from 'svelte';
+
  import { session } from '@app/session';
+
  import Create from '@app/base/orgs/Create.svelte';
+
  import type { Config } from '@app/config';
+

+
  export let config: Config;
+

+
  let modal: typeof SvelteComponent | null = null;
+

+
  $: owner = $session && $session.address;
+
</script>
+

+
<style>
+
</style>
+

+
<main>
+
  <button on:click={() => modal = Create} disabled={!owner} class="secondary">
+
    Create an Org
+
  </button>
+
</main>
+

+
<svelte:component this={modal} {owner} {config} on:close={() => modal = null} />
deleted src/base/orgs/Orgs.svelte
@@ -1,23 +0,0 @@
-
<script lang="typescript">
-
  import type { SvelteComponent } from 'svelte';
-
  import { session } from '@app/session';
-
  import Create from '@app/base/orgs/Create.svelte';
-
  import type { Config } from '@app/config';
-

-
  export let config: Config;
-

-
  let modal: typeof SvelteComponent | null = null;
-

-
  $: owner = $session && $session.address;
-
</script>
-

-
<style>
-
</style>
-

-
<main>
-
  <button on:click={() => modal = Create} disabled={!owner} class="secondary">
-
    Create an Org
-
  </button>
-
</main>
-

-
<svelte:component this={modal} {owner} {config} on:close={() => modal = null} />
deleted src/base/orgs/Profile.svelte
@@ -1,162 +0,0 @@
-
<script lang="typescript">
-
  import { onMount } from 'svelte';
-
  import { Link } from 'svelte-routing';
-
  import type { SvelteComponent } from 'svelte';
-
  import { navigate } from 'svelte-routing';
-
  import type { Config } from '@app/config';
-
  import type { Registration } from '@app/base/register/registrar';
-
  import { getRegistration } from '@app/base/register/registrar';
-
  import { parseEnsLabel, explorerLink } from '@app/utils';
-
  import { Org } from './Org';
-
  import { session } from '@app/session';
-
  import Loading from '@app/Loading.svelte';
-
  import Modal from '@app/Modal.svelte';
-
  import Error from '@app/Error.svelte';
-
  import Icon from '@app/Icon.svelte';
-
  import SetName from '@app/ens/SetName.svelte';
-

-
  export let address: string;
-
  export let config: Config;
-

-
  let registration: Registration | null = null;
-
  let name: string | null = null;
-

-
  const back = () => navigate("/orgs");
-

-
  onMount(async () => {
-
    name = await config.provider.lookupAddress(address);
-
    registration = await getRegistration(name, config);
-
  });
-

-
  let setNameForm: typeof SvelteComponent | null = null;
-
  const setName = () => {
-
    setNameForm = SetName;
-
  };
-

-
  $: label = name && parseEnsLabel(name, config);
-
  $: isOwner = (org: Org): boolean => {
-
    return org.safe === ($session && $session.address);
-
  };
-
</script>
-

-
<style>
-
  main > header {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    margin-bottom: 2rem;
-
  }
-
  main > header > * {
-
    margin: 0 1rem 0 0;
-
  }
-
  .info {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    align-items: left;
-
  }
-
  .info a {
-
    border: none;
-
  }
-
  .fields {
-
    display: grid;
-
    grid-template-columns: 1fr 8fr;
-
    grid-gap: 1rem;
-
  }
-
  .fields > div {
-
    justify-self: start;
-
    align-self: center;
-
    height: 2rem;
-
    line-height: 2rem;
-
  }
-
  .avatar {
-
    width: 64px;
-
    height: 64px;
-
  }
-
  .links {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
  }
-
  .url {
-
    display: flex; /* Ensures correct vertical positioning of icons */
-
    margin-right: 1rem;
-
  }
-
</style>
-

-
{#await Org.get(address, config)}
-
  <Loading />
-
{:then org}
-
  {#if org}
-
    <main>
-
      <header>
-
        {#if registration && registration.avatar}
-
          <div class="avatar">
-
            <img src={registration.avatar} alt="avatar" />
-
          </div>
-
        {/if}
-
        <div class="info">
-
          <span class="title bold">{registration ? label : address}</span>
-
          <div class="links">
-
            {#if registration}
-
              {#if registration.url}
-
                <a class="url" href={registration.url}>{registration.url}</a>
-
              {/if}
-
              {#if registration.twitter}
-
                <a class="url" href={registration.twitter}>
-
                  <Icon name="twitter" />
-
                </a>
-
              {/if}
-
              {#if registration.github}
-
                <a class="url" href={registration.github}>
-
                  <Icon name="github" />
-
                </a>
-
              {/if}
-
            {/if}
-
          </div>
-
        </div>
-
      </header>
-

-
      <div class="fields">
-
        <div class="label">Address</div><div>{org.address}</div>
-
        <div class="label">Owner</div><div>{org.safe}</div>
-
        <div class="label">Name</div>
-
        <div>
-
          {#await org.lookupAddress(config)}
-
            <Loading small />
-
          {:then name}
-
            {#if name}
-
              <Link to={`/registrations/${label}`}>{name}</Link>
-
            {:else if isOwner(org)}
-
              <button class="tiny primary" on:click={setName}>
-
                Set
-
              </button>
-
            {:else}
-
              <span class="subtle">Not set</span>
-
            {/if}
-
          {/await}
-
        </div>
-
      </div>
-
    </main>
-
  {:else}
-
    <Modal subtle>
-
      <span slot="title">🏜️</span>
-
      <span slot="body">
-
        <p class="highlight"><strong>{address}</strong></p>
-
        <p>Sorry, there is no Org at this address.</p>
-
        <p>
-
          <a href={explorerLink(address, config)} target="_blank">View in explorer</a>
-
          <span class="faded">↗</span>
-
        </p>
-
      </span>
-
      <span slot="actions">
-
        <button on:click={back}>
-
          Back
-
        </button>
-
      </span>
-
    </Modal>
-
  {/if}
-
  <svelte:component this={setNameForm} {org} {config} on:close={() => setNameForm = null} />
-
{:catch err}
-
  <Error error={err} />
-
{/await}
modified src/base/orgs/Routes.svelte
@@ -1,16 +1,16 @@
<script lang="typescript">
  import { Route } from "svelte-routing";
-
  import Orgs from '@app/base/orgs/Orgs.svelte';
-
  import Profile from '@app/base/orgs/Profile.svelte';
+
  import Index from '@app/base/orgs/Index.svelte';
+
  import View from '@app/base/orgs/View.svelte';
  import type { Config } from '@app/config';

  export let config: Config;
</script>

<Route path="/orgs">
-
  <Orgs {config} />
+
  <Index {config} />
</Route>

<Route path="/orgs/:address" let:params>
-
  <Profile {config} address={params.address} />
+
  <View {config} address={params.address} />
</Route>
added src/base/orgs/View.svelte
@@ -0,0 +1,165 @@
+
<script lang="typescript">
+
  import { onMount } from 'svelte';
+
  import { Link } from 'svelte-routing';
+
  import type { SvelteComponent } from 'svelte';
+
  import { navigate } from 'svelte-routing';
+
  import type { Config } from '@app/config';
+
  import type { Registration } from '@app/base/registrations/registrar';
+
  import { getRegistration } from '@app/base/registrations/registrar';
+
  import { parseEnsLabel, explorerLink } from '@app/utils';
+
  import { Org } from './Org';
+
  import { session } from '@app/session';
+
  import Loading from '@app/Loading.svelte';
+
  import Modal from '@app/Modal.svelte';
+
  import Error from '@app/Error.svelte';
+
  import Icon from '@app/Icon.svelte';
+
  import SetName from '@app/ens/SetName.svelte';
+
  import * as utils from '@app/utils';
+

+
  export let address: string;
+
  export let config: Config;
+

+
  let registration: Registration | null = null;
+
  let name: string | null = null;
+

+
  const back = () => navigate("/orgs");
+

+
  onMount(async () => {
+
    name = await config.provider.lookupAddress(address);
+
    registration = await getRegistration(name, config);
+
  });
+

+
  let setNameForm: typeof SvelteComponent | null = null;
+
  const setName = () => {
+
    setNameForm = SetName;
+
  };
+

+
  $: label = name && parseEnsLabel(name, config);
+
  $: isOwner = (org: Org): boolean => {
+
    return org.safe === ($session && $session.address);
+
  };
+
</script>
+

+
<style>
+
  main > header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    margin-bottom: 2rem;
+
  }
+
  main > header > * {
+
    margin: 0 1rem 0 0;
+
  }
+
  .info {
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    align-items: left;
+
  }
+
  .info a {
+
    border: none;
+
  }
+
  .fields {
+
    display: grid;
+
    grid-template-columns: 1fr 8fr;
+
    grid-gap: 1rem;
+
  }
+
  .fields > div {
+
    justify-self: start;
+
    align-self: center;
+
    height: 2rem;
+
    line-height: 2rem;
+
  }
+
  .avatar {
+
    width: 64px;
+
    height: 64px;
+
  }
+
  .links {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
  }
+
  .url {
+
    display: flex; /* Ensures correct vertical positioning of icons */
+
    margin-right: 1rem;
+
  }
+
</style>
+

+
{#await Org.get(address, config)}
+
  <Loading />
+
{:then org}
+
  {#if org}
+
    <main>
+
      <header>
+
        {#if registration && registration.avatar}
+
          <div class="avatar">
+
            <img src={registration.avatar} alt="avatar" />
+
          </div>
+
        {/if}
+
        <div class="info">
+
          <span class="title bold">{registration ? label : address}</span>
+
          <div class="links">
+
            {#if registration}
+
              {#if registration.url}
+
                <a class="url" href={registration.url}>{registration.url}</a>
+
              {/if}
+
              {#if registration.twitter}
+
                <a class="url" href={registration.twitter}>
+
                  <Icon name="twitter" />
+
                </a>
+
              {/if}
+
              {#if registration.github}
+
                <a class="url" href={registration.github}>
+
                  <Icon name="github" />
+
                </a>
+
              {/if}
+
            {/if}
+
          </div>
+
        </div>
+
      </header>
+

+
      <div class="fields">
+
        <div class="label">Address</div><div>{org.address}</div>
+
        <div class="label">Owner</div><div>{org.safe}</div>
+
        <div class="label">Name</div>
+
        <div>
+
          {#await org.lookupAddress(config)}
+
            <Loading small />
+
          {:then name}
+
            {#if name}
+
              <Link to={`/registrations/${label}`}>{name}</Link>
+
            {:else if isOwner(org)}
+
              <button class="tiny primary" on:click={setName}>
+
                Set
+
              </button>
+
            {:else}
+
              <span class="subtle">Not set</span>
+
            {/if}
+
          {/await}
+
        </div>
+
      </div>
+
    </main>
+
  {:else}
+
    <Modal subtle>
+
      <span slot="title">🏜️</span>
+
      <span slot="body">
+
        <p class="highlight"><strong>{address}</strong></p>
+
        <p>Sorry, there is no Org at this address.</p>
+
        {#if utils.isAddress(address)}
+
          <p>
+
            <a href={explorerLink(address, config)} target="_blank">View in explorer</a>
+
            <span class="faded">↗</span>
+
          </p>
+
        {/if}
+
      </span>
+
      <span slot="actions">
+
        <button on:click={back}>
+
          Back
+
        </button>
+
      </span>
+
    </Modal>
+
  {/if}
+
  <svelte:component this={setNameForm} {org} {config} on:close={() => setNameForm = null} />
+
{:catch err}
+
  <Error error={err} />
+
{/await}
deleted src/base/register/Register.svelte
@@ -1,103 +0,0 @@
-
<script lang="typescript">
-
  import { navigate } from "svelte-routing";
-
  import { registrar } from './registrar';
-
  import type { Config } from '@app/config';
-

-
  import Modal from '@app/Modal.svelte';
-
  import DomainInput from '@app/ens/DomainInput.svelte';
-

-
  enum State {
-
    Idle,
-
    CheckingAvailability,
-
    NameAvailable,
-
    NameUnavailable,
-
  }
-

-
  export let config: Config;
-

-
  let state = State.Idle;
-
  let inputValue: string;
-

-
  function checkAvailability(name: string) {
-
    state = State.CheckingAvailability;
-

-
    registrar(config).available(name).then((isAvailable: boolean) => {
-
      if (isAvailable) {
-
        state = State.NameAvailable;
-
        navigate(`/register/${name}`);
-
      } else {
-
        state = State.NameUnavailable;
-
      }
-
    });
-
  }
-
</script>
-

-
<style>
-
  main {
-
    padding-top: 2rem;
-
    align-self: center;
-
  }
-
  div.input-caption {
-
    font-size: 1.25rem;
-
    text-align: left;
-
    margin-left: 1.5rem;
-
    padding-left: 1.5rem;
-
    color: var(--color-secondary);
-
  }
-
  div.input-main {
-
    display: flex;
-
    align-items: center;
-
    flex-direction: row;
-
    margin-left: 1.5rem;
-
    color: var(--color-secondary);
-
  }
-
  .name {
-
    margin: 1rem;
-
  }
-
</style>
-

-
<main>
-
  {#if state === State.NameUnavailable}
-
    <Modal floating>
-
      <span slot="title">
-
        {inputValue}.{config.registrar.domain}
-
      </span>
-
      <span slot="body">
-
        The name <span class="highlight">{inputValue}</span> is not available for registration.
-
      </span>
-
      <span slot="actions">
-
        <button on:click={() => state = State.Idle} class="secondary">
-
          Back
-
        </button>
-
      </span>
-
    </Modal>
-
  {/if}
-

-
  <div class="input-caption">
-
    Register a <strong>{config.registrar.domain}</strong> name
-
  </div>
-
  <div class="input-main">
-
    <span class="name">
-
      <DomainInput
-
        bind:value={inputValue}
-
        autofocus
-
        placeholder=""
-
        disabled={state === State.CheckingAvailability}
-
        root={config.registrar.domain}
-
      />
-
    </span>
-
    {#if !inputValue}
-
      <button disabled class="primary register">
-
        Check
-
      </button>
-
    {:else if state === State.CheckingAvailability}
-
      <button disabled class="primary register" data-waiting>
-
        Check
-
      </button>
-
    {:else}
-
      <button on:click={() => checkAvailability(inputValue)} class="primary register">
-
        Check
-
      </button>
-
    {/if}
-
  </div>
-
</main>
deleted src/base/register/Registration.svelte
@@ -1,148 +0,0 @@
-
<script lang="typescript">
-
  import { getRegistration } from './registrar';
-
  import { setRecords } from './resolver';
-
  import type { EnsRecord } from './resolver';
-
  import type { Registration } from './registrar';
-
  import type { Config } from '@app/config';
-
  import { session } from '@app/session';
-
  import Loading from '@app/Loading.svelte';
-
  import Link from '@app/Link.svelte';
-
  import Modal from '@app/Modal.svelte';
-
  import Form from '@app/Form.svelte';
-
  import type { Field } from '@app/Form.svelte';
-
  import { assert } from '@app/error';
-

-
  enum State {
-
    Idle,
-
    Signing,
-
    Pending,
-
    Success,
-
    Failed,
-
  }
-

-
  export let subdomain: string;
-
  export let config: Config;
-

-
  let state = State.Idle;
-
  let editable = false;
-
  let fields: Field[] = [];
-
  let registration: Registration | null = null;
-
  let name = `${subdomain}.${config.registrar.domain}`;
-

-
  const loadRegistration = getRegistration(name, config)
-
    .then(r => {
-
      if (r) {
-
        fields = [
-
          { name: "owner", placeholder: "",
-
            value: r.owner, editable: false },
-
          { name: "address", placeholder: "Not set",
-
            value: r.address, editable: true },
-
          { name: "url", label: "URL", placeholder: "Not set",
-
            value: r.url, editable: true },
-
          { name: "avatar", placeholder: "Not set",
-
            value: r.avatar, editable: true },
-
          { name: "twitter", placeholder: "Not set",
-
            value: r.twitter, editable: true },
-
          { name: "github", placeholder: "Not set",
-
            value: r.github, editable: true },
-
        ];
-
        registration = r;
-
      }
-
      return r;
-
    });
-

-
  const save = async (event: { detail: Field[] }) => {
-
    assert(registration, "registration was found");
-

-
    const recs: EnsRecord[] = event.detail
-
      .filter(r => r.editable && r.value !== null)
-
      .map(f => {
-
        assert(f.value !== null);
-
        return { name: f.name, value: f.value }
-
      });
-

-
    try {
-
      state = State.Signing;
-
      const tx = await setRecords(subdomain, recs, registration.resolver, config);
-
      state = State.Pending;
-
      await tx.wait();
-
      state = State.Success;
-
    } catch (e) {
-
      console.error(e);
-
      state = State.Failed;
-
    }
-
  };
-

-
  $: isOwner = (registration: Registration): boolean => {
-
    return registration.owner === ($session && $session.address);
-
  };
-
</script>
-

-
<style>
-
  main > header {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    margin-bottom: 2rem;
-
  }
-
  main > header > * {
-
    margin: 0 1rem 0 0;
-
  }
-
</style>
-

-
{#await loadRegistration}
-
  <Loading />
-
{:then registration}
-
  {#if registration}
-
    {#if state === State.Idle}
-
      <main>
-
        <header>
-
          <h1 class="bold">{subdomain}.{config.registrar.domain}</h1>
-
          <button
-
            class="tiny primary" class:active={editable} disabled={!isOwner(registration)}
-
            on:click={() => editable = !editable}>
-
              Edit
-
          </button>
-
          <button class="tiny secondary" disabled={!isOwner(registration)}>
-
            Transfer
-
          </button>
-
        </header>
-
        <Form {editable} {fields} on:save={save} on:cancel={() => editable = false} />
-
      </main>
-
    {:else}
-
      <Modal floating>
-
        <span slot="title">
-
          Transaction
-
        </span>
-
        <span slot="body">
-
          {#if state === State.Signing}
-
            <p>Please confirm the transaction in your wallet...</p>
-
          {:else if state === State.Pending}
-
            <p>Transaction submitted. Waiting for inclusion...</p>
-
          {:else if state === State.Success}
-
            Success!
-
          {/if}
-
        </span>
-
        <span slot="actions">
-
          {#if [State.Signing, State.Pending].includes(state)}
-
            <Loading center small />
-
          {/if}
-
        </span>
-
      </Modal>
-
    {/if}
-
  {:else}
-
    <Modal subtle>
-
      <span slot="title">
-
        {subdomain}.{config.registrar.domain}
-
      </span>
-

-
      <span slot="body">
-
        <p>The name <strong>{subdomain}</strong> is not registered.</p>
-
      </span>
-

-
      <span slot="actions">
-
        <Link to={`/register/${subdomain}`} primary>Register &rarr;</Link>
-
      </span>
-
    </Modal>
-
  {/if}
-
{/await}
deleted src/base/register/Routes.svelte
@@ -1,37 +0,0 @@
-
<script lang="typescript">
-
  import { Route, navigate } from "svelte-routing";
-
  import Register from '@app/base/register/Register.svelte';
-
  import Begin from '@app/base/register/steps/Begin.svelte';
-
  import Submit from '@app/base/register/steps/Submit.svelte';
-
  import Registration from '@app/base/register/Registration.svelte';
-
  import Error from '@app/Error.svelte';
-
  import type { Config } from '@app/config';
-
  import type { Session } from '@app/session';
-
  import { getSearchParam } from '@app/utils';
-

-
  export let session: Session | null;
-
  export let config: Config;
-
</script>
-

-
<Route path="register">
-
  <Register {config} />
-
</Route>
-

-
<Route path="register/:name" let:params let:location>
-
  <Begin {config} subdomain={params.name} owner={getSearchParam("owner", location)} />
-
</Route>
-

-
<Route path="register/:name/submit" let:params let:location>
-
  {#if session}
-
    <Submit {config} subdomain={params.name} owner={getSearchParam("owner", location)} {session} />
-
  {:else}
-
    <Error
-
      message={"You must connect your wallet to register"}
-
      on:close={() => navigate("/register")}
-
    />
-
  {/if}
-
</Route>
-

-
<Route path="registrations/:name" let:params>
-
  <Registration {config} subdomain={params.name} />
-
</Route>
deleted src/base/register/registrar.ts
@@ -1,210 +0,0 @@
-
// TODO: Show "look at your wallet" / "confirm tx" before state change.
-
// TODO: Two registration actions with same label
-
import { ethers } from 'ethers';
-
import type { BigNumber } from 'ethers';
-
import type { EnsResolver } from '@ethersproject/providers';
-
import type { TypedDataSigner } from '@ethersproject/abstract-signer';
-
import { State, state } from './state';
-
import * as session from '@app/session';
-
import { Failure } from '@app/error';
-
import type { Config } from '@app/config';
-
import { unixTime } from '@app/utils';
-
import { assert } from '@app/error';
-

-
const registrarAbi = [
-
  'function rad() view returns (address)',
-
  'function radNode() view returns (bytes32)',
-
  'function minCommitmentAge() view returns (uint256)',
-
  'function registrationFeeRad() view returns (uint256)',
-
  '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)',
-
];
-

-
export interface Registration {
-
  name: string
-
  owner: string
-
  address: string | null
-
  url: string | null
-
  avatar: string | null
-
  twitter: string | null
-
  github: string | null
-
  resolver: EnsResolver
-
}
-

-
export async function getRegistration(name: string, config: Config): Promise<Registration | null> {
-
  const resolver = await config.provider.getResolver(name);
-
  if (! resolver) {
-
    return null;
-
  }
-

-
  const owner = await getOwner(name, config);
-
  const address = await resolver.getAddress();
-
  const avatar = await resolver.getText('avatar');
-
  const url = await resolver.getText('url');
-
  const twitter = await resolver.getText('vnd.twitter');
-
  const github = await resolver.getText('vnd.github');
-

-
  return {
-
    name,
-
    url,
-
    avatar,
-
    owner,
-
    address,
-
    twitter,
-
    github,
-
    resolver,
-
  };
-
}
-

-
export function registrar(config: Config) {
-
  return new ethers.Contract(config.registrar.address, registrarAbi, config.provider);
-
}
-

-
export async function registrationFee(config: Config) {
-
  return await registrar(config).registrationFeeRad();
-
}
-

-
export async function registerName(name: string, owner: string, config: Config) {
-
  if (! name) return;
-

-
  let commitmentJson = window.localStorage.getItem('commitment');
-
  let 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, config);
-
    } else {
-
      await commitAndRegister(name, owner, config);
-
    }
-
  } catch (e) {
-
    throw { type: Failure.TransactionFailed, message: e.message, txHash: e.txHash };
-
  }
-
}
-

-
async function commitAndRegister(name: string, owner: string, config: Config) {
-
  let salt = ethers.utils.randomBytes(32);
-
  let minAge = (await registrar(config).minCommitmentAge()).toNumber();
-
  let fee = await registrationFee(config);
-

-
  await commit(makeCommitment(name, owner, salt), fee, minAge, config);
-
  // 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)
-
  }));
-

-
  await register(name, owner, salt, config);
-
}
-

-
async function commit(commitment: string, fee: BigNumber, minAge: number, config: Config) {
-
  state.set(State.Committing);
-

-
  const owner = config.signer;
-
  const ownerAddr = await owner.getAddress();
-
  const spender = config.registrar.address;
-
  const deadline = ethers.BigNumber.from(unixTime()).add(3600); // Expire one hour from now.
-
  const token = session.token(config);
-
  const signature = await permitSignature(owner, token, spender, fee, deadline);
-
  const tx = await registrar(config)
-
    .connect(config.signer)
-
    .commitWithPermit(
-
      commitment,
-
      ownerAddr,
-
      fee,
-
      deadline,
-
      signature.v,
-
      signature.r,
-
      signature.s,
-
      { gasLimit: 150000 })
-
    .catch((e: Error) => console.error(e));
-

-
  await tx.wait(1);
-
  session.state.updateBalance(fee.mul(-1));
-

-
  // TODO: Getting "commitment too new"
-
  state.set(State.WaitingToRegister);
-
  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);
-

-
  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, config: Config) {
-
  state.set(State.Registering);
-

-
  const signer = config.provider.getSigner();
-
  const tx = await registrar(config).connect(signer).register(
-
    name, owner, ethers.BigNumber.from(salt), { gasLimit: 150000 }
-
  );
-
  console.log("Sent", tx);
-

-
  await tx.wait();
-
  window.localStorage.clear();
-
  state.set(State.Registered);
-
}
-

-
function makeCommitment(name: string, owner: string, salt: Uint8Array): string {
-
  let bytes = ethers.utils.concat([
-
    ethers.utils.toUtf8Bytes(name),
-
    ethers.utils.getAddress(owner),
-
    ethers.BigNumber.from(salt).toHexString(),
-
  ]);
-
  return ethers.utils.keccak256(bytes);
-
}
-

-
async function getOwner(name: string, config: Config): Promise<string> {
-
  const ensAbi = [
-
    "function owner(bytes32 node) view returns (address)"
-
  ];
-

-
  let ensAddr = config.provider.network.ensAddress;
-
  if (! ensAddr) {
-
    throw new Error("ENS address is not defined");
-
  }
-

-
  let registry = new ethers.Contract(ensAddr, ensAbi, config.provider);
-
  let owner = await registry.owner(ethers.utils.namehash(name));
-

-
  return owner;
-
}
deleted src/base/register/resolver.ts
@@ -1,45 +0,0 @@
-
import type { TransactionResponse } from '@ethersproject/providers';
-
import type { EnsResolver } from '@ethersproject/providers';
-
import { ethers } from 'ethers';
-
import type { Config } from '@app/config';
-

-
const resolverAbi = [
-
  "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)",
-
];
-

-
export type EnsRecord = { name: string, value: string };
-

-
export async function setRecords(name: string, records: EnsRecord[], resolver: EnsResolver, config: Config): Promise<TransactionResponse> {
-
  const resolverContract = new ethers.Contract(resolver.address, resolverAbi, config.signer);
-
  const node = ethers.utils.namehash(`${name}.${config.registrar.domain}`);
-

-
  let calls = [];
-
  const iface = new ethers.utils.Interface(resolverAbi);
-

-
  for (let 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, "vnd." + r.name, r.value])
-
        );
-
        break;
-
      default:
-
        console.error(`unknown field "${r.name}"`);
-
    }
-
  }
-
  return resolverContract.multicall(calls);
-
}
deleted src/base/register/state.ts
@@ -1,16 +0,0 @@
-
import { derived, writable } from "svelte/store";
-

-
export enum State {
-
  Failed = -1,
-
  Idle,
-
  Committing,
-
  WaitingToRegister,
-
  Registering,
-
  Registered,
-
}
-

-
export const state = writable(State.Idle);
-

-
state.subscribe(s => {
-
  console.log("regiter.state", s);
-
});
deleted src/base/register/steps/Begin.svelte
@@ -1,83 +0,0 @@
-
<script lang="typescript">
-
  // TODO: Should check for availability here, before saying a name is available.
-
  // Perhaps the availability check should be moved out of the 'submit' step then.
-
  import { navigate } from 'svelte-routing';
-
  import { formatAddress } from '@app/utils';
-
  import { registrar } from '../registrar';
-
  import { session } from '@app/session';
-
  import type { Config } from '@app/config';
-

-
  import Connect from '@app/Connect.svelte';
-
  import Modal from '@app/Modal.svelte';
-

-
  enum State {
-
    Initial,
-
    CheckingAvailability,
-
    NameUnavailable,
-
  }
-

-
  export let config: Config;
-
  export let subdomain: string;
-
  export let owner: string | null;
-

-
  let state = State.Initial;
-
  $: registrationOwner = owner || ($session && $session.address);
-

-
  async function begin() {
-
    state = State.CheckingAvailability;
-

-
    if (await registrar(config).available(subdomain)) {
-
      navigate(`/register/${subdomain}/submit?${
-
        registrationOwner ? new URLSearchParams({ owner: registrationOwner }) : ''
-
      }`);
-
    } else {
-
      state = State.NameUnavailable;
-
    }
-
  }
-
</script>
-

-
<style>
-
</style>
-

-
<Modal>
-
  <span slot="title">
-
    {subdomain}.{config.registrar.domain}
-
  </span>
-

-
  <span slot="body">
-
    {#if state === State.Initial || state === State.CheckingAvailability}
-
      {#if registrationOwner}
-
        The name <span class="highlight">{subdomain}</span> is available for registration
-
        under account <span class="highlight">{formatAddress(registrationOwner)}</span>.
-
      {:else}
-
        The name <span class="highlight">{subdomain}</span> is available for registration.
-
      {/if}
-
    {:else if state === State.NameUnavailable}
-
      The name <span class="highlight">{subdomain}</span> is not available for registration.
-
    {/if}
-
  </span>
-

-
  <span slot="actions">
-
    {#if state === State.CheckingAvailability}
-
      <button disabled class="primary register">
-
        Checking availability...
-
      </button>
-
    {:else if state === State.NameUnavailable}
-
      <button on:click={() => navigate("/register")} class="">
-
        Back
-
      </button>
-
    {:else}
-
      {#if $session}
-
        <button on:click={begin} class="primary register">
-
          Begin registration &rarr;
-
        </button>
-
      {:else}
-
        <Connect caption="Connect to register" className="primary" {config} />
-
      {/if}
-

-
      <button on:click={() => navigate("/register")} class="text">
-
        Cancel
-
      </button>
-
    {/if}
-
  </span>
-
</Modal>
deleted src/base/register/steps/Submit.svelte
@@ -1,75 +0,0 @@
-
<script lang="typescript">
-
  // TODO: When name is registered, prompt user to edit records.
-
  // TODO: When transfering name, warn about transfering to org.
-
  import { onMount } from 'svelte';
-
  import { navigate } from 'svelte-routing';
-
  import { registerName } from '../registrar';
-
  import { State, state } from '../state';
-
  import type { Session } from '@app/session';
-
  import type { Config } from '@app/config';
-
  import Loading from '@app/Loading.svelte';
-
  import Modal from '@app/Modal.svelte';
-
  import Err from '@app/Error.svelte';
-

-
  export let config: Config;
-
  export let subdomain: string;
-
  export let owner: string | null;
-
  export let session: Session;
-

-
  let error: Error | null = null;
-
  let registrationOwner = owner || session.address;
-

-
  const done = () => navigate(`/registrations/${subdomain}`)
-

-
  onMount(async () => {
-
    try {
-
      await registerName(subdomain, registrationOwner, config);
-
    } catch (e) {
-
      console.error("Error", e);
-

-
      state.set(State.Idle);
-
      error = e;
-
    }
-
  });
-
</script>
-

-
<style></style>
-

-
{#if error}
-
  <Err
-
    title="Transaction failed"
-
    message={error.message}
-
    on:close={() => navigate('/register')}
-
  />
-
{:else}
-
  <Modal>
-
    <span slot="title">
-
      {subdomain}.{config.registrar.domain}
-
    </span>
-

-
    <span slot="body">
-
      {#if $state === State.Committing}
-
        Committing...
-
      {:else if $state === State.WaitingToRegister}
-
        Waiting for commitment time...
-
      {:else if $state === State.Registering}
-
        Registering name...
-
      {:else if $state === State.Registered}
-
        The name <strong>{subdomain}</strong> has been successfully registered to
-
        <strong>{registrationOwner}</strong>.
-
      {/if}
-
    </span>
-

-
    <span slot="actions">
-
      {#if $state === State.Registered}
-
        <button on:click={done} class="primary register">
-
          Done
-
        </button>
-
      {:else}
-
        <div class="modal-actions">
-
          <Loading small center />
-
        </div>
-
      {/if}
-
    </span>
-
  </Modal>
-
{/if}
added src/base/registrations/Index.svelte
@@ -0,0 +1,103 @@
+
<script lang="typescript">
+
  import { navigate } from "svelte-routing";
+
  import { registrar } from './registrar';
+
  import type { Config } from '@app/config';
+

+
  import Modal from '@app/Modal.svelte';
+
  import DomainInput from '@app/ens/DomainInput.svelte';
+

+
  enum State {
+
    Idle,
+
    CheckingAvailability,
+
    NameAvailable,
+
    NameUnavailable,
+
  }
+

+
  export let config: Config;
+

+
  let state = State.Idle;
+
  let inputValue: string;
+

+
  function checkAvailability(name: string) {
+
    state = State.CheckingAvailability;
+

+
    registrar(config).available(name).then((isAvailable: boolean) => {
+
      if (isAvailable) {
+
        state = State.NameAvailable;
+
        navigate(`/registrations/${name}/form`);
+
      } else {
+
        state = State.NameUnavailable;
+
      }
+
    });
+
  }
+
</script>
+

+
<style>
+
  main {
+
    padding-top: 2rem;
+
    align-self: center;
+
  }
+
  div.input-caption {
+
    font-size: 1.25rem;
+
    text-align: left;
+
    margin-left: 1.5rem;
+
    padding-left: 1.5rem;
+
    color: var(--color-secondary);
+
  }
+
  div.input-main {
+
    display: flex;
+
    align-items: center;
+
    flex-direction: row;
+
    margin-left: 1.5rem;
+
    color: var(--color-secondary);
+
  }
+
  .name {
+
    margin: 1rem;
+
  }
+
</style>
+

+
<main>
+
  {#if state === State.NameUnavailable}
+
    <Modal floating>
+
      <span slot="title">
+
        {inputValue}.{config.registrar.domain}
+
      </span>
+
      <span slot="body">
+
        The name <span class="highlight">{inputValue}</span> is not available for registration.
+
      </span>
+
      <span slot="actions">
+
        <button on:click={() => state = State.Idle} class="secondary">
+
          Back
+
        </button>
+
      </span>
+
    </Modal>
+
  {/if}
+

+
  <div class="input-caption">
+
    Register a <strong>{config.registrar.domain}</strong> name
+
  </div>
+
  <div class="input-main">
+
    <span class="name">
+
      <DomainInput
+
        bind:value={inputValue}
+
        autofocus
+
        placeholder=""
+
        disabled={state === State.CheckingAvailability}
+
        root={config.registrar.domain}
+
      />
+
    </span>
+
    {#if !inputValue}
+
      <button disabled class="primary register">
+
        Check
+
      </button>
+
    {:else if state === State.CheckingAvailability}
+
      <button disabled class="primary register" data-waiting>
+
        Check
+
      </button>
+
    {:else}
+
      <button on:click={() => checkAvailability(inputValue)} class="primary register">
+
        Check
+
      </button>
+
    {/if}
+
  </div>
+
</main>
added src/base/registrations/New.svelte
@@ -0,0 +1,84 @@
+
<script lang="typescript">
+
  // TODO: Should check for availability here, before saying a name is available.
+
  // Perhaps the availability check should be moved out of the 'submit' step then.
+
  import { navigate } from 'svelte-routing';
+
  import { formatAddress } from '@app/utils';
+
  import { session } from '@app/session';
+
  import type { Config } from '@app/config';
+

+
  import Connect from '@app/Connect.svelte';
+
  import Modal from '@app/Modal.svelte';
+

+
  import { registrar } from './registrar';
+

+
  enum State {
+
    Initial,
+
    CheckingAvailability,
+
    NameUnavailable,
+
  }
+

+
  export let config: Config;
+
  export let subdomain: string;
+
  export let owner: string | null;
+

+
  let state = State.Initial;
+
  $: registrationOwner = owner || ($session && $session.address);
+

+
  async function begin() {
+
    state = State.CheckingAvailability;
+

+
    if (await registrar(config).available(subdomain)) {
+
      navigate(`/registrations/${subdomain}/submit?${
+
        registrationOwner ? new URLSearchParams({ owner: registrationOwner }) : ''
+
      }`);
+
    } else {
+
      state = State.NameUnavailable;
+
    }
+
  }
+
</script>
+

+
<style>
+
</style>
+

+
<Modal>
+
  <span slot="title">
+
    {subdomain}.{config.registrar.domain}
+
  </span>
+

+
  <span slot="body">
+
    {#if state === State.Initial || state === State.CheckingAvailability}
+
      {#if registrationOwner}
+
        The name <span class="highlight">{subdomain}</span> is available for registration
+
        under account <span class="highlight">{formatAddress(registrationOwner)}</span>.
+
      {:else}
+
        The name <span class="highlight">{subdomain}</span> is available for registration.
+
      {/if}
+
    {:else if state === State.NameUnavailable}
+
      The name <span class="highlight">{subdomain}</span> is not available for registration.
+
    {/if}
+
  </span>
+

+
  <span slot="actions">
+
    {#if state === State.CheckingAvailability}
+
      <button disabled class="primary register">
+
        Checking availability...
+
      </button>
+
    {:else if state === State.NameUnavailable}
+
      <button on:click={() => navigate("/registrations")} class="">
+
        Back
+
      </button>
+
    {:else}
+
      {#if $session}
+
        <button on:click={begin} class="primary register">
+
          Begin registration &rarr;
+
        </button>
+
      {:else}
+
        <Connect caption="Connect to register" className="primary" {config} />
+
      {/if}
+

+
      <button on:click={() => navigate("/registrations")} class="text">
+
        Cancel
+
      </button>
+
    {/if}
+
  </span>
+
</Modal>
added src/base/registrations/Routes.svelte
@@ -0,0 +1,37 @@
+
<script lang="typescript">
+
  import { Route, navigate } from "svelte-routing";
+
  import Index from '@app/base/registrations/Index.svelte';
+
  import New from '@app/base/registrations/New.svelte';
+
  import Submit from '@app/base/registrations/Submit.svelte';
+
  import View from '@app/base/registrations/View.svelte';
+
  import Error from '@app/Error.svelte';
+
  import type { Config } from '@app/config';
+
  import type { Session } from '@app/session';
+
  import { getSearchParam } from '@app/utils';
+

+
  export let session: Session | null;
+
  export let config: Config;
+
</script>
+

+
<Route path="registrations">
+
  <Index {config} />
+
</Route>
+

+
<Route path="registrations/:name/form" let:params let:location>
+
  <New {config} subdomain={params.name} owner={getSearchParam("owner", location)} />
+
</Route>
+

+
<Route path="registrations/:name/submit" let:params let:location>
+
  {#if session}
+
    <Submit {config} subdomain={params.name} owner={getSearchParam("owner", location)} {session} />
+
  {:else}
+
    <Error
+
      message={"You must connect your wallet to register"}
+
      on:close={() => navigate("/registrations")}
+
    />
+
  {/if}
+
</Route>
+

+
<Route path="registrations/:name" let:params>
+
  <View {config} subdomain={params.name} />
+
</Route>
added src/base/registrations/Submit.svelte
@@ -0,0 +1,76 @@
+
<script lang="typescript">
+
  // TODO: When name is registered, prompt user to edit records.
+
  // TODO: When transfering name, warn about transfering to org.
+
  import { onMount } from 'svelte';
+
  import { navigate } from 'svelte-routing';
+
  import type { Session } from '@app/session';
+
  import type { Config } from '@app/config';
+
  import Loading from '@app/Loading.svelte';
+
  import Modal from '@app/Modal.svelte';
+
  import Err from '@app/Error.svelte';
+

+
  import { registerName } from './registrar';
+
  import { State, state } from './state';
+

+
  export let config: Config;
+
  export let subdomain: string;
+
  export let owner: string | null;
+
  export let session: Session;
+

+
  let error: Error | null = null;
+
  let registrationOwner = owner || session.address;
+

+
  const done = () => navigate(`/registrations/${subdomain}`)
+

+
  onMount(async () => {
+
    try {
+
      await registerName(subdomain, registrationOwner, config);
+
    } catch (e) {
+
      console.error("Error", e);
+

+
      state.set(State.Idle);
+
      error = e;
+
    }
+
  });
+
</script>
+

+
<style></style>
+

+
{#if error}
+
  <Err
+
    title="Transaction failed"
+
    message={error.message}
+
    on:close={() => navigate('/registrations')}
+
  />
+
{:else}
+
  <Modal>
+
    <span slot="title">
+
      {subdomain}.{config.registrar.domain}
+
    </span>
+

+
    <span slot="body">
+
      {#if $state === State.Committing}
+
        Committing...
+
      {:else if $state === State.WaitingToRegister}
+
        Waiting for commitment time...
+
      {:else if $state === State.Registering}
+
        Registering name...
+
      {:else if $state === State.Registered}
+
        The name <strong>{subdomain}</strong> has been successfully registered to
+
        <strong>{registrationOwner}</strong>.
+
      {/if}
+
    </span>
+

+
    <span slot="actions">
+
      {#if $state === State.Registered}
+
        <button on:click={done} class="primary register">
+
          Done
+
        </button>
+
      {:else}
+
        <div class="modal-actions">
+
          <Loading small center />
+
        </div>
+
      {/if}
+
    </span>
+
  </Modal>
+
{/if}
added src/base/registrations/View.svelte
@@ -0,0 +1,149 @@
+
<script lang="typescript">
+
  import { getRegistration } from './registrar';
+
  import { setRecords } from './resolver';
+
  import type { EnsRecord } from './resolver';
+
  import type { Registration } from './registrar';
+
  import type { Config } from '@app/config';
+
  import { session } from '@app/session';
+
  import Loading from '@app/Loading.svelte';
+
  import Link from '@app/Link.svelte';
+
  import Modal from '@app/Modal.svelte';
+
  import Form from '@app/Form.svelte';
+
  import type { Field } from '@app/Form.svelte';
+
  import { assert } from '@app/error';
+

+
  enum State {
+
    Idle,
+
    Signing,
+
    Pending,
+
    Success,
+
    Failed,
+
  }
+

+
  export let subdomain: string;
+
  export let config: Config;
+

+
  let state = State.Idle;
+
  let editable = false;
+
  let fields: Field[] = [];
+
  let registration: Registration | null = null;
+
  let name = `${subdomain}.${config.registrar.domain}`;
+

+
  // TODO: Handle failure (network error)
+
  const loadRegistration = getRegistration(name, config)
+
    .then(r => {
+
      if (r) {
+
        fields = [
+
          { name: "owner", placeholder: "",
+
            value: r.owner, editable: false },
+
          { name: "address", placeholder: "Not set",
+
            value: r.address, editable: true },
+
          { name: "url", label: "URL", placeholder: "Not set",
+
            value: r.url, editable: true },
+
          { name: "avatar", placeholder: "Not set",
+
            value: r.avatar, editable: true },
+
          { name: "twitter", placeholder: "Not set",
+
            value: r.twitter, editable: true },
+
          { name: "github", placeholder: "Not set",
+
            value: r.github, editable: true },
+
        ];
+
        registration = r;
+
      }
+
      return r;
+
    });
+

+
  const save = async (event: { detail: Field[] }) => {
+
    assert(registration, "registration was found");
+

+
    const recs: EnsRecord[] = event.detail
+
      .filter(r => r.editable && r.value !== null)
+
      .map(f => {
+
        assert(f.value !== null);
+
        return { name: f.name, value: f.value }
+
      });
+

+
    try {
+
      state = State.Signing;
+
      const tx = await setRecords(subdomain, recs, registration.resolver, config);
+
      state = State.Pending;
+
      await tx.wait();
+
      state = State.Success;
+
    } catch (e) {
+
      console.error(e);
+
      state = State.Failed;
+
    }
+
  };
+

+
  $: isOwner = (registration: Registration): boolean => {
+
    return registration.owner === ($session && $session.address);
+
  };
+
</script>
+

+
<style>
+
  main > header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    margin-bottom: 2rem;
+
  }
+
  main > header > * {
+
    margin: 0 1rem 0 0;
+
  }
+
</style>
+

+
{#await loadRegistration}
+
  <Loading />
+
{:then registration}
+
  {#if registration}
+
    {#if state === State.Idle}
+
      <main>
+
        <header>
+
          <h1 class="bold">{subdomain}.{config.registrar.domain}</h1>
+
          <button
+
            class="tiny primary" class:active={editable} disabled={!isOwner(registration)}
+
            on:click={() => editable = !editable}>
+
              Edit
+
          </button>
+
          <button class="tiny secondary" disabled={!isOwner(registration)}>
+
            Transfer
+
          </button>
+
        </header>
+
        <Form {editable} {fields} on:save={save} on:cancel={() => editable = false} />
+
      </main>
+
    {:else}
+
      <Modal floating>
+
        <span slot="title">
+
          Transaction
+
        </span>
+
        <span slot="body">
+
          {#if state === State.Signing}
+
            <p>Please confirm the transaction in your wallet...</p>
+
          {:else if state === State.Pending}
+
            <p>Transaction submitted. Waiting for inclusion...</p>
+
          {:else if state === State.Success}
+
            Success!
+
          {/if}
+
        </span>
+
        <span slot="actions">
+
          {#if [State.Signing, State.Pending].includes(state)}
+
            <Loading center small />
+
          {/if}
+
        </span>
+
      </Modal>
+
    {/if}
+
  {:else}
+
    <Modal subtle>
+
      <span slot="title">
+
        {subdomain}.{config.registrar.domain}
+
      </span>
+

+
      <span slot="body">
+
        <p>The name <strong>{subdomain}</strong> is not registered.</p>
+
      </span>
+

+
      <span slot="actions">
+
        <Link to={`/registrations/${subdomain}/form`} primary>Register &rarr;</Link>
+
      </span>
+
    </Modal>
+
  {/if}
+
{/await}
added src/base/registrations/registrar.ts
@@ -0,0 +1,210 @@
+
// TODO: Show "look at your wallet" / "confirm tx" before state change.
+
// TODO: Two registration actions with same label
+
import { ethers } from 'ethers';
+
import type { BigNumber } from 'ethers';
+
import type { EnsResolver } from '@ethersproject/providers';
+
import type { TypedDataSigner } from '@ethersproject/abstract-signer';
+
import { State, state } from './state';
+
import * as session from '@app/session';
+
import { Failure } from '@app/error';
+
import type { Config } from '@app/config';
+
import { unixTime } from '@app/utils';
+
import { assert } from '@app/error';
+

+
const registrarAbi = [
+
  'function rad() view returns (address)',
+
  'function radNode() view returns (bytes32)',
+
  'function minCommitmentAge() view returns (uint256)',
+
  'function registrationFeeRad() view returns (uint256)',
+
  '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)',
+
];
+

+
export interface Registration {
+
  name: string
+
  owner: string
+
  address: string | null
+
  url: string | null
+
  avatar: string | null
+
  twitter: string | null
+
  github: string | null
+
  resolver: EnsResolver
+
}
+

+
export async function getRegistration(name: string, config: Config): Promise<Registration | null> {
+
  const resolver = await config.provider.getResolver(name);
+
  if (! resolver) {
+
    return null;
+
  }
+

+
  const owner = await getOwner(name, config);
+
  const address = await resolver.getAddress();
+
  const avatar = await resolver.getText('avatar');
+
  const url = await resolver.getText('url');
+
  const twitter = await resolver.getText('vnd.twitter');
+
  const github = await resolver.getText('vnd.github');
+

+
  return {
+
    name,
+
    url,
+
    avatar,
+
    owner,
+
    address,
+
    twitter,
+
    github,
+
    resolver,
+
  };
+
}
+

+
export function registrar(config: Config) {
+
  return new ethers.Contract(config.registrar.address, registrarAbi, config.provider);
+
}
+

+
export async function registrationFee(config: Config) {
+
  return await registrar(config).registrationFeeRad();
+
}
+

+
export async function registerName(name: string, owner: string, config: Config) {
+
  if (! name) return;
+

+
  let commitmentJson = window.localStorage.getItem('commitment');
+
  let 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, config);
+
    } else {
+
      await commitAndRegister(name, owner, config);
+
    }
+
  } catch (e) {
+
    throw { type: Failure.TransactionFailed, message: e.message, txHash: e.txHash };
+
  }
+
}
+

+
async function commitAndRegister(name: string, owner: string, config: Config) {
+
  let salt = ethers.utils.randomBytes(32);
+
  let minAge = (await registrar(config).minCommitmentAge()).toNumber();
+
  let fee = await registrationFee(config);
+

+
  await commit(makeCommitment(name, owner, salt), fee, minAge, config);
+
  // 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)
+
  }));
+

+
  await register(name, owner, salt, config);
+
}
+

+
async function commit(commitment: string, fee: BigNumber, minAge: number, config: Config) {
+
  state.set(State.Committing);
+

+
  const owner = config.signer;
+
  const ownerAddr = await owner.getAddress();
+
  const spender = config.registrar.address;
+
  const deadline = ethers.BigNumber.from(unixTime()).add(3600); // Expire one hour from now.
+
  const token = session.token(config);
+
  const signature = await permitSignature(owner, token, spender, fee, deadline);
+
  const tx = await registrar(config)
+
    .connect(config.signer)
+
    .commitWithPermit(
+
      commitment,
+
      ownerAddr,
+
      fee,
+
      deadline,
+
      signature.v,
+
      signature.r,
+
      signature.s,
+
      { gasLimit: 150000 })
+
    .catch((e: Error) => console.error(e));
+

+
  await tx.wait(1);
+
  session.state.updateBalance(fee.mul(-1));
+

+
  // TODO: Getting "commitment too new"
+
  state.set(State.WaitingToRegister);
+
  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);
+

+
  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, config: Config) {
+
  state.set(State.Registering);
+

+
  const signer = config.provider.getSigner();
+
  const tx = await registrar(config).connect(signer).register(
+
    name, owner, ethers.BigNumber.from(salt), { gasLimit: 150000 }
+
  );
+
  console.log("Sent", tx);
+

+
  await tx.wait();
+
  window.localStorage.clear();
+
  state.set(State.Registered);
+
}
+

+
function makeCommitment(name: string, owner: string, salt: Uint8Array): string {
+
  let bytes = ethers.utils.concat([
+
    ethers.utils.toUtf8Bytes(name),
+
    ethers.utils.getAddress(owner),
+
    ethers.BigNumber.from(salt).toHexString(),
+
  ]);
+
  return ethers.utils.keccak256(bytes);
+
}
+

+
async function getOwner(name: string, config: Config): Promise<string> {
+
  const ensAbi = [
+
    "function owner(bytes32 node) view returns (address)"
+
  ];
+

+
  let ensAddr = config.provider.network.ensAddress;
+
  if (! ensAddr) {
+
    throw new Error("ENS address is not defined");
+
  }
+

+
  let registry = new ethers.Contract(ensAddr, ensAbi, config.provider);
+
  let owner = await registry.owner(ethers.utils.namehash(name));
+

+
  return owner;
+
}
added src/base/registrations/resolver.ts
@@ -0,0 +1,45 @@
+
import type { TransactionResponse } from '@ethersproject/providers';
+
import type { EnsResolver } from '@ethersproject/providers';
+
import { ethers } from 'ethers';
+
import type { Config } from '@app/config';
+

+
const resolverAbi = [
+
  "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)",
+
];
+

+
export type EnsRecord = { name: string, value: string };
+

+
export async function setRecords(name: string, records: EnsRecord[], resolver: EnsResolver, config: Config): Promise<TransactionResponse> {
+
  const resolverContract = new ethers.Contract(resolver.address, resolverAbi, config.signer);
+
  const node = ethers.utils.namehash(`${name}.${config.registrar.domain}`);
+

+
  let calls = [];
+
  const iface = new ethers.utils.Interface(resolverAbi);
+

+
  for (let 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, "vnd." + r.name, r.value])
+
        );
+
        break;
+
      default:
+
        console.error(`unknown field "${r.name}"`);
+
    }
+
  }
+
  return resolverContract.multicall(calls);
+
}
added src/base/registrations/state.ts
@@ -0,0 +1,16 @@
+
import { derived, writable } from "svelte/store";
+

+
export enum State {
+
  Failed = -1,
+
  Idle,
+
  Committing,
+
  WaitingToRegister,
+
  Registering,
+
  Registered,
+
}
+

+
export const state = writable(State.Idle);
+

+
state.subscribe(s => {
+
  console.log("regiter.state", s);
+
});
deleted src/base/resolve/Resolve.svelte
@@ -1,27 +0,0 @@
-
<script lang="typescript">
-
  import { onMount } from 'svelte';
-
  import { ethers } from 'ethers';
-
  import { navigate } from 'svelte-routing';
-
  import type { Config } from '@app/config';
-
  import * as utils from '@app/utils';
-

-
  export let config: Config;
-
  export let query: string | null;
-

-
  onMount(() => {
-
    if (query) {
-
      if (ethers.utils.isAddress(query)) {
-
        // Go to org.
-
        navigate(`/orgs/${query}`, { replace: true });
-
      } else if (utils.isRadicleId(query)) {
-
        // Go to Radicle entity.
-
        alert("Radicle IDs are not yet supported");
-
      } else {
-
        let label = utils.parseEnsLabel(query, config);
-
        navigate(`/registrations/${label}`, { replace: true });
-
      }
-
    } else {
-
      navigate('/');
-
    }
-
  });
-
</script>
deleted src/base/resolve/Routes.svelte
@@ -1,12 +0,0 @@
-
<script lang="typescript">
-
  import { Route } from "svelte-routing";
-
  import Resolve from '@app/base/resolve/Resolve.svelte';
-
  import type { Config } from '@app/config';
-
  import * as utils from '@app/utils';
-

-
  export let config: Config;
-
</script>
-

-
<Route path="/resolve" let:location>
-
  <Resolve {config} query={utils.getSearchParam("q", location)} />
-
</Route>
added src/base/resolver/Query.svelte
@@ -0,0 +1,42 @@
+
<script lang="typescript">
+
  import { onMount } from 'svelte';
+
  import { ethers } from 'ethers';
+
  import { navigate } from 'svelte-routing';
+
  import type { Config } from '@app/config';
+
  import * as utils from '@app/utils';
+
  import Error from '@app/Error.svelte';
+

+
  export let config: Config;
+
  export let query: string | null;
+

+
  let error = false;
+

+
  onMount(() => {
+
    if (query) {
+
      if (ethers.utils.isAddress(query)) {
+
        // Go to org.
+
        navigate(`/orgs/${query}`, { replace: true });
+
      } else if (utils.isRadicleId(query)) {
+
        // Go to Radicle entity.
+
        alert("Radicle IDs are not yet supported");
+
      } else {
+
        let label = utils.parseEnsLabel(query, config);
+
        if (label.includes(".")) {
+
          error = true;
+
        } else {
+
          navigate(`/registrations/${label}`, { replace: true });
+
        }
+
      }
+
    } else {
+
      navigate('/');
+
    }
+
  });
+
</script>
+

+
<main>
+
  {#if error}
+
    <Error on:close={() => navigate('/')}>
+
      Invalid query string “{query}”
+
    </Error>
+
  {/if}
+
</main>
added src/base/resolver/Routes.svelte
@@ -0,0 +1,12 @@
+
<script lang="typescript">
+
  import { Route } from "svelte-routing";
+
  import Resolve from '@app/base/resolver/Query.svelte';
+
  import type { Config } from '@app/config';
+
  import * as utils from '@app/utils';
+

+
  export let config: Config;
+
</script>
+

+
<Route path="/resolver/query" let:location>
+
  <Resolve {config} query={utils.getSearchParam("q", location)} />
+
</Route>
added src/base/vesting/Index.svelte
@@ -0,0 +1,122 @@
+
<script lang="typescript">
+
  import { onMount } from 'svelte';
+
  import { formatAddress } from '@app/utils';
+
  import { State, state } from './state';
+
  import { getInfo, withdrawVested } from './vesting';
+
  import type { VestingInfo } from './vesting';
+
  import type { Session } from '@app/session';
+
  import type { Config } from '@app/config';
+
  import Modal from '@app/Modal.svelte';
+

+
  let input: HTMLElement;
+

+
  onMount(() => {
+
    input.focus();
+
  });
+

+
  export let config: Config;
+
  export let session: Session | null;
+

+
  let contractAddress = "";
+
  let info: VestingInfo | null = null;
+

+
  async function loadContract(config: Config) {
+
    state.set(State.Loading);
+
    info = await getInfo(contractAddress, config);
+
    state.set(State.Idle);
+
  }
+

+
  function reset() {
+
    info = null;
+
    state.set(State.Idle);
+
  }
+

+
  $: isBeneficiary = info && session && (info.beneficiary === session.address);
+
</script>
+

+
<style>
+
  div.input-caption {
+
    font-size: 1.25rem;
+
    text-align: left;
+
    margin-left: 1.5rem;
+
    padding-left: 1.5rem;
+
    margin-bottom: 1rem;
+
    color: var(--color-secondary);
+
  }
+
  div.input-main {
+
    display: flex;
+
    align-items: center;
+
    flex-direction: row;
+
    margin-left: 1.5rem;
+
    color: var(--color-secondary);
+
  }
+
</style>
+

+
<main>
+
  {#if info}
+
    <Modal>
+
      <span slot="title">
+
        {contractAddress}
+
      </span>
+
      <span slot="body">
+
        {#if $state === State.Withdrawn}
+
          Tokens successfully withdrawn to {formatAddress(info.beneficiary)}.
+
        {:else}
+
          <table>
+
            <tr><td class="label">Beneficiary</td><td>{info.beneficiary}</td></tr>
+
            <tr><td class="label">Allocation</td><td>{info.totalVesting} <strong>{info.symbol}</strong></td></tr>
+
            <tr><td class="label">Withdrawn</td><td>{info.withdrawn} <strong>{info.symbol}</strong></td></tr>
+
            <tr><td class="label">Withdrawable</td><td>{info.withdrawableBalance} <strong>{info.symbol}</strong></td></tr>
+
          </table>
+
        {/if}
+
      </span>
+
      <span slot="actions">
+
        {#if isBeneficiary}
+
          {#if $state === State.WithdrawingSign}
+
            <button disabled data-waiting class="primary small">
+
              Waiting for signature...
+
            </button>
+
          {:else if $state === State.Withdrawing}
+
            <button disabled data-waiting class="primary small">
+
              Withdrawing...
+
            </button>
+
          {:else if $state === State.Idle}
+
            <button on:click={() => withdrawVested(contractAddress, config)} class="primary small">
+
              Withdraw
+
            </button>
+
          {/if}
+
        {/if}
+
        <button on:click={reset} class="small">
+
          Back
+
        </button>
+
      </span>
+
    </Modal>
+
  {:else}
+
    <div class="input-caption">
+
      Enter your Radicle <strong>vesting contract</strong> address
+
    </div>
+
    <div class="input-main">
+
      <span class="name">
+
        <div>
+
          <input
+
            size="40"
+
            placeholder=""
+
            class="subdomain"
+
            disabled={$state === State.Loading}
+
            type="text"
+
            bind:this={input}
+
            bind:value={contractAddress}
+
          />
+
        </div>
+
      </span>
+
      <button
+
        on:click={() => loadContract(config)}
+
        class="primary"
+
        data-waiting={$state === State.Loading || null}
+
        disabled={$state === State.Loading}
+
      >
+
        Load
+
      </button>
+
    </div>
+
  {/if}
+
</main>
deleted src/base/vesting/Vesting.svelte
@@ -1,122 +0,0 @@
-
<script lang="typescript">
-
  import { onMount } from 'svelte';
-
  import { formatAddress } from '@app/utils';
-
  import { State, state } from './state';
-
  import { getInfo, withdrawVested } from './vesting';
-
  import type { VestingInfo } from './vesting';
-
  import type { Session } from '@app/session';
-
  import type { Config } from '@app/config';
-
  import Modal from '@app/Modal.svelte';
-

-
  let input: HTMLElement;
-

-
  onMount(() => {
-
    input.focus();
-
  });
-

-
  export let config: Config;
-
  export let session: Session | null;
-

-
  let contractAddress = "";
-
  let info: VestingInfo | null = null;
-

-
  async function loadContract(config: Config) {
-
    state.set(State.Loading);
-
    info = await getInfo(contractAddress, config);
-
    state.set(State.Idle);
-
  }
-

-
  function reset() {
-
    info = null;
-
    state.set(State.Idle);
-
  }
-

-
  $: isBeneficiary = info && session && (info.beneficiary === session.address);
-
</script>
-

-
<style>
-
  div.input-caption {
-
    font-size: 1.25rem;
-
    text-align: left;
-
    margin-left: 1.5rem;
-
    padding-left: 1.5rem;
-
    margin-bottom: 1rem;
-
    color: var(--color-secondary);
-
  }
-
  div.input-main {
-
    display: flex;
-
    align-items: center;
-
    flex-direction: row;
-
    margin-left: 1.5rem;
-
    color: var(--color-secondary);
-
  }
-
</style>
-

-
<main>
-
  {#if info}
-
    <Modal>
-
      <span slot="title">
-
        {contractAddress}
-
      </span>
-
      <span slot="body">
-
        {#if $state === State.Withdrawn}
-
          Tokens successfully withdrawn to {formatAddress(info.beneficiary)}.
-
        {:else}
-
          <table>
-
            <tr><td class="label">Beneficiary</td><td>{info.beneficiary}</td></tr>
-
            <tr><td class="label">Allocation</td><td>{info.totalVesting} <strong>{info.symbol}</strong></td></tr>
-
            <tr><td class="label">Withdrawn</td><td>{info.withdrawn} <strong>{info.symbol}</strong></td></tr>
-
            <tr><td class="label">Withdrawable</td><td>{info.withdrawableBalance} <strong>{info.symbol}</strong></td></tr>
-
          </table>
-
        {/if}
-
      </span>
-
      <span slot="actions">
-
        {#if isBeneficiary}
-
          {#if $state === State.WithdrawingSign}
-
            <button disabled data-waiting class="primary small">
-
              Waiting for signature...
-
            </button>
-
          {:else if $state === State.Withdrawing}
-
            <button disabled data-waiting class="primary small">
-
              Withdrawing...
-
            </button>
-
          {:else if $state === State.Idle}
-
            <button on:click={() => withdrawVested(contractAddress, config)} class="primary small">
-
              Withdraw
-
            </button>
-
          {/if}
-
        {/if}
-
        <button on:click={reset} class="small">
-
          Back
-
        </button>
-
      </span>
-
    </Modal>
-
  {:else}
-
    <div class="input-caption">
-
      Enter your Radicle <strong>vesting contract</strong> address
-
    </div>
-
    <div class="input-main">
-
      <span class="name">
-
        <div>
-
          <input
-
            size="40"
-
            placeholder=""
-
            class="subdomain"
-
            disabled={$state === State.Loading}
-
            type="text"
-
            bind:this={input}
-
            bind:value={contractAddress}
-
          />
-
        </div>
-
      </span>
-
      <button
-
        on:click={() => loadContract(config)}
-
        class="primary"
-
        data-waiting={$state === State.Loading || null}
-
        disabled={$state === State.Loading}
-
      >
-
        Load
-
      </button>
-
    </div>
-
  {/if}
-
</main>
modified src/utils.ts
@@ -19,7 +19,7 @@ export function capitalize(s: string) {

// Takes a domain name, eg. 'cloudhead.radicle.eth' and
// returns the label, eg. 'cloudhead', otherwise `null`.
-
export function parseEnsLabel(name: string, config: Config): string | null {
+
export function parseEnsLabel(name: string, config: Config): string {
  let domain = config.registrar.domain.replace(".", "\\.");
  let label = name.replace(new RegExp(`\\.${domain}$`), "");