Open spacesailor24 opened 2 months ago
The proposed design doesn't include clawback functionality, this must be accounted for if we decide to move forward with the refactor
We got to make sure one party can't clawback if one side of the transaction has already been done, and this is a tricky problem. An off-the-cuff idea would be to transfer from the custodian PKP to a PKP only Alice (or Bob) has permission to sign with within a LA. This would make the funds unavailable for the swap, and allow Alice (or Bob) to clawback whenever they want (and retry if the tx fails)
Scrap the above proposal, the following is a drastically simplified architecture that avoids many of the pitfalls of the previous designs.
I've created two diagrams to walk though the happy path (a swap executes successfully), and the sad path (a swap expires before being executed):
I believe I've addressed all the edge cases that would result in either a loss of funds, or one party getting all of the funds, but need further review to be sure.
At a high-level, the new architecture removes the use of a PKP to custodian funds, and replaces it with two generic private keys. The swap has two phases that are executed within a Lit Action:
generate
phase
swapObject
that's signed by both swap partiesprivateKeyA
and privateKeyB
)execute
phase
privateKeyA
and privateKeyB
are decrypted and re-encrypted with Access Control Conditions that only allow a opposite swap party to decrypt i.e. Alice is the only person who can decrypt privateKeyB
to take possession of the funds Bob providedprivateKeyA
to take possession of the funds Alice providedJust realized there's an unsolved edge case where both parties fund the address with the full swap amounts, but the swap expires. The swap wouldn't be allowed to execute (since it's expired), and no refunds would be made since it's a requirement that one of the swap address was funded with less than the swap amounts
This can probably be accounted for, but will need to think about it
One benefit of the existing design is it's simpler: each party of the swap is providing their consent to the trade by simply sending funds to the PKP address the Lit Action is authorized for. This works since we're generating a new PKP (and Lit Action) for every swap we want to make. However, I think there is a design where we init a single generic Lit Action that uses provided info to perform a swap, as long as the chains being requested are supported by the Lit nodes. I believe this could work as follows:
Step 1: Deploy the Generic Swap Lit Action
At a high level, the flow is:
userAAuthSig
is checked to verify userA has signed theswapObjectHash
userBAuthSig
is checked to verify userB has signed theswapObjectHash
expirationA
andexpirationB
are checked to ensure they are not in the pastcurrencyA
is checked onchainA
to ensure it is >=amountA
currencyB
is checked onchainB
to ensure it is >=amountB
transactionA
is created and signed by the PKP to transferamountA
ofcurrencyA
toaccountB
onchainA
transactionB
is created and signed by the PKP to transferamountB
ofcurrencyB
toaccountA
onchainB
transactionA
andtransactionB
are returned to the clientLit Action Example Code
```ts const getSwapObjectHashString = (swapObject) => { const sortedSwapString = JSON.stringify( Object.keys(swapObject) .sort() .reduce((obj, key) => { obj[key] = swapObject[key]; return obj; }, {}) ); return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(sortedSwapString)); }; const checkIfUserAuthorizedSwap = async (swapObjectHash, userAuthSig) => { return Lit.Action.checkConditions({ conditions: [{ contractAddress: "", standardContractType: "SIWE", chain: "ethereum", method: "", parameters: [":statement"], returnValueTest: { comparator: "=", value: swapObjectHash, }, }, ], authSig: userAuthSig, chain: "ethereum", }); }; const checkUserFundedPkp = async (swapObject, isUserA) => { const user = isUserA ? "A" : "B"; const authSig = isUserA ? userAAuthSig : userBAuthSig; return await Lit.Action.checkConditions({ conditions: [{ contractAddress: swapObject[`contract${user}`], standardContractType: swapObject[`currency${user}Type`], chain: swapObject[`chain${user}`], method: swapObject[`currency${user}Type`] === "NATIVE" ? "eth_getBalance" : "balanceOf", parameters: [pkpAddress], returnValueTest: { comparator: ">=", value: swapObject[`amount${user}`], }, }, ], authSig: authSig, chain: "ethereum", }); }; const buildAndSignTransaction = async (swapObject, isTransactionA) => { try { const chain = isTransactionA ? swapObject.chainA : swapObject.chainB; const currencyType = isTransactionA ? swapObject.currencyAType : swapObject.currencyBType; const amount = isTransactionA ? swapObject.amountA : swapObject.amountB; const recipient = isTransactionA ? swapObject.accountB : swapObject.accountA; const contractAddress = isTransactionA ? swapObject.contractA : swapObject.contractB; const chainId = isTransactionA ? swapObject.chainAId : swapObject.chainBId; const rpcUrl = await Lit.Actions.getRpcUrl({ chain }); const provider = new ethers.providers.JsonRpcProvider(rpcUrl); const nonce = await Lit.Actions.getLatestNonce({ address: pkpAddress, chain, }); const tx = { chainId, nonce, gasPrice: await provider.getGasPrice(), }; if (currencyType === "NATIVE") { tx.to = recipient; tx.value = amount; tx.gasLimit = await provider.estimateGas(tx); } else { const erc20Interface = new ethers.utils.Interface([ "function transfer(address to, uint256 amount)", ]); tx.to = contractAddress; tx.data = erc20Interface.encodeFunctionData("transfer", [ recipient, amount, ]); tx.gasLimit = await provider.estimateGas(tx); } const signedTx = await Lit.Actions.signEcdsa({ toSign: ethers.utils.arrayify( ethers.utils.keccak256(ethers.utils.serializeTransaction(tx)) ), publicKey: pkpPublicKey, sigName: isTransactionA ? "sigA" : "sigB", }); return ethers.utils.serializeTransaction(tx, signedTx.signature); } catch (error) { return `Error: When building and signing transaction: ${error.message}`; } }; const _litActionCode = async () => { const swapObjectHash = getSwapObjectHashString(swapObject); const userAAuthorizedSwap = await checkIfUserAuthorizedSwap( swapObjectHash, userAAuthSig ); if (!userAAuthorizedSwap) return Lit.Actions.setResponse({ response: "userAAuthSig does not authorize provided swap object", }); const userBAuthorizedSwap = await checkIfUserAuthorizedSwap( swapObjectHash, userBAuthSig ); if (!userBAuthorizedSwap) return Lit.Actions.setResponse({ response: "userBAuthSig does not authorize provided swap object", }); const currentTimestamp = Math.floor(Date.now() / 1000); if (swapObjectHash.expirationA >= currentTimestamp) return Lit.Actions.setResponse({ response: "expirationA has passed, swap no longer valid", }); if (swapObjectHash.expirationB >= currentTimestamp) return Lit.Actions.setResponse({ response: "expirationB has passed, swap no longer valid", }); const userAFundedPkp = await checkUserFundedPkp(swapObject, true); if (!userAFundedPkp) return Lit.Actions.setResponse({ response: "userA did not fund pkp", }); const userBFundedPkp = await checkUserFundedPkp(swapObject, false); if (!userBFundedPkp) return Lit.Actions.setResponse({ response: "userB did not fund pkp", }); try { const signedTxA = await buildAndSignTransaction(swapObject, true); const signedTxB = await buildAndSignTransaction(swapObject, false); if (signedTxA.startsWith("Error:") || signedTxB.startsWith("Error:")) { throw new Error( `Failed to build and sign transactions: ${ signedTxA.startsWith("Error:") ? signedTxA : signedTxB }` ); } Lit.Actions.setResponse({ response: JSON.stringify({ signedTxA, signedTxB, }), }); } catch (error) { Lit.Actions.setResponse({ response: JSON.stringify({ error: `Swap failed: ${error.message}`, }), }); } }; export const litActionCode = `(${_litActionCode.toString()})();`; ```Step 2: Mint a PKP with the Lit Action as the Only Permitted Auth Method
Step 3: Generate the Swap Object
Step 4: Get Approval for the Swap from Alice and Bob
Step 5: Verify Alice and Bob Auth Sig for Swap
This step would be necessary in an actual deployment of this code example since we want to make sure we have the correct swap object that both Alice and Bob agreed to before we move forward. In an actual deployment, the Auth Sig generated by Alice and Bob for the swap would happen async, so we'd need to take a given Auth Sig and validate it's approving the swap object we'll be submitting to the Lit Action
Step 6: Verify the Auth Sigs from Alice and Bob haven't Expired
Step 7: Verify the PKP Address is Funded with the Swap Amounts on Both Chains
Step 8: Execute the Lit Action
Step 9: Broadcast the Signed Transactions to Perform the Swap