Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(wallet-connection): Connect dapp directly to wallet UI #5750

Merged
merged 17 commits into from
Jul 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ for the Prometheus scrape endpoint to export telemetry.

Lifetime: until we decide not to support Prometheus for metrics export

## SOLO_BRIDGE_TARGET

Affects: solo

This enables a proxy so that the solo bridge interface (/wallet-bridge.html) is backed by the smart wallet (/wallet/bridge.html). Dapps designed for the solo bridge can enable this until they connect to the smart wallet directly.

```
BRIDGE_TARGET=http://localhost:3001 make BASE_PORT=8002 scenario3-run
```

Lifetime: smart wallet transition period

## SOLO_LMDB_MAP_SIZE

Affects: solo
Expand Down
2 changes: 1 addition & 1 deletion packages/casting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ An example of following an on-chain mailbox in code (using this package) is:
```js
// First, obtain a Hardened JS environment via Endo.
import '@endo/init/pre-remoting.js'; // needed only for the next line
import '@agoric/castingSpec/node-fetch-shim.js'; // needed for Node.js
import '@agoric/casting/node-fetch-shim.js'; // needed for Node.js
import '@endo/init';

import {
Expand Down
1 change: 1 addition & 0 deletions packages/solo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"deterministic-json": "^1.0.5",
"esm": "agoric-labs/esm#Agoric-built",
"express": "^4.17.1",
"http-proxy-middleware": "^2.0.6",
"import-meta-resolve": "^1.1.1",
"minimist": "^1.2.0",
"morgan": "^1.9.1",
Expand Down
15 changes: 10 additions & 5 deletions packages/solo/public/wallet-bridge.html
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,16 @@
});
};

// Start the flow of messages.
if (window.parent !== window) {
window.parent.postMessage({ type: 'walletBridgeLoaded' }, '*');
}
retryWebSocket();
// This ensures the message wont be posted until after the iframe's
// "onLoad" event fires so we can rely on the consistent ordering of
// events in the WalletConnection component.
window.addEventListener('load', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ensures the message wont be posted until after the iframe's "onLoad" event fires. This is the way it works with the react bridge.jsx, so we can rely on the consistent ordering of events in the WalletConnection component.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

your PR comment would make a good code comment.

Suggested change
window.addEventListener('load', () => {
// This ensures the message wont be posted until after the iframe's "onLoad" event fires
// so we can rely on the consistent ordering of events in the WalletConnection component.
window.addEventListener('load', () => {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, please!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// Start the flow of messages.
if (window.parent !== window) {
window.parent.postMessage({ type: 'walletBridgeLoaded' }, '*');
}
retryWebSocket();
});
</script>
</body>
</html>
30 changes: 28 additions & 2 deletions packages/solo/src/web.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/* global setTimeout clearTimeout setInterval clearInterval */
/* global setTimeout clearTimeout setInterval clearInterval process */
// Start a network service
import path from 'path';
import http from 'http';
import { createConnection } from 'net';
import { existsSync as existsSyncAmbient } from 'fs';
samsiegart marked this conversation as resolved.
Show resolved Hide resolved
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import WebSocket from 'ws';
import anylogger from 'anylogger';
import morgan from 'morgan';
Expand Down Expand Up @@ -71,6 +73,7 @@ export async function makeHTTPListener(
walletHtmlDir = '',
validateAndInstallBundle,
connections,
{ env = process.env, existsSync = existsSyncAmbient } = {},
) {
// Enrich the inbound command with some metadata.
const inboundCommand = (
Expand Down Expand Up @@ -126,13 +129,36 @@ export async function makeHTTPListener(
app.use(express.json({ limit: maximumBundleSize })); // parse application/json
const server = http.createServer(app);

// Proxy to another wallet bridge
const { SOLO_BRIDGE_TARGET: bridgeTarget } = env;
if (bridgeTarget) {
app.use(
['/wallet-bridge.html', '/wallet'],
createProxyMiddleware({
target: bridgeTarget,
pathRewrite: {
'^/wallet-bridge.html': '/wallet/bridge.html',
},
// changeOrigin: true,
}),
);
}

// serve the static HTML for the UI
const htmldir = path.join(basedir, 'html');
log(`Serving static files from ${htmldir}`);
app.use(express.static(htmldir));
app.use(express.static(new URL('../public', import.meta.url).pathname));

if (walletHtmlDir) {
if (walletHtmlDir && !bridgeTarget) {
// Transition localStorage based bridge
if (existsSync(path.join(walletHtmlDir, 'bridge.html'))) {
console.log('redirecting wallet bridge');
app.get('/wallet-bridge.html', (req, res) =>
res.redirect('/wallet/bridge.html'),
);
}

// Serve the wallet directory.
app.use('/wallet', express.static(walletHtmlDir));

Expand Down
6 changes: 5 additions & 1 deletion packages/wallet/api/src/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,11 @@ export function buildRootObject(vatPowers) {
},
getDepositFacetId: walletAdmin.getDepositFacetId,
getAdminFacet() {
return Far('adminFacet', { ...walletAdmin, ...notifiers });
return Far('adminFacet', {
...walletAdmin,
...notifiers,
getScopedBridge: wallet.getScopedBridge,
});
},
getIssuer: walletAdmin.getIssuer,
getIssuers: walletAdmin.getIssuers,
Expand Down
47 changes: 47 additions & 0 deletions packages/wallet/ui/config-overrides/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
// cribbed from https://www.npmjs.com/package/react-app-rewire-multiple-entry
samsiegart marked this conversation as resolved.
Show resolved Hide resolved
/* global __dirname, require, module */

const path = require('path');
const HtmlWebPackPlugin = require('html-webpack-plugin');
const multiEntry = require('react-app-rewire-multiple-entry');

const bridgeTemplate = path.resolve(
path.join(__dirname, '..', 'public', 'bridge.html'),
);

const multipleEntry = multiEntry([
{
template: 'public/index.html',
entry: 'src/index.jsx',
},
{
template: 'public/bridge.html',
entry: 'src/bridge.jsx',
},
]);

module.exports = function override(config, _env) {
config.resolve.fallback = { path: false, crypto: false };
config.ignoreWarnings = [/Failed to parse source map/];

const htmlWebpackPlugin = config.plugins.find(
plugin => plugin.constructor.name === 'HtmlWebpackPlugin',
);
if (!htmlWebpackPlugin) {
throw new Error("Can't find HtmlWebpackPlugin");
}

multipleEntry.addMultiEntry(config);

const bridgeKeys = Object.keys(config.entry).filter(k =>
k.startsWith('bridge'),
);
const opts = {
...htmlWebpackPlugin.userOptions,
template: bridgeTemplate,
filename: './bridge.html',
chunks: bridgeKeys,
};
htmlWebpackPlugin.userOptions = {
...htmlWebpackPlugin.userOptions,
excludeChunks: bridgeKeys,
};
const plug2 = new HtmlWebPackPlugin(opts);
config.plugins.push(plug2);
return config;
};
1 change: 1 addition & 0 deletions packages/wallet/ui/jsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"jsx": "react",
"target": "esnext",
"module": "esnext",
"noEmit": true,
Expand Down
1 change: 1 addition & 0 deletions packages/wallet/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"eslint-plugin-react-hooks": "^4.3.0",
"process": "^0.11.10",
"react": "^16.8.0",
"react-app-rewire-multiple-entry": "^2.2.3",
"react-app-rewired": "^2.2.1",
"react-dom": "^16.8.0",
"react-router-dom": "^5.3.0",
Expand Down
15 changes: 15 additions & 0 deletions packages/wallet/ui/public/bridge.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<script src="%PUBLIC_URL%/lockdown.umd.js"></script>
<title>Agoric Wallet Bridge</title>
</head>

<body style="overflow-x: hidden; overflow-y: scroll">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>

</html>
135 changes: 135 additions & 0 deletions packages/wallet/ui/src/bridge.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import '@endo/eventual-send/shim';
import './lockdown.js';

import React from 'react';
import ReactDOM from 'react-dom';

Error.stackTraceLimit = Infinity;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL.

How do we know this is necessary?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. I looked around for clues without much luck. It does merit a comment, at least to point to some discussion of why we do this. Help, @kriskowal ? @erights ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can leave this convo open but I won't block this PR on it since it's preexisting code.


/** ISSUE: where are these defined? */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISSUE like file an issue?

In master they're magic strings. I see two posts and one receive that's just a validation:

window.parent.postMessage({ type: 'walletBridgeLoaded' }, '*');

window.parent.postMessage({ type: 'walletBridgeOpened' }, '*');

let onMessage = event => {
console.log(component.state, 'connect received', event);
const { data, send } = event.detail;
assert.equal(
data.type,
'walletBridgeLoaded',
X`Unexpected connect message type ${data.type}`,
);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue like: decide between us, whether to sweep it under the rug, address it, or file an issue. Yes, the magic strings pre-date this PR. Is it time to fix them?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks to me that this PR solves the problem already. the question "where are these defined?" is answered and they're no longer magic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the other occurrences don't refer to BridgeProtocol. And I think there are one or two other such strings. And this BridgeProtocol doesn't provide much of a definition: what are the valid state transitions? What do the states mean?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, I thought this PR replaced the occurrences I linked to.

ok, unresolved issue. can be punted imo. this whole package needs a types cleanup

const BridgeProtocol = /** @type {const} */ ({
loaded: 'walletBridgeLoaded',
opened: 'walletBridgeOpened',
});

const checkParentWindow = () => {
const me = window;
const { parent } = window;
if (me === parent) {
throw Error('window.parent === parent!!!');
}
};

/**
* Install a dApp "connection" where messages posted from
* the dApp are forwarded to localStorage and vice versa.
*
* @param {{
* addEventListener: typeof window.addEventListener,
* parentPost: typeof window.postMessage,
* t0: number,
* }} io
*/
const installDappConnection = ({ addEventListener, parentPost, t0 }) => {
checkParentWindow();

/** @type { string } */
let origin;

/** @type {(key: string, value: string) => void} */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised ts-check isn't alerting that setItem is undefined at this point.

Oh, there's no type checking in this package yet. #5757

let setItem;
/** @type {string[]} */
const buffer = [];

console.debug('bridge: installDappConnection');

parentPost({ type: BridgeProtocol.loaded }, '*');
parentPost({ type: BridgeProtocol.opened }, '*');
Comment on lines +48 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no distinction anymore?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. It feels a bit magic, to me. In wallet-bridge.html, opened seems to go later in the process... analogous to when the user clicks the button... but I tried it that way without luck. Perhaps Sam's recent work addressed that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, walletBridgeOpened doesn't seem to be read anywhere. Maybe useful for debugging, but only walletBridgeLoaded has any logic dependent on it.


let outIx = 0; // TODO: persist index in storage
/** @param {Record<string, any>} payload */

const { stringify, parse } = JSON;

addEventListener('message', ev => {
if (!ev.data?.type?.startsWith('CTP_')) {
return;
}

if (origin === undefined) {
// First-come, first-serve.
origin = ev.origin;
}
if (setItem) {
console.debug('bridge: message from dapp->localStorage', origin, ev.data);
setItem(stringify(['out', origin, t0, outIx]), stringify(ev.data));
} else {
console.debug('bridge: message from dapp->buffer', origin, ev.data);
buffer.push(harden(ev.data));
}
});

return harden({
/** @param {typeof window.localStorage} storage */
connectStorage: storage => {
addEventListener('storage', ev => {
if (!ev.key || !ev.newValue) {
return;
}
const [tag, targetOrigin, targetT, _keyIx] = JSON.parse(ev.key); // or throw
if (tag !== 'in' || targetOrigin !== origin || targetT !== t0) {
return;
}
const payload = parse(ev.newValue); // or throw
storage.removeItem(ev.key);
console.debug('bridge: storage -> message to dapp', origin, payload);
parentPost(payload, '*');
});
setItem = (key, value) => {
storage.setItem(key, value);
outIx += 1;
};
if (buffer.length) {
console.debug('sending', buffer.length, 'queued messages from', origin);
while (buffer.length) {
setItem(
stringify(['out', origin, t0, outIx]),
stringify(buffer.shift()),
);
}
}
},
});
};

const conn = installDappConnection({
addEventListener: window.addEventListener,
parentPost: (payload, origin) => window.parent.postMessage(payload, origin),
t0: Date.now(),
});

const signalBridge = () => {
if ('requestStorageAccess' in document) {
document
.requestStorageAccess()
.then(() => {
console.debug('bridge: storage access granted');
conn.connectStorage(window.localStorage);
})
.catch(whyNot =>
console.error('bridge: requestStorageAccess rejected with', whyNot),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the UX in this case? worth an alert?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually not sure, requestStorageAccess isn't in chrome.

);
} else {
console.debug('bridge: SKIP document.requestStorageAccess');
conn.connectStorage(window.localStorage);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this fail?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I don't think so. It only calls addEventListener; I don't see how that could fail.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been bitten by localStorage permission problems, but I suppose if you've got the object you won't find out about any errors until mutating. And even then you could have failures later with hitting quotas, the permission being revoked, etc. So fine to handle those cases later.

}
};

ReactDOM.render(
<button id="signalBridge" onClick={signalBridge}>
Signal Bridge
</button>,
document.getElementById('root'),
);
3 changes: 2 additions & 1 deletion packages/wallet/ui/src/components/Offer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ const OfferWithoutContext = ({
const {
instancePetname,
instanceHandleBoardId,
requestContext: { date, dappOrigin, origin = 'unknown origin' } = {},
requestContext: { dappOrigin, origin = 'unknown origin' } = {},
id,
meta: { creationStamp: date },
} = offer;
let status = offer.status || 'proposed';

Expand Down
Loading