LIT-Protocol / evm-evm-swap-example

0 stars 0 forks source link

Refactor Proposal #1

Open spacesailor24 opened 2 months ago

spacesailor24 commented 2 months ago
  1. I don't think this needs to be executed within the browser, I think this could be executed with node
  2. The code example should be written in Typescript
  3. Need to write tests that we can plug into the monitoring project to make sure the example continues to work
  4. I believe the Lit Action could be refactored to be generic, supporting any swap using chains the Lit nodes have RPC URLs for

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:

  1. userAAuthSig is checked to verify userA has signed the swapObjectHash
  2. userBAuthSig is checked to verify userB has signed the swapObjectHash
  3. expirationA and expirationB are checked to ensure they are not in the past
  4. The PKP balance of currencyA is checked on chainA to ensure it is >= amountA
  5. The PKP balance of currencyB is checked on chainB to ensure it is >= amountB
  6. transactionA is created and signed by the PKP to transfer amountA of currencyA to accountB on chainA
  7. transactionB is created and signed by the PKP to transfer amountB of currencyB to accountA on chainB
  8. Signed transactionA and transactionB are returned to the client
    • We could submit the transactions to the respective networks within the Lit Action, but we run the risk of partial failure (i.e. one transaction succeeds while the other fails) for various reasons, and it's probably not worth the complexity cost of accounting for this within the Lit Action
Lit 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

const swapObject = {
  chainA: "sepolia",
  chainAId: "11155111",
  chainB: "baseSepolia",
  chainBId: "84532",
  accountA: "0x000000000000000000000000000000000000000a",
  accountB: "0x000000000000000000000000000000000000000b",
  amountA: "8000000000000000000",
  amountB: "4000000000000000000",
  contractA: "0x0000000000000000000000000000000000000001",
  contractB: "",
  currencyAType: 'ERC20',
  currencyBType: 'NATIVE',
  expirationA: "1757090845",
  expirationB: "1788626845",
};

Step 4: Get Approval for the Swap from Alice and Bob

import { createSiweMessage, generateAuthSig } from "@lit-protocol/auth-helpers";

const ethersWalletAlice = new ethers.Wallet(ALICE_ETHEREUM_PRIVATE_KEY);
const ethersWalletBob = new ethers.Wallet(BOB_ETHEREUM_PRIVATE_KEY);

const litNodeClient = new LitNodeClient(...);

// If we don't sort the object keys, then we'we could get a different hash
// for the same object since object key order isn't static in JavaScript
const sortedSwapString = JSON.stringify(
  Object.keys(swapObject)
    .sort()
    .reduce((obj, key) => {
      obj[key] = swapObject[key];
      return obj;
    }, {})
);

const swapObjectHash = ethers.utils.keccak256(
  ethers.utils.toUtf8Bytes(sortedSwapString)
);

const siweMessageAlice = createSiweMessage({
    walletAddress: ethersWalletAlice.address,
    nonce: await litNodeClient.getLatestBlockhash(),
    expiration: swapObject.expirationA,
    statement: swapObjectHash
});
const authSigAlice = await generateAuthSig({
    signer: ethersWalletAlice,
    toSign: siweMessageAlice,
});

const siweMessageBob = createSiweMessage({
    walletAddress: ethersWalletBob.address,
    nonce: await litNodeClient.getLatestBlockhash(),
    expiration: swapObject.expirationB,
    statement: swapObjectHash
});
const authSigBob = await generateAuthSig({
    signer: ethersWalletBob,
    toSign: siweMessageBob,
});

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

const litActionResult = await litNodeClient.executeJs({
    sessionSigs,
    ipfsId: litActionIpfsCid,
    jsParams: {
        swapObject,
        userAAuthSig: authSigAlice,
        userBAuthSig: authSigBob,
        pkpPublicKey,
        pkpAddress,
    },
});

Step 9: Broadcast the Signed Transactions to Perform the Swap

spacesailor24 commented 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)

spacesailor24 commented 2 months ago

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:

spacesailor24 commented 2 months ago

Just 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