Skip to content

Commit

Permalink
feat: support offer signing with keplr
Browse files Browse the repository at this point in the history
  • Loading branch information
samsiegart committed Aug 15, 2023
1 parent 4dd5c43 commit bcd1575
Show file tree
Hide file tree
Showing 9 changed files with 355 additions and 13 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"type": "module",
"packageManager": "yarn@1.22.19",
"scripts": {
"postinstall": "patch-package",
"format": "yarn prettier --write packages",
"lint": "yarn workspaces run lint",
"test": "yarn workspaces run test"
Expand Down Expand Up @@ -37,6 +38,8 @@
"eslint-plugin-react-hooks": "^4.6.0",
"lerna": "^7.0.1",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"rollup": "^2.0.0"
},
Expand Down
1 change: 1 addition & 0 deletions packages/rpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"vite-tsconfig-paths": "^4.2.0"
},
"devDependencies": {
"@agoric/smart-wallet": "0.5.3",
"@typescript-eslint/eslint-plugin": "^5.35.1",
"@typescript-eslint/parser": "^5.35.1",
"@vitest/coverage-c8": "^0.25.3",
Expand Down
16 changes: 11 additions & 5 deletions packages/rpc/src/chainStorageWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/* eslint-disable no-use-before-define */
/* eslint-disable import/extensions */
import type { FromCapData } from '@endo/marshal';
import type { UpdateHandler } from './types';
/* eslint-disable import/no-extraneous-dependencies */
import { makeImportContext } from '@agoric/smart-wallet/src/marshal-contexts';
import { AgoricChainStoragePathKind } from './types';
import { batchVstorageQuery, keyToPath, pathToKey } from './batchQuery';
import type { UpdateHandler } from './types';

type Subscriber<T> = {
onUpdate: UpdateHandler<T>;
Expand Down Expand Up @@ -40,8 +41,8 @@ export type ChainStorageWatcher = ReturnType<
* requests for efficiency.
* @param rpcAddr RPC server URL
* @param chainId the chain id to use
* @param unmarshal CapData unserializer to use
* @param onError
* @param marshal CapData marshal to use
* @param newPathQueryDelayMs
* @param refreshLowerBoundMs
* @param refreshUpperBoundMs
Expand All @@ -50,8 +51,8 @@ export type ChainStorageWatcher = ReturnType<
export const makeAgoricChainStorageWatcher = (
rpcAddr: string,
chainId: string,
unmarshal: FromCapData<string>,
onError?: (e: Error) => void,
marshal = makeImportContext().fromBoard,
newPathQueryDelayMs = defaults.newPathQueryDelayMs,
refreshLowerBoundMs = defaults.refreshLowerBoundMs,
refreshUpperBoundMs = defaults.refreshUpperBoundMs,
Expand Down Expand Up @@ -105,7 +106,11 @@ export const makeAgoricChainStorageWatcher = (
}

try {
const data = await batchVstorageQuery(rpcAddr, unmarshal, paths);
const data = await batchVstorageQuery(
rpcAddr,
marshal.fromCapData,
paths,
);
watchedPathsToSubscribers.forEach((subscribers, path) => {
// Path was watched after query fired, wait until next round.
if (!data[path]) return;
Expand Down Expand Up @@ -201,5 +206,6 @@ export const makeAgoricChainStorageWatcher = (
watchLatest,
chainId,
rpcAddr,
marshal,
};
};
156 changes: 156 additions & 0 deletions packages/web-components/src/wallet-connection/makeInteractiveSigner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { fromBech32, toBech32, fromBase64, toBase64 } from '@cosmjs/encoding';
import { Registry } from '@cosmjs/proto-signing';
import {
AminoTypes,
defaultRegistryTypes,
assertIsDeliverTxSuccess,
createBankAminoConverters,
createAuthzAminoConverters,
} from '@cosmjs/stargate';
import { MsgWalletSpendAction } from '@agoric/cosmic-proto/swingset/msgs.js';
import { stableCurrency, bech32Config } from './chainInfo.js';

Check failure on line 11 in packages/web-components/src/wallet-connection/makeInteractiveSigner.js

View workflow job for this annotation

GitHub Actions / main

Unable to resolve path to module './chainInfo.js'
import { Errors } from '../errors.js';

/** @typedef {import("@cosmjs/proto-signing").EncodeObject} EncodeObject */
/** @typedef {import("@cosmjs/stargate").AminoConverters} AminoConverters */
/** @typedef {import("@cosmjs/stargate").StdFee} StdFee */
/** @typedef {import('@keplr-wallet/types').ChainInfo} ChainInfo */
/** @typedef {import('@keplr-wallet/types').Keplr} Keplr */

/**
* @param {string} address
* @returns {Uint8Array}
*/
const toAccAddress = address => {
return fromBech32(address).data;
};

/**
* `/agoric.swingset.XXX` matches package agoric.swingset in swingset/msgs.proto
* aminoType taken from Type() in golang/cosmos/x/swingset/types/msgs.go
*/
const SwingsetMsgs = {
MsgWalletSpendAction: {
typeUrl: '/agoric.swingset.MsgWalletSpendAction',
aminoType: 'swingset/WalletSpendAction',
},
};

/** @typedef {{owner: string, spendAction: string}} WalletSpendAction */

const SwingsetRegistry = new Registry([
...defaultRegistryTypes,
// XXX should this list be "upstreamed" to @agoric/cosmic-proto?
[SwingsetMsgs.MsgWalletSpendAction.typeUrl, MsgWalletSpendAction],
]);

/**
* @returns {StdFee}
*/
const zeroFee = () => {
const { coinMinimalDenom: denom } = stableCurrency;
const fee = {
amount: [{ amount: '0', denom }],
gas: '300000', // TODO: estimate gas?
};
return fee;
};

/**
* @type {AminoConverters}
*/
const SwingsetConverters = {
[SwingsetMsgs.MsgWalletSpendAction.typeUrl]: {
aminoType: SwingsetMsgs.MsgWalletSpendAction.aminoType,
toAmino: ({ spendAction, owner }) => ({
spend_action: spendAction,
owner: toBech32(bech32Config.bech32PrefixAccAddr, fromBase64(owner)),
}),
fromAmino: ({ spend_action: spendAction, owner }) => ({
spendAction,
owner: toBase64(toAccAddress(owner)),
}),
},
};

/**
* Use Keplr to sign offers and delegate object messaging to local storage key.
* @param {string} chainId
* @param {string} rpc
* @param {Keplr} keplr
* @param {typeof import('@cosmjs/stargate').SigningStargateClient.connectWithSigner} connectWithSigner
* Ref: https://docs.keplr.app/api/
*/
export const makeInteractiveSigner = async (
chainId,
rpc,
keplr,
connectWithSigner,
) => {
try {
// eslint-disable-next-line @jessie.js/safe-await-separator
await keplr.enable(chainId);
} catch {
throw Error(Errors.enableKeplr);
}

const key = await keplr.getKey(chainId);

// Until we have SIGN_MODE_TEXTUAL,
// Use Amino because Direct results in ugly protobuf in the keplr UI.
const offlineSigner = await keplr.getOfflineSignerOnlyAmino(chainId);
console.debug('InteractiveSigner', { offlineSigner });

// Currently, Keplr extension manages only one address/public key pair.
const [account] = await offlineSigner.getAccounts();
const { address } = account;

const converters = {
...SwingsetConverters,
...createBankAminoConverters(),
...createAuthzAminoConverters(),
};
const signingClient = await connectWithSigner(rpc, offlineSigner, {
aminoTypes: new AminoTypes(converters),
registry: SwingsetRegistry,
});
console.debug('InteractiveSigner', { signingClient });

const fee = zeroFee();

return harden({
address, // TODO: address can change
isNanoLedger: key.isNanoLedger,
/**
* Sign and broadcast WalletSpendAction
*
* @param {string} spendAction marshaled offer
* @throws if account does not exist on chain, user cancels,
* RPC connection fails, RPC service fails to broadcast (
* for example, if signature verification fails)
*/
submitSpendAction: async spendAction => {
const { accountNumber, sequence } = await signingClient.getSequence(
address,
);
console.debug({ accountNumber, sequence });

const act1 = {
typeUrl: SwingsetMsgs.MsgWalletSpendAction.typeUrl,
value: {
owner: toBase64(toAccAddress(address)),
spendAction,
},
};

const msgs = [act1];
console.debug('sign spend action', { address, msgs, fee });

const tx = await signingClient.signAndBroadcast(address, msgs, fee);
console.debug('spend action result tx', tx);
assertIsDeliverTxSuccess(tx);

return tx;
},
});
};
69 changes: 67 additions & 2 deletions packages/web-components/src/wallet-connection/walletConnection.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,78 @@
// @ts-check
import { getKeplrAddress } from '../getKeplrAddress.js';
import { SigningStargateClient } from '@cosmjs/stargate';
import { subscribeLatest } from '@agoric/notifier';
import { makeInteractiveSigner } from './makeInteractiveSigner.js';
import { watchWallet } from './watchWallet.js';
import { Errors } from '../errors.js';

export const makeAgoricWalletConnection = async chainStorageWatcher => {
const address = await getKeplrAddress(chainStorageWatcher.chainId);
if (!('keplr' in window)) {
throw Error(Errors.noKeplr);
}
/** @type {import('@keplr-wallet/types').Keplr} */
// @ts-expect-error cast (checked above)
const keplr = window.keplr;

const { address, submitSpendAction } = await makeInteractiveSigner(
chainStorageWatcher.chainId,
chainStorageWatcher.rpcAddr,
keplr,
SigningStargateClient.connectWithSigner,
);

const walletNotifiers = await watchWallet(chainStorageWatcher, address);

const makeOffer = async (
invitationSpec,
proposal,
offerArgs,
onStatusChange,
) => {
const { marshal } = chainStorageWatcher;
const id = new Date().getTime();
const spendAction = marshal.serialize(
harden({
method: 'executeOffer',
offer: {
id,
invitationSpec,
proposal,
offerArgs,
},
}),
);

try {
// eslint-disable-next-line @jessie.js/safe-await-separator
const txn = await submitSpendAction(JSON.stringify(spendAction));
onStatusChange({ status: 'seated', data: { txn, offerId: id } });
} catch (e) {
onStatusChange({ status: 'error', data: e });
return;
}

const iterator = subscribeLatest(walletNotifiers.walletUpdatesNotifier);
for await (const update of iterator) {
if (update.updated !== 'offerStatus' || update.status.id !== id) {
continue;
}
if (update.status.error !== undefined) {
onStatusChange({ status: 'error', data: update.status.error });
return;
}
if (update.status.numWantsSatisfied === 0) {
onStatusChange({ status: 'refunded' });
return;
}
if (update.status.numWantsSatisfied === 1) {
onStatusChange({ status: 'accepted' });
return;
}
}
};

return {
makeOffer,
address,
...walletNotifiers,
};
Expand Down
21 changes: 21 additions & 0 deletions packages/web-components/src/wallet-connection/watchWallet.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check
import { makeNotifierKit } from '@agoric/notifier';
import { AmountMath } from '@agoric/ertp';
import { iterateLatest, makeFollower, makeLeader } from '@agoric/casting';
import { Errors } from '../errors.js';
import { queryBankBalances } from '../queryBankBalances.js';

Expand Down Expand Up @@ -45,6 +46,12 @@ export const watchWallet = async (chainStorageWatcher, address) => {
),
);

const walletUpdatesNotifierKit = makeNotifierKit(
/** @type { import('@agoric/smart-wallet/src/smartWallet.js').UpdateRecord | null } */ (
null
),
);

// NB: this watches '.current' but only notifies of changes to offerToPublicSubscriberPaths
await /** @type {Promise<void>} */ (
new Promise((res, rej) => {
Expand Down Expand Up @@ -122,10 +129,24 @@ export const watchWallet = async (chainStorageWatcher, address) => {
void watchBank();
};

const watchWalletUpdates = async () => {
const leader = makeLeader(chainStorageWatcher.rpcAddr);
const follower = makeFollower(`:published.wallet.${address}`, leader, {
proof: 'none',
});

for await (const { value } of iterateLatest(follower)) {
console.debug('wallet update', value);
walletUpdatesNotifierKit.updater.updateState(harden(value));
}
};

watchChainBalances();
watchWalletUpdates();

return {
pursesNotifier: pursesNotifierKit.notifier,
publicSubscribersNotifier: publicSubscriberPathsNotifierKit.notifier,
walletUpdatesNotifier: walletUpdatesNotifierKit.notifier,
};
};
2 changes: 1 addition & 1 deletion packages/web-components/test/walletConnection.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, vi, expect, it } from 'vitest';
import { makeAgoricWalletConnection } from '../src/wallet-connection/walletConnection.js';
import { getKeplrAddress } from '../src/getKeplrAddress.js';
import { getKeplrAddress } from '../src/getKeplrSigner.js/index.js';

Check failure on line 3 in packages/web-components/test/walletConnection.test.js

View workflow job for this annotation

GitHub Actions / main

Unable to resolve path to module '../src/getKeplrSigner.js/index.js'
import { Errors } from '../src/errors.js';

const testAddress = 'agoric123test';
Expand Down
13 changes: 13 additions & 0 deletions patches/@agoric+smart-wallet+0.5.3.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
diff --git a/node_modules/@agoric/smart-wallet/src/marshal-contexts.js b/node_modules/@agoric/smart-wallet/src/marshal-contexts.js
index de5b3b9..b7594b6 100644
--- a/node_modules/@agoric/smart-wallet/src/marshal-contexts.js
+++ b/node_modules/@agoric/smart-wallet/src/marshal-contexts.js
@@ -286,7 +286,7 @@ export const makeImportContext = (makePresence = defaultMakePresence) => {
* @param {string} iface
*/
fromBoard: (slot, iface) => {
- isDefaultBoardId(slot) || Fail`bad board slot ${q(slot)}`;
+ isDefaultBoardId(slot) || slot === null || Fail`bad board slot ${q(slot)}`;
return provideVal(boardObjects, slot, iface);
},

0 comments on commit bcd1575

Please sign in to comment.