From 97a8897d874178f45debcbbe044c4d6b83c5bae8 Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Wed, 24 Nov 2021 11:26:54 +0100 Subject: [PATCH] Add pending projects and anchor signing Signed-off-by: Sebastian Martinez --- src/Avatar.svelte | 2 +- src/base/orgs/Org.ts | 74 +++++++++- src/base/orgs/View.svelte | 25 +--- src/base/orgs/View/Anchor.svelte | 211 +++++++++++++++++++++++++++++ src/base/orgs/View/Projects.svelte | 67 +++++++++ src/base/projects/Widget.svelte | 45 ++++-- src/config.json | 1 + src/project.ts | 5 + src/utils.ts | 71 +++++++++- 9 files changed, 459 insertions(+), 42 deletions(-) create mode 100644 src/base/orgs/View/Anchor.svelte create mode 100644 src/base/orgs/View/Projects.svelte diff --git a/src/Avatar.svelte b/src/Avatar.svelte index ef84703e7..ac179933e 100644 --- a/src/Avatar.svelte +++ b/src/Avatar.svelte @@ -49,4 +49,4 @@ - + diff --git a/src/base/orgs/Org.ts b/src/base/orgs/Org.ts index 8b73d2505..02dae9792 100644 --- a/src/base/orgs/Org.ts +++ b/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 { @@ -162,7 +164,9 @@ export class Org { } async getMembers(config: Config): Promise> { - 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 { + if (this.safe) return this.safe; + return utils.getSafe(this.owner, config); } async isMember(address: string, config: Config): Promise { const members = await this.getMembers(config); - return members.includes(ethers.utils.getAddress(address)); + return members.includes(address.toLowerCase()); } - async getProjects(config: Config): Promise> { + async getProjects(config: Config): Promise { 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 { + 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> { + 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 { 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; +} diff --git a/src/base/orgs/View.svelte b/src/base/orgs/View.svelte index e666acde2..fcbc2a16d 100644 --- a/src/base/orgs/View.svelte +++ b/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} -
- {#await org.getProjects(config)} - - {:then projects} - {#each projects as project} -
- -
- {/each} - {:catch err} - - Error: failed to load projects: {err.message}. - - {/await} -
+ {/await} {:else} diff --git a/src/base/orgs/View/Anchor.svelte b/src/base/orgs/View/Anchor.svelte new file mode 100644 index 000000000..05ba210f1 --- /dev/null +++ b/src/base/orgs/View/Anchor.svelte @@ -0,0 +1,211 @@ + + + + + + {#if pending > 0} + {pending} signature(s) pending + {/if} + + +
+ {#each project.confirmations as signee} + + {/each} +
+ + +{#if pending <= 0} + + +{:else if isSigned} + ✓ signed +{:else} + +{/if} + + +{#if state !== State.Idle && action === Action.Sign} + + +
⚓
+
Anchor project
+
+ + + {#if state == State.Confirm} + Initiate the transaction... + {:else if state == State.Signing} + Sign the transaction in your wallet... + {:else if state == State.Submitting} + Transaction is being confirmed... + {:else if state == State.Success} + Transaction confirmed. + {/if} + + + + {#if state == State.Confirm} +
+
Project
{project.id} +
Hash
{project.anchor.stateHash} +
+ {/if} +
+ + + {#if state == State.Confirm} + + + {:else if state == State.Success} + + {/if} + +
+{:else if state !== State.Idle && action === Action.Execute} + + +
⚡
+
Execute Safe Transaction
+
+ + + {#if state == State.Confirm} + Sign the transaction in your wallet... + {:else if state == State.Submitting} + Transaction is being confirmed... + {:else if state == State.Success} + Transaction confirmed. + {/if} + + + + {#if state == State.Confirm} +
+
TxHash
{utils.formatHash(project.safeTxHash)} +
Quorum
{project.confirmations.length} of {safe.threshold} +
+ {/if} +
+ + + {#if state == State.Confirm} + + + {:else if state == State.Success} + + {/if} + +
+{/if} diff --git a/src/base/orgs/View/Projects.svelte b/src/base/orgs/View/Projects.svelte new file mode 100644 index 000000000..798653453 --- /dev/null +++ b/src/base/orgs/View/Projects.svelte @@ -0,0 +1,67 @@ + + + + +
+ {#await getProjects()} + + {:then projects} + {#each projects as project} +
+ {#if "safeTxHash" in project} + + + {#if org.safe && $session} + updateRecords()} account={$session.address} {config} /> + + {/if} + + + {:else} + + {/if} +
+ {/each} + {:catch err} + + Error: failed to load projects: {err.message}. + + {/await} +
diff --git a/src/base/projects/Widget.svelte b/src/base/projects/Widget.svelte index 5e3a2d9d8..11387ba87 100644 --- a/src/base/projects/Widget.svelte +++ b/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 @@ } -
+
{#if info}
{info.meta.name}{project.id}
{info.meta.description}
- commit {project.anchor.stateHash} - - {#each info.meta.maintainers as urn} - - - - {/each} + commit {project.anchor.stateHash} + + + {#each info.meta.maintainers as urn} + + + + {/each} +
{:else} @@ -120,6 +139,12 @@ {/if} -
commit {project.anchor.stateHash}
+
+ commit {project.anchor.stateHash} + + + + +
{/if}
diff --git a/src/config.json b/src/config.json index b6cfc54ac..fdc7b24f3 100644 --- a/src/config.json +++ b/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)" ], diff --git a/src/project.ts b/src/project.ts index 0440f5ac4..b887939ea 100644 --- a/src/project.ts +++ b/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; diff --git a/src/utils.ts b/src/utils.ts index 822f94b5b..a9eaee1d8 100644 --- a/src/utils.ts +++ b/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 { + 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 { + 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 ''; + } +}