Add pending projects and anchor signing

Signed-off-by: Sebastian Martinez <sebastinez@me.com>
This commit is contained in:
Sebastian Martinez
2021-11-26 13:18:17 +01:00
parent b73f5d5d69
commit 97a8897d87
9 changed files with 459 additions and 42 deletions
+1 -1
View File
@@ -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 />
+67 -7
View File
@@ -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;
}
+2 -23
View File
@@ -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}
+211
View File
@@ -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}
+67
View File
@@ -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>
+35 -10
View File
@@ -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>
+1
View File
@@ -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)"
],
+5
View File
@@ -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;
+70 -1
View File
@@ -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 '';
}
}