Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add pending projects and anchor signing
Sebastian Martinez committed 4 years ago
commit 97a8897d874178f45debcbbe044c4d6b83c5bae8
parent b73f5d5d690e14c2afc99895bd9980080f626efc
9 files changed +459 -42
modified src/Avatar.svelte
@@ -49,4 +49,4 @@
</style>

<!-- svelte-ignore a11y-missing-attribute -->
-
<img class="avatar" class:inline src={source} on:error={handleMissingFile} class:glowOnHover />
+
<img class="avatar" class:inline src={source} title={address} on:error={handleMissingFile} class:glowOnHover />
modified src/base/orgs/Org.ts
@@ -7,7 +7,7 @@ import { assert } from '@app/error';
import * as utils from '@app/utils';
import type { Safe } from '@app/utils';
import type { Config } from '@app/config';
-
import type { Project } from '@app/project';
+
import type { PendingProject, Project } from '@app/project';

const GetProjects = `
  query GetProjects($org: ID!) {
@@ -69,14 +69,16 @@ export const GetSafe = `
export class Org {
  address: string;
  owner: string;
-
  name?: string;
+
  name?: string | null;
+
  safe?: Safe | null;

-
  constructor(address: string, owner: string, name?: string) {
+
  constructor(address: string, owner: string, name?: string | null, safe?: Safe | 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;
+
    this.safe = safe;
  }

  async setName(name: string, config: Config): Promise<TransactionResponse> {
@@ -162,7 +164,9 @@ export class Org {
  }

  async getMembers(config: Config): Promise<Array<string>> {
-
    const safe = await utils.getSafe(this.owner, config);
+
    if (this.safe) return this.safe.owners;
+

+
    const safe = await this.getSafe(config);
    if (safe) {
      return safe.owners;
    }
@@ -170,15 +174,17 @@ export class Org {
  }

  async getSafe(config: Config): Promise<Safe | null> {
+
    if (this.safe) return this.safe;
+

    return utils.getSafe(this.owner, config);
  }

  async isMember(address: string, config: Config): Promise<boolean> {
    const members = await this.getMembers(config);
-
    return members.includes(ethers.utils.getAddress(address));
+
    return members.includes(address.toLowerCase());
  }

-
  async getProjects(config: Config): Promise<Array<Project>> {
+
  async getProjects(config: Config): Promise<Project[]> {
    const result = await utils.querySubgraph(
      config.orgs.subgraph, GetProjects, { org: this.address }
    );
@@ -202,6 +208,39 @@ export class Org {
    return projects;
  }

+
  async getPendingProjects(config: Config): Promise<PendingProject[]> {
+
    if (! config.safe.client) return [];
+

+
    const orgAddr = ethers.utils.getAddress(this.address);
+
    const response = await config.safe.client.getPendingTransactions(
+
      ethers.utils.getAddress(this.owner)
+
    );
+
    const projects: PendingProject[] = [];
+

+
    for (const tx of response.results || []) {
+
      if (tx.data && tx.to === orgAddr) {
+
        const project = parseAnchorTx(tx.data, config);
+
        const confirmations = tx.confirmations?.map(t => t.owner) || [];
+

+
        if (project) {
+
          projects.push({ ...project, confirmations, safeTxHash: tx.safeTxHash });
+
        }
+
      }
+
    }
+
    return projects;
+
  }
+

+
  async getAllProjects(config: Config): Promise<Array<Project | PendingProject>> {
+
    const result = await Promise.allSettled([
+
      this.getPendingProjects(config),
+
      this.getProjects(config),
+
    ]);
+

+
    return result.flatMap(r => {
+
      return r.status === "fulfilled" ? r.value : [];
+
    });
+
  }
+

  static async getAnchor(orgAddr: string, urn: string, config: Config): Promise<string | null> {
    const org = new ethers.Contract(
      orgAddr,
@@ -271,9 +310,12 @@ export class Org {
        org.resolvedAddress,
      ]);

+
      const safe = await utils.getSafe(owner, config);
      // If what is resolved is not the same as the input, it's because we
      // were given a name.
-
      if (utils.isAddressEqual(addressOrName, resolved)) {
+
      if (utils.isAddressEqual(addressOrName, resolved) && safe) {
+
        return new Org(resolved, owner, null, safe);
+
      } else if (safe === null) {
        return new Org(resolved, owner);
      } else {
        return new Org(resolved, owner, addressOrName);
@@ -336,3 +378,21 @@ export class Org {
    });
  }
}
+

+
export function parseAnchorTx(data: string, config: Config): Project | null {
+
  const iface = new ethers.utils.Interface(config.abi.org);
+
  const parsedTx = iface.parseTransaction({ data });
+

+
  if (parsedTx.name === "anchor") {
+
    const encodedProjectUrn = parsedTx.args[0];
+
    const encodedCommitHash = parsedTx.args[2];
+
    const id = utils.formatRadicleId(
+
      ethers.utils.arrayify(`${encodedProjectUrn}`)
+
    );
+
    const byteArray = ethers.utils.arrayify(encodedCommitHash);
+
    const stateHash = utils.formatProjectHash(byteArray);
+

+
    return { id, anchor: { stateHash } };
+
  }
+
  return null;
+
}
modified src/base/orgs/View.svelte
@@ -8,15 +8,14 @@
  import Error from '@app/Error.svelte';
  import Icon from '@app/Icon.svelte';
  import SetName from '@app/ens/SetName.svelte';
-
  import Project from '@app/base/projects/Widget.svelte';
  import Address from '@app/Address.svelte';
  import Avatar from '@app/Avatar.svelte';
-
  import Message from '@app/Message.svelte';
  import * as utils from '@app/utils';

  import { Org } from '@app/base/orgs/Org';
  import TransferOwnership from '@app/base/orgs/TransferOwnership.svelte';
  import { Profile, ProfileType } from '@app/profile';
+
  import Projects from './View/Projects.svelte';

  export let addressOrName: string;
  export let config: Config;
@@ -137,12 +136,6 @@
    width: 1rem;
    margin-right: 0.5rem;
  }
-
  .projects {
-
    margin-top: 2rem;
-
  }
-
  .projects .project {
-
    margin-bottom: 1rem;
-
  }
  .members {
    margin-top: 2rem;
    align-items: center;
@@ -326,21 +319,7 @@
          {/if}
        {/await}

-
        <div class="projects">
-
          {#await org.getProjects(config)}
-
            <Loading center />
-
          {:then projects}
-
            {#each projects as project}
-
              <div class="project">
-
                <Project {project} org={profile.nameOrAddress} config={profile.config(config)} />
-
              </div>
-
            {/each}
-
          {:catch err}
-
            <Message error>
-
              <strong>Error: </strong> failed to load projects: {err.message}.
-
            </Message>
-
          {/await}
-
        </div>
+
        <Projects {org} {config} />
      {/await}
    </main>
  {:else}
added src/base/orgs/View/Anchor.svelte
@@ -0,0 +1,211 @@
+
<script lang="ts">
+
  import { ethers } from "ethers";
+
  import type { Safe } from "@app/utils";
+
  import type { PendingProject } from "@app/project";
+
  import type { Config } from "@app/config";
+
  import * as utils from "@app/utils";
+
  import Modal from "@app/Modal.svelte";
+
  import Avatar from "@app/Avatar.svelte";
+
  import { createEventDispatcher } from 'svelte';
+

+
  export let safe: Safe;
+
  export let project: PendingProject;
+
  export let account: string;
+
  export let config: Config;
+

+
  enum State {
+
    Idle,
+
    Confirm,
+
    Signing,
+
    Submitting,
+
    Success,
+
    Execute,
+
    Failed,
+
  }
+

+
  enum Action {
+
    Sign,
+
    Execute,
+
  }
+

+
  const dispatch = createEventDispatcher();
+
  let state = State.Idle;
+
  let action: null | Action = null;
+
  const isSigned = project.confirmations.includes(
+
    ethers.utils.getAddress(account)
+
  );
+
  const close = () => {
+
    action = null;
+
    state = State.Idle;
+
    // Could eventually be a separate function if we want to handle a Cancel event differently.
+
    dispatch("success");
+
  };
+
  const pending = safe.threshold - project.confirmations.length;
+
  const executeTransaction = async (safeTxHash: string) => {
+
    try {
+
      action = Action.Execute;
+
      state = State.Confirm;
+
      const txResult = await utils.executeSignedSafeTransaction(safe.address, safeTxHash, config);
+

+
      state = State.Submitting;
+
      await txResult.transactionResponse?.wait();
+

+
      state = State.Success;
+
    } catch (err) {
+
      console.error(err);
+
    }
+
  };
+
  const confirmAnchor = async (safeTxHash: string) => {
+
    try {
+
      action = Action.Sign;
+
      state = State.Signing;
+
      const signature = await utils.signSafeTransaction(safe.address, safeTxHash, config);
+

+
      state = State.Submitting;
+
      await config.safe.client?.confirmTransaction(safeTxHash, signature.data);
+

+
      state = State.Success;
+
    } catch (err) {
+
      console.error(err);
+
      state = State.Failed;
+
    }
+
  };
+
</script>
+

+
<style>
+
  .confirmations {
+
    margin-right: 0.5rem;
+
  }
+
  .table {
+
    display: grid;
+
    grid-template-columns: 5rem 4fr;
+
    grid-gap: 1rem;
+
    text-align: left;
+
  }
+
  .table > *:nth-child(odd) { /* Labels */
+
    color: var(--color-secondary);
+
  }
+
  .table > *:nth-child(even) { /* Values */
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
  }
+
  .avatars {
+
    display: flex;
+
  }
+
</style>
+

+
<span class="confirmations">
+
  {#if pending > 0}
+
    <strong>{pending}</strong> signature(s) pending
+
  {/if}
+
</span>
+

+
<div class="avatars">
+
  {#each project.confirmations as signee}
+
    <Avatar inline source={signee} address={signee} glowOnHover />
+
  {/each}
+
</div>
+

+
<!-- Check whether the threshold has been matched or passed -->
+
{#if pending <= 0}
+
  <button on:click|stopPropagation={() => {
+
    action = Action.Execute;
+
    state = State.Confirm;
+
  }} class="tiny">
+
    Execute
+
  </button>
+
  <!-- Check whether or not we've signed this proposal -->
+
{:else if isSigned}
+
  <span class="badge safe no-margin">✓ signed</span>
+
{:else}
+
  <button on:click|stopPropagation={() => {
+
    action = Action.Sign;
+
    state = State.Confirm;
+
    }} class="tiny">
+
    Confirm
+
  </button>
+
{/if}
+

+
<!-- We've initiated an action -->
+
{#if state !== State.Idle && action === Action.Sign}
+
  <Modal floating>
+
    <span slot="title">
+
      <div>⚓</div>
+
      <div>Anchor project</div>
+
    </span>
+

+
    <span slot="subtitle">
+
      {#if state == State.Confirm}
+
        <span>Initiate the transaction...</span>
+
      {:else if state == State.Signing}
+
        <span>Sign the transaction in your wallet...</span>
+
      {:else if state == State.Submitting}
+
        <span>Transaction is being confirmed...</span>
+
      {:else if state == State.Success}
+
        <span>Transaction confirmed.</span>
+
      {/if}
+
    </span>
+

+
    <span slot="body">
+
      {#if state == State.Confirm}
+
        <div class="table">
+
          <div>Project</div><code>{project.id}</code>
+
          <div>Hash</div><code>{project.anchor.stateHash}</code>
+
        </div>
+
      {/if}
+
    </span>
+

+
    <span slot="actions">
+
      {#if state == State.Confirm}
+
        <button class="primary" on:click={() => confirmAnchor(project.safeTxHash)}>
+
          Confirm
+
        </button>
+
        <button class="text" on:click={close}>
+
          Cancel
+
        </button>
+
      {:else if state == State.Success}
+
        <button on:click={close}>Done</button>
+
      {/if}
+
    </span>
+
  </Modal>
+
{:else if state !== State.Idle && action === Action.Execute}
+
  <Modal floating>
+
    <span slot="title">
+
      <div>⚡</div>
+
      <div>Execute Safe Transaction</div>
+
    </span>
+

+
    <span slot="subtitle">
+
      {#if state == State.Confirm}
+
        <span>Sign the transaction in your wallet...</span>
+
      {:else if state == State.Submitting}
+
        <span>Transaction is being confirmed...</span>
+
      {:else if state == State.Success}
+
        <span>Transaction confirmed.</span>
+
      {/if}
+
    </span>
+

+
    <span slot="body">
+
      {#if state == State.Confirm}
+
        <div class="table">
+
          <div>TxHash</div><code>{utils.formatHash(project.safeTxHash)}</code>
+
          <div>Quorum</div><code>{project.confirmations.length} of {safe.threshold}</code>
+
        </div>
+
      {/if}
+
    </span>
+

+
    <span slot="actions">
+
      {#if state == State.Confirm}
+
        <button class="primary" on:click={() => executeTransaction(project.safeTxHash)}>
+
          Confirm
+
        </button>
+
        <button class="text" on:click={close}>
+
          Cancel
+
        </button>
+
      {:else if state == State.Success}
+
        <button on:click={() => close}>Done</button>
+
      {/if}
+
    </span>
+
  </Modal>
+
{/if}
added src/base/orgs/View/Projects.svelte
@@ -0,0 +1,67 @@
+
<script lang="ts">
+
  import type { Config } from "@app/config";
+
  import type { Org } from "@app/base/orgs/Org";
+
  import type { PendingProject, Project } from "@app/project";
+
  import { session } from '@app/session';
+
  import Loading from "@app/Loading.svelte";
+
  import Message from "@app/Message.svelte";
+
  import Widget from '@app/base/projects/Widget.svelte';
+
  import Anchor from './Anchor.svelte';
+

+
  export let org: Org;
+
  export let config: Config;
+

+
  let getProjects = queryProjects;
+

+
  function updateRecords() {
+
    getProjects = queryProjects;
+
  }
+

+
  async function queryProjects(): Promise<(Project | PendingProject)[]> {
+
    if ($session) {
+
      const result = await org.isMember($session.address, config);
+
      return result ? org.getAllProjects(config) : org.getProjects(config);
+
    }
+
    return org.getProjects(config);
+
  }
+
</script>
+

+
<style>
+
  .projects {
+
    margin-top: 2rem;
+
  }
+
  .projects .project {
+
    margin-bottom: 1rem;
+
  }
+
  .anchor {
+
    display: flex;
+
    align-items: center;
+
  }
+
</style>
+

+
<div class="projects">
+
  {#await getProjects()}
+
    <Loading center />
+
  {:then projects}
+
    {#each projects as project}
+
      <div class="project">
+
        {#if "safeTxHash" in project} <!-- Pending project -->
+
          <Widget {project} org={org.address} {config} faded>
+
            <span class="anchor" slot="actions">
+
              {#if org.safe && $session}
+
                <Anchor {project} safe={org.safe} on:success={() => updateRecords()} account={$session.address} {config} />
+
                <button on:click={() => updateRecords()}>Update projects</button>
+
              {/if}
+
            </span>
+
          </Widget>
+
        {:else} <!-- Anchored project -->
+
          <Widget {project} org={org.address} {config} />
+
        {/if}
+
      </div>
+
    {/each}
+
  {:catch err}
+
    <Message error>
+
      <strong>Error: </strong> failed to load projects: {err.message}.
+
    </Message>
+
  {/await}
+
</div>
modified src/base/projects/Widget.svelte
@@ -17,6 +17,7 @@
  export let config: Config;
  export let org: string | undefined = undefined;
  export let user: string | undefined = undefined;
+
  export let faded = false;

  let state: State = { status: Status.Loading };
  let info: proj.Info | null = null;
@@ -27,6 +28,7 @@
      state = { status: Status.Loaded };
      info = result;
    } catch (err) {
+
      console.error(err.message);
      state = { status: Status.Error, error: err.message };
    }
  });
@@ -55,23 +57,38 @@
  article.has-info {
    cursor: pointer;
  }
+
  article.project-faded {
+
    border: 1px dashed var(--color-foreground-subtle);
+
    cursor: not-allowed;
+
  }
  article.has-info:hover {
    border-color: var(--color-secondary);
  }
+
  article.project-faded.has-info:hover {
+
    border-color: var(--color-foreground-faded);
+
  }
  article .id {
    font-size: 1rem;
    font-weight: 600;
    margin-bottom: 0.5rem;
  }
  article .description {
-
    margin-bottom: 0.75rem;
+
    margin-bottom: 0.25rem;
    font-size: 0.75rem;
  }
  article .anchor {
    color: var(--color-secondary);
    font-size: 0.75rem;
+
    min-height: 2rem;
+
    display: flex;
+
    align-items: center;
+
  }
+
  article .commit, article .actions {
    font-family: var(--font-family-monospace);
  }
+
  article.project-faded .anchor {
+
    color: var(--color-foreground-faded);
+
  }
  article .id, article .anchor {
    display: flex;
    justify-content: space-between;
@@ -97,20 +114,22 @@
  }
</style>

-
<article on:click={onClick} class:has-info={info}>
+
<article on:click={onClick} class:has-info={info} class:project-faded={faded}>
  {#if info}
    <div class="id">
      <span class="name">{info.meta.name}</span><span class="urn">{project.id}</span>
    </div>
    <div class="description">{info.meta.description}</div>
    <div class="anchor">
-
      <span>commit {project.anchor.stateHash}</span>
-
      <span>
-
        {#each info.meta.maintainers as urn}
-
          <span class="avatar">
-
            <Blockies address={urn} />
-
          </span>
-
        {/each}
+
      <span class="commit">commit {project.anchor.stateHash}</span>
+
      <span class="actions">
+
        <slot name="actions">
+
          {#each info.meta.maintainers as urn}
+
            <span class="avatar">
+
              <Blockies address={urn} />
+
            </span>
+
          {/each}
+
        </slot>
      </span>
    </div>
  {:else}
@@ -120,6 +139,12 @@
        <Loading small />
      {/if}
    </div>
-
    <div class="anchor">commit {project.anchor.stateHash}</div>
+
    <div class="anchor">
+
      <span class="commit">commit {project.anchor.stateHash}</span>
+
      <span class="actions">
+
        <slot name="actions">
+
        </slot>
+
      </span>
+
    </div>
  {/if}
</article>
modified src/config.json
@@ -116,6 +116,7 @@
    "org": [
      "function owner() view returns (address)",
      "function anchors(bytes32) view returns (uint32, bytes)",
+
      "function anchor(bytes32, uint32, bytes)",
      "function setOwner(address)",
      "function setName(string, address) returns (bytes32)"
    ],
modified src/project.ts
@@ -10,6 +10,11 @@ export interface Project {
  };
}

+
export interface PendingProject extends Project {
+
  safeTxHash: string; // Safe transaction hash.
+
  confirmations: string[]; // Owner addresses who have confirmed.
+
}
+

export interface Info {
  head: string;
  meta: Meta;
modified src/utils.ts
@@ -2,7 +2,8 @@ import { ethers } from "ethers";
import { BigNumber } from "ethers";
import multibase from 'multibase';
import multihashes from 'multihashes';
-
import EthersSafe, { EthersAdapter } from "@gnosis.pm/safe-core-sdk";
+
import EthersSafe, { EthersAdapter, TransactionResult } from "@gnosis.pm/safe-core-sdk";
+
import type { SafeSignature } from "@gnosis.pm/safe-core-sdk-types";
import type { Config } from '@app/config';
import config from "@app/config.json";
import { assert } from '@app/error';
@@ -428,3 +429,71 @@ export async function proposeSafeTransaction(
    signature
  );
}
+

+
// Sign a Gnosis Safe multi-sig transaction.
+
export async function signSafeTransaction(
+
  safeAddress: string,
+
  safeTxHash: string,
+
  config: Config
+
): Promise<SafeSignature> {
+
  assert(config.signer);
+

+
  const ethAdapter = new EthersAdapter({
+
    ethers, signer: config.signer
+
  });
+
  const safeSdk = await EthersSafe.create({
+
    ethAdapter, safeAddress
+
  });
+
  return await safeSdk.signTransactionHash(safeTxHash);
+
}
+

+
// Execute a Gnosis Safe signed transaction by safeTxHash.
+
export async function executeSignedSafeTransaction(
+
  safeAddress: string,
+
  safeTxHash: string,
+
  config: Config
+
): Promise<TransactionResult> {
+
  assert(config.signer);
+
  assert(config.safe.client);
+

+
  const ethAdapter = new EthersAdapter({
+
    ethers, signer: config.signer
+
  });
+
  const safeSdk = await EthersSafe.create({
+
    ethAdapter, safeAddress
+
  });
+

+
  const signedTx = await config.safe.client.getTransaction(safeTxHash);
+

+
  assert(signedTx.data);
+
  assert(signedTx.confirmations);
+

+
  const safeTx = await safeSdk.createTransaction({
+
    ...signedTx,
+
    gasPrice: Number(signedTx.gasPrice),
+
    data: signedTx.data
+
  } );
+

+
  signedTx.confirmations.forEach(confirmation => {
+
    const signature = new EthSignSignature(confirmation.owner, confirmation.signature);
+
    safeTx.addSignature(signature);
+
  });
+

+
  return await safeSdk.executeTransaction(safeTx);
+
}
+

+
export class EthSignSignature {
+
  signer: string;
+
  data: string;
+

+
  constructor(signer: string, signature: string) {
+
    this.signer = signer;
+
    this.data = signature;
+
  }
+
  staticPart(): string {
+
    return this.data;
+
  }
+
  dynamicPart(): string {
+
    return '';
+
  }
+
}