diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 015ecd31..579facd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: jobs: - contracts: + e2e: runs-on: ubuntu-latest defaults: run: @@ -40,13 +40,9 @@ jobs: - name: Build SDK run: pnpm nx build sdk - # Build contracts and generate types - - name: Build contracts - run: pnpm nx build contracts - - # Run contract tests - - name: Run contract test - run: pnpm nx test contracts + # Deploy contracts + - name: Deploy contracts + run: pnpm nx deploy contracts # Run E2E tests - name: Install Playwright Browsers @@ -60,3 +56,41 @@ jobs: name: playwright-report path: packages/demo-app/playwright-report/ retention-days: 3 + + + contracts: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./ + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + # Start node + - name: Era Test Node Action + uses: dutterbutter/era-test-node-action@36ffd2eefd46dc16e7e2a8e1715124400ec0a3ba # v1 + + # Setup pnpm + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.11.0 + + - name: Use Node.js + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4 + with: + node-version: lts/Iron + cache: 'pnpm' + + # Install dependencies for contracts/gateway/sdk + - name: Install dependencies + run: pnpm install -r --frozen-lockfile + + # Build contracts and generate types + - name: Build contracts + run: pnpm nx build contracts + + # Run contract tests + - name: Run contract test + run: pnpm nx test contracts + diff --git a/docs/sdk/client/README.md b/docs/sdk/client/README.md index 300adf06..6bcb1ff2 100644 --- a/docs/sdk/client/README.md +++ b/docs/sdk/client/README.md @@ -24,24 +24,18 @@ development principles in mind. 2. Deploy the account ```ts - import { generatePrivateKey, privateKeyToAccount } from "viem"; + import { generatePrivateKey, privateKeyToAddress } from "viem"; import { deployAccount } from "zksync-account/client"; const deployerClient = ...; // Any client for deploying the account, make sure it has enough balance to cover the deployment cost - const sessionKey = generatePrivateKey(); - const sessionPublicKey = privateKeyToAccount(sessionKey.value).address; + const sessionPrivateKey = generatePrivateKey(); + const sessionKey = privateKeyToAddress(sessionPrivateKey.value); const { address } = await deployAccount(deployerClient, { credentialPublicKey, - initialSessions: [ - { - sessionPublicKey, - expiresAt: (new Date(Date.now() + 1000 * 60 * 60 * 24)).toISOString(), // 1 day expiry - spendLimit: { - [Token.address]: "1000", - }, - }, - ], + // You can either create a session during deployment by passing a spec + // here, or create it later using `createSession` -- see step 4. + // initialSession: { ... }, contracts, }); ``` @@ -73,9 +67,17 @@ development principles in mind. 4. Activating session key ```ts - await passkeyClient.addSessionKey({ - sessionPublicKey, - token: Token.address, // Address of the token - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), // 1 day expiry + await passkeyClient.createSession({ + session: { + sessionKey, + expiry: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 1 week + feeLimit: { limit: parseEther("0.01") }, + transferPolicies: [ + { + target: transferTarget.address, + maxValuePerUse: parseEther("0.1"), + }, + ], + }, }); ``` diff --git a/packages/contracts/src/ClaveAccount.sol b/packages/contracts/src/ClaveAccount.sol index a5e3f105..f98414c0 100644 --- a/packages/contracts/src/ClaveAccount.sol +++ b/packages/contracts/src/ClaveAccount.sol @@ -103,13 +103,14 @@ contract ClaveAccount is bytes32 suggestedSignedHash, Transaction calldata transaction ) external payable override onlyBootloader returns (bytes4 magic) { + // FIXME session txs have their own nonce managers, + // so they have to not alter this nonce _incrementNonce(transaction.nonce); // The fact there is enough balance for the account // should be checked explicitly to prevent user paying for fee for a // transaction that wouldn't be included on Ethereum. if (transaction.totalRequiredBalance() > address(this).balance) { - Logger.logString("revert Errors.INSUFFICIENT_FUNDS()"); revert Errors.INSUFFICIENT_FUNDS(); } @@ -209,13 +210,10 @@ contract ClaveAccount is bytes32 signedHash, Transaction calldata transaction ) internal returns (bytes4 magicValue) { - Logger.logString("_validateTransaction"); if (transaction.signature.length == 65) { - Logger.logString("transaction.signature.length == 65"); (address signer, ) = ECDSA.tryRecover(signedHash, transaction.signature); - Logger.logString("recovered signer"); + Logger.logString("recovered EOA signer"); Logger.logAddress(signer); - // gas estimation? if (signer == address(0)) { return bytes4(0); } diff --git a/packages/contracts/src/interfaces/IValidatorManager.sol b/packages/contracts/src/interfaces/IValidatorManager.sol index 8e84d165..c30c2e33 100644 --- a/packages/contracts/src/interfaces/IValidatorManager.sol +++ b/packages/contracts/src/interfaces/IValidatorManager.sol @@ -18,6 +18,12 @@ interface IValidatorManager { */ event K1AddValidator(address indexed validator); + /** + * @notice Event emitted when a modular validator is added + * @param validator address - Address of the added modular validator + */ + event AddModuleValidator(address indexed validator); + /** * @notice Event emitted when a r1 validator is removed * @param validator address - Address of the removed r1 validator @@ -30,6 +36,12 @@ interface IValidatorManager { */ event K1RemoveValidator(address indexed validator); + /** + * @notice Event emitted when a modular validator is removed + * @param validator address - Address of the removed modular validator + */ + event RemoveModuleValidator(address indexed validator); + /** * @notice Adds a validator to the list of r1 validators * @dev Can only be called by self or a whitelisted module @@ -67,6 +79,13 @@ interface IValidatorManager { */ function k1RemoveValidator(address validator) external; + /** + * @notice Removes a validator from the list of modular validators + * @dev Can only be called by self or a whitelisted module + * @param validator address - Address of the validator to remove + */ + function removeModuleValidator(address validator) external; + /** * @notice Checks if an address is in the r1 validator list * @param validator address -Address of the validator to check @@ -81,6 +100,13 @@ interface IValidatorManager { */ function k1IsValidator(address validator) external view returns (bool); + /** + * @notice Checks if an address is in the modular validator list + * @param validator address - Address of the validator to check + * @return True if the address is a validator, false otherwise + */ + function isModuleValidator(address validator) external view returns (bool); + /** * @notice Returns the list of r1 validators * @return validatorList address[] memory - Array of r1 validator addresses @@ -92,4 +118,10 @@ interface IValidatorManager { * @return validatorList address[] memory - Array of k1 validator addresses */ function k1ListValidators() external view returns (address[] memory validatorList); + + /** + * @notice Returns the list of modular validators + * @return validatorList address[] memory - Array of modular validator addresses + */ + function listModuleValidators() external view returns (address[] memory validatorList); } diff --git a/packages/contracts/src/managers/ModuleManager.sol b/packages/contracts/src/managers/ModuleManager.sol index 680745f3..b617338c 100644 --- a/packages/contracts/src/managers/ModuleManager.sol +++ b/packages/contracts/src/managers/ModuleManager.sol @@ -15,8 +15,6 @@ import { IModuleManager } from "../interfaces/IModuleManager.sol"; import { IUserOpValidator } from "../interfaces/IERC7579Validator.sol"; import { IERC7579Module, IExecutor } from "../interfaces/IERC7579Module.sol"; -import "../helpers/Logger.sol"; - /** * @title Manager contract for modules * @notice Abstract contract for managing the enabled modules of the account @@ -66,10 +64,7 @@ abstract contract ModuleManager is IModuleManager, Auth { function _addNativeModule(address moduleAddress, bytes memory moduleData) internal { if (!_supportsModule(moduleAddress)) { - Logger.logString("module is not supported"); - Logger.logAddress(moduleAddress); - // FIXME: support native modules on install - // revert Errors.MODULE_ERC165_FAIL(); + revert Errors.MODULE_ERC165_FAIL(); } _modulesLinkedList().add(moduleAddress); @@ -173,6 +168,8 @@ abstract contract ModuleManager is IModuleManager, Auth { } function _supportsModule(address module) internal view returns (bool) { + // this is pretty dumb, since type(IModule).interfaceId is 0x00000000, but is correct as per ERC165 + // context: https://github.com/ethereum/solidity/issues/7856#issuecomment-585337461 return module.supportsInterface(type(IModule).interfaceId); } } diff --git a/packages/contracts/src/managers/ValidatorManager.sol b/packages/contracts/src/managers/ValidatorManager.sol index f5fe5af1..16fb1b4d 100644 --- a/packages/contracts/src/managers/ValidatorManager.sol +++ b/packages/contracts/src/managers/ValidatorManager.sol @@ -47,6 +47,11 @@ abstract contract ValidatorManager is IValidatorManager, Auth { _k1RemoveValidator(validator); } + ///@inheritdoc IValidatorManager + function removeModuleValidator(address validator) external onlySelfOrModule { + _removeModuleValidator(validator); + } + /// @inheritdoc IValidatorManager function r1IsValidator(address validator) external view override returns (bool) { return _r1IsValidator(validator); @@ -57,6 +62,11 @@ abstract contract ValidatorManager is IValidatorManager, Auth { return _k1IsValidator(validator); } + /// @inheritdoc IValidatorManager + function isModuleValidator(address validator) external view override returns (bool) { + return _isModuleValidator(validator); + } + /// @inheritdoc IValidatorManager function r1ListValidators() external view override returns (address[] memory validatorList) { validatorList = _r1ValidatorsLinkedList().list(); @@ -67,6 +77,11 @@ abstract contract ValidatorManager is IValidatorManager, Auth { validatorList = _k1ValidatorsLinkedList().list(); } + /// @inheritdoc IValidatorManager + function listModuleValidators() external view override returns (address[] memory validatorList) { + validatorList = _moduleValidatorsLinkedList().list(); + } + function _r1AddValidator(address validator) internal { if (!_supportsR1(validator)) { revert Errors.VALIDATOR_ERC165_FAIL(); @@ -78,8 +93,14 @@ abstract contract ValidatorManager is IValidatorManager, Auth { } function _addModuleValidator(address validator, bytes memory accountValidationKey) internal { + if (!_supportsModuleValidator(validator)) { + revert Errors.VALIDATOR_ERC165_FAIL(); + } + _moduleValidatorsLinkedList().add(validator); IModuleValidator(validator).addValidationKey(accountValidationKey); + + emit AddModuleValidator(validator); } function _k1AddValidator(address validator) internal { @@ -108,6 +129,12 @@ abstract contract ValidatorManager is IValidatorManager, Auth { emit K1RemoveValidator(validator); } + function _removeModuleValidator(address validator) internal { + _moduleValidatorsLinkedList().remove(validator); + + emit RemoveModuleValidator(validator); + } + function _r1IsValidator(address validator) internal view returns (bool) { return _r1ValidatorsLinkedList().exists(validator); } @@ -128,6 +155,10 @@ abstract contract ValidatorManager is IValidatorManager, Auth { return validator.supportsInterface(type(IK1Validator).interfaceId); } + function _supportsModuleValidator(address validator) internal view returns (bool) { + return validator.supportsInterface(type(IModuleValidator).interfaceId); + } + function _r1ValidatorsLinkedList() private view returns (mapping(address => address) storage r1Validators) { r1Validators = ClaveStorage.layout().r1Validators; } diff --git a/packages/contracts/src/test/ERC20.sol b/packages/contracts/src/test/ERC20.sol new file mode 100644 index 00000000..294df5c5 --- /dev/null +++ b/packages/contracts/src/test/ERC20.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract TestERC20 is ERC20 { + constructor(address mintTo) ERC20("Test ERC20", "TEST") { + _mint(mintTo, 10 ** 18); + } +} diff --git a/packages/contracts/src/validators/PasskeyValidator.sol b/packages/contracts/src/validators/PasskeyValidator.sol index 2be134c3..cb3a5b97 100644 --- a/packages/contracts/src/validators/PasskeyValidator.sol +++ b/packages/contracts/src/validators/PasskeyValidator.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import { Base64 } from "../helpers/Base64.sol"; import { IR1Validator, IERC165 } from "../interfaces/IValidator.sol"; +import { IModule } from "../interfaces/IModule.sol"; import { Errors } from "../libraries/Errors.sol"; import { VerifierCaller } from "../helpers/VerifierCaller.sol"; import { JsmnSolLib } from "../libraries/JsmnSolLib.sol"; @@ -41,7 +42,7 @@ contract PasskeyValidator is IR1Validator, VerifierCaller { } /// @inheritdoc IERC165 - function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + function supportsInterface(bytes4 interfaceId) public pure virtual returns (bool) { return interfaceId == type(IR1Validator).interfaceId || interfaceId == type(IERC165).interfaceId; } diff --git a/packages/contracts/src/validators/SessionKeyValidator.sol b/packages/contracts/src/validators/SessionKeyValidator.sol new file mode 100644 index 00000000..05f1f52b --- /dev/null +++ b/packages/contracts/src/validators/SessionKeyValidator.sol @@ -0,0 +1,584 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "../interfaces/IERC7579Module.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import { IModule } from "../interfaces/IModule.sol"; +import { IValidationHook } from "../interfaces/IHook.sol"; +import { IModuleValidator } from "../interfaces/IModuleValidator.sol"; + +import { Transaction } from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol"; +import { IHook } from "../interfaces/IERC7579Module.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +import { IHookManager } from "../interfaces/IHookManager.sol"; +import { IValidatorManager } from "../interfaces/IValidatorManager.sol"; + +library SessionLib { + using SessionLib for SessionLib.Constraint; + using SessionLib for SessionLib.UsageLimit; + + uint256 constant MAX_CONSTRAINTS = 16; + + // We do not permit session keys to be reused to open multiple sessions + // (after one expires or is closed, e.g.). + // For each session key, its session status can only be changed + // from NotInitialized to Active, and from Active to Closed. + enum Status { + NotInitialized, + Active, + Closed + } + + // This struct is used to track usage information for each session. + // Along with `status`, this is considered the session state. + // While everything else is considered the session spec. + struct UsageTrackers { + UsageTracker fee; + // (target) => transfer value tracker + mapping(address => UsageTracker) transferValue; + // (target, selector) => call value tracker + mapping(address => mapping(bytes4 => UsageTracker)) callValue; + // (target, selector, index) => call parameter tracker + // index is the constraint index in callPolicy, not the parameter index + mapping(address => mapping(bytes4 => mapping(uint256 => UsageTracker))) params; + } + + // This is the main struct that holds information about all sessions and their state. + // This struct has weird layout because of the AA storage access restrictions for validation. + // Innermost mappings are all mapping(address account => ...) because of this. + struct SessionStorage { + // (target, selector) => call policy + mapping(address => mapping(bytes4 => mapping(address => CallPolicy))) callPolicy; + // (target) => transfer policy. Used for calls with calldata.length < 4. + mapping(address => mapping(address => TransferPolicy)) transferPolicy; + mapping(address => Status) status; + // Timestamp after which session is considered expired + mapping(address => uint256) expiry; + // Tracks gasLimit * maxFeePerGas of each transaction + mapping(address => UsageLimit) feeLimit; + UsageTrackers trackers; + // These 2 mappings are only used in getters / view functions, not used during validation. + mapping(address => CallTarget[]) callTargets; + mapping(address => address[]) transferTargets; + } + + struct CallPolicy { + // this flag is needed, as otherwise, an empty CallPolicy (default mapping entry) + // would mean no constraints + bool isAllowed; + uint256 maxValuePerUse; + UsageLimit valueLimit; + // We restrain from using a dynamic array here, as it would mean further + // complications for the storage layout due to the AA storage access restrictions. + uint256 totalConstraints; + Constraint[MAX_CONSTRAINTS] constraints; + } + + // For transfers, i.e. calls without a selector + struct TransferPolicy { + bool isAllowed; + uint256 maxValuePerUse; + UsageLimit valueLimit; + } + + struct Constraint { + Condition condition; + uint64 index; + bytes32 refValue; + UsageLimit limit; + } + + struct UsageTracker { + // Used for LimitType.Lifetime + mapping(address => uint256) lifetimeUsage; + // Used for LimitType.Allowance + // period => used that period + mapping(uint256 => mapping(address => uint256)) allowanceUsage; + } + + struct UsageLimit { + LimitType limitType; + uint256 limit; // ignored if limitType == Unlimited + uint256 period; // ignored if limitType != Allowance + } + + enum LimitType { + Unlimited, + Lifetime, + Allowance + } + + enum Condition { + Unconstrained, + Equal, + Greater, + Less, + GreaterOrEqual, + LessOrEqual, + NotEqual + } + + struct CallTarget { + address target; + bytes4 selector; + } + + struct SessionSpec { + address signer; + uint256 expiry; + UsageLimit feeLimit; + CallSpec[] callPolicies; + TransferSpec[] transferPolicies; + } + + struct CallSpec { + address target; + bytes4 selector; + uint256 maxValuePerUse; + UsageLimit valueLimit; + Constraint[] constraints; + // add max data length restriction? + // add max number of calls restriction? + } + + struct TransferSpec { + address target; + uint256 maxValuePerUse; + UsageLimit valueLimit; + } + + struct LimitState { + // this might also be limited by a constraint or `maxValuePerUse`, + // which is not reflected here + uint256 remaining; + address target; + // ignored for transfer value + bytes4 selector; + // ignored for transfer and call value + uint256 index; + } + + // Info about remaining session limits and its status + struct SessionState { + Status status; + uint256 fee; + LimitState[] transferValue; + LimitState[] callValue; + LimitState[] callParams; + } + + function checkAndUpdate(UsageLimit storage limit, UsageTracker storage tracker, uint256 value) internal { + if (limit.limitType == LimitType.Lifetime) { + require(tracker.lifetimeUsage[msg.sender] + value <= limit.limit, "Lifetime limit exceeded"); + tracker.lifetimeUsage[msg.sender] += value; + } + // TODO: uncomment when it's possible to check timestamps during validation + // if (limit.limitType == LimitType.Allowance) { + // uint256 period = block.timestamp / limit.period; + // require(tracker.allowanceUsage[period] + value <= limit.limit); + // tracker.allowanceUsage[period] += value; + // } + } + + function checkAndUpdate(Constraint storage constraint, UsageTracker storage tracker, bytes calldata data) internal { + uint256 index = 4 + constraint.index * 32; + bytes32 param = bytes32(data[index:index + 32]); + Condition condition = constraint.condition; + bytes32 refValue = constraint.refValue; + + if (condition == Condition.Equal) { + require(param == refValue, "EQUAL constraint not met"); + } else if (condition == Condition.Greater) { + require(param > refValue, "GREATER constraint not met"); + } else if (condition == Condition.Less) { + require(param < refValue, "LESS constraint not met"); + } else if (condition == Condition.GreaterOrEqual) { + require(param >= refValue, "GREATER_OR_EQUAL constraint not met"); + } else if (condition == Condition.LessOrEqual) { + require(param <= refValue, "LESS_OR_EQUAL constraint not met"); + } else if (condition == Condition.NotEqual) { + require(param != refValue, "NOT_EQUAL constraint not met"); + } + + constraint.limit.checkAndUpdate(tracker, uint256(param)); + } + + function validate(SessionStorage storage session, Transaction calldata transaction) internal { + require(session.status[msg.sender] == Status.Active, "Session is not active"); + + // TODO uncomment when it's possible to check timestamps during validation + // require(block.timestamp <= session.expiry); + + // TODO: update fee allowance with the gasleft/refund at the end of execution + uint256 fee = transaction.maxFeePerGas * transaction.gasLimit; + session.feeLimit[msg.sender].checkAndUpdate(session.trackers.fee, fee); + + address target = address(uint160(transaction.to)); + + if (transaction.data.length >= 4) { + bytes4 selector = bytes4(transaction.data[:4]); + CallPolicy storage callPolicy = session.callPolicy[target][selector][msg.sender]; + + require(callPolicy.isAllowed, "Call not allowed"); + require(transaction.value <= callPolicy.maxValuePerUse, "Value exceeds limit"); + callPolicy.valueLimit.checkAndUpdate(session.trackers.callValue[target][selector], transaction.value); + + for (uint256 i = 0; i < callPolicy.totalConstraints; i++) { + callPolicy.constraints[i].checkAndUpdate(session.trackers.params[target][selector][i], transaction.data); + } + } else { + TransferPolicy storage transferPolicy = session.transferPolicy[target][msg.sender]; + require(transferPolicy.isAllowed, "Transfer not allowed"); + require(transaction.value <= transferPolicy.maxValuePerUse, "Value exceeds limit"); + transferPolicy.valueLimit.checkAndUpdate(session.trackers.transferValue[target], transaction.value); + } + } + + function fill(SessionStorage storage session, SessionSpec memory newSession, address account) internal { + session.status[account] = Status.Active; + session.expiry[account] = newSession.expiry; + session.feeLimit[account] = newSession.feeLimit; + for (uint256 i = 0; i < newSession.callPolicies.length; i++) { + CallSpec memory newPolicy = newSession.callPolicies[i]; + session.callTargets[account].push(CallTarget({ target: newPolicy.target, selector: newPolicy.selector })); + CallPolicy storage callPolicy = session.callPolicy[newPolicy.target][newPolicy.selector][account]; + callPolicy.isAllowed = true; + callPolicy.maxValuePerUse = newPolicy.maxValuePerUse; + callPolicy.valueLimit = newPolicy.valueLimit; + require(newPolicy.constraints.length <= MAX_CONSTRAINTS, "Too many constraints"); + callPolicy.totalConstraints = newPolicy.constraints.length; + for (uint256 j = 0; j < newPolicy.constraints.length; j++) { + callPolicy.constraints[j] = newPolicy.constraints[j]; + } + } + for (uint256 i = 0; i < newSession.transferPolicies.length; i++) { + TransferSpec memory newPolicy = newSession.transferPolicies[i]; + session.transferTargets[account].push(newPolicy.target); + TransferPolicy storage transferPolicy = session.transferPolicy[newPolicy.target][account]; + transferPolicy.isAllowed = true; + transferPolicy.maxValuePerUse = newPolicy.maxValuePerUse; + transferPolicy.valueLimit = newPolicy.valueLimit; + } + } + + function getSpec(SessionStorage storage session, address account) internal view returns (SessionSpec memory) { + CallSpec[] memory callPolicies = new CallSpec[](session.callTargets[account].length); + TransferSpec[] memory transferPolicies = new TransferSpec[](session.transferTargets[account].length); + for (uint256 i = 0; i < session.callTargets[account].length; i++) { + CallTarget memory target = session.callTargets[account][i]; + CallPolicy storage callPolicy = session.callPolicy[target.target][target.selector][account]; + Constraint[] memory constraints = new Constraint[](callPolicy.totalConstraints); + for (uint256 j = 0; j < callPolicy.totalConstraints; j++) { + constraints[j] = callPolicy.constraints[j]; + } + callPolicies[i] = CallSpec({ + target: target.target, + selector: target.selector, + maxValuePerUse: callPolicy.maxValuePerUse, + valueLimit: callPolicy.valueLimit, + constraints: constraints + }); + } + for (uint256 i = 0; i < session.transferTargets[account].length; i++) { + address target = session.transferTargets[account][i]; + TransferPolicy storage transferPolicy = session.transferPolicy[target][account]; + transferPolicies[i] = TransferSpec({ + target: target, + maxValuePerUse: transferPolicy.maxValuePerUse, + valueLimit: transferPolicy.valueLimit + }); + } + return + SessionSpec({ + // Signer addresses are not stored in SessionStorage, + // and are filled in later in the `sessionSpec()` getter. + signer: address(0), + expiry: session.expiry[account], + feeLimit: session.feeLimit[account], + callPolicies: callPolicies, + transferPolicies: transferPolicies + }); + } + + function remainingLimit( + UsageLimit memory limit, + UsageTracker storage tracker, + address account + ) internal view returns (uint256) { + if (limit.limitType == LimitType.Unlimited) { + // this might be still limited by `maxValuePerUse` or a constraint + return type(uint256).max; + } + if (limit.limitType == LimitType.Lifetime) { + return limit.limit - tracker.lifetimeUsage[account]; + } + if (limit.limitType == LimitType.Allowance) { + uint256 period = block.timestamp / limit.period; + return limit.limit - tracker.allowanceUsage[period][account]; + } + } + + function getState(SessionStorage storage session, address account) internal view returns (SessionState memory) { + SessionSpec memory spec = getSpec(session, account); + + LimitState[] memory transferValue = new LimitState[](spec.transferPolicies.length); + LimitState[] memory callValue = new LimitState[](spec.callPolicies.length); + LimitState[] memory callParams = new LimitState[](MAX_CONSTRAINTS * spec.callPolicies.length); // there will be empty ones at the end + uint256 paramLimitIndex = 0; + + for (uint256 i = 0; i < transferValue.length; i++) { + TransferSpec memory transferSpec = spec.transferPolicies[i]; + transferValue[i] = LimitState({ + remaining: remainingLimit( + transferSpec.valueLimit, + session.trackers.transferValue[transferSpec.target], + account + ), + target: spec.transferPolicies[i].target, + selector: bytes4(0), + index: 0 + }); + } + + for (uint256 i = 0; i < callValue.length; i++) { + CallSpec memory callSpec = spec.callPolicies[i]; + callValue[i] = LimitState({ + remaining: remainingLimit( + callSpec.valueLimit, + session.trackers.callValue[callSpec.target][callSpec.selector], + account + ), + target: callSpec.target, + selector: callSpec.selector, + index: 0 + }); + + for (uint256 j = 0; j < callSpec.constraints.length; j++) { + if (callSpec.constraints[j].limit.limitType != LimitType.Unlimited) { + callParams[paramLimitIndex++] = LimitState({ + remaining: remainingLimit( + callSpec.constraints[j].limit, + session.trackers.params[callSpec.target][callSpec.selector][j], + account + ), + target: callSpec.target, + selector: callSpec.selector, + index: callSpec.constraints[j].index + }); + } + } + } + + // shrink array to actual size + assembly { + mstore(callParams, paramLimitIndex) + } + + return + SessionState({ + status: session.status[account], + fee: remainingLimit(spec.feeLimit, session.trackers.fee, account), + transferValue: transferValue, + callValue: callValue, + callParams: callParams + }); + } +} + +contract SessionKeyValidator is IHook, IValidationHook, IModuleValidator, IModule { + using SessionLib for SessionLib.SessionStorage; + using EnumerableSet for EnumerableSet.AddressSet; + + bytes4 constant EIP1271_SUCCESS_RETURN_VALUE = 0x1626ba7e; + + // session owner => session storage + mapping(address => SessionLib.SessionStorage) private sessions; + // account => owners + mapping(address => EnumerableSet.AddressSet) sessionOwners; + + function sessionSpec(address account, address signer) public view returns (SessionLib.SessionSpec memory spec) { + spec = sessions[signer].getSpec(account); + spec.signer = signer; + } + + function sessionState(address account, address signer) public view returns (SessionLib.SessionState memory) { + return sessions[signer].getState(account); + } + + function activeSigners(address account) external view returns (address[] memory) { + return sessionOwners[account].values(); + } + + function sessionList( + address account + ) external view returns (SessionLib.SessionState[] memory states, SessionLib.SessionSpec[] memory specs) { + uint256 length = sessionOwners[account].length(); + states = new SessionLib.SessionState[](length); + specs = new SessionLib.SessionSpec[](length); + for (uint256 i = 0; i < length; i++) { + address signer = sessionOwners[account].at(i); + specs[i] = sessionSpec(account, signer); + states[i] = sessionState(account, signer); + } + } + + function handleValidation(bytes32 signedHash, bytes memory signature) external view returns (bool) { + // this only validates that the session key is linked to the account, not the transaction against the session spec + return isValidSignature(signedHash, signature) == EIP1271_SUCCESS_RETURN_VALUE; + } + + function addValidationKey(bytes memory sessionData) external returns (bool) { + if (sessionData.length == 0) { + return false; + } + SessionLib.SessionSpec memory newSession = abi.decode(sessionData, (SessionLib.SessionSpec)); + createSession(newSession); + return true; + } + + function createSession(SessionLib.SessionSpec memory newSession) public { + require(_isInitialized(msg.sender), "Account not initialized"); + require(newSession.signer != address(0), "Invalid signer"); + require( + sessions[newSession.signer].status[msg.sender] == SessionLib.Status.NotInitialized, + "Session already exists" + ); + require(newSession.feeLimit.limitType != SessionLib.LimitType.Unlimited, "Unlimited fee allowance is not safe"); + sessionOwners[msg.sender].add(newSession.signer); + sessions[newSession.signer].fill(newSession, msg.sender); + } + + function init(bytes calldata data) external { + // to prevent recursion, since addHook also calls init + if (!_isInitialized(msg.sender)) { + IValidatorManager(msg.sender).addModuleValidator(address(this), data); + IHookManager(msg.sender).addHook(abi.encodePacked(address(this)), true); + } + } + + function onInstall(bytes calldata data) external override { + // TODO + } + + function onUninstall(bytes calldata) external override { + // TODO + _uninstall(); + } + + function disable() external { + if (_isInitialized(msg.sender)) { + _uninstall(); + IValidatorManager(msg.sender).removeModuleValidator(address(this)); + IHookManager(msg.sender).removeHook(address(this), true); + } + } + + function _uninstall() internal { + // Here we have to revoke all keys, so that if the module + // is installed again later, there will be no active sessions from the past. + // Problem: if there are too many keys, this will run out of gas. + // Solution: before uninstalling, require that all keys are revoked manually. + require(sessionOwners[msg.sender].length() == 0, "Revoke all keys first"); + } + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return + interfaceId != 0xffffffff && + (interfaceId == type(IERC165).interfaceId || + interfaceId == type(IValidationHook).interfaceId || + interfaceId == type(IModuleValidator).interfaceId || + interfaceId == type(IModule).interfaceId); + } + + // TODO: make the session owner able revoke its own key, in case it was leaked, to prevent further misuse? + function revokeKey(address sessionOwner) public { + require(sessions[sessionOwner].status[msg.sender] == SessionLib.Status.Active, "Nothing to revoke"); + sessions[sessionOwner].status[msg.sender] = SessionLib.Status.Closed; + sessionOwners[msg.sender].remove(sessionOwner); + } + + function revokeKeys(address[] calldata owners) external { + for (uint256 i = 0; i < owners.length; i++) { + revokeKey(owners[i]); + } + } + + /* + * If there are any spend limits configured + * @param smartAccount The smart account to check + * @return true if spend limits are configured initialized, false otherwise + */ + function isInitialized(address smartAccount) external view returns (bool) { + return _isInitialized(smartAccount); + } + + function _isInitialized(address smartAccount) internal view returns (bool) { + return IHookManager(smartAccount).isHook(address(this)); + // && IValidatorManager(smartAccount).isModuleValidator(address(this)); + } + + /* + * Currently doing 1271 validation, but might update the interface to match the zksync account validation + */ + function isValidSignature(bytes32 hash, bytes memory signature) public view returns (bytes4 magic) { + magic = EIP1271_SUCCESS_RETURN_VALUE; + (address recoveredAddress, ) = ECDSA.tryRecover(hash, signature); + SessionLib.Status status = sessions[recoveredAddress].status[msg.sender]; + if (status != SessionLib.Status.Active) { + magic = bytes4(0); + } + } + + function validationHook(bytes32 signedHash, Transaction calldata transaction, bytes calldata _hookData) external { + (bytes memory signature, address validator, ) = abi.decode(transaction.signature, (bytes, address, bytes[])); + if (validator != address(this)) { + // This transaction is not meant to be validated by this module + return; + } + (address recoveredAddress, ) = ECDSA.tryRecover(signedHash, signature); + require(recoveredAddress != address(0), "Invalid signer"); + sessions[recoveredAddress].validate(transaction); + } + + /** + * The name of the module + * @return name The name of the module + */ + function name() external pure returns (string memory) { + return "SessionKeyValidator"; + } + + /** + * Currently in dev + * @return version The version of the module + */ + function version() external pure returns (string memory) { + return "0.0.0"; + } + + /* + * Does validation and hooks transaction depending on the key + * @param typeID The type ID to check + * @return true if the module is of the given type, false otherwise + */ + function isModuleType(uint256 typeID) external pure override returns (bool) { + return typeID == MODULE_TYPE_VALIDATOR; + } + + /* + * Look at the transaction data to parse out what needs to be done + */ + function preCheck( + address msgSender, + uint256 msgValue, + bytes calldata msgData + ) external returns (bytes memory hookData) {} + + /* + * Validate data from the pre-check hook after the transaction is executed + */ + function postCheck(bytes calldata hookData) external {} +} diff --git a/packages/contracts/src/validators/SessionPasskeySpendLimitModule.sol b/packages/contracts/src/validators/SessionPasskeySpendLimitModule.sol deleted file mode 100644 index 1bce857d..00000000 --- a/packages/contracts/src/validators/SessionPasskeySpendLimitModule.sol +++ /dev/null @@ -1,369 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "../interfaces/IERC7579Module.sol"; - -import { IModule } from "../interfaces/IModule.sol"; - -import { IERC7579Module } from "../interfaces/IERC7579Module.sol"; -import { IR1Validator } from "../interfaces/IValidator.sol"; -import { IModuleValidator } from "../interfaces/IModuleValidator.sol"; - -import "../helpers/Logger.sol"; - -/** - * Looking to combine with the validator to ensure that the spend limit is touched - * Working on using the 7579 module + zksync validator - * Flow is create passkey with optional spend-limit (as a validator) - * have that passkey create a time & spend limited session key, - * then reject transactions (as a validator) when the session key expires. - */ -contract SessionPasskeySpendLimitModule is IERC7579Module, IModule, IModuleValidator { - bytes4 constant EIP1271_SUCCESS_RETURN_VALUE = 0x1626ba7e; - - struct TokenSpendLimit { - uint256 limit; - // timestamp per transfer - mapping(uint256 timeperiod => uint256) spent; - } - - struct SessionData { - // account address that is getting spend-limited - address accountAddress; - // block timestamp - uint256 expiresAt; - // token spend limit is per session - mapping(address tokenAddress => TokenSpendLimit spendLimit) spendLimitByToken; - } - - // 2-way lookup between session and token-spend-limits, need to be kept in sync - mapping(address sessionAccount => SessionData limitedAccount) spendLimitBySession; - mapping(address limitedAccount => address[] sessionAccount) sessionsByAccount; - - struct SpendLimit { - // ERC-20 address - address tokenAddress; - uint256 limit; - } - - // this is used to create/manage sessions/limits, but not for storage - struct SessionKey { - // the public address of the session - address sessionKey; - // block timestamp - uint256 expiresAt; - // if not de-duplicated, the last token address wins - SpendLimit[] spendLimits; - } - - function handleValidation(bytes32 signedHash, bytes memory signature) external view returns (bool) { - // this only validates that the session key is linked to the account, not the spend limit - return isValidSignature(signedHash, signature) == EIP1271_SUCCESS_RETURN_VALUE; - } - - // expects SessionKey[] - function addValidationKey(bytes memory installData) external returns (bool) { - Logger.logString("installing session-key spend-limit module"); - SessionKey[] memory sessionKeys = abi.decode(installData, (SessionKey[])); - for (uint256 sessionKeyIndex = 0; sessionKeyIndex < sessionKeys.length; sessionKeyIndex++) { - setSessionKey(sessionKeys[sessionKeyIndex]); - } - return false; - } - - function init(bytes calldata initData) external { - _install(initData); - } - - /* array of token spend limit configurations (sane defaults) - * @param data TokenConfig[] - */ - function onInstall(bytes calldata data) external override { - _install(data); - } - - function _install(bytes calldata installData) internal { - Logger.logString("installing session-key spend-limit module"); - SessionKey[] memory sessionKeys = abi.decode(installData, (SessionKey[])); - for (uint256 sessionKeyIndex = 0; sessionKeyIndex < sessionKeys.length; sessionKeyIndex++) { - setSessionKey(sessionKeys[sessionKeyIndex]); - } - } - - /* Remove all the spending limits for the message sender - * @param data (unused, but needed to satisfy interfaces) - */ - function onUninstall(bytes calldata) external override { - _clearSender(); - } - - function disable() external { - _clearSender(); - } - - function _clearSender() internal { - // FIXME: spend limits are orphaned without a reverse token mapping - // delete spendLimitByAccount[msg.sender]; - - uint256 sessionLength = sessionsByAccount[msg.sender].length; - for (uint256 index = 0; index < sessionLength; index++) { - delete spendLimitBySession[sessionsByAccount[msg.sender][index]]; - delete sessionsByAccount[msg.sender][index]; - } - } - - function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { - // found by example - return interfaceId == 0x01ffc9a7 || interfaceId == 0xffffffff; - } - - function setSessionKey(SessionKey memory sessionKey) internal { - SessionData storage sessionData = spendLimitBySession[sessionKey.sessionKey]; - if (sessionData.accountAddress == address(0)) { - sessionData.accountAddress = msg.sender; - } else { - require(sessionData.accountAddress == msg.sender, "Session key is already assigned to another account"); - } - sessionData.expiresAt = sessionKey.expiresAt; - - for (uint256 spendLimitIndex = 0; spendLimitIndex < sessionKey.spendLimits.length; spendLimitIndex++) { - SpendLimit memory initSpendLimit = sessionKey.spendLimits[spendLimitIndex]; - - TokenSpendLimit storage initTokenSpendLimit = sessionData.spendLimitByToken[initSpendLimit.tokenAddress]; - require(initSpendLimit.limit >= 0, "Spend limit must be set, cannot be 0"); - initTokenSpendLimit.limit = initSpendLimit.limit; - } - } - - /* - * Update spend limit of sender for provided tokens in list - * @param configs TokenConfig[] to update - */ - function setSessionKeys(SessionKey[] calldata sessionKeys) external { - for (uint256 sessionKeyIndex = 0; sessionKeyIndex < sessionKeys.length; sessionKeyIndex++) { - setSessionKey(sessionKeys[sessionKeyIndex]); - } - } - - function revokeSession(address sessionKey) external { - SessionData storage sessionToRemove = spendLimitBySession[sessionKey]; - require(sessionToRemove.accountAddress == msg.sender, "cannot remove session for another account"); - // this doesn't clear the spend limits if the session is re-added - delete spendLimitBySession[sessionKey]; - - uint256 sessionLength = sessionsByAccount[msg.sender].length; - for (uint256 index = 0; index < sessionLength; index++) { - if (sessionsByAccount[msg.sender][index] == sessionKey) { - delete sessionsByAccount[msg.sender][index]; - } - } - } - - /* - * If there are any spend limits configured - * @param smartAccount The smart account to check - * @return true if spend limits are configured initialized, false otherwise - */ - function isInitialized(address smartAccount) external view returns (bool) { - return sessionsByAccount[smartAccount].length > 0; - } - - /* - * Currently doing 1271 validation, but might update the interface to match the zksync account validation - */ - function isValidSignature(bytes32 _hash, bytes memory _signature) public view returns (bytes4 magic) { - magic = EIP1271_SUCCESS_RETURN_VALUE; - - if (_signature.length != 65) { - // Signature is invalid anyway, but we need to proceed with the signature verification as usual - // in order for the fee estimation to work correctly - _signature = new bytes(65); - - // Making sure that the signatures look like a valid ECDSA signature and are not rejected rightaway - // while skipping the main verification process. - _signature[64] = bytes1(uint8(27)); - } - - // extract ECDSA signature - uint8 v; - bytes32 r; - bytes32 s; - // Signature loading code - // we jump 32 (0x20) as the first slot of bytes contains the length - // we jump 65 (0x41) per signature - // for v we load 32 bytes ending with v (the first 31 come from s) then apply a mask - assembly { - r := mload(add(_signature, 0x20)) - s := mload(add(_signature, 0x40)) - v := and(mload(add(_signature, 0x41)), 0xff) - } - - if (v != 27 && v != 28) { - magic = bytes4(0); - Logger.logString("session key signature v is invalid(27 or 28)"); - } - - // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature - // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines - // the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most - // signatures from current libraries generate a unique signature with an s-value in the lower half order. - // - // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value - // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or - // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept - // these malleable signatures as well. - if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { - magic = bytes4(0); - Logger.logString("session key signature s is too high"); - } - - address recoveredAddress = ecrecover(_hash, v, r, s); - Logger.logString("recoveredAddress(sessionKey)"); - Logger.logAddress(recoveredAddress); - - SessionData storage sessionData = spendLimitBySession[recoveredAddress]; - Logger.logString("sessionData.accountAddress"); - Logger.logAddress(sessionData.accountAddress); - Logger.logString("msg.sender"); - Logger.logAddress(msg.sender); - if (sessionData.accountAddress != msg.sender || recoveredAddress == address(0)) { - // Note, that we should abstain from using the require here in order to allow for fee estimation to work - magic = bytes4(0); - Logger.logString("invalid session key"); - } - } - - /** - * @dev Getting the target and session key together is the trick here. - * For ERC20 transfers, compare the token contract address (target) - * with any spend limits configured for the account. - * Revert if the spend-limit has been exceeded - */ - function _checkSpendingLimit(address target, address sessionKey, bytes calldata callData) internal { - SessionData storage accountSessionData = spendLimitBySession[sessionKey]; - TokenSpendLimit storage spendLimit = accountSessionData.spendLimitByToken[target]; - uint256 timeperiod = block.timestamp / 1 weeks; - (, uint256 value) = abi.decode(callData[4:], (address, uint256)); - if (spendLimit.spent[timeperiod] + value > spendLimit.limit) { - revert("SpendingLimitHook: spending limit exceeded"); - } else { - spendLimit.spent[timeperiod] += value; - } - } - - // check the spending limit of the target for the transaction - function onExecute( - address account, - address msgSender, - address target, - uint256 value, - bytes calldata callData - ) internal virtual returns (bytes memory hookData) {} - - /** - * The name of the module - * @return name The name of the module - */ - function name() external pure returns (string memory) { - return "SessionPasskeySpendLimitModule"; - } - - /** - * Currently in dev - * @return version The version of the module - */ - function version() external pure returns (string memory) { - return "0.0.0"; - } - - /* - * Does validation and hooks transaction depending on the key - * @param typeID The type ID to check - * @return true if the module is of the given type, false otherwise - */ - function isModuleType(uint256 typeID) external pure override returns (bool) { - return typeID == MODULE_TYPE_VALIDATOR; - } - - /* - * CALLDATA DECODING - this needs to check 2 things: - * 1. Does this transaction contain an ERC20 transfer? - * 2. What is the token contract address for this transfer - * @return address the token contract for the transfer, otherwise 0 - */ - function _preCheckParsing( - address msgSender, - uint256 msgValue, - bytes calldata msgData - ) internal returns (address tokenAddress) { - require( - bytes4(msgData[2:10]) == IERC20.transfer.selector || bytes4(msgData[2:10]) == IERC20.transferFrom.selector, - "Must perform ERC20 transfer" - ); - // XXX: not sure if the offsets here are correct given that this is being used with a smart account - tokenAddress = address(bytes20(msgData[16:35])); - } - - /** - * Get the sender of the transaction - * - * @return account the sender of the transaction - */ - function _getAccount() internal view returns (address account) { - account = msg.sender; - address _account; - address forwarder; - if (msg.data.length >= 40) { - assembly { - _account := shr(96, calldataload(sub(calldatasize(), 20))) - forwarder := shr(96, calldataload(sub(calldatasize(), 40))) - } - if (forwarder == msg.sender) { - account = _account; - } - } - } - - /* - * Look at the transaction data to parse out what needs to be done - */ - function preCheck( - address msgSender, - uint256 msgValue, - bytes calldata msgData - ) external returns (bytes memory hookData) { - address target = _preCheckParsing(msgSender, msgValue, msgData); - // TODO: how can the hook get the signing key from just the transaction data - // (can you recover it again from the signature if that's part of the msgData?) - address sessionKey = address(0); - _checkSpendingLimit(target, sessionKey, msgData); - } - - /* - * Validate data from the pre-check hook after the transaction is executed - */ - function postCheck(bytes calldata hookData) external {} - - // Returns all registered session keys. - function getSessionKeys() external view returns (address[] memory) { - return sessionsByAccount[msg.sender]; - } - - // Returns session key data for the given session public key. - function getSessionKeyData(address sessionPublicKey) external view returns (SessionKey memory sessionKey) { - SessionData storage sessionAccountData = spendLimitBySession[sessionPublicKey]; - sessionKey.expiresAt = sessionAccountData.expiresAt; - sessionKey.sessionKey = sessionAccountData.accountAddress; - // TODO: also return configured token spend limits - } - - // Returns the remaining spend limit for a specific token under the session key (total - used). - function getRemainingSpendLimit(address sessionPublicKey, address token) external view returns (uint256) { - SessionData storage accountSessionData = spendLimitBySession[sessionPublicKey]; - TokenSpendLimit storage spendLimit = accountSessionData.spendLimitByToken[token]; - uint256 timeperiod = block.timestamp / 1 weeks; - // XXX: This range index appears incorrect - return spendLimit.limit - spendLimit.spent[timeperiod]; - } -} diff --git a/packages/contracts/src/validators/WebAuthValidator.sol b/packages/contracts/src/validators/WebAuthValidator.sol index 7f4a9cc7..2b3c5456 100644 --- a/packages/contracts/src/validators/WebAuthValidator.sol +++ b/packages/contracts/src/validators/WebAuthValidator.sol @@ -128,4 +128,9 @@ contract WebAuthValidator is PasskeyValidator, IModuleValidator { bytes32 message = _createMessage(authenticatorData, bytes(clientDataJSON)); valid = callVerifier(P256_VERIFIER, message, rs, pubKey); } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return super.supportsInterface(interfaceId) || interfaceId == type(IModuleValidator).interfaceId; + } } diff --git a/packages/contracts/test/BasicTest.ts b/packages/contracts/test/BasicTest.ts index 4f206401..4e9f09ec 100644 --- a/packages/contracts/test/BasicTest.ts +++ b/packages/contracts/test/BasicTest.ts @@ -6,8 +6,7 @@ import { SmartAccount, utils } from "zksync-ethers"; import { ERC7579Account__factory } from "../typechain-types"; import { CallStruct } from "../typechain-types/src/batch/BatchCaller"; -import { ContractFixtures } from "./EndToEndSpendLimit"; -import { getProvider } from "./utils"; +import { ContractFixtures, getProvider } from "./utils"; describe("Basic tests", function () { const fixtures = new ContractFixtures(); diff --git a/packages/contracts/test/EndToEndSpendLimit.ts b/packages/contracts/test/EndToEndSpendLimit.ts deleted file mode 100644 index e79c7cd6..00000000 --- a/packages/contracts/test/EndToEndSpendLimit.ts +++ /dev/null @@ -1,707 +0,0 @@ -import { assert, expect } from "chai"; -import { AbiCoder, BytesLike, Contract, ethers, parseEther, randomBytes, ZeroAddress } from "ethers"; -import * as hre from "hardhat"; -import { it } from "mocha"; -import { Address, createWalletClient, getAddress, Hash, http, publicActions } from "viem"; -import { generatePrivateKey, privateKeyToAccount, privateKeyToAddress } from "viem/accounts"; -import { waitForTransactionReceipt } from "viem/actions"; -import { zksyncInMemoryNode } from "viem/chains"; -import { createZksyncSessionClient, deployAccount } from "zksync-account/client"; -import { setSessionKey } from "zksync-account/client/actions"; -import { encodePasskeyModuleParameters, encodeSessionSpendLimitParameters } from "zksync-account/utils"; -import { SmartAccount, types, utils, Wallet } from "zksync-ethers"; - -import type { AAFactory, ERC7579Account, SessionPasskeySpendLimitModule, WebAuthValidator } from "../typechain-types"; -import { createZksyncPasskeyClient } from "./sdk/PasskeyClient"; -import { create2, deployFactory, getProvider, getWallet, LOCAL_RICH_WALLETS, logInfo, RecordedResponse } from "./utils"; - -// Token Config Interface definitions -interface SpendLimit { - tokenAddress: Address; - limit: bigint; -} - -interface SessionKey { - // the public address of the session - sessionKey: Address; - // block timestamp - expiresAt: bigint; - // if not de-duplicated, the last token address wins - spendLimits: SpendLimit[]; -} -export class ContractFixtures { - // NOTE: CHANGING THE READONLY VALUES WILL REQUIRE UPDATING THE STATIC SIGNATURE - readonly wallet: Wallet = getWallet(LOCAL_RICH_WALLETS[0].privateKey); - readonly viemSessionKeyWallet: Wallet = getWallet(LOCAL_RICH_WALLETS[2].privateKey); - readonly ethersStaticSalt = new Uint8Array([ - 205, 241, 161, 186, 101, 105, 79, - 248, 98, 64, 50, 124, 168, 204, - 200, 71, 214, 169, 195, 118, 199, - 62, 140, 111, 128, 47, 32, 21, - 177, 177, 174, 166, - ]); - - readonly viemStaticSalt = new Uint8Array([ - 0, 0, 0, 0, 0, 0, 0, - 248, 98, 64, 50, 124, 168, 204, - 200, 71, 214, 169, 195, 118, 199, - 62, 140, 111, 128, 47, 32, 21, - 177, 177, 174, 166, - ]); - - readonly tokenForSpendLimit = "0xAe045DE5638162fa134807Cb558E15A3F5A7F853"; - - private abiCoder = new AbiCoder(); - - private _aaFactory: AAFactory; - async getAaFactory() { - if (!this._aaFactory) { - this._aaFactory = await deployFactory("AAFactory", this.wallet); - } - return this._aaFactory; - } - - private _sessionSpendLimitModule: SessionPasskeySpendLimitModule; - async getSessionSpendLimitContract() { - if (!this._sessionSpendLimitModule) { - this._sessionSpendLimitModule = await create2("SessionPasskeySpendLimitModule", this.wallet, this.ethersStaticSalt); - } - return this._sessionSpendLimitModule; - } - - private _webauthnValidatorModule: WebAuthValidator; - // does passkey validation via modular interface - async getWebAuthnVerifierContract() { - if (!this._webauthnValidatorModule) { - this._webauthnValidatorModule = await create2("WebAuthValidator", this.wallet, this.ethersStaticSalt); - } - return this._webauthnValidatorModule; - } - - private _passkeyModuleAddress: Address; - async getPasskeyModuleAddress() { - if (!this._passkeyModuleAddress) { - const passkeyModule = await this.getWebAuthnVerifierContract(); - this._passkeyModuleAddress = getAddress(await passkeyModule.getAddress()); - } - return this._passkeyModuleAddress; - } - - private _accountImplContract: ERC7579Account; - // wraps the clave account - async getAccountImplContract() { - if (!this._accountImplContract) { - this._accountImplContract = await create2("ERC7579Account", this.wallet, this.ethersStaticSalt); - } - return this._accountImplContract; - } - - private _accountImplAddress: Address; - // deploys the base account for future proxy use - async getAccountImplAddress() { - if (!this._accountImplAddress) { - const accountImpl = await this.getAccountImplContract(); - this._accountImplAddress =
await accountImpl.getAddress(); - } - return this._accountImplAddress; - } - - private _proxyAccountContract: Contract; - async getProxyAccountContract() { - const claveAddress = await this.getAccountImplAddress(); - if (!this._proxyAccountContract) { - this._proxyAccountContract = await create2("AccountProxy", this.wallet, this.ethersStaticSalt, [claveAddress]); - } - return this._proxyAccountContract; - } - - // need to store values that works on equal for use in map to memoize results - private _fundedProxyAccountAddress: Map = new Map(); - async getFundedProxyAccount(salt: Uint8Array, response: RecordedResponse, initialSessionKeyWallet: Wallet) { - const uniqueAccountKey = salt.toString() + response.passkeyBytes.toString() + initialSessionKeyWallet.address; - const cachedProxyAddress = this._fundedProxyAccountAddress.get(uniqueAccountKey); - if (cachedProxyAddress) { - return cachedProxyAddress; - } - const passkeyModule = await this.getWebAuthnVerifierContract(); - const passkeyModuleAddress = await passkeyModule.getAddress(); - const sessionModuleContract = await this.getSessionSpendLimitContract(); - const sessionModuleAddress = await sessionModuleContract.getAddress(); - const factory = await this.getAaFactory(); - const accountImpl = await this.getAccountImplAddress(); - const proxyFix = await this.getProxyAccountContract(); - assert(proxyFix != null, "should deploy proxy"); - - const sessionModuleData = this.abiCoder.encode( - ["address", "bytes"], - [sessionModuleAddress, this.getEncodedSessionModuleData(initialSessionKeyWallet.address as Address)]); - const passkeyModuleData = this.abiCoder.encode( - ["address", "bytes"], - [passkeyModuleAddress, this.getEncodedPasskeyModuleData(response)]); - const proxyAccount = await factory.deployProxy7579Account( - salt, - accountImpl, - uniqueAccountKey, - [sessionModuleData, passkeyModuleData], - [], - [], - ); - - const proxyAccountReceipt = await proxyAccount.wait(); - const proxyAccountAddress =
proxyAccountReceipt!.contractAddress!; - assert.isDefined(proxyAccountAddress, "no address set"); - await ( - await this.wallet.sendTransaction({ - to: proxyAccountAddress, - value: parseEther("0.002"), - }) - ).wait(); - const accountBalance = await this.wallet.provider.getBalance(proxyAccountAddress); - assert(accountBalance > BigInt(0), "account balance should be positive"); - - this._fundedProxyAccountAddress.set(uniqueAccountKey, proxyAccountAddress); - return getAddress(proxyAccountAddress); - } - - async passkeySigner(_hash: BytesLike, secret: RecordedResponse) { - const fatSignature = this.abiCoder.encode(["bytes", "bytes", "bytes32[2]"], [ - secret.authDataBuffer, - secret.clientDataBuffer, - [secret.rs.r, secret.rs.s], - ]); - - // clave expects signature + validator address + validator hook data - const fullFormattedSig = this.abiCoder.encode(["bytes", "address", "bytes[]"], [ - fatSignature, - await this.getPasskeyModuleAddress(), - [], - ]); - - return fullFormattedSig; - }; - - async sessionKeySigner(hash: BytesLike, secret: ethers.SigningKey) { - const sessionKeySignature = secret.sign(hash); - const spendLimitModule = await this.getSessionSpendLimitContract(); - return this.abiCoder.encode(["bytes", "address", "bytes[]"], [ - sessionKeySignature.serialized, - await spendLimitModule.getAddress(), - [], - ]); - }; - - getSessionSpendLimitModuleData(sessionPublicKey: Address): SessionKey { - return { - sessionKey: sessionPublicKey, - expiresAt: BigInt(1000000), - spendLimits: [{ - tokenAddress: this.tokenForSpendLimit, - limit: BigInt(1000), - }], - }; - } - - getEncodedSessionModuleData(sessionPublicKey: Address) { - const sessionKeyData = this.getSessionSpendLimitModuleData(sessionPublicKey); - return encodeSessionSpendLimitParameters([{ - sessionKey: sessionKeyData.sessionKey, - expiresAt: new Date(parseInt((sessionKeyData.expiresAt * BigInt(1000)).toString())).toISOString(), - spendLimit: Object.fromEntries(sessionKeyData.spendLimits.map((limit) => [ - limit.tokenAddress as Address, limit.limit.toString() as Hash, - ])), - }]); - } - - // passkey has the public key + origin domain - getEncodedPasskeyModuleData(response: RecordedResponse) { - return encodePasskeyModuleParameters({ - passkeyPublicKey: response.getXyPublicKeys(), - expectedOrigin: response.expectedOrigin, - }); - } -} - -describe("Spend limit validation", function () { - const fixtures = new ContractFixtures(); - const ethersResponse = new RecordedResponse("test/signed-challenge.json"); - const viemResponse = new RecordedResponse("test/signed-viem-challenge.json"); - const abiCoder = new AbiCoder(); - const provider = getProvider(); - - it("should deploy module", async () => { - const sessionModuleContract = await fixtures.getSessionSpendLimitContract(); - assert(sessionModuleContract != null, "No session spend limit module deployed"); - }); - - it("should deploy verifier", async () => { - const validatorModule = await fixtures.getWebAuthnVerifierContract(); - assert(validatorModule != null, "No passkey verifier deployed"); - }); - - it("should deploy implemention", async () => { - const accountImplContract = await fixtures.getAccountImplContract(); - assert(accountImplContract != null, "No account impl deployed"); - }); - - it("should deploy proxy directly", async () => { - const proxyAccountContract = await fixtures.getProxyAccountContract(); - assert(proxyAccountContract != null, "No account proxy deployed"); - }); - - // This test relies on static data that is not available in the repo - describe.skip("using viem", () => { - it("should deploy proxy account via factory, create a new session key with a passkey, then send funds with the initial session key", async () => { - const passkeyModule = await fixtures.getWebAuthnVerifierContract(); - const sessionModule = await fixtures.getSessionSpendLimitContract(); - const factoryContract = await fixtures.getAaFactory(); - const factoryAddress = await factoryContract.getAddress() as Address; - - const sessionModuleAddress = await sessionModule.getAddress() as Address; - const passkeyModuleAddress = await passkeyModule.getAddress() as Address; - const accountImplementationAddress = await fixtures.getAccountImplAddress() as Address; - - // fix for .only deployment - const proxyFix = await fixtures.getProxyAccountContract(); - assert(proxyFix != null, "should deploy proxy"); - - const localClient = { - ...zksyncInMemoryNode, - rpcUrls: { - default: { - http: [hre.network.config["url"]], // Override if not using the default port - }, - }, - }; - - const richWallet = createWalletClient({ - account: privateKeyToAccount(fixtures.wallet.privateKey as Hash), - chain: localClient, - transport: http(), - }).extend(publicActions); - - /* 1. Deploy smart account */ - const rawSessionKeyData = fixtures.getSessionSpendLimitModuleData(fixtures.viemSessionKeyWallet.address as Address); - const sessionKeyData = { - sessionPublicKey: rawSessionKeyData.sessionKey, - expiresAt: new Date(parseInt((rawSessionKeyData.expiresAt * BigInt(1000)).toString())).toISOString(), - spendLimit: Object.fromEntries(rawSessionKeyData.spendLimits.map((limit) => [ - limit.tokenAddress, limit.limit.toString(), - ])), - }; - - const proxyAccountDeployment = await deployAccount(richWallet as any, { - credentialPublicKey: viemResponse.passkeyBytes, - expectedOrigin: viemResponse.expectedOrigin, - uniqueAccountId: "viemSpendLimitAccount", - salt: fixtures.viemStaticSalt, - contracts: { - accountFactory: factoryAddress, - accountImplementation: accountImplementationAddress, - passkey: passkeyModuleAddress, - session: sessionModuleAddress, - }, - initialSessions: [ - { - sessionPublicKey: sessionKeyData.sessionPublicKey, - expiresAt: sessionKeyData.expiresAt, - spendLimit: sessionKeyData.spendLimit, - }, - ], - }); - const proxyAccountAddress = proxyAccountDeployment.address; - assert.isDefined(proxyAccountAddress, "no address set"); - - /* 1.1 Fund smart account with some ETH to pay for transaction fees */ - const fundAccountTransactionHash = await waitForTransactionReceipt(richWallet, { - hash: await richWallet.sendTransaction({ - to: proxyAccountAddress, - value: parseEther("0.05"), - }), - }); - assert.equal(fundAccountTransactionHash.status, "success", "should fund without errors"); - - /* 2. Validate passkey signed transactions */ - const passkeyClient = createZksyncPasskeyClient({ - address: proxyAccountAddress as Address, - chain: localClient, - contracts: { - passkey: passkeyModuleAddress, - session: sessionModuleAddress, - accountFactory: factoryAddress, - accountImplementation: accountImplementationAddress, - }, - signHash: async () => ({ - authenticatorData: viemResponse.authenticatorData, - clientDataJSON: viemResponse.clientData, - signature: viemResponse.b64SignedChallenge, - }), - transport: http(), - }); - - await setSessionKey(passkeyClient as any, { - sessionKey: sessionKeyData.sessionPublicKey, - expiresAt: sessionKeyData.expiresAt, - spendLimit: sessionKeyData.spendLimit, - contracts: passkeyClient.contracts, - }); - - /* 3. Verify session key signed transactions */ - const sessionKeyClient = createZksyncSessionClient({ - address: proxyAccountAddress, - sessionKey: fixtures.viemSessionKeyWallet.privateKey as Hash, - contracts: { - session: sessionModuleAddress, - }, - chain: localClient, - transport: http(), - }); - - const sessionKeySignedTransactionHash = await sessionKeyClient.sendTransaction({ - to: privateKeyToAddress(generatePrivateKey()), // send any transaction to a random address - value: 1n, - }); - const sessionKeyReceipt = await waitForTransactionReceipt(sessionKeyClient as any, { hash: sessionKeySignedTransactionHash }); - assert.equal(sessionKeyReceipt.status, "success", "(sessionkey) transaction should be successful"); - }); - }); - - describe("using ethers", () => { - it("should deploy proxy account via factory", async () => { - const aaFactoryContract = await fixtures.getAaFactory(); - assert(aaFactoryContract != null, "No AA Factory deployed"); - - const spendLimitModule = await fixtures.getSessionSpendLimitContract(); - assert(spendLimitModule != null, "no module available"); - - const passkeyModule = await fixtures.getWebAuthnVerifierContract(); - assert(passkeyModule != null, "no verifier available"); - - const forceDeploy = await fixtures.getProxyAccountContract(); - assert(forceDeploy != null, "proxy fails"); - - const sessionKeyWallet = Wallet.createRandom(getProvider()); - const webauthModuleData = abiCoder.encode( - ["address", "bytes"], - [await passkeyModule.getAddress(), fixtures.getEncodedPasskeyModuleData(ethersResponse)]); - const sessionSpendModuleData = abiCoder.encode( - ["address", "bytes"], - [await spendLimitModule.getAddress(), fixtures.getEncodedSessionModuleData(sessionKeyWallet.address as Address)]); - const proxyAccount = await aaFactoryContract.deployProxy7579Account( - randomBytes(32), - await fixtures.getAccountImplAddress(), - "testProxyAccount", - [webauthModuleData, sessionSpendModuleData], - [], - [], - ); - const proxyAccountTxReceipt = await proxyAccount.wait(); - - // Extract and decode the return address from the return data/logs - // Assuming the return data is in the first log's data field - // - // Alternatively, we could emit an event like: - // event ProxyAccountDeployed(address accountAddress) - // - // Then, this would be more precise with decodeEventLog() - const newAddress = abiCoder.decode(["address"], proxyAccountTxReceipt!.logs[0].data); - const proxyAccountAddress = newAddress[0]; - - expect(proxyAccountAddress, "the proxy account location via logs").to.not.equal(ZeroAddress, "be a valid address"); - expect(proxyAccountTxReceipt!.contractAddress, "the proxy account location via return").to.not.equal(ZeroAddress, "be a non-zero address"); - }); - - it("should add passkey and verifier to account", async () => { - // - // PART ONE: Initialize ClaveAccount implemention, verifier module, spendlimit module, and factory - // - const aaFactoryContract = await fixtures.getAaFactory(); - assert(aaFactoryContract != null, "No AA Factory deployed"); - - const validatorModule = await fixtures.getWebAuthnVerifierContract(); - const expensiveVerifierAddress = await validatorModule.getAddress(); - - const sessionModuleAddress = await (await fixtures.getSessionSpendLimitContract()).getAddress(); - // - // PART TWO: Install Module with passkey (salt needs to be random to not collide with other tests) - // - const sessionKeyWallet = Wallet.createRandom(getProvider()); - const passkeyModuleData = abiCoder.encode( - ["address", "bytes"], - [expensiveVerifierAddress, fixtures.getEncodedPasskeyModuleData(ethersResponse)]); - const sessionModuleData = abiCoder.encode( - ["address", "bytes"], - [sessionModuleAddress, fixtures.getEncodedSessionModuleData(sessionKeyWallet.address as Address)]); - const proxyAccount = await aaFactoryContract.deployProxy7579Account( - randomBytes(32), - await fixtures.getAccountImplAddress(), - "passkeyVerifierAccount", - [passkeyModuleData], - [sessionModuleData], - [], - ); - const proxyAccountTxReceipt = await proxyAccount.wait(); - - assert(proxyAccountTxReceipt!.contractAddress != ethers.ZeroAddress, "valid proxy account address"); - }); - - // This test relies on static data that is not available in the repo - it.skip("should add a new session key with a passkey", async () => { - const initialSessionKeyWallet: Wallet = getWallet("0xf51513036f18ef46508ddb0fff7aa153260ff76721b2f53c33fc178152fb481e"); - const proxyAccountAddress = await fixtures.getFundedProxyAccount( - fixtures.ethersStaticSalt, - ethersResponse, - initialSessionKeyWallet); - - const passkeySmartAccount = new SmartAccount({ - payloadSigner: fixtures.passkeySigner.bind(fixtures), - address: proxyAccountAddress, - secret: ethersResponse, - }, getProvider()); - - // we just need a stable wallet address, the fact that this is a rich wallet shouldn't matter - const extraSessionKeyWallet: Wallet = getWallet(LOCAL_RICH_WALLETS[4].privateKey); - const tokenData = fixtures.getSessionSpendLimitModuleData(extraSessionKeyWallet.address as Address); - const sessionModuleContract = await fixtures.getSessionSpendLimitContract(); - const callData = sessionModuleContract.interface.encodeFunctionData("setSessionKeys", [[tokenData]]); - const aaTx = { - from: proxyAccountAddress, - to: getAddress(await sessionModuleContract.getAddress()), - data: callData, - gasPrice: await provider.getGasPrice(), - customData: {} as types.Eip712Meta, - gasLimit: BigInt(0), - }; - aaTx["gasLimit"] = await provider.estimateGas(aaTx); - - const passkeySignedTransaction = await passkeySmartAccount.signTransaction(aaTx); - assert(passkeySignedTransaction != null, "valid passkey transaction to sign"); - - const passkeyTransactionResponse = await provider.broadcastTransaction(passkeySignedTransaction); - const passkeyTransactionRecipt = await passkeyTransactionResponse.wait(); - assert.equal(passkeyTransactionRecipt.status, 1, "failed passkey transaction"); - }); - - // This test relies on static data that is not available in the repo - it.skip("might be able to add a session key with passkey, then a session key", async () => { - const ethersPasskeyResponse = new RecordedResponse("test/signed-ethers-passkey.json"); - const initialSessionKeyWallet = getWallet("0xae3f083edae2d6fb1dfeaa6952ea260596eb67f9f26f4e17ca7d6916479ff9fa"); - const salt = new Uint8Array([ - 200, 241, 161, 186, 101, 105, 79, - 240, 98, 64, 50, 124, 168, 204, - 200, 71, 214, 169, 195, 118, 199, - 60, 140, 111, 128, 47, 32, 21, - 170, 177, 174, 166, - ]); - const proxyAccountAddress = await fixtures.getFundedProxyAccount( - salt, - ethersPasskeyResponse, - initialSessionKeyWallet); - - const passkeySmartAccount = new SmartAccount({ - payloadSigner: fixtures.passkeySigner.bind(fixtures), - address: proxyAccountAddress, - secret: ethersPasskeyResponse, - }, getProvider()); - - // we just need a stable wallet address, the fact that this is a rich wallet shouldn't matter - const extraSessionKeyWallet: Wallet = getWallet("0x97006fa3cfc8f133ae17d8f0c9a815a8224246b0c667bf08b7a122f5be858c34"); - const tokenData = fixtures.getSessionSpendLimitModuleData(extraSessionKeyWallet.address as Address); - - const sessionModuleContract = await fixtures.getSessionSpendLimitContract(); - const callData = sessionModuleContract.interface.encodeFunctionData("setSessionKeys", [[tokenData]]); - const transactionForPasskey = { - from: proxyAccountAddress, - to: getAddress(await sessionModuleContract.getAddress()), - data: callData, - gasPrice: await provider.getGasPrice(), - customData: {} as types.Eip712Meta, - gasLimit: BigInt(0), - }; - transactionForPasskey["gasLimit"] = await provider.estimateGas(transactionForPasskey); - - const passkeySignedTransaction = await passkeySmartAccount.signTransaction(transactionForPasskey); - assert(passkeySignedTransaction != null, "valid passkey transaction to sign"); - - const passkeyTransactionResponse = await provider.broadcastTransaction(passkeySignedTransaction); - const passkeyTransactionRecipt = await passkeyTransactionResponse.wait(); - assert.equal(passkeyTransactionRecipt.status, 1, "failed passkey transaction"); - - // now the part that fails for a different novel reason? - const thirdSessionKeyWallet = Wallet.createRandom(getProvider()); - const secondExtraSessionKeyData = fixtures.getSessionSpendLimitModuleData(thirdSessionKeyWallet.address as Address); - const thirdSessionKeyCallData = sessionModuleContract.interface.encodeFunctionData("setSessionKeys", [[secondExtraSessionKeyData]]); - const sessionModuleAddress = await sessionModuleContract.getAddress(); - const transactionForSessionKey = { - from: proxyAccountAddress, - to: getAddress(sessionModuleAddress), - data: thirdSessionKeyCallData, - gasPrice: await provider.getGasPrice(), - customData: {} as types.Eip712Meta, - gasLimit: BigInt(0), - }; - transactionForSessionKey["gasLimit"] = await provider.estimateGas(transactionForSessionKey); - - transactionForSessionKey["nonce"] = await provider.getTransactionCount(proxyAccountAddress); - transactionForSessionKey["gasLimit"] = await provider.estimateGas(transactionForSessionKey); - const sessionKeySmartAccount = new SmartAccount({ - payloadSigner: fixtures.sessionKeySigner.bind(fixtures), - address: proxyAccountAddress, - secret: initialSessionKeyWallet.signingKey, - }, getProvider()); - - const sessionKeySignedTransaction = await sessionKeySmartAccount.signTransaction(transactionForSessionKey); - assert(sessionKeySignedTransaction != null, "valid session key transaction to sign"); - - const sessionKeyTransactionResponse = await provider.broadcastTransaction(sessionKeySignedTransaction); - const sessionKeyTransactionRecipt = await sessionKeyTransactionResponse.wait(); - assert.equal(sessionKeyTransactionRecipt.status, 1, "failed session key transaction"); - }); - - // (this will break when we implement permissions) - - it("can currently add a session key with another session key", async () => { - const sessionModuleContract = await fixtures.getSessionSpendLimitContract(); - const sessionModuleAddress = await sessionModuleContract.getAddress(); - const factory = await fixtures.getAaFactory(); - const accountImpl = await fixtures.getAccountImplAddress(); - const proxyFix = await fixtures.getProxyAccountContract(); - assert(proxyFix != null, "should deploy proxy"); - - // specfially empty wallet to ensure that it doesn't pay like an EOA - const initialSessionKeyWallet = Wallet.createRandom(getProvider()); - const sessionModuleData = abiCoder.encode( - ["address", "bytes"], - [sessionModuleAddress, fixtures.getEncodedSessionModuleData(initialSessionKeyWallet.address as Address)]); - const salt = new Uint8Array([ - 200, 241, 161, 186, 101, 105, 70, - 240, 98, 64, 50, 124, 168, 200, - 200, 71, 214, 169, 195, 118, 190, - 60, 140, 111, 128, 47, 32, 20, - 170, 177, 174, 160, - ]); - const proxyAccount = await factory.deployProxy7579Account( - salt, - accountImpl, - "sessionKeyAddingAnotherSessionKey", - [sessionModuleData], - [], - [], - ); - - const proxyAccountReceipt = await proxyAccount.wait(); - const proxyAccountAddress = proxyAccountReceipt!.contractAddress; - assert.isDefined(proxyAccountAddress, "no address set"); - await ( - await fixtures.wallet.sendTransaction({ - to: proxyAccountAddress, - value: parseEther("0.002"), - }) - ).wait(); - - const accountBalance = await provider.getBalance(proxyAccountAddress!); - assert(accountBalance > BigInt(0), "account balance needs to be positive"); - - const extraSessionKeyWallet = Wallet.createRandom(getProvider()); - const tokenData = fixtures.getSessionSpendLimitModuleData(extraSessionKeyWallet.address as Address); - const callData = sessionModuleContract.interface.encodeFunctionData("setSessionKeys", [[tokenData]]); - const aaTx = { - from: proxyAccountAddress, - to: sessionModuleAddress as Address, - data: callData, - gasPrice: await provider.getGasPrice(), - customData: {} as types.Eip712Meta, - gasLimit: BigInt(0), - }; - aaTx["gasLimit"] = await provider.estimateGas(aaTx); - - aaTx["nonce"] = await provider.getTransactionCount(proxyAccountAddress!); - aaTx["gasLimit"] = await provider.estimateGas(aaTx); - const sessionKeySmartAccount = new SmartAccount({ - payloadSigner: fixtures.sessionKeySigner.bind(fixtures), - address: proxyAccountAddress!, - secret: initialSessionKeyWallet.signingKey, - }, getProvider()); - - const sessionKeySignedTransaction = await sessionKeySmartAccount.signTransaction(aaTx); - assert(sessionKeySignedTransaction != null, "valid session key transaction to sign"); - - const sessionKeyTransactionResponse = await provider.broadcastTransaction(sessionKeySignedTransaction); - const sessionKeyTransactionRecipt = await sessionKeyTransactionResponse.wait(); - assert.equal(sessionKeyTransactionRecipt.status, 1, "failed session key transaction"); - }); - - // This looks like it's is trying to use the session key's EOA to perform a transfer, - // which isn't the point of having a smart account! - // even if we do fund the EOA we get a validation error: 0xe7931438 - it.skip("should be able to use a session key to perform a transfer", async () => { - const sessionKeyAccount = Wallet.createRandom(); - const proxyAccountAddress = await fixtures.getFundedProxyAccount(randomBytes(32), ethersResponse, getWallet(sessionKeyAccount.privateKey)); - const accountBalanceBefore = await getProvider().getBalance(proxyAccountAddress); - assert(accountBalanceBefore > BigInt(0), "account balance needs to start positive"); - const sessionKeySmartAccount = new SmartAccount({ - payloadSigner: fixtures.sessionKeySigner.bind(fixtures), - address: proxyAccountAddress, - secret: sessionKeyAccount.signingKey, - }, getProvider()); - - // sending to a random burn address, just want to see the amount dedecuted - const transferAmount = ethers.parseEther("0.01"); - const transferTx = await sessionKeySmartAccount.transfer({ - token: utils.ETH_ADDRESS, - to: Wallet.createRandom().address, - amount: transferAmount, - }); - const sessionKeyTransferReceipt = await transferTx.wait(); - assert.equal(sessionKeyTransferReceipt.status, 1, "failed session key transfer"); - - const accountBalanceAfter = await getProvider().getBalance(proxyAccountAddress); - // minus gas as well - assert(accountBalanceAfter <= (accountBalanceBefore - transferAmount), "account balance to go down after transfer"); - }); - - // this complains about the bad private key, but this account doesn't have a k1 private key - // it only has a passkey and other tests are setup for custom signing and this one isn't even trying - // similar to the session key transfer, this might be trying to treat the secret like an EOA during - // the transfer insted of using the custom signer - it.skip("should be able to use a passkey to perform a transfer", async () => { - const salt = new Uint8Array([ - 200, 240, 161, 186, 101, 105, 70, - 240, 90, 64, 50, 124, 168, 200, - 200, 70, 214, 169, 195, 118, 190, - 60, 140, 111, 128, 47, 32, 20, - 170, 170, 174, 160, - ]); - const proxyAccountAddress = await fixtures.getFundedProxyAccount( - salt, - ethersResponse, - getWallet("0x2073ec805e7eaeefacccff067834afa4d81ea817c5d73fd05f0a1bc470b49887")); - const passKeySmartAccount = new SmartAccount({ - payloadSigner: fixtures.passkeySigner.bind(fixtures), - address: proxyAccountAddress, - secret: ethersResponse, - }, getProvider()); - const transferTx = await passKeySmartAccount.transfer({ - token: utils.ETH_ADDRESS, - to: Wallet.createRandom().address, - amount: ethers.parseEther("0.01"), - }); - const passKeyTransferReceipt = await transferTx.wait(); - assert.equal(passKeyTransferReceipt.status, 1, "failed pass key transfer"); - }); - }); - - // NOTE: Use `pnpm run deploy` to only deploy the contracts to your environment - it("should deploy all contracts", async () => { - const verifierContract = await fixtures.getWebAuthnVerifierContract(); - const sessionModuleContract = await fixtures.getSessionSpendLimitContract(); - const proxyContract = await fixtures.getProxyAccountContract(); - const erc7579Contract = await fixtures.getAccountImplContract(); - const factoryContract = await fixtures.getAaFactory(); - - logInfo(`Session Address : ${await sessionModuleContract.getAddress()}`); - logInfo(`Passkey Address : ${await verifierContract.getAddress()}`); - logInfo(`Account Factory Address : ${await factoryContract.getAddress()}`); - logInfo(`Account Implementation Address : ${await erc7579Contract.getAddress()}`); - logInfo(`Proxy Account Address : ${await proxyContract.getAddress()}`); - }); -}); diff --git a/packages/contracts/test/SessionKeyTest.ts b/packages/contracts/test/SessionKeyTest.ts new file mode 100644 index 00000000..217fcfa7 --- /dev/null +++ b/packages/contracts/test/SessionKeyTest.ts @@ -0,0 +1,412 @@ +import { assert, expect } from "chai"; +import { parseEther, randomBytes } from "ethers"; +import { ethers, Wallet, ZeroAddress } from "ethers"; +import hre from "hardhat"; +import { it } from "mocha"; +import { SmartAccount, utils } from "zksync-ethers"; + +import type { ERC20 } from "../typechain-types"; +import { ERC7579Account__factory } from "../typechain-types"; +import type { SessionLib } from "../typechain-types/src/validators/SessionKeyValidator"; +import { ContractFixtures, getProvider, logInfo } from "./utils"; + +const fixtures = new ContractFixtures(); +const abiCoder = new ethers.AbiCoder(); +const provider = getProvider(); + +enum Condition { + Unconstrained = 0, + Equal = 1, + Greater = 2, + Less = 3, + GreaterEqual = 4, + LessEqual = 5, + NotEqual = 6, +} + +enum LimitType { + Unlimited = 0, + Lifetime = 1, + Allowance = 2, +} + +type PartialLimit = { + limit: ethers.BigNumberish; + period?: ethers.BigNumberish; +}; + +type PartialSession = { + expiry?: number; + feeLimit?: PartialLimit; + callPolicies?: { + target: string; + selector?: string; + maxValuePerUse?: ethers.BigNumberish; + valueLimit?: PartialLimit; + constraints?: { + condition?: Condition; + index: ethers.BigNumberish; + refValue?: ethers.BytesLike; + limit?: PartialLimit; + }[]; + }[]; + transferPolicies?: { + target: string; + maxValuePerUse?: ethers.BigNumberish; + valueLimit?: PartialLimit; + }[]; +}; + +class SessionTester { + public sessionOwner: Wallet; + public session: SessionLib.SessionSpecStruct; + public sessionAccount: SmartAccount; + + constructor(public proxyAccountAddress: string, sessionKeyModuleAddress: string) { + this.sessionOwner = new Wallet(Wallet.createRandom().privateKey, provider); + this.sessionAccount = new SmartAccount({ + payloadSigner: async (hash) => abiCoder.encode( + ["bytes", "address", "bytes[]"], + [ + this.sessionOwner.signingKey.sign(hash).serialized, + sessionKeyModuleAddress, + ["0x"], // this array supplies data for hooks + ], + ), + address: this.proxyAccountAddress, + secret: this.sessionOwner.privateKey, + }, provider); + } + + async createSession(newSession: PartialSession) { + const sessionKeyModuleContract = await fixtures.getSessionKeyContract(); + const smartAccount = new SmartAccount({ + address: this.proxyAccountAddress, + secret: fixtures.wallet.privateKey, + }, provider); + + const [oldList] = await sessionKeyModuleContract.sessionList(this.proxyAccountAddress); + const oldState = await sessionKeyModuleContract.sessionState(this.proxyAccountAddress, this.sessionOwner.address); + expect(oldState.status).to.equal(0, "session should not be initialized"); + + this.session = this.getSession(newSession); + + const aaTx = { + ...await this.aaTxTemplate(), + to: await sessionKeyModuleContract.getAddress(), + data: sessionKeyModuleContract.interface.encodeFunctionData("createSession", [this.session]), + }; + aaTx.gasLimit = await provider.estimateGas(aaTx); + + const signedTransaction = await smartAccount.signTransaction(aaTx); + const tx = await provider.broadcastTransaction(signedTransaction); + await tx.wait(); + + const [newList] = await sessionKeyModuleContract.sessionList(this.proxyAccountAddress); + expect(newList).to.have.lengthOf(oldList.length + 1, "session should be created"); + const newState = await sessionKeyModuleContract.sessionState(this.proxyAccountAddress, this.sessionOwner.address); + expect(newState.status).to.equal(1, "session should be active"); + this.assertSession(await sessionKeyModuleContract.sessionSpec(this.proxyAccountAddress, this.sessionOwner.address)); + } + + assertSession(session: SessionLib.SessionSpecStruct) { + const deepEqual = (a, b) => Object.keys(a).forEach((key) => { + if (Array.isArray(a[key]) && Array.isArray(b[key])) { + expect(a[key]).to.have.lengthOf(b[key].length, `key ${key} should have same length`); + a[key].forEach((item, i) => deepEqual(item, b[key][i])); + } else if (typeof a[key] === "object" && typeof b[key] === "object") { + deepEqual(a[key], b[key]); + } else { + expect(a[key]).to.equal(b[key], `key ${key} should match`); + } + }); + + deepEqual(this.session, session); + } + + async revokeKey() { + const sessionKeyModuleContract = await fixtures.getSessionKeyContract(); + let state = await sessionKeyModuleContract.sessionState(this.proxyAccountAddress, this.sessionOwner.address); + expect(state.status).to.equal(1, "session should be active"); + + const smartAccount = new SmartAccount({ + address: this.proxyAccountAddress, + secret: fixtures.wallet.privateKey, + }, provider); + + const aaTx = { + ...await this.aaTxTemplate(), + to: await sessionKeyModuleContract.getAddress(), + data: sessionKeyModuleContract.interface.encodeFunctionData("revokeKey", [this.sessionOwner.address]), + }; + aaTx.gasLimit = await provider.estimateGas(aaTx); + + const signedTransaction = await smartAccount.signTransaction(aaTx); + const tx = await provider.broadcastTransaction(signedTransaction); + await tx.wait(); + state = await sessionKeyModuleContract.sessionState(this.proxyAccountAddress, this.sessionOwner.address); + expect(state.status).to.equal(2, "session should be revoked"); + } + + async sendTxSuccess(txRequest: ethers.TransactionRequest = {}) { + const aaTx = { + ...await this.aaTxTemplate(), + ...txRequest, + }; + // FIXME gas estimation is incorrect + // aaTx.gasLimit = await provider.estimateGas(aaTx); + + const signedTransaction = await this.sessionAccount.signTransaction(aaTx); + const tx = await provider.broadcastTransaction(signedTransaction); + await tx.wait(); + } + + async sendTxFail(tx: ethers.TransactionRequest = {}) { + const aaTx = { + ...await this.aaTxTemplate(), + gasLimit: 100_000_000n, + ...tx, + }; + + const signedTransaction = await this.sessionAccount.signTransaction(aaTx); + await expect(provider.broadcastTransaction(signedTransaction)).to.be.reverted; + }; + + getLimit(limit?: PartialLimit): SessionLib.UsageLimitStruct { + return limit == null + ? { + limitType: LimitType.Unlimited, + limit: 0, + period: 0, + } + : limit.period == null + ? { + limitType: LimitType.Lifetime, + limit: limit.limit, + period: 0, + } + : { + limitType: LimitType.Allowance, + limit: limit.limit, + period: limit.period, + }; + } + + getSession(session: PartialSession): SessionLib.SessionSpecStruct { + return { + signer: this.sessionOwner.address, + expiry: session.expiry ?? Math.floor(Date.now() / 1000) + 60 * 60 * 24, + // unlimited fees are not safe + feeLimit: session.feeLimit ? this.getLimit(session.feeLimit) : this.getLimit({ limit: parseEther("0.1") }), + callPolicies: session.callPolicies?.map((policy) => ({ + target: policy.target, + selector: policy.selector ?? "0x00000000", + maxValuePerUse: policy.maxValuePerUse ?? 0, + valueLimit: this.getLimit(policy.valueLimit), + constraints: policy.constraints?.map((constraint) => ({ + condition: constraint.condition ?? 0, + index: constraint.index, + refValue: constraint.refValue ?? ethers.ZeroHash, + limit: this.getLimit(constraint.limit), + })) ?? [], + })) ?? [], + transferPolicies: session.transferPolicies?.map((policy) => ({ + target: policy.target, + maxValuePerUse: policy.maxValuePerUse ?? 0, + valueLimit: this.getLimit(policy.valueLimit), + })) ?? [], + }; + } + + async aaTxTemplate() { + return { + type: 113, + from: this.proxyAccountAddress, + data: "0x", + value: 0, + chainId: (await provider.getNetwork()).chainId, + nonce: await provider.getTransactionCount(this.proxyAccountAddress), + gasPrice: await provider.getGasPrice(), + customData: { gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT }, + gasLimit: 0n, + }; + } +} + +describe("SessionKeyModule tests", function () { + let proxyAccountAddress: string; + + (hre.network.name == "dockerizedNode" ? it : it.skip)("should deposit funds", async () => { + const deposit = await fixtures.wallet.deposit({ + token: utils.ETH_ADDRESS, + amount: parseEther("10"), + }); + await deposit.waitL1Commit(); + }); + + it("should deploy all contracts", async () => { + const verifierContract = await fixtures.getWebAuthnVerifierContract(); + assert(verifierContract != null, "No verifier deployed"); + const sessionModuleContract = await fixtures.getSessionKeyContract(); + assert(sessionModuleContract != null, "No session module deployed"); + const proxyContract = await fixtures.getProxyAccountContract(); + assert(proxyContract != null, "No proxy account deployed"); + const erc7579Contract = await fixtures.getAccountImplContract(); + assert(erc7579Contract != null, "No ERC7579 deployed"); + const factoryContract = await fixtures.getAaFactory(); + assert(factoryContract != null, "No AA Factory deployed"); + + logInfo(`Session Address : ${await sessionModuleContract.getAddress()}`); + logInfo(`Passkey Address : ${await verifierContract.getAddress()}`); + logInfo(`Account Factory Address : ${await factoryContract.getAddress()}`); + logInfo(`Account Implementation Address : ${await erc7579Contract.getAddress()}`); + logInfo(`Proxy Account Address : ${await proxyContract.getAddress()}`); + }); + + it("should deploy proxy account via factory", async () => { + const factoryContract = await fixtures.getAaFactory(); + const sessionKeyModuleAddress = await fixtures.getSessionKeyModuleAddress(); + const sessionKeyPayload = abiCoder.encode(["address", "bytes"], [sessionKeyModuleAddress, "0x"]); + + const deployTx = await factoryContract.deployProxy7579Account( + randomBytes(32), + await fixtures.getAccountImplAddress(), + "id", + [], + [sessionKeyPayload], + [fixtures.wallet.address], + ); + + const deployTxReceipt = await deployTx.wait(); + proxyAccountAddress = deployTxReceipt!.contractAddress!; + expect(proxyAccountAddress, "the proxy account location via logs").to.not.equal(ZeroAddress, "be a valid address"); + + const fundTx = await fixtures.wallet.sendTransaction({ value: parseEther("1"), to: proxyAccountAddress }); + await fundTx.wait(); + + const account = ERC7579Account__factory.connect(proxyAccountAddress, provider); + assert(await account.k1IsOwner(fixtures.wallet.address)); + assert(await account.isHook(sessionKeyModuleAddress), "session key module should be a hook"); + assert(await account.isModuleValidator(sessionKeyModuleAddress), "session key module should be a validator"); + }); + + describe("Value transfer limit test", function () { + let tester: SessionTester; + const sessionTarget = Wallet.createRandom().address; + + it("should create a session", async () => { + tester = new SessionTester(proxyAccountAddress, await fixtures.getSessionKeyModuleAddress()); + await tester.createSession({ + transferPolicies: [{ + target: sessionTarget, + maxValuePerUse: parseEther("0.01"), + }], + }); + }); + + it("should use a session key to send a transaction", async () => { + await tester.sendTxSuccess({ + to: sessionTarget, + value: parseEther("0.01"), + gasLimit: 10_000_000n, + }); + expect(await provider.getBalance(sessionTarget)) + .to.equal(parseEther("0.01"), "session target should have received the funds"); + }); + + it("should reject a session key transaction that goes over limit", async () => { + await tester.sendTxFail({ + to: sessionTarget, + value: parseEther("0.02"), + }); + }); + }); + + describe("ERC20 transfer limit", function () { + let tester: SessionTester; + let erc20: ERC20; + const sessionTarget = Wallet.createRandom().address; + + it("should deploy and mint an ERC20 token", async () => { + erc20 = await fixtures.deployERC20(proxyAccountAddress); + expect(await erc20.balanceOf(proxyAccountAddress)).to.equal(10n ** 18n, "should have some tokens"); + }); + + it("should create a session", async () => { + tester = new SessionTester(proxyAccountAddress, await fixtures.getSessionKeyModuleAddress()); + await tester.createSession({ + callPolicies: [{ + target: await erc20.getAddress(), + selector: erc20.interface.getFunction("transfer").selector, + constraints: [ + // can only transfer to sessionTarget + { + index: 0, + refValue: ethers.zeroPadValue(sessionTarget, 32), + condition: Condition.Equal, + }, + // can only transfer upto 1000 tokens per tx + // can only transfer upto 1500 tokens in total + { + index: 1, + refValue: ethers.toBeHex(1000, 32), + condition: Condition.LessEqual, + limit: { limit: 1500 }, + }, + ], + }], + }); + }); + + it("should reject a session key transaction to wrong target", async () => { + await tester.sendTxFail({ + to: await erc20.getAddress(), + data: erc20.interface.encodeFunctionData("transfer", [Wallet.createRandom().address, 1n]), + }); + }); + + it("should reject a session key transaction that goes over per-tx limit", async () => { + await tester.sendTxFail({ + to: await erc20.getAddress(), + data: erc20.interface.encodeFunctionData("transfer", [sessionTarget, 1001n]), + }); + }); + + it("should successfully send a session key transaction", async () => { + await tester.sendTxSuccess({ + to: await erc20.getAddress(), + data: erc20.interface.encodeFunctionData("transfer", [sessionTarget, 1000n]), + gasLimit: 10_000_000n, + }); + expect(await erc20.balanceOf(sessionTarget)) + .to.equal(1000n, "session target should have received the tokens"); + }); + + it("should reject a session key transaction that goes over total limit", async () => { + const sessionKeyModuleContract = await fixtures.getSessionKeyContract(); + const remainingLimits = await sessionKeyModuleContract.sessionState(proxyAccountAddress, tester.sessionOwner.address); + expect(remainingLimits.callParams[0].remaining).to.equal(500n, "should have 500 tokens remaining in allowance"); + + await tester.sendTxFail({ + to: await erc20.getAddress(), + data: erc20.interface.encodeFunctionData("transfer", [sessionTarget, 501n]), + }); + }); + + it("should successfully revoke a session key", async () => { + await tester.revokeKey(); + }); + + it("should reject a revoked session key transaction", async () => { + await tester.sendTxFail({ + to: await erc20.getAddress(), + data: erc20.interface.encodeFunctionData("transfer", [sessionTarget, 1n]), + }); + }); + }); + + // TODO: module uninstall tests + // TODO: session expiry tests + // TODO: session fee limit tests + // TODO: allowance tests +}); diff --git a/packages/contracts/test/utils.ts b/packages/contracts/test/utils.ts index db6fcfda..b22bb0de 100644 --- a/packages/contracts/test/utils.ts +++ b/packages/contracts/test/utils.ts @@ -9,7 +9,95 @@ import * as hre from "hardhat"; import { base64UrlToUint8Array, getPublicKeyBytesFromPasskeySignature, unwrapEC2Signature } from "zksync-account/utils"; import { ContractFactory, Provider, utils, Wallet } from "zksync-ethers"; -import { AAFactory, AAFactory__factory } from "../typechain-types"; +import type { AAFactory, ERC20, ERC7579Account, SessionKeyValidator, WebAuthValidator } from "../typechain-types"; +import { AAFactory__factory, ERC20__factory, ERC7579Account__factory, SessionKeyValidator__factory, WebAuthValidator__factory } from "../typechain-types"; + +export class ContractFixtures { + // NOTE: CHANGING THE READONLY VALUES WILL REQUIRE UPDATING THE STATIC SIGNATURE + readonly wallet: Wallet = getWallet(LOCAL_RICH_WALLETS[0].privateKey); + readonly ethersStaticSalt = new Uint8Array([ + 205, 241, 161, 186, 101, 105, 79, + 248, 98, 64, 50, 124, 168, 204, + 200, 71, 214, 169, 195, 118, 199, + 62, 140, 111, 128, 47, 32, 21, + 177, 177, 174, 166, + ]); + + private _aaFactory: AAFactory; + async getAaFactory() { + if (!this._aaFactory) { + this._aaFactory = await deployFactory("AAFactory", this.wallet); + } + return this._aaFactory; + } + + private _sessionKeyModule: SessionKeyValidator; + async getSessionKeyContract() { + if (!this._sessionKeyModule) { + const contract = await create2("SessionKeyValidator", this.wallet, this.ethersStaticSalt); + this._sessionKeyModule = SessionKeyValidator__factory.connect(await contract.getAddress(), this.wallet); + } + return this._sessionKeyModule; + } + + async getSessionKeyModuleAddress() { + return (await this.getSessionKeyContract()).getAddress(); + } + + private _webauthnValidatorModule: WebAuthValidator; + // does passkey validation via modular interface + async getWebAuthnVerifierContract() { + if (!this._webauthnValidatorModule) { + const contract = await create2("WebAuthValidator", this.wallet, this.ethersStaticSalt); + this._webauthnValidatorModule = WebAuthValidator__factory.connect(await contract.getAddress(), this.wallet); + } + return this._webauthnValidatorModule; + } + + private _passkeyModuleAddress: string; + async getPasskeyModuleAddress() { + if (!this._passkeyModuleAddress) { + const passkeyModule = await this.getWebAuthnVerifierContract(); + this._passkeyModuleAddress = await passkeyModule.getAddress(); + } + return this._passkeyModuleAddress; + } + + private _accountImplContract: ERC7579Account; + // wraps the clave account + async getAccountImplContract() { + if (!this._accountImplContract) { + const contract = await create2("ERC7579Account", this.wallet, this.ethersStaticSalt); + this._accountImplContract = ERC7579Account__factory.connect(await contract.getAddress(), this.wallet); + } + return this._accountImplContract; + } + + private _accountImplAddress: string; + // deploys the base account for future proxy use + async getAccountImplAddress() { + if (!this._accountImplAddress) { + const accountImpl = await this.getAccountImplContract(); + this._accountImplAddress = await accountImpl.getAddress(); + } + return this._accountImplAddress; + } + + private _proxyAccountContract: ERC7579Account; + async getProxyAccountContract() { + const claveAddress = await this.getAccountImplAddress(); + if (!this._proxyAccountContract) { + const contract = await create2("AccountProxy", this.wallet, this.ethersStaticSalt, [claveAddress]); + this._proxyAccountContract = ERC7579Account__factory.connect(await contract.getAddress(), this.wallet); + } + return this._proxyAccountContract; + } + + async deployERC20(mintTo: string): Promise { + const contract = await create2("TestERC20", this.wallet, this.ethersStaticSalt, [mintTo]); + return ERC20__factory.connect(await contract.getAddress(), this.wallet); + } +} // Load env file dotenv.config(); @@ -24,6 +112,16 @@ export const getProvider = () => { return provider; }; +export const getProviderL1 = () => { + const rpcUrl = hre.network.config["ethNetwork"]; + if (!rpcUrl) { + console.warn(`No ethNetwork URL specified for network ${hre.network.name}`); + } + + const provider = new ethers.JsonRpcProvider(rpcUrl); + return provider; +}; + export async function deployFactory(factoryName: string, wallet: Wallet, expectedAddress?: string): Promise { const factoryArtifact = JSON.parse(await promises.readFile(`artifacts-zk/src/${factoryName}.sol/${factoryName}.json`, "utf8")); const proxyAaArtifact = JSON.parse(await promises.readFile("artifacts-zk/src/AccountProxy.sol/AccountProxy.json", "utf8")); @@ -48,9 +146,10 @@ export const getWallet = (privateKey?: string) => { } const provider = getProvider(); + const providerL1 = getProviderL1(); // Initialize zkSync Wallet - const wallet = new Wallet(privateKey ?? process.env.WALLET_PRIVATE_KEY!, provider); + const wallet = new Wallet(privateKey ?? process.env.WALLET_PRIVATE_KEY!, provider, providerL1); return wallet; }; @@ -111,15 +210,22 @@ export function logWarning(message: string) { console.log("\x1b[33m%s\x1b[0m", message); } +const masterWallet = ethers.Wallet.fromPhrase("stuff slice staff easily soup parent arm payment cotton trade scatter struggle"); + /** * Rich wallets can be used for testing purposes. * Available on ZKsync In-memory node and docker node. */ export const LOCAL_RICH_WALLETS = [ - { - address: "0xBC989fDe9e54cAd2aB4392Af6dF60f04873A033A", - privateKey: "0x3d3cbc973389cb26f657686445bcc75662b415b656078503592ac8c1abb8810e", - }, + hre.network.name == "dockerizedNode" + ? { + address: masterWallet.address, + privateKey: masterWallet.privateKey, + } + : { + address: "0xBC989fDe9e54cAd2aB4392Af6dF60f04873A033A", + privateKey: "0x3d3cbc973389cb26f657686445bcc75662b415b656078503592ac8c1abb8810e", + }, { address: "0x36615Cf349d7F6344891B1e7CA7C72883F5dc049", privateKey: "0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110", diff --git a/packages/demo-app/pages/index.vue b/packages/demo-app/pages/index.vue index 3b35bd5a..e8ac9f4a 100644 --- a/packages/demo-app/pages/index.vue +++ b/packages/demo-app/pages/index.vue @@ -44,6 +44,7 @@ import { createWeb3Modal, defaultWagmiConfig } from "@web3modal/wagmi/vue"; import { parseEther } from "viem"; import { zksyncInMemoryNode } from "viem/chains"; import { zksyncAccountConnector } from "zksync-account/connector"; +import { getSession } from "zksync-account/utils"; const address = ref(null); const balance = ref(null); @@ -61,17 +62,19 @@ const config = defaultWagmiConfig({ icon: "http://localhost:3004/favicon.ico", }, gatewayUrl: "http://localhost:3002/confirm", - session: { - expiresAt: Date.now() + 1000 * 60 * 60 * 24, // Expires in 24 hours (1 day) from now - spendLimit: { - ["0x000000000000000000000000000000000000800A"]: 1000000000000000000, - }, - }, + session: getSession({ + feeLimit: { limit: parseEther("0.01") }, + transferPolicies: [{ + target: sessionTarget, + maxValuePerUse: parseEther("0.1"), + }], + }), }), ], }); const web3modal = createWeb3Modal({ wagmiConfig: config, projectId }); +const sessionTarget = "0x55bE1B079b53962746B2e86d12f158a41DF294A6"; // Rich Account 1 // Check for updates to the current account watchAccount(config, { @@ -115,8 +118,9 @@ const sendTokens = async () => { try { await sendTransaction(config, { - to: "0x55bE1B079b53962746B2e86d12f158a41DF294A6", // Rich Account 1 + to: sessionTarget, value: parseEther("0.1"), + gas: 100_000_000n, }); const currentBalance = await getBalance(config, { diff --git a/packages/gateway/components/views/Login.vue b/packages/gateway/components/views/Login.vue index 9b749c74..5c01f0ef 100644 --- a/packages/gateway/components/views/Login.vue +++ b/packages/gateway/components/views/Login.vue @@ -104,7 +104,7 @@