on master help sign in
checkpoint: initial refactor of Admin.js
commit1a0a4dd2a3452a078f15710e1d279d4f7bbe510a authorMatt Condon (shrugs) <mattgcondon@gmail.com> dateMon 8 Jul 2019, 20:17 (7 years ago) parenta531c30
21 files changed+478−503
src/components/View.js+6−2 src/components/ViewHeader.js+7 src/components/old/Base.js−2 src/components/old/Passport.js−235 src/indigo-react/components/Button.js+1−1 src/lib/invite.js+12−48 src/lib/keys.js+96−59 src/lib/need.js+9−4 src/lib/router.js−2 src/lib/txn.js+12−32 src/lib/useKeyfileGenerator.js+97 src/lib/usePermissionsForPoint.js+29 src/lib/wallet.js+1 src/store/lib/useInvitesStore.js+3−11 src/store/lib/useOptimisticDetailsStore.js+38 src/store/lib/usePointStore.js+17−10 src/store/wallet.js+1 src/views/Admin.js+40−71 src/views/Admin/AdminHome.js+105 src/views/Invite.js+4−3 src/views/Point/Actions.js−23
src/components/View.js+6−2
@@ -3,9 +3,13 @@ import cn from 'classnames';
33import { Flex } from 'indigo-react';
44
55import Footer from './Footer';
6+import useBreakpoints from 'lib/useBreakpoints';
67
78// View is a top-level component that all Views must render to inherit styling
89function View({ className, children, inset = false, full = false, ...rest }) {
10+ const isMobile = useBreakpoints([true, false, false]);
11+ const shouldInset = inset && !isMobile;
12+
913 return (
1014 <Flex
1115 col
@@ -21,8 +25,8 @@ function View({ className, children, inset = false, full = false, ...rest }) {
2125 {...rest}>
2226 <Flex.Item
2327 className={cn('pb5', {
24− pt10: inset,
25− pt5: !inset,
28+ pt10: shouldInset,
29+ pt5: !shouldInset,
2630 })}>
2731 {children}
2832 </Flex.Item>
src/components/ViewHeader.jsnew file+7
@@ -0,0 +1,7 @@
1+import React from 'react';
2+import cn from 'classnames';
3+import { H5 } from 'indigo-react';
4+
5+export default function ViewHeader({ children, className }) {
6+ return <H5 className={cn(className, 'mv4')}>{children}</H5>;
7+}
src/components/old/Base.js−2
@@ -63,7 +63,6 @@ import Warning from './Warning';
6363import ValidatedSigil from './ValidatedSigil';
6464import ValidatedBlockie from './ValidatedBlockie';
6565import ShowBlockie from './ShowBlockie';
66−import Passport from './Passport';
6766
6867// HOC validated form components
6968
@@ -178,5 +177,4 @@ export {
178177 P,
179178 Code,
180179 Warning,
181− Passport,
182180};
src/components/old/Passport.jsdeleted−235
@@ -1,235 +0,0 @@
1−import React from 'react';
2−import { pour } from 'sigil-js';
3−import * as ob from 'urbit-ob';
4−import ReactSVGComponents from '../ReactSVGComponents';
5−import PropTypes from 'prop-types';
6−import { Button } from './Base';
7−import PaperRenderer from 'components/PaperRenderer';
8−
9−import { WALLET_STATES } from '../../lib/invite';
10−import { downloadWallet } from '../../lib/invite';
11−import { compose } from '../../lib/lib';
12−import { withWallet } from '../../store/wallet';
13−
14−class Passport extends React.Component {
15− constructor(props) {
16− super(props);
17−
18− this.state = {
19− paper: null,
20− };
21−
22− this.masterCensor = this.generateCensor();
23− this.managementCensor = this.generateCensor();
24−
25− this.download = this.download.bind(this);
26− }
27−
28− download() {
29− downloadWallet(this.state.paper);
30− this.props.pushWalletState(WALLET_STATES.DOWNLOADED);
31− }
32−
33− generateCensor() {
34− let blocks = [];
35−
36− new Uint32Array(86).forEach((_, i) => {
37− let rand = Math.round(Math.random());
38− let filled = rand || i === 0 || i === 43;
39−
40− let className = filled ? 'passport-block bg-gray-30' : 'passport-block';
41−
42− blocks.push(<div key={i} data-key={i} className={className} />);
43− });
44−
45− return <div className="flex flex-wrap passport-censor">{blocks}</div>;
46− }
47−
48− getCurrentDate() {
49− let date = new Date();
50− const fil = n => (n >= 10 ? n : `0${n}`);
51− let fmtDate =
52− `${date.getUTCFullYear()}.` +
53− `${date.getUTCMonth() + 1}.` +
54− `${fil(date.getUTCDate())}`;
55−
56− return fmtDate;
57− }
58−
59− getWalletProgress() {
60− let { walletStates } = this.props;
61−
62− if (!walletStates.includes(WALLET_STATES.UNLOCKING)) return null;
63− if (walletStates.includes(WALLET_STATES.TRANSACTIONS)) return null;
64−
65− let progressTimes = {
66− [WALLET_STATES.UNLOCKING]: { ms: '2813', pct: '44%' },
67− [WALLET_STATES.GENERATING]: { ms: '2433', pct: '38%' },
68− [WALLET_STATES.RENDERING]: { ms: '986', pct: '15%' },
69− };
70−
71− const progressBars = Object.keys(progressTimes).map(state => {
72− let progressIndex = walletStates.indexOf(state);
73− let progressDone =
74− progressIndex !== -1 && progressIndex < walletStates.length - 1;
75−
76− let progressStyle = {
77− width: progressTimes[state].pct,
78− };
79−
80− return (
81− <div className="passport-wallet-progress" style={progressStyle}>
82− {progressDone && <div className="passport-wallet-progress-fill" />}
83− </div>
84− );
85− });
86−
87− return <div className="flex space-between mt-3">{progressBars}</div>;
88− }
89−
90− getWalletStates() {
91− let { walletStates } = this.props;
92−
93− if (walletStates.includes(WALLET_STATES.TRANSACTIONS)) return null;
94−
95− let stateElems = walletStates.map(state => {
96− if (state === WALLET_STATES.DOWNLOADED) return null;
97−
98− const lastIndex = walletStates.indexOf(state) === walletStates.length - 1;
99− const checkmark = lastIndex ? null : (
100− <div className="text-700 text-sm lh-6 green-dark">✓</div>
101− );
102−
103− return (
104− <div className="flex justify-between">
105− <div className="text-mono text-sm lh-6 green-dark">{state}</div>
106− {checkmark}
107− </div>
108− );
109− });
110−
111− return <div className="mt-3">{stateElems}</div>;
112− }
113−
114− render() {
115− let pointVal = this.props.point.matchWith({
116− Just: p => p.value,
117− Nothing: () => '',
118− });
119−
120− let patp = pointVal === '' ? '' : ob.patp(pointVal);
121−
122− let wallet = this.props.wallet.matchWith({
123− Just: w => w.value,
124− Nothing: () => null,
125− });
126−
127− let ownershipAddress = this.props.wallet.matchWith({
128− Just: w => w.value.ownership.keys.address,
129− Nothing: () => null,
130− });
131−
132− const sigil =
133− patp !== '' ? (
134− pour({
135− patp: patp,
136− renderer: ReactSVGComponents,
137− size: 128,
138− })
139− ) : (
140− <div className="passport-sigil-blank" />
141− );
142−
143− let currentDate = this.getCurrentDate();
144− let walletStates = this.getWalletStates();
145− let walletProgress = this.getWalletProgress();
146− let paperReady =
147− this.state.paper &&
148− this.props.walletStates.includes(WALLET_STATES.PAPER_READY);
149− let downloadColor = paperReady ? 'green' : 'black';
150−
151− let downloadBtn = (
152− <Button
153− disabled={!paperReady}
154− prop-size={'lg wide'}
155− prop-color={downloadColor}
156− onClick={this.download}>
157− {'Download'}
158− </Button>
159− );
160−
161− let paperCollateral = wallet ? (
162− <PaperRenderer
163− point={pointVal}
164− wallet={wallet}
165− callback={data => {
166− this.setState({ paper: data });
167− this.props.pushWalletState(WALLET_STATES.PAPER_READY);
168− }}
169− />
170− ) : (
171− <div />
172− );
173−
174− let walletAddresses = (
175− <div className="mt-8">
176− <div className="passport-label text-mono">Ethereum Address</div>
177− <div className="passport-value">
178− {ownershipAddress || 'Ungenerated'}
179− </div>
180−
181− <div className="passport-label gray-50 mt-3">Master Ticket</div>
182− <div className="passport-value passport-censor overflow-hidden">
183− {this.masterCensor}
184− </div>
185−
186− <div className="passport-label gray-50 mt-5">Management Seed</div>
187− <div className="passport-value passport-censor overflow-hidden">
188− {this.managementCensor}
189− </div>
190− </div>
191− );
192−
193− let walletMetadata = (
194− <div className="flex">
195− <div className="passport-sigil">{sigil}</div>
196− <div className="passport-metadata ml-4">
197− <div className="passport-label text-mono">Point</div>
198− <div className="text-mono">{patp || '-'}</div>
199−
200− <div className="passport-label gray-50 mt-6">Created on</div>
201− <div className="passport-value">{patp ? currentDate : '-'}</div>
202−
203− <div className="passport-label gray-50 mt-2">Invited by</div>
204− <div className="passport-value">{this.props.invitedBy || '-'}</div>
205− </div>
206− </div>
207− );
208−
209− return (
210− <div>
211− <div className="passport-border">
212− <div className={`passport ${paperReady && 'passport-filled'}`}>
213− {walletMetadata}
214− {walletAddresses}
215− {paperCollateral}
216− </div>
217− {downloadBtn}
218− </div>
219−
220− {walletProgress}
221− {walletStates}
222− </div>
223− );
224− }
225−}
226−
227−Passport.propTypes = {
228− wallet: PropTypes.object,
229−};
230−
231−Passport.defaultProps = {
232− wallet: null,
233−};
234−
235−export default compose(withWallet)(Passport);
src/indigo-react/components/Button.js+1−1
@@ -27,7 +27,7 @@ export default function Button({
2727 as={As}
2828 gap={1}
2929 className={cn(
30− 'pointer pv4 truncate flex-row justify-between',
30+ 'pointer pv4 truncate flex-row justify-between us-none',
3131 {
3232 p4: solid,
3333 },
src/lib/invite.js+12−48
@@ -9,26 +9,13 @@ import * as tank from './tank';
99import JSZip from 'jszip';
1010import saveAs from 'file-saver';
1111
12−import { sendSignedTransaction, hexify } from './txn';
13−import { attemptNetworkSeedDerivation } from './keys';
14−import { addHexPrefix, WALLET_TYPES } from './wallet';
12+import { hexify, sendAndAwaitConfirm } from './txn';
13+import { deriveNetworkSeedFromUrbitWallet } from './keys';
14+import { addHexPrefix } from './wallet';
1515import { GAS_LIMITS } from './constants';
1616
17−const INVITE_STAGES = {
18− INVITE_LOGIN: 'invite login',
19− INVITE_WALLET: 'invite wallet',
20− INVITE_VERIFY: 'invite verify',
21− INVITE_TRANSACTIONS: 'invite transactions',
22−};
23−
24−const WALLET_STATES = {
25− UNLOCKING: 'Unlocking invite wallet',
26− GENERATING: 'Generating your wallet',
27− RENDERING: 'Creating paper collateral',
28− PAPER_READY: 'Download your wallet',
29− DOWNLOADED: 'Wallet downloaded',
30− TRANSACTIONS: 'Sending transactions',
31−};
17+// the initial network key revision is always 1
18+const INITIAL_NETWORK_KEY_REVISION = 1;
3219
3320const TRANSACTION_STATES = {
3421 GENERATING: {
@@ -57,14 +44,14 @@ const TRANSACTION_STATES = {
5744 },
5845};
5946
60−async function generateWallet(point) {
47+export async function generateWallet(point) {
6148 const ticket = await wg.makeTicket(point);
6249 const wallet = await wg.generateWallet(point, ticket);
6350 return wallet;
6451}
6552
6653//TODO should be moved to lib/walletgen
67−async function downloadWallet(paper) {
54+export async function downloadWallet(paper) {
6855 return new Promise((resolve, reject) => {
6956 const zip = new JSZip();
7057 //TODO the categories here aren't explained in bridge at all...
@@ -94,7 +81,7 @@ async function downloadWallet(paper) {
9481 });
9582}
9683
97−async function claimPointFromInvite({
84+export async function claimPointFromInvite({
9885 inviteWallet,
9986 wallet,
10087 point,
@@ -136,14 +123,10 @@ async function claimPointFromInvite({
136123 transferTmpTx.gas = GAS_LIMITS.TRANSFER;
137124
138125 // configure networking public keys
139−
140− //TODO feels like more of this logic should live in a lib?
141− const seed = await attemptNetworkSeedDerivation(true, {
142− walletType: WALLET_TYPES.TICKET,
143− urbitWallet: Just(wallet),
144− pointCursor: Just(point),
145− pointCache: { [point]: { keyRevisionNumber: 0 } },
146− });
126+ const seed = await deriveNetworkSeedFromUrbitWallet(
127+ Just(wallet),
128+ INITIAL_NETWORK_KEY_REVISION
129+ );
147130
148131 const networkSeed = seed.matchWith({
149132 Nothing: () => {
@@ -290,22 +273,3 @@ async function claimPointFromInvite({
290273
291274 progress(TRANSACTION_STATES.DONE);
292275}
293−
294−async function sendAndAwaitConfirm(web3, signedTxs, usedTank) {
295− await Promise.all(
296− signedTxs.map(tx => {
297− return new Promise((resolve, reject) => {
298− sendSignedTransaction(web3, tx, usedTank, resolve);
299− });
300− })
301− );
302−}
303−
304−export {
305− generateWallet,
306− downloadWallet,
307− claimPointFromInvite,
308− INVITE_STAGES,
309− WALLET_STATES,
310− TRANSACTION_STATES,
311−};
src/lib/keys.js+96−59
@@ -6,8 +6,9 @@ import * as noun from '../nockjs/noun';
66import * as serial from '../nockjs/serial';
77import * as kg from 'urbit-key-generation/dist';
88
9−import { BRIDGE_ERROR } from './error';
10−import { WALLET_TYPES, eqAddr } from './wallet';
9+import { eqAddr, addHexPrefix } from './wallet';
10+
11+const NETWORK_KEY_CURVE_PARAMETER = '42';
1112
1213// ctsy joemfb
1314const b64 = buf => {
@@ -50,9 +51,20 @@ const jam = seed => {
5051 return Buffer.from(pad, 'hex').reverse();
5152};
5253
53−const genKey = (networkSeed, point, revision) => {
54− const pair = kg.deriveNetworkKeys(networkSeed);
55− const bnsec = new BN(pair.crypt.private + pair.auth.private + '42', 'hex'); // '42' is curve parameter
54+// simple alias to avoid importing kg into component scope
55+export const deriveNetworkKeys = seed => kg.deriveNetworkKeys(seed);
56+
57+/**
58+ * @param {object} pair
59+ * @param {number} point
60+ * @param {number} revision
61+ * @return {string}
62+ */
63+export const compileNetworkingKey = (pair, point, revision) => {
64+ const bnsec = new BN(
65+ pair.crypt.private + pair.auth.private + NETWORK_KEY_CURVE_PARAMETER,
66+ 'hex'
67+ );
5668
5769 const sed = noun.dwim(
5870 noun.Atom.fromInt(point),
@@ -64,68 +76,93 @@ const genKey = (networkSeed, point, revision) => {
6476 return b64(jam(sed));
6577};
6678
67−// 'next' refers to setting the next set of keys
68−// if false, we use revision - 1
69−const attemptNetworkSeedDerivation = async (next, args) => {
70− const { walletType, urbitWallet, authMnemonic } = args;
71−
72− // NB (jtobin):
73− //
74− // following code is intentionally verbose for sake of clarity
75−
76− const point = need.point(args.pointCursor);
77− const pointDetails = need.fromPointCache(args.pointCache, point);
78−
79− const revision =
80− next === true
81− ? parseInt(pointDetails.keyRevisionNumber)
82− : parseInt(pointDetails.keyRevisionNumber) - 1;
83−
84− let managementSeed = '';
85−
86− const ticketLike = [WALLET_TYPES.TICKET, WALLET_TYPES.SHARDS];
87−
88− if (ticketLike.includes(walletType)) {
89− const uwal = urbitWallet.matchWith({
90− Just: uw => uw.value,
91− Nothing: _ => {
92− throw BRIDGE_ERROR.MISSING_URBIT_WALLET;
93− },
94− });
95−
96− managementSeed = uwal.management.seed;
97− } else if (walletType === WALLET_TYPES.MNEMONIC) {
98− const walProxy = need.addressFromWallet(args.wallet);
79+/**
80+ * @param {Maybe<any>} urbitWallet
81+ * @param {number} revision
82+ * @return {Promise<Maybe<string>>}
83+ */
84+export const deriveNetworkSeedFromUrbitWallet = async (
85+ urbitWallet,
86+ revision = 1
87+) => {
88+ const _urbitWallet = need.wallet(urbitWallet);
89+ return deriveNetworkSeedFromSeed(_urbitWallet.management.seed, revision);
90+};
9991
100− const mnemonic = authMnemonic.matchWith({
101− Just: mnem => mnem.value,
102− Nothing: _ => {
103− throw BRIDGE_ERROR.MISSING_MNEMONIC;
104− },
105− });
92+/**
93+ * @param {Maybe<any>} wallet
94+ * @param {Maybe<string>} authMnemonic
95+ * @param {object} details
96+ * @param {number} revision
97+ * @return {Promise<Maybe<string>>}
98+ */
99+export const deriveNetworkSeedFromMnemonic = async (
100+ wallet,
101+ authMnemonic,
102+ details,
103+ revision = 1
104+) => {
105+ const _wallet = need.wallet(wallet);
106+ const _mnemonic = need.authMnemonic(authMnemonic);
107+ const isManagementProxy = eqAddr(_wallet.address, details.managementProxy);
108+
109+ if (isManagementProxy) {
110+ return await deriveNetworkSeedFromSeed(_mnemonic, revision);
111+ }
106112
107− const chainProxy = pointDetails.managementProxy;
113+ return Nothing();
114+};
108115
109− // the network seed is only derivable from mnemonic if the derived
110− // management seed equals the record we have on chain
111− const networkSeedDerivable = eqAddr(walProxy, chainProxy);
116+/**
117+ * @param {string} seed
118+ * @param {number} revision
119+ * @return {Promise<Maybe<string>>}
120+ */
121+const deriveNetworkSeedFromSeed = async (seed, revision) => {
122+ const networkSeed = await kg.deriveNetworkSeed(seed, '', revision);
123+ // apparently deriveNetworkSeed can return empty string
124+ // and perhaps even malformed
125+ return networkSeed === '' ? Nothing() : Just(networkSeed);
126+};
112127
113− if (networkSeedDerivable === true) {
114− managementSeed = mnemonic;
115− }
128+/**
129+ * @return {Promise<Maybe<string>>}
130+ */
131+export const attemptNetworkSeedDerivation = async ({
132+ urbitWallet,
133+ wallet,
134+ authMnemonic,
135+ details,
136+ revision,
137+}) => {
138+ if (Just.hasInstance(urbitWallet)) {
139+ return await deriveNetworkSeedFromUrbitWallet(urbitWallet, revision);
116140 }
117141
118− if (managementSeed !== '') {
119− const seed = await kg.deriveNetworkSeed(managementSeed, '', revision);
120−
121− if (seed === '') {
122− return Nothing();
123− }
124−
125− return Just(seed);
142+ if (Just.hasInstance(wallet) && Just.hasInstance(authMnemonic)) {
143+ return await deriveNetworkSeedFromMnemonic(
144+ wallet,
145+ authMnemonic,
146+ details,
147+ revision
148+ );
126149 }
127150
128151 return Nothing();
129152};
130153
131−export { genKey, attemptNetworkSeedDerivation };
154+/**
155+ *
156+ * @param {object} pair
157+ * @param {object} details
158+ * @return {boolean}
159+ */
160+export const keysMatchChain = (pair, details) => {
161+ const { crypt, auth } = pair;
162+ const { encryptionKey, authenticationKey } = details;
163+
164+ return (
165+ encryptionKey === addHexPrefix(crypt.public) &&
166+ authenticationKey === addHexPrefix(auth.public)
167+ );
168+};
src/lib/need.js+9−4
@@ -8,12 +8,17 @@ const needBuilder = fn => obj => {
88 fn();
99 }
1010
11− return obj.matchWith({
12− Nothing: fn,
13− Just: p => p.value,
14− });
11+ return obj.orElse(fn);
1512};
1613
14+export const details = needBuilder(() => {
15+ throw new Error('need details of point');
16+});
17+
18+export const authMnemonic = needBuilder(() => {
19+ throw new Error('need auth mnemonic');
20+});
21+
1722export const web3 = needBuilder(() => {
1823 throw new Error(BRIDGE_ERROR.MISSING_WEB3.message);
1924});
src/lib/router.js−2
@@ -8,7 +8,6 @@ import Permissions from '../views/Permissions';
88import AcceptTransfer from '../views/AcceptTransfer';
99import CancelTransfer from '../views/CancelTransfer';
1010import CreateGalaxy from '../views/CreateGalaxy';
11−import GenKeyfile from '../views/GenKeyfile';
1211import IssueChild from '../views/IssueChild';
1312import ViewPoint from '../views/ViewPoint';
1413import SentTransaction from '../views/SentTransaction';
@@ -49,5 +48,4 @@ export const ROUTES = {
4948 [ROUTE_NAMES.SET_KEYS]: SetKeys,
5049 [ROUTE_NAMES.TRANSFER]: Transfer,
5150 [ROUTE_NAMES.SENT_TRANSACTION]: SentTransaction,
52− [ROUTE_NAMES.GEN_KEYFILE]: GenKeyfile,
5351};
src/lib/txn.js+12−32
@@ -12,36 +12,6 @@ import { WALLET_TYPES, addHexPrefix } from './wallet';
1212const CHECK_BLOCK_EVERY_MS =
1313 process.env.NODE_ENV === 'development' ? 1000 : 5000;
1414
15−const TXN_PURPOSE = {
16− SET_MANAGEMENT_PROXY: Symbol('SET_MANAGEMENT_PROXY'),
17− SET_TRANSFER_PROXY: Symbol('SET_TRANSFER_PROXY'),
18− SET_SPAWN_PROXY: Symbol('SET_SPAWN_PROXY'),
19− CREATE_GALAXY: Symbol('CREATE_GALAXY'),
20− ISSUE_CHILD: Symbol('ISSUE_CHILD'),
21− SET_KEYS: Symbol('SET_KEYS'),
22− TRANSFER: Symbol('TRANSFER'),
23− CANCEL_TRANSFER: Symbol('CANCEL_TRANSFER'),
24−};
25−
26−const renderTxnPurpose = purpose =>
27− purpose === TXN_PURPOSE.SET_MANAGEMENT_PROXY
28− ? 'set this management proxy'
29− : purpose === TXN_PURPOSE.SET_SPAWN_PROXY
30− ? 'set this spawn proxy'
31− : purpose === TXN_PURPOSE.SET_TRANSFER_PROXY
32− ? 'set this transfer proxy'
33− : purpose === TXN_PURPOSE.CREATE_GALAXY
34− ? 'create this galaxy'
35− : purpose === TXN_PURPOSE.ISSUE_CHILD
36− ? 'issue this point'
37− : purpose === TXN_PURPOSE.SET_KEYS
38− ? 'set these network keys'
39− : purpose === TXN_PURPOSE.TRANSFER
40− ? 'transfer this point'
41− : purpose === TXN_PURPOSE.CANCEL_TRANSFER
42− ? 'cancel this transfer'
43− : 'perform this transaction';
44−
4515const signTransaction = async config => {
4616 let {
4717 wallet,
@@ -209,6 +179,16 @@ const isTransactionConfirmed = async (web3, txHash) => {
209179 return receipt !== null;
210180};
211181
182+async function sendAndAwaitConfirm(web3, signedTxs, usedTank) {
183+ await Promise.all(
184+ signedTxs.map(tx => {
185+ return new Promise((resolve, reject) => {
186+ sendSignedTransaction(web3, tx, usedTank, resolve);
187+ });
188+ })
189+ );
190+}
191+
212192const hexify = val => addHexPrefix(val.toString('hex'));
213193
214194const renderSignedTx = stx => ({
@@ -230,6 +210,7 @@ const getTxnInfo = async (web3, addr) => {
230210 };
231211};
232212
213+// TODO(shrugs): deprecate, unifiy with other callsites
233214const canDecodePatp = p => {
234215 try {
235216 ob.patp2dec(p);
@@ -243,10 +224,9 @@ export {
243224 signTransaction,
244225 sendSignedTransaction,
245226 waitForTransactionConfirm,
227+ sendAndAwaitConfirm,
246228 isTransactionConfirmed,
247− TXN_PURPOSE,
248229 getTxnInfo,
249− renderTxnPurpose,
250230 hexify,
251231 renderSignedTx,
252232 toHex,
src/lib/useKeyfileGenerator.jsnew file+97
@@ -0,0 +1,97 @@
1+import { useCallback, useState } from 'react';
2+import saveAs from 'file-saver';
3+import * as ob from 'urbit-ob';
4+
5+import { usePointCache } from 'store/pointCache';
6+import { useWallet } from 'store/wallet';
7+
8+import * as need from 'lib/need';
9+
10+import {
11+ attemptNetworkSeedDerivation,
12+ keysMatchChain,
13+ deriveNetworkKeys,
14+ compileNetworkingKey,
15+} from './keys';
16+import usePermissionsForPoint from './usePermissionsForPoint';
17+
18+export default function useKeyfileGenerator(point) {
19+ const { getDetails, getOptimisticDetails } = usePointCache();
20+ const { urbitWallet, wallet, authMnemonic } = useWallet();
21+
22+ const [generating, setGenerating] = useState(false);
23+ const [keyfile, setKeyfile] = useState();
24+
25+ const details = need.details(getDetails(point));
26+ const optimisticDetails = getOptimisticDetails(point);
27+
28+ // use the optimistic key revision number if available
29+ // (because we may have set networking keys earlier)
30+ const networkRevision = optimisticDetails.keyRevisionNumber.matchWith({
31+ Nothing: () => parseInt(details.keyRevisionNumber, 10),
32+ Just: p => p.value,
33+ });
34+
35+ const { isOwner, isManagementProxy } = usePermissionsForPoint(point);
36+ const hasNetworkingKeys = networkRevision > 0;
37+ const available = (isOwner || isManagementProxy) && hasNetworkingKeys;
38+
39+ const generate = useCallback(async () => {
40+ setGenerating(true);
41+
42+ const networkSeed = await attemptNetworkSeedDerivation({
43+ urbitWallet,
44+ wallet,
45+ authMnemonic,
46+ details,
47+ revision: networkRevision,
48+ });
49+
50+ const _networkSeed = networkSeed.orElse(() => {
51+ setGenerating(false);
52+ throw new Error('Could not derive networking seed.');
53+ });
54+
55+ const pair = deriveNetworkKeys(_networkSeed);
56+
57+ if (!keysMatchChain(pair, details)) {
58+ setGenerating(false);
59+ throw new Error(
60+ 'Derived networking keys do not match public keys on chain.'
61+ );
62+ }
63+
64+ setKeyfile(compileNetworkingKey(pair, point, networkRevision));
65+ setGenerating(false);
66+ }, [
67+ setGenerating,
68+ setKeyfile,
69+ urbitWallet,
70+ wallet,
71+ authMnemonic,
72+ details,
73+ networkRevision,
74+ point,
75+ ]);
76+
77+ const download = useCallback(() => {
78+ saveAs(
79+ new Blob([keyfile], {
80+ type: 'text/plain;charset=utf-8',
81+ }),
82+ `${ob.patp(point).slice(1)}-${networkRevision}.key`
83+ // TODO: ^ unifiy "remove tilde" calls
84+ );
85+ }, [point, keyfile, networkRevision]);
86+
87+ const generateAndDownload = useCallback(async () => {
88+ await generate();
89+ download();
90+ }, [generate, download]);
91+
92+ return {
93+ generating,
94+ available,
95+ generateAndDownload,
96+ };
97+}
src/lib/usePermissionsForPoint.jsnew file+29
@@ -0,0 +1,29 @@
1+import { eqAddr } from './wallet';
2+import { usePointCache } from 'store/pointCache';
3+
4+const NULL_PERMISSIONS = {
5+ isOwner: false,
6+ isTransferProxy: false,
7+ isManagementProxy: false,
8+ canTransfer: false,
9+};
10+
11+/**
12+ * @param {string} address
13+ * @param {number} point
14+ */
15+export default function usePermissionsForPoint(address, point) {
16+ const { getDetails } = usePointCache();
17+
18+ return getDetails(point).matchWith({
19+ Nothing: () => NULL_PERMISSIONS,
20+ Just: ({ value: details }) => {
21+ const isOwner = eqAddr(address, details.owner);
22+ const isTransferProxy = eqAddr(address, details.transferProxy);
23+ const isManagementProxy = eqAddr(address, details.managementProxy);
24+ const canTransfer = isOwner || isTransferProxy;
25+
26+ return { isOwner, isTransferProxy, isManagementProxy, canTransfer };
27+ },
28+ });
29+}
src/lib/wallet.js+1
@@ -42,6 +42,7 @@ export const addressFromSecp256k1Public = pub => {
4242 return toChecksumAddress(addr);
4343};
4444
45+// TODO: move all of these generic crypto helpers to another file
4546export const addHexPrefix = hex =>
4647 hex.slice(0, 2) === '0x' ? hex : '0x' + hex;
4748
src/store/lib/useInvitesStore.js+3−11
@@ -1,8 +1,9 @@
1−import { useCallback, useState } from 'react';
1+import { useCallback } from 'react';
22import { Just, Nothing } from 'folktale/maybe';
33import * as azimuth from 'azimuth-js';
44
55import { useNetwork } from '../network';
6+import useSetState from 'lib/useSetState';
67
78// the default value of a point's invites
89const EMPTY_INVITES = {
@@ -13,16 +14,7 @@ const EMPTY_INVITES = {
1314
1415export default function useInvitesStore() {
1516 const { contracts } = useNetwork();
16− const [invitesCache, _setInvitesCache] = useState({});
17−
18− const addToInvitesCache = useCallback(
19− entry =>
20− _setInvitesCache(cache => ({
21− ...cache,
22− ...entry,
23− })),
24− [_setInvitesCache]
25− );
17+ const [invitesCache, addToInvitesCache] = useSetState({});
2618
2719 const getInvites = useCallback(
2820 point => invitesCache[point] || EMPTY_INVITES,
src/store/lib/useOptimisticDetailsStore.jsnew file+38
@@ -0,0 +1,38 @@
1+import { useCallback } from 'react';
2+import { Just, Nothing } from 'folktale/maybe';
3+
4+import useSetState from 'lib/useSetState';
5+
6+const DEFAULT_OPTIMISTIC_DETAILS = {
7+ keyRevisionNumber: Nothing(),
8+};
9+
10+export default function useOptimisticDetailsStore() {
11+ const [optimisticDetails, addToOptimisticDetails] = useSetState({});
12+
13+ const getOptimisticDetails = useCallback(
14+ point => optimisticDetails[point] || DEFAULT_OPTIMISTIC_DETAILS,
15+ [optimisticDetails]
16+ );
17+
18+ const _buildSetter = key =>
19+ useCallback(
20+ (point, value) =>
21+ addToOptimisticDetails({
22+ [point]: {
23+ ...DEFAULT_OPTIMISTIC_DETAILS,
24+ ...optimisticDetails[point],
25+ ...optimisticDetails({ [key]: Just(value) }),
26+ },
27+ }),
28+ // NOTE: this usage of useCallback is ok because it is called a constant
29+ // number of times. the linter also doesn't understand that it's within
30+ // a closure, so we disable the rule here.
31+ // eslint-disable-next-line react-hooks/exhaustive-deps
32+ [addToOptimisticDetails, optimisticDetails]
33+ );
34+
35+ const setOptimisticKeyRevision = _buildSetter('keyRevisionNumber');
36+
37+ return { getOptimisticDetails, setOptimisticKeyRevision };
38+}
src/store/lib/usePointStore.js+17−10
@@ -6,36 +6,42 @@ import useRekeyDateStore from './useRekeyDateStore';
66import useInvitesStore from './useInvitesStore';
77import useControlledPointsStore from './useControlledPointsStore';
88import useEclipticOwnerStore from './useEclipticOwnerStore';
9+import useOptimisticPointDetailsStore from './useOptimisticDetailsStore';
910
1011export default function usePointStore() {
1112 const { syncDetails, ...details } = useDetailsStore();
1213 const { syncBirthday, ...birthdays } = useBirthdaysStore();
1314 const { syncRekeyDate, ...rekeyDates } = useRekeyDateStore();
1415 const { syncInvites, ...invites } = useInvitesStore();
15− const ecliptic = useEclipticOwnerStore();
1616 const {
1717 syncControlledPoints,
1818 ...controlledPoints
1919 } = useControlledPointsStore();
20+ const ecliptic = useEclipticOwnerStore();
21+ const optimistic = useOptimisticPointDetailsStore();
2022
2123 // sync all of the on-chain info required to display a known point
2224 const syncKnownPoint = useCallback(
23− async point => Promise.all([syncBirthday(point), syncRekeyDate(point)]),
25+ point => {
26+ syncBirthday(point);
27+ syncRekeyDate(point);
28+ },
2429 [syncBirthday, syncRekeyDate]
2530 );
2631
2732 // sync all of the on-chain info required to display a foreign point
28− const syncForeignPoint = useCallback(async point => Promise.all([]), []);
33+ const syncForeignPoint = useCallback(point => {}, []);
2934
3035 // sync all of the on-chain info required for a point that the user owns
3136 const syncOwnedPoint = useCallback(
32− async point =>
33− Promise.all([
34− syncKnownPoint(point),
35− syncDetails(point),
36− syncInvites(point),
37− ]),
38− [syncKnownPoint, syncDetails, syncInvites]
37+ point => {
38+ syncForeignPoint(point);
39+ syncKnownPoint(point);
40+ //
41+ syncDetails(point);
42+ syncInvites(point);
43+ },
44+ [syncForeignPoint, syncKnownPoint, syncDetails, syncInvites]
3945 );
4046
4147 return {
@@ -45,6 +51,7 @@ export default function usePointStore() {
4551 ...invites,
4652 ...controlledPoints,
4753 ...ecliptic,
54+ ...optimistic,
4855 syncInvites,
4956 syncControlledPoints,
5057 syncKnownPoint,
src/store/wallet.js+1
@@ -55,6 +55,7 @@ function _useWallet(initialWallet = Nothing(), initialMnemonic = Nothing()) {
5555 const setWallet = useCallback(
5656 wallet => {
5757 // force that public addresses are derived for each wallet
58+ // NOTE wallet is Maybe<> and .map is Maybe#map
5859 wallet.map(wal => {
5960 wal.address = wal.address || addressFromSecp256k1Public(wal.publicKey);
6061 return wal;
src/views/Admin.js+40−71
@@ -1,84 +1,53 @@
1−import React, { useCallback } from 'react';
2−import { Just } from 'folktale/maybe';
1+import React from 'react';
32import { Grid } from 'indigo-react';
4−import * as need from 'lib/need';
53
64import View from 'components/View';
7−import { ForwardButton } from 'components/Buttons';
8−
9−import { ROUTE_NAMES } from 'lib/routeNames';
10−import { eqAddr } from 'lib/wallet';
115
126import { useHistory } from 'store/history';
13−import { useWallet } from 'store/wallet';
14−import { usePointCursor } from 'store/pointCursor';
15−import { usePointCache } from 'store/pointCache';
16−import FooterButton from 'components/FooterButton';
17−
18−export default function Admin() {
19− const history = useHistory();
20− const { urbitWallet, wallet } = useWallet();
21− const { pointCursor } = usePointCursor();
22− const { pointCache } = usePointCache();
23−
24− const point = need.point(pointCursor);
25− const pointDetails = need.fromPointCache(pointCache, point);
26− const address = need.addressFromWallet(wallet);
7+import Crumbs from 'components/Crumbs';
8+import useCurrentPointName from 'lib/useCurrentPointName';
9+import useRouter from 'lib/useRouter';
10+import AdminHome from './Admin/AdminHome';
11+import { LocalRouterProvider } from 'lib/LocalRouter';
2712
28− const goRedownload = useCallback(() => history.push(ROUTE_NAMES.REDOWNLOAD), [
29− history,
30− ]);
13+const NAMES = {
14+ HOME: 'HOME',
15+};
3116
32− const goReticket = useCallback(() => history.push(ROUTE_NAMES.RETICKET), [
33− history,
34− ]);
17+const VIEWS = {
18+ [NAMES.HOME]: AdminHome,
19+};
3520
36− const goEditPerms = useCallback(() => history.push(ROUTE_NAMES.PERMISSIONS), [
37− history,
38− ]);
39−
40− const goTransfer = useCallback(() => history.push(ROUTE_NAMES.TRANSFER), [
41− history,
42− ]);
21+export default function Admin() {
22+ const history = useHistory();
23+ const name = useCurrentPointName();
4324
44− const canDownloadPassport = Just.hasInstance(urbitWallet);
45− const isOwner = eqAddr(address, pointDetails.owner);
25+ const { Route, ...router } = useRouter({
26+ names: NAMES,
27+ views: VIEWS,
28+ initialRoutes: [{ key: NAMES.HOME }],
29+ });
4630
4731 return (
48− <View>
49− <Grid className="pt2">
50− <Grid.Item
51− full
52− as={ForwardButton}
53− disabled={!canDownloadPassport}
54− onClick={goRedownload}
55− detail="Re-download your paper wallet">
56− Download Passport
57− </Grid.Item>
58− <Grid.Divider />
59− <Grid.Item
60− full
61− as={ForwardButton}
62− disabled={!isOwner}
63− onClick={goReticket}
64− detail="Move to brand new wallet, re-setting all permissions">
65− Reticket
66− </Grid.Item>
67− <Grid.Item
68− full
69− as={ForwardButton}
70− onClick={goEditPerms}
71− detail="Management, networking keys, etc.">
72− Edit permissions
73− </Grid.Item>
74− </Grid>
75−
76− <FooterButton
77− detail="Transfer this identity to a new owner"
78− disabled={!isOwner}
79− onClick={goTransfer}>
80− Transfer
81− </FooterButton>
82− </View>
32+ <LocalRouterProvider value={router}>
33+ <View inset>
34+ <Grid className="mb4">
35+ <Grid.Item
36+ as={Crumbs}
37+ routes={[
38+ {
39+ text: name,
40+ action: () => history.pop(),
41+ },
42+ {
43+ text: 'Admin',
44+ },
45+ ]}
46+ full
47+ />
48+ </Grid>
49+ <Route />
50+ </View>
51+ </LocalRouterProvider>
8352 );
8453}
src/views/Admin/AdminHome.jsnew file+105
@@ -0,0 +1,105 @@
1+import React, { useCallback } from 'react';
2+import { Just } from 'folktale/maybe';
3+import { Grid } from 'indigo-react';
4+import * as need from 'lib/need';
5+
6+import { ForwardButton, DownloadButton } from 'components/Buttons';
7+
8+import { useHistory } from 'store/history';
9+import { useWallet } from 'store/wallet';
10+import { usePointCursor } from 'store/pointCursor';
11+
12+import useKeyfileGenerator from 'lib/useKeyfileGenerator';
13+import usePermissionsForPoint from 'lib/usePermissionsForPoint';
14+
15+import FooterButton from 'components/FooterButton';
16+import ViewHeader from 'components/ViewHeader';
17+import Blinky from 'components/Blinky';
18+
19+export default function AdminHome() {
20+ const history = useHistory();
21+ const { urbitWallet, wallet } = useWallet();
22+ const { pointCursor } = usePointCursor();
23+
24+ const point = need.point(pointCursor);
25+ const address = need.addressFromWallet(wallet);
26+ const { isOwner, canTransfer } = usePermissionsForPoint(address, point);
27+
28+ const {
29+ generating: generatingKeyfile,
30+ available: keyfileAvailable,
31+ generateAndDownload: generateAndDownloadKeyfile,
32+ } = useKeyfileGenerator(point);
33+
34+ const goRedownload = useCallback(
35+ () => history.push(history.names.REDOWNLOAD),
36+ [history]
37+ );
38+
39+ const goReticket = useCallback(() => history.push(history.names.RETICKET), [
40+ history,
41+ ]);
42+
43+ const goEditPermissions = useCallback(
44+ () => history.push(history.names.PERMISSIONS),
45+ [history]
46+ );
47+
48+ const goTransfer = useCallback(() => history.push(history.names.TRANSFER), [
49+ history,
50+ ]);
51+
52+ const canDownloadPassport = Just.hasInstance(urbitWallet);
53+
54+ return (
55+ <Grid>
56+ <Grid.Item full as={ViewHeader}>
57+ Admin
58+ </Grid.Item>
59+
60+ <Grid.Item
61+ full
62+ as={ForwardButton}
63+ disabled={!canDownloadPassport}
64+ onClick={goRedownload}
65+ detail="Re-download your paper wallet">
66+ Download Passport
67+ </Grid.Item>
68+ <Grid.Divider />
69+ <Grid.Item
70+ full
71+ as={DownloadButton}
72+ disabled={!keyfileAvailable}
73+ accessory={generatingKeyfile ? <Blinky /> : undefined}
74+ onClick={generateAndDownloadKeyfile}
75+ detail="Download your Arvo Keyfile">
76+ Download Arvo Keyfile
77+ </Grid.Item>
78+ <Grid.Divider />
79+ <Grid.Item
80+ full
81+ as={ForwardButton}
82+ disabled={!isOwner}
83+ onClick={goReticket}
84+ detail="Move to brand new wallet, resetting all permissions">
85+ Reticket
86+ </Grid.Item>
87+ <Grid.Divider />
88+ <Grid.Item
89+ full
90+ as={ForwardButton}
91+ onClick={goEditPermissions}
92+ detail="Proxies, networking keys, etc.">
93+ Edit permissions
94+ </Grid.Item>
95+ <Grid.Divider />
96+
97+ <FooterButton
98+ detail="Transfer this identity to a new owner"
99+ disabled={!canTransfer}
100+ onClick={goTransfer}>
101+ Transfer
102+ </FooterButton>
103+ </Grid>
104+ );
105+}
src/views/Invite.js+4−3
@@ -1,6 +1,6 @@
11import React from 'react';
22import * as need from 'lib/need';
3−import { Grid, H5, Text } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
44
55import { usePointCursor } from 'store/pointCursor';
66import { useHistory } from 'store/history';
@@ -18,6 +18,7 @@ import { LocalRouterProvider } from 'lib/LocalRouter';
1818
1919import InviteUrl from './Invite/InviteUrl';
2020import InviteEmail from './Invite/InviteEmail';
21+import ViewHeader from 'components/ViewHeader';
2122
2223const NAMES = {
2324 URL: 'URL',
@@ -53,7 +54,7 @@ export default function Invite() {
5354
5455 return (
5556 <LocalRouterProvider value={router}>
56− <View>
57+ <View inset>
5758 <Grid className="mb4">
5859 <Grid.Item
5960 as={Crumbs}
@@ -68,7 +69,7 @@ export default function Invite() {
6869 ]}
6970 full
7071 />
71− <Grid.Item as={H5} className="mv4" full>
72+ <Grid.Item full as={ViewHeader}>
7273 Invite
7374 </Grid.Item>
7475 <Grid.Item as={Text} className="f5" full>
src/views/Point/Actions.js−23
@@ -67,19 +67,6 @@ function Actions() {
6767 eqAddr(deets.value.owner, addr),
6868 });
6969
70− const canGenKeyfile = pointDetails.matchWith({
71− Nothing: () => false,
72− Just: deets => {
73− const hasPermission =
74− eqAddr(deets.value.owner, addr) ||
75− eqAddr(deets.value.managementProxy, addr);
76−
77− const isBooted = deets.value.keyRevisionNumber > 0;
78−
79− return hasPermission && isBooted;
80− },
81− });
82−
8370 const canAcceptTransfer = pointDetails.matchWith({
8471 Nothing: () => false,
8572 Just: deets => eqAddr(deets.value.transferProxy, addr),
@@ -142,16 +129,6 @@ function Actions() {
142129 }}>
143130 {'Cancel outgoing transfer'}
144131 </Button>
145−
146− <Button
147− prop-size={'sm'}
148− prop-type={'link'}
149− disabled={!canGenKeyfile}
150− onClick={() => {
151− history.push(ROUTE_NAMES.GEN_KEYFILE);
152− }}>
153− {'Generate Arvo keyfile'}
154− </Button>
155132 </Grid.Item>
156133 <Grid.Item third={2}>
157134 <Button