diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 691e699..d8abb53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,12 @@ importers: ethereum-tx-observer: specifier: ^0.0.3 version: 0.0.3 + fuzd-executor: + specifier: ^0.1.5 + version: 0.1.5 + fuzd-scheduler: + specifier: ^0.1.5 + version: 0.1.5 jolly-roger-common: specifier: workspace:* version: link:../common @@ -157,9 +163,15 @@ importers: radiate: specifier: ^0.0.1 version: 0.0.1 + remote-account: + specifier: ^0.0.4 + version: 0.0.4 theme-change: specifier: ^2.5.0 version: 2.5.0 + tlock-js: + specifier: ^0.7.0 + version: 0.7.0 viem: specifier: ^1.16.6 version: 1.16.6(typescript@5.2.2) @@ -939,6 +951,11 @@ packages: tweetnacl-util: 0.15.1 dev: true + /@noble/bls12-381@1.4.0: + resolution: {integrity: sha512-mIYqC2jMX7Lcu1QtU/FFftMPDSXNCdlGex6BSf5nPojHjnzzBgs1klFWpB1R8YjqHmOO9xrCzF19f2c42+z3vg==} + deprecated: Switch to @noble/curves for security updates + dev: false + /@noble/curves@1.1.0: resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} dependencies: @@ -966,6 +983,10 @@ packages: /@noble/secp256k1@1.7.1: resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} + /@noble/secp256k1@2.0.0: + resolution: {integrity: sha512-rUGBd95e2a45rlmFTqQJYEFA4/gdIARFfuTuTqLglz0PZ6AKyzyXsEZZq7UZn8hZsvaBgpCzKKBJizT2cJERXw==} + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1408,6 +1429,53 @@ packages: antlr4ts: 0.5.0-alpha.4 dev: true + /@stablelib/aead@1.0.1: + resolution: {integrity: sha512-q39ik6sxGHewqtO0nP4BuSe3db5G1fEJE8ukvngS2gLkBXyy6E7pLubhbYgnkDFv6V8cWaxcE4Xn0t6LWcJkyg==} + dev: false + + /@stablelib/binary@1.0.1: + resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==} + dependencies: + '@stablelib/int': 1.0.1 + dev: false + + /@stablelib/chacha20poly1305@1.0.1: + resolution: {integrity: sha512-MmViqnqHd1ymwjOQfghRKw2R/jMIGT3wySN7cthjXCBdO+qErNPUBnRzqNpnvIwg7JBCg3LdeCZZO4de/yEhVA==} + dependencies: + '@stablelib/aead': 1.0.1 + '@stablelib/binary': 1.0.1 + '@stablelib/chacha': 1.0.1 + '@stablelib/constant-time': 1.0.1 + '@stablelib/poly1305': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/chacha@1.0.1: + resolution: {integrity: sha512-Pmlrswzr0pBzDofdFuVe1q7KdsHKhhU24e8gkEwnTGOmlC7PADzLVxGdn2PoNVBBabdg0l/IfLKg6sHAbTQugg==} + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/constant-time@1.0.1: + resolution: {integrity: sha512-tNOs3uD0vSJcK6z1fvef4Y+buN7DXhzHDPqRLSXUel1UfqMB1PWNsnnAezrKfEwTLpN0cGH2p9NNjs6IqeD0eg==} + dev: false + + /@stablelib/int@1.0.1: + resolution: {integrity: sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==} + dev: false + + /@stablelib/poly1305@1.0.1: + resolution: {integrity: sha512-1HlG3oTSuQDOhSnLwJRKeTRSAdFNVB/1djy2ZbS35rBSJ/PFqx9cf9qatinWghC2UbfOYD8AcrtbUQl8WoxabA==} + dependencies: + '@stablelib/constant-time': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/wipe@1.0.1: + resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==} + dev: false + /@sveltejs/adapter-static@2.0.3(@sveltejs/kit@1.27.0): resolution: {integrity: sha512-VUqTfXsxYGugCpMqQv1U0LIdbR3S5nBkMMDmpjGVJyM6Q2jHVMFtdWJCkeHMySc6mZxJ+0eZK3T7IgmUCDrcUQ==} peerDependencies: @@ -2014,7 +2082,6 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: true /basic-auth@2.0.1: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} @@ -2204,7 +2271,6 @@ packages: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 - dev: true /builtins@5.0.1: resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} @@ -2933,6 +2999,14 @@ packages: resolution: {integrity: sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ==} dev: true + /drand-client@1.2.1: + resolution: {integrity: sha512-ht7ku30RfqJDFVv1jVE6EaHTfu8w6DQ4BZbviLRqn3ZCp+8kI38XFwr61Y3GFEFBM0cAwfnTtgjJ9f541XIBqQ==} + engines: {node: '>= 10.4.0'} + dependencies: + '@noble/curves': 1.2.0 + buffer: 6.0.3 + dev: false + /ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} dependencies: @@ -3679,6 +3753,30 @@ packages: resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} dev: true + /fuzd-common@0.1.4: + resolution: {integrity: sha512-18Xc/k/sdcP50GBIakhyUCJrIP4r3bN8MufCIhy+w6s0XP+TD+nGtLHMPRwX+tZUKWqLmgZcc+MKiRESTx5jQA==} + dependencies: + named-logs: 0.2.2 + dev: false + + /fuzd-executor@0.1.5: + resolution: {integrity: sha512-sPPzxLGfS9ssTow6Je9XGDZqgtnctZcbnZRYY/kWL6uwp6V55QeA/C4BqDzvThsLJcDffCZcNP25oHSKB45ILQ==} + dependencies: + '@noble/hashes': 1.3.2 + fuzd-common: 0.1.4 + named-logs: 0.2.2 + wighawag-ono: 7.1.3 + zod: 3.22.4 + dev: false + + /fuzd-scheduler@0.1.5: + resolution: {integrity: sha512-hCAcaB7BWRMRqb9rcisqeAIdvUhL3V3V1kpdv1z0B4QSsW6WA6LE5g0MYQ06rI20eHprlRHzshSerhO98Gy4vg==} + dependencies: + named-logs: 0.2.2 + wighawag-ono: 7.1.3 + zod: 3.22.4 + dev: false + /get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -4132,7 +4230,6 @@ packages: /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: true /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} @@ -6356,6 +6453,14 @@ packages: engines: {node: '>=8'} dev: true + /remote-account@0.0.4: + resolution: {integrity: sha512-tFriVFpgm5zpl8Zdm8PMC8IKqaHnJH2Sql4tSocZIEpOdgIyAuuLtQDGaA8KGyUhMpvpdSjS+q2OZHaaPcrG0A==} + dependencies: + '@noble/hashes': 1.3.2 + '@noble/secp256k1': 2.0.0 + '@scure/bip32': 1.3.2 + dev: false + /request@2.88.2: resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} engines: {node: '>= 6'} @@ -7418,6 +7523,17 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tlock-js@0.7.0: + resolution: {integrity: sha512-Liwhm39z0uCokT9shOLaz37p2xWpN/4EFqB7xd9tJ+sR0J9eparfvz1HcG2EbU8NBuiCKppGJT8kYLj4EOwd+g==} + engines: {node: '>= 16.0.0'} + dependencies: + '@noble/bls12-381': 1.4.0 + '@noble/hashes': 1.3.2 + '@stablelib/chacha20poly1305': 1.0.1 + buffer: 6.0.3 + drand-client: 1.2.1 + dev: false + /tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -8051,6 +8167,10 @@ packages: stackback: 0.0.2 dev: true + /wighawag-ono@7.1.3: + resolution: {integrity: sha512-5AutMWc2ig5QAOEBAPIiAscwJETr9qSHh7Q1UpwUgiB/LmVZ2mpxSRD4t4suJPSQTAnktVfBo5TFJ3gZJpZ++w==} + dev: false + /word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -8232,3 +8352,7 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} dev: true + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + dev: false diff --git a/web/.env b/web/.env index 8829aed..ef488c4 100644 --- a/web/.env +++ b/web/.env @@ -13,3 +13,5 @@ PUBLIC_ETH_NODE_URI= # Can specify a block time for localhost PUBLIC_LOCALHOST_BLOCK_TIME=${BLOCK_TIME} + +PUBLIC_FUZD_URI=http://127.0.0.1:34002/ diff --git a/web/package.json b/web/package.json index 603e947..3a1201f 100644 --- a/web/package.json +++ b/web/package.json @@ -32,12 +32,16 @@ "@types/lodash-es": "^4.17.10", "ethereum-indexer-browser": "^0.6.10", "ethereum-tx-observer": "^0.0.3", + "fuzd-executor": "^0.1.5", + "fuzd-scheduler": "^0.1.5", "jolly-roger-common": "workspace:*", "jolly-roger-indexer": "workspace:*", "lodash-es": "^4.17.21", "named-logs": "^0.2.2", "radiate": "^0.0.1", + "remote-account": "^0.0.4", "theme-change": "^2.5.0", + "tlock-js": "^0.7.0", "viem": "^1.16.6", "web3-connection": "^0.1.8", "web3-connection-viem": "^0.0.1" diff --git a/web/src/lib/account/index.ts b/web/src/lib/account/index.ts index ef7e9a5..9de6a81 100644 --- a/web/src/lib/account/index.ts +++ b/web/src/lib/account/index.ts @@ -12,7 +12,7 @@ export type AccountFunctions = { data: AccountData, tx: EIP1193TransactionWithMetadata, hash: `0x${string}`, - inclusion?: 'Broadcasted' + inclusion?: 'Broadcasted', ) => Action; }; @@ -35,7 +35,7 @@ export function initAccount({ for (const actionData of actionsData) { const pending_transaction: PendingTransaction = fromActionToPendingTransactions( actionData.hash, - actionData.action + actionData.action, ); pending_transactions.push(pending_transaction); } diff --git a/web/src/lib/blockchain/state/PendingState.ts b/web/src/lib/blockchain/state/PendingState.ts index b5df121..8bc4a88 100644 --- a/web/src/lib/blockchain/state/PendingState.ts +++ b/web/src/lib/blockchain/state/PendingState.ts @@ -11,96 +11,101 @@ const logger = logs(`pending-state`); * This allow us to optimistically update the UI with pending messages from the user * */ -export const pendingState = derived([syncing, state, accountData.data], ([$syncing, $state, $accountData]) => { - const pendingGreetings: {account: `0x${string}`; message: string; pending: boolean}[] = $state.greetings.map((v) => ({ - message: v.message, - account: v.account, - pending: false, - })); +export const pendingState = derived( + [syncing, state, accountData.onchainActions], + ([$syncing, $state, $onchainActions]) => { + const pendingGreetings: {account: `0x${string}`; message: string; pending: boolean}[] = $state.greetings.map( + (v) => ({ + message: v.message, + account: v.account, + pending: false, + }), + ); - logger.info(`num greetings: ${$state.greetings.length}`); + logger.info(`num greetings: ${$state.greetings.length}`); - const accountIndexes: {[from: `0x${string}`]: number} = {}; - for (let i = 0; i < pendingGreetings.length; i++) { - accountIndexes[pendingGreetings[i].account] = i; - logger.info(`${i}: ${pendingGreetings[i].account}`); - } + const accountIndexes: {[from: `0x${string}`]: number} = {}; + for (let i = 0; i < pendingGreetings.length; i++) { + accountIndexes[pendingGreetings[i].account] = i; + logger.info(`${i}: ${pendingGreetings[i].account}`); + } - const pendingMessages: {[from: `0x${string}`]: string} = {}; + const pendingMessages: {[from: `0x${string}`]: string} = {}; - const pendingHashes: {[hash: string]: boolean} = {}; - if ($syncing.lastSync && $syncing.lastSync.unconfirmedBlocks) { - for (const block of $syncing.lastSync.unconfirmedBlocks) { - for (const event of block.events) { - pendingHashes[event.transactionHash] = true; + const pendingHashes: {[hash: string]: boolean} = {}; + if ($syncing.lastSync && $syncing.lastSync.unconfirmedBlocks) { + for (const block of $syncing.lastSync.unconfirmedBlocks) { + for (const event of block.events) { + pendingHashes[event.transactionHash] = true; + } } } - } - - if ($accountData) { - for (const hash of Object.keys($accountData.actions)) { - const action = $accountData.actions[hash as `0x${string}`]; - if (action.final) { - // in this case, the indexer will pick the correct state once synced up - // we can ignore the pending tx - // the tx-broadcaster should stop caring about this one - continue; - } - if (action.status === 'Failure') { - // tx failed so we can ignore it - // TODO? this failure can be picked up elsewhere to let the user know - // but we could also modify the PendingState type to include information here - continue; - } - if (pendingHashes[hash]) { - // if tx is already considered in the index, we can skip - continue; - } - switch (action.inclusion) { - case 'Cancelled': - // tx cancelled, we ignore it + if ($onchainActions) { + for (const hash of Object.keys($onchainActions)) { + const action = $onchainActions[hash as `0x${string}`]; + if (action.final) { + // in this case, the indexer will pick the correct state once synced up + // we can ignore the pending tx + // the tx-broadcaster should stop caring about this one continue; - case 'BeingFetched': - // TODO add to state that loading is still going for txs.... - // tx state is loading + } + if (action.status === 'Failure') { + // tx failed so we can ignore it + // TODO? this failure can be picked up elsewhere to let the user know + // but we could also modify the PendingState type to include information here continue; - case 'Included': - case 'NotFound': - case 'Broadcasted': - // else we consider it - } - if (action.tx.metadata && typeof action.tx.metadata === 'object' && 'message' in action.tx.metadata) { - const fromAccount = getAddress(action.tx.from); - pendingMessages[fromAccount] = action.tx.metadata.message as string; + } + + if (pendingHashes[hash]) { + // if tx is already considered in the index, we can skip + continue; + } + switch (action.inclusion) { + case 'Cancelled': + // tx cancelled, we ignore it + continue; + case 'BeingFetched': + // TODO add to state that loading is still going for txs.... + // tx state is loading + continue; + case 'Included': + case 'NotFound': + case 'Broadcasted': + // else we consider it + } + if (action.tx.metadata && typeof action.tx.metadata === 'object' && 'message' in action.tx.metadata) { + const fromAccount = getAddress(action.tx.from); + pendingMessages[fromAccount] = action.tx.metadata.message as string; + } } } - } - for (const from of Object.keys(pendingMessages)) { - const account = from as `0x${string}`; - const i = accountIndexes[account]; - if (isNaN(i)) { - logger.info(`new: ${account}`); - pendingGreetings.push({ - account, - message: pendingMessages[account], - pending: true, - }); - } else { - logger.info(`${pendingGreetings[i].message} and ${pendingMessages[account]}`); - // remove that when `if (indexer.includes(hash)) {continue;}` is implemented - if (pendingGreetings[i].message != pendingMessages[account]) { - pendingGreetings[i].message = pendingMessages[account]; - pendingGreetings[i].pending = true; + for (const from of Object.keys(pendingMessages)) { + const account = from as `0x${string}`; + const i = accountIndexes[account]; + if (isNaN(i)) { + logger.info(`new: ${account}`); + pendingGreetings.push({ + account, + message: pendingMessages[account], + pending: true, + }); + } else { + logger.info(`${pendingGreetings[i].message} and ${pendingMessages[account]}`); + // remove that when `if (indexer.includes(hash)) {continue;}` is implemented + if (pendingGreetings[i].message != pendingMessages[account]) { + pendingGreetings[i].message = pendingMessages[account]; + pendingGreetings[i].pending = true; + } } } - } - return { - greetings: pendingGreetings, - }; -}); + return { + greetings: pendingGreetings, + }; + }, +); if (typeof window !== 'undefined') { (window as any).pendingState = pendingState; diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 73a152a..4eb963a 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -7,6 +7,7 @@ import { PUBLIC_ETH_NODE_URI, PUBLIC_LOCALHOST_BLOCK_TIME, PUBLIC_DEV_NODE_URI, + PUBLIC_FUZD_URI, } from '$env/static/public'; import _contractsInfos from '$data/contracts'; @@ -43,12 +44,21 @@ if (!defaultRPCURL) { } } +function noEndSlash(str: string) { + if (str.endsWith('/')) { + return str.slice(0, -1); + } + return str; +} + +const FUZD_URI = noEndSlash(params.fuzd || PUBLIC_FUZD_URI); + const localRPC = isUsingLocalDevNetwork && PUBLIC_DEV_NODE_URI ? {chainId: contractsChainId, url: PUBLIC_DEV_NODE_URI} : undefined; const defaultRPC = defaultRPCURL ? {chainId: contractsChainId, url: defaultRPCURL} : undefined; -export {defaultRPC, isUsingLocalDevNetwork, localRPC, blockTime}; +export {defaultRPC, isUsingLocalDevNetwork, localRPC, blockTime, FUZD_URI}; let _setContractsInfos: any; export const contractsInfos = readable(_contractsInfos, (set) => { diff --git a/web/src/lib/fuzd/index.ts b/web/src/lib/fuzd/index.ts new file mode 100644 index 0000000..ffe8447 --- /dev/null +++ b/web/src/lib/fuzd/index.ts @@ -0,0 +1,128 @@ +import {testnetClient, timelockEncrypt, roundTime, roundAt, timelockDecrypt, Buffer, HttpChainClient} from 'tlock-js'; +(globalThis as any).Buffer = Buffer; + +import type {ScheduleInfo, ScheduledExecution, TimeBasedTiming, RoundBasedTiming} from 'fuzd-scheduler'; +import type {BroadcastSchedule, TransactionSubmission} from 'fuzd-executor'; +import {privateKeyToAccount} from 'viem/accounts'; +import {deriveRemoteAddress} from 'remote-account'; + +export {testnetClient, mainnetClient} from 'tlock-js'; + +export type ClientConfig = { + drand: HttpChainClient; + schedulerEndPoint: string | ((id: string, execution: string, signature: `0x${string}`) => Promise); + privateKey: `0x${string}`; +}; + +// TODO share with decrypter +export type DecryptedPayload = + | { + type: 'time-locked'; + payload: string; + timing: RoundBasedTiming; + } + | { + type: 'clear'; + transaction: TransactionDataType; + }; + +export function createClient(config: ClientConfig) { + if (typeof config.schedulerEndPoint !== 'string') { + throw new Error(`only support uri for schedulerEndPoint`); + } + const schedulerEndPoint = config.schedulerEndPoint.endsWith('/') + ? config.schedulerEndPoint.slice(0, -1) + : config.schedulerEndPoint; + const wallet = privateKeyToAccount(config.privateKey); + + async function getRemoteAccount() { + const publicKey = await fetch(`${schedulerEndPoint}/publicKey`).then((v) => v.text()); + const remoteAddress = deriveRemoteAddress(publicKey, wallet.address); + return remoteAddress; + } + async function submitExecution( + execution: { + slot: string; + chainId: `0x${string}` | string; + gas: bigint; + broadcastSchedule: [ + { + duration: number; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + }, + ]; + data: `0x${string}`; + to: `0x${string}`; + time: number; + }, + options?: {fakeEncrypt?: boolean}, + ): Promise { + let executionToSend: ScheduledExecution< + TransactionSubmission, + RoundBasedTiming | TimeBasedTiming, + RoundBasedTiming | TimeBasedTiming + >; + + const chainId = ( + execution.chainId.startsWith('0x') ? execution.chainId : `0x` + parseInt(execution.chainId).toString(16) + ) as `0x${string}`; + + const payloadJSON: DecryptedPayload = { + type: 'clear', + transaction: { + type: '0x2', + chainId, + gas: ('0x' + execution.gas.toString(16)) as `0x${string}`, + broadcastSchedule: execution.broadcastSchedule.map((v) => ({ + duration: ('0x' + v.duration.toString(16)) as `0x${string}`, + maxFeePerGas: ('0x' + v.maxFeePerGas.toString(16)) as `0x${string}`, + maxPriorityFeePerGas: ('0x' + v.maxPriorityFeePerGas.toString(16)) as `0x${string}`, + })) as BroadcastSchedule, + data: execution.data, + to: execution.to, + }, + }; + const payloadAsJSONString = JSON.stringify(payloadJSON); + + let round: number; + const drandChainInfo = await config.drand.chain().info(); + round = roundAt(options?.fakeEncrypt ? Date.now() : execution.time * 1000, drandChainInfo); + + const payload = await timelockEncrypt(round, Buffer.from(payloadAsJSONString, 'utf-8'), config.drand); + executionToSend = { + slot: execution.slot, + chainId, + timing: { + type: 'fixed', + value: { + type: 'round', + expectedTime: execution.time, + round, + }, + }, + type: 'time-locked', + payload, + } as any; + const jsonAsString = JSON.stringify(executionToSend); + const signature = await wallet.signMessage({message: jsonAsString}); + if (typeof config.schedulerEndPoint === 'string') { + const response = await fetch(`${schedulerEndPoint}/scheduleExecution`, { + method: 'POST', + body: jsonAsString, + headers: { + signature, + 'content-type': 'application/json', + }, + }); + return response.json(); + } else { + return config.schedulerEndPoint(signature, jsonAsString, signature); + } + } + + return { + submitExecution, + getRemoteAccount, + }; +} diff --git a/web/src/lib/web3/AccountSignIn.svelte b/web/src/lib/web3/AccountSignIn.svelte new file mode 100644 index 0000000..ca51d55 --- /dev/null +++ b/web/src/lib/web3/AccountSignIn.svelte @@ -0,0 +1,25 @@ + + + +

Welcome to Stratagems

+

+ In order to continue and get a safe place to save data, you'll need to sign a message. Be carefull and only sign + this message on trusted frontend. +

+
+ +
+ +
diff --git a/web/src/lib/web3/Web3AccountInfo.svelte b/web/src/lib/web3/Web3AccountInfo.svelte index 9155219..3f038c9 100644 --- a/web/src/lib/web3/Web3AccountInfo.svelte +++ b/web/src/lib/web3/Web3AccountInfo.svelte @@ -1,6 +1,7 @@ @@ -16,12 +17,24 @@ {/if} {#if $account.loadingStep} - -

{$account.loadingStep}

-

{$account.loadingStep}

- -
+ {#if $account.loadingStep.id == 'SIGNING'} + +

Welcome to Stratagems

+

Sign the message to access to your data.

+ +
+ {:else if $account.loadingStep.id == 'WELCOME'} + + {:else} + +

{$account.loadingStep.id}

+

{$account.loadingStep.id}

+ +
+ {/if} {/if} diff --git a/web/src/lib/web3/account-data.ts b/web/src/lib/web3/account-data.ts index 04e8c01..7172912 100644 --- a/web/src/lib/web3/account-data.ts +++ b/web/src/lib/web3/account-data.ts @@ -1,70 +1,250 @@ import type {EIP1193TransactionWithMetadata} from 'web3-connection'; -import type {PendingTransaction} from 'ethereum-tx-observer'; -import {initAccount} from '../account'; - -export type Action = { - tx: EIP1193TransactionWithMetadata; -} & ( - | { - inclusion: 'BeingFetched' | 'Broadcasted' | 'NotFound' | 'Cancelled'; - final: undefined; - status: undefined; - } - | { - inclusion: 'Included'; - status: 'Failure' | 'Success'; - final: number; - } -); - -export type Actions = {[hash: `0x${string}`]: Action}; - -export type AccountData = {actions: Actions}; +import type {PendingTransaction, PendingTransactionState} from 'ethereum-tx-observer'; +import {initEmitter} from 'radiate'; +import {writable} from 'svelte/store'; +import {bytesToHex, hexToBytes} from 'viem'; +import {FUZD_URI} from '$lib/config'; +import {createClient, mainnetClient} from '$lib/fuzd'; +import {AccountDB} from './account-db'; + +export type SendMessageMetadata = { + type: 'send'; + message: string; +}; +export type AnyMetadata = SendMessageMetadata; + +export type JollyRogerTransaction = EIP1193TransactionWithMetadata & { + metadata?: { + epoch: { + hash: `0x${string}`; + number: number; + }; + } & T; +}; + +export type OnChainAction = { + tx: JollyRogerTransaction; +} & PendingTransactionState; +export type OnChainActions = {[hash: `0x${string}`]: OnChainAction}; + +export type AccountData = { + onchainActions: OnChainActions; +}; + +const emptyAccountData: AccountData = {onchainActions: {}}; + +function fromOnChainActionToPendingTransaction(hash: `0x${string}`, onchainAction: OnChainAction): PendingTransaction { + return { + hash, + request: onchainAction.tx, + final: onchainAction.final, + inclusion: onchainAction.inclusion, + status: onchainAction.status, + } as PendingTransaction; +} export function initAccountData() { - return initAccount({ - fromAccountDataToActions(data: AccountData): {action: Action; hash: `0x${string}`}[] { - return Object.keys(data.actions).map((v) => { - const hash = v as `0x${string}`; - const action: Action = data.actions[hash]; - return { - action, - hash, - }; + const emitter = initEmitter<{name: 'newTx'; txs: PendingTransaction[]} | {name: 'clear'}>(); + + const $onchainActions: OnChainActions = {}; + const onchainActions = writable($onchainActions); + + let fuzdClient: ReturnType | undefined; + + let accountDB: AccountDB | undefined; + async function load(info: { + address: `0x${string}`; + chainId: string; + genesisHash: string; + privateSignature: `0x${string}`; + }) { + const key = hexToBytes(info.privateSignature).slice(0, 32); + const data = await _load({ + address: info.address, + chainId: info.chainId, + genesisHash: info.genesisHash, + key, + }); + + for (const hash in data.onchainActions) { + const onchainAction = (data.onchainActions as any)[hash]; + ($onchainActions as any)[hash] = onchainAction; + } + onchainActions.set($onchainActions); + handleTxs($onchainActions); + } + + function handleTxs(onChainActions: OnChainActions) { + const pending_transactions: PendingTransaction[] = []; + for (const hash in onChainActions) { + const onchainAction = (onChainActions as any)[hash]; + const tx = fromOnChainActionToPendingTransaction(hash as `0x${string}`, onchainAction); + pending_transactions.push(tx); + if (onchainAction.revealTx) { + const tx = { + hash: onchainAction.revealTx.hash, + request: onchainAction.revealTx.request, + final: onchainAction.revealTx.final, + inclusion: onchainAction.revealTx.inclusion, + status: onchainAction.revealTx.status, + } as PendingTransaction; + pending_transactions.push(tx); + } + } + emitter.emit({name: 'newTx', txs: pending_transactions}); + } + + async function unload() { + //save before unload + await save(); + + accountDB = undefined; + fuzdClient = undefined; + + // delete all + for (const hash of Object.keys($onchainActions)) { + delete ($onchainActions as any)[hash]; + } + onchainActions.set($onchainActions); + + emitter.emit({name: 'clear'}); + } + + async function save() { + _save({ + onchainActions: $onchainActions, + }); + } + + async function _load(info: { + address: `0x${string}`; + chainId: string; + genesisHash: string; + key: Uint8Array; + }): Promise { + const privateKey = info.key; + accountDB = new AccountDB(info.address, info.chainId, info.genesisHash); + if (FUZD_URI) { + fuzdClient = createClient({ + drand: mainnetClient(), + privateKey: bytesToHex(privateKey), + schedulerEndPoint: FUZD_URI, }); - }, - fromActionToPendingTransactions(hash: `0x${string}`, action: Action) { - return { - hash, - request: action.tx, - final: action.final, - inclusion: action.inclusion, - status: action.status, - } as PendingTransaction; - }, - addActionFromTransaction(data: AccountData, tx: EIP1193TransactionWithMetadata, hash: `0x${string}`, inclusion) { - const action: Action = { - tx, - inclusion: inclusion || 'BeingFetched', - final: undefined, - status: undefined, - }; - data.actions[hash] = action; - return action; - }, - updateActionFromPendingTransactionUpdate(data: AccountData, pendingTransaction: PendingTransaction) { - const action = data.actions[pendingTransaction.hash]; - if (action) { - action.inclusion = pendingTransaction.inclusion; - action.status = pendingTransaction.status; - action.final = pendingTransaction.final; - - // TODO specific to jolly-roger which does not need user acknowledgement for deleting the actions - if (action.final) { - delete data.actions[pendingTransaction.hash]; - } + } + return (await accountDB.load()) || emptyAccountData; + } + + async function _save(accountData: AccountData) { + if (accountDB) { + accountDB.save(accountData); + } + } + + function addAction(tx: EIP1193TransactionWithMetadata, hash: `0x${string}`, inclusion?: 'Broadcasted') { + if (!tx.metadata) { + console.error(`no metadata on the tx, we still save it, but this will not let us know what this tx is about`); + } else if (typeof tx.metadata !== 'object') { + console.error(`metadata is not an object and so do not conform to DungeonTransaction`); + } else { + if (!('type' in tx.metadata)) { + console.error(`no field "type" in the metadata and so do not conform to DungeonTransaction`); } - return action; + } + + const onchainAction: OnChainAction = { + tx: tx as JollyRogerTransaction, + inclusion: inclusion || 'BeingFetched', + final: undefined, + status: undefined, + }; + + $onchainActions[hash] = onchainAction; + save(); + onchainActions.set($onchainActions); + + emitter.emit({ + name: 'newTx', + txs: [fromOnChainActionToPendingTransaction(hash, onchainAction)], + }); + } + + function _updateTx(pendingTransaction: PendingTransaction) { + const action = $onchainActions[pendingTransaction.hash]; + if (action) { + action.inclusion = pendingTransaction.inclusion; + action.status = pendingTransaction.status; + action.final = pendingTransaction.final; + } + } + + function updateTx(pendingTransaction: PendingTransaction) { + _updateTx(pendingTransaction); + onchainActions.set($onchainActions); + save(); + } + + function updateTxs(pendingTransactions: PendingTransaction[]) { + for (const p of pendingTransactions) { + _updateTx(p); + } + onchainActions.set($onchainActions); + save(); + } + + // use with caution + async function _reset() { + await unload(); + accountDB?.clearData(); + } + + async function getFuzd() { + if (!fuzdClient) { + throw new Error(`no fuzd client setup`); + } + const remoteAccount = await fuzdClient.getRemoteAccount(); + return { + remoteAccount, + submitExecution( + execution: { + slot: string; + chainId: string; + gas: bigint; + broadcastSchedule: [{duration: number; maxFeePerGas: bigint; maxPriorityFeePerGas: bigint}]; + data: `0x${string}`; + to: `0x${string}`; + time: number; + }, + options?: {fakeEncrypt?: boolean}, + ) { + if (!fuzdClient) { + throw new Error(`no fuzd client setup`); + } + return fuzdClient.submitExecution(execution, options); + }, + }; + } + + return { + $onchainActions, + onchainActions: { + subscribe: onchainActions.subscribe, + }, + + load, + unload, + updateTx, + updateTxs, + + getFuzd, + + onTxSent(tx: EIP1193TransactionWithMetadata, hash: `0x${string}`) { + addAction(tx, hash, 'Broadcasted'); + save(); }, - }); + + on: emitter.on, + off: emitter.off, + + _reset, + }; } diff --git a/web/src/lib/web3/account-db.ts b/web/src/lib/web3/account-db.ts new file mode 100644 index 0000000..772a138 --- /dev/null +++ b/web/src/lib/web3/account-db.ts @@ -0,0 +1,48 @@ +import localCache from '$lib/utils/localCache'; +import {privateKeyToAccount, type PrivateKeyAccount} from 'viem/accounts'; +import {bytesToHex} from 'viem'; + +import {logs} from 'named-logs'; +const logger = logs('AccountDB'); + +const LOCAL_STORAGE_PRIVATE_ACCOUNT = '_account'; +function LOCAL_STORAGE_KEY(address: string, chainId: string, genesisHash: string) { + return `${LOCAL_STORAGE_PRIVATE_ACCOUNT}_${address.toLowerCase()}_${chainId}_${genesisHash}`; +} + +export class AccountDB> { + constructor( + public readonly ownerAddress: string, + public readonly chainId: string, + public readonly genesisHash: string, + ) {} + + async save(data: T): Promise { + this._saveToLocalStorage(data); + } + + async clearData(): Promise { + await this._saveToLocalStorage({} as T); + } + + async load(): Promise { + return this._getFromLocalStorage(); + } + + private async _getFromLocalStorage(): Promise { + const fromStorage = await localCache.getItem(LOCAL_STORAGE_KEY(this.ownerAddress, this.chainId, this.genesisHash)); + if (fromStorage) { + try { + return JSON.parse(fromStorage); + } catch (e) { + console.error(e); + } + } + return undefined; + } + + private async _saveToLocalStorage(data: T): Promise { + const toStorage = JSON.stringify(data); + await localCache.setItem(LOCAL_STORAGE_KEY(this.ownerAddress, this.chainId, this.genesisHash), toStorage); + } +} diff --git a/web/src/lib/web3/index.ts b/web/src/lib/web3/index.ts index 9dce3b5..e7e9d8f 100644 --- a/web/src/lib/web3/index.ts +++ b/web/src/lib/web3/index.ts @@ -4,6 +4,7 @@ import {initAccountData} from './account-data'; import {initTransactionProcessor} from 'ethereum-tx-observer'; import {initViemContracts} from 'web3-connection-viem'; import {logs} from 'named-logs'; +import {stringToHex} from 'viem'; const logger = logs('jolly-roger'); @@ -30,11 +31,76 @@ const stores = init({ }, acccountData: { async loadWithNetworkConnected(state, setLoadingMessage, waitForStep) { + console.log({loading: '...'}); const chainId = state.network.chainId; const address = state.address; - await accountData.load(address, chainId, state.network.genesisHash); + + let signature: `0x${string}` | undefined; + + const private_signature_storageKey = `__private_signature__${address.toLowerCase()}`; + try { + const fromStorage = localStorage.getItem(private_signature_storageKey); + if (fromStorage && fromStorage.startsWith('0x')) { + signature = fromStorage as `0x${string}`; + } + } catch (err) {} + + let remoteSyncEnabled: boolean = true; + const remoteSync_storageKey = `__remoteSync_${address.toLowerCase}`; + try { + const fromStorage = localStorage.getItem(remoteSync_storageKey); + if (fromStorage === 'true') { + remoteSyncEnabled = true; + } else if (fromStorage === 'false') { + remoteSyncEnabled = false; + } + } catch (err) {} + + if (!signature) { + async function signMessage() { + const msg = stringToHex( + 'Welcome to Jolly-Roger, Please sign this message only on trusted frontend. This gives access to your local data that you are supposed to keep secret.', + ); + const signature = await state.connection.provider + .request({ + method: 'personal_sign', + params: [msg, address], + }) + .catch((e: any) => { + account.rejectLoadingStep(); + }); + account.acceptLoadingStep(signature); + } + // setLoadingMessage('Please Sign The Authentication Message To Go Forward'); + + console.log({remoteSyncEnabled}); + const {doNotAskAgainSignature, remoteSyncEnabled: remoteSyncEnabledAsked} = (await waitForStep('WELCOME', { + remoteSyncEnabled, + })) as { + remoteSyncEnabled: boolean; + doNotAskAgainSignature: boolean; + }; + remoteSyncEnabled = remoteSyncEnabledAsked; + try { + localStorage.setItem(remoteSync_storageKey, remoteSyncEnabled ? 'true' : 'false'); + } catch (err) {} + signMessage(); + signature = (await waitForStep('SIGNING')) as `0x${string}`; + if (doNotAskAgainSignature) { + try { + localStorage.setItem(private_signature_storageKey, signature); + } catch (err) {} + } + } + await accountData.load({ + address, + chainId, + genesisHash: state.network.genesisHash || '', + privateSignature: signature, + }); }, async unload() { + console.log({unloading: '...'}); await accountData.unload(); }, },