티스토리 뷰
계정 추상화 시리즈
2024.04.18 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - 테스트를 통한 Paymaster와 LegacyTokenPaymaster의 동작 이해
2024.04.18 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - 테스트를 통한 Account Factory의 동작 이해
2024.04.17 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - 테스트 수정 사항
2024.04.17 - [블록체인/Ethereum] - ERC-4337: 계정 추상화 - 테스트를 통한 Account와 EntryPoint의 동작 이해
2024.04.16 - [블록체인/Ethereum] - ERC-4337: 계정 추상화
테스트에 참고한 컨트랙트
- samples/bls
이전 게시글에서 이어집니다.
BLS 서명
BLS(Boneh-Lynn-Shacham) 서명은 비대칭 암호화 기법의 하나로, 서명 집계를 통해 여러 개의 서명을 효율적으로 검증할 수 있다는 특징을 기반으로 블록체인 업계에서 두각을 드러내고 있습니다. 이 글에서는 BLS 서명을 서명 체계로 사용하는 스마트 계정과 BLS 서명을 집계하고 검증하는 집계자(Aggregator)가 어떻게 동작하는지 테스트를 통해 살펴보고자 합니다.
테스트에 들어가기에 앞서, 우선 BLS 서명 생성과 검증 프로세스, 그리고 일부 개념에 대해 간단히 살펴보겠습니다.
BLS 서명 및 검증 프로세스
- 키 생성 (Key Generation)
- 개인 키 (Private Key) : Alice는 무작위 숫자 'sk'를 선택합니다. 이 숫자가 Alice의 개인 키가 됩니다.
- 공개 키 (Public Key) : Alice는 공개적으로 알려진 타원 곡선 위의 점 'G'에 'sk'를 곱하여 공개 키 'pk'를 계산합니다. 즉, 'pk = sk * G'입니다.
- 메시지 서명 (Signing)
- 메시지 : Alice는 서명하고자 하는 메시지 'm'을 선택합니다.
- 해시 변환 : Alice는 메시지 'm'을 해시 함수 'H'의 입력으로 사용하여 이를 타원 곡선 위의 점 'H(m)'으로 변환합니다.
- 서명 생성 : Alice는 점 'H(m)'에 개인 키 'sk'를 곱하여 서명 'S'를 생성합니다. 즉, 'S = H(m) * sk'입니다.
- 서명 검증 (Verification)
- 공개된 정보 : Bob은 Alice의 메시지 'm', 서명 'S', 그리고 Alice의 공개키 'pk'를 받습니다.
- 해시 변환 : Bob은 메시지 'm'을 해시 함수 'H'의 입력으로 사용하여 이를 타원 곡선 위의 점 'H(m)'으로 변환합니다.
- 검증 수행 : Bob은 Alice의 공개 키 'pk'와 서명 'S'를 사용하여 'e(S, G)'와 'e(H(m), pk)'가 동일한지 비교합니다. 여기서 'e'는 타원 곡선의 쌍선형 페어링(bilinear pairing) 연산입니다. 이 두 값이 같다면 서명은 유효한 것으로 판단됩니다.
- 요약
- 서명자는 개인 키를 사용하여 메시지에 대한 서명을 생성합니다.
- 검증자는 서명자의 공개 키와 메시지 그리고 쌍선형 페어링 연산을 통해 서명을 검증합니다.
쌍선형성 (Bilinearity)
쌍선형성은 타원 곡선 암호학에서 사용되는 특별한 종류의 페어링(pairing) 연산과 관련이 있습니다. 이 연산은 두 개의 타원 곡선 위의 점을 입력으로 받아 하나의 값을 출력합니다. 쌍선형 페어링의 핵심은 두 점의 선형 조합에 대해 선형적으로 작용하는 것입니다.
즉, 두 점 P와 Q에 대한 쌍선형 페어링 e(P, Q)가 있을 때, 다음 두 가지 성질이 성립합니다:
- 첫 번째 인자에 대한 선형성 : a와 b가 스칼라 값일 때, e(aP + bR, Q) = e(aP, Q) * e(bR, Q) = e(P, Q)a * e(R, Q)b가 성립합니다. 여기서 R도 타원 곡선 위의 점입니다.
- 두 번째 인자에 대한 선형성 : 마찬가지로, e(P, aQ + bS) = e(P, aQ) * e(P, bS) = e(P, Q)a * e(P, S)b가 성립합니다. 여기서 S도 타원 곡선 위의 점입니다.
쌍선형성은 이렇게 두 입력 값의 선형 조합에 대해 선형적으로 작용함으로써, 복잡한 암호학적 문제를 간단하고 효과적으로 해결할 수 있는 기반을 제공합니다. BLS 서명에서 이 속성은 서명의 유효성을 검증하고, 여러 개의 서명을 하나로 합치는 데 사용됩니다.
서명 집계 (Aggregation)
서명 집계(aggregation)는 BLS 서명 방식의 강력한 특성 중 하나로, 여러 개의 개별 서명을 단일 서명으로 결합하는 기능을 말합니다.
서명 집계의 예시
- 참여자 설정
- 참여자 : Alice, Bob, Carol이 각각 개인 키 sk1, sk2, sk3와 공개 키 pk1, pk2, pk3를 가지고 있습니다.
- 개별 서명
- 메시지 : 모든 참여자가 같은 메시지 m에 서명합니다. (다른 메시지도 가능)
- 개별 서명 생성
- Alice의 서명 : S1 = sk1 * H(m)
- Bob의 서명 : S2 = sk2 * H(m)
- Carol의 서명 : S3 = sk3 * H(m)
- 여기서 H(m)은 메시지 m의 해시를 타원 곡선의 점으로 변환한 것입니다.
- 서명 집계
- 집계된 서명 생성 :
- 집계된 서명 Sagg = S1 + S2 + S3
- 이는 Sagg = (sk1 + sk2 + sk3) * H(m)과 동일합니다.
- 집계된 서명은 개별 서명들의 단순 합입니다.
- 집계된 서명 생성 :
- 서명 검증
- 집계된 서명 검증 :
- 공개 키를 집계 pkagg = pk1 + pk2 + pk3
- 검증 과정 : e(Sagg, G)와 e(H(m), pkagg)가 동일한지 확인합니다.
- G는 타원 곡선의 기저점입니다.
- 만약 동일하다면, 집계된 서명은 유효한 것으로 판단됩니다.
- 집계된 서명 검증 :
이러한 집계 기능은 여러 서명을 하나로 합치므로 서명 검증 과정에서 필요한 계산량이 줄어들어 전체 시스템의 효율성이 향상됩니다. BLS 서명의 이러한 특성은 특히 트랜잭션을 대량으로 처리해야 하는 블록체인 네트워크에서 확장성 개선을 위한 중요한 포인트가 될 수 있습니다.
컨트랙트 살펴보기
BLSAccount
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.23;
import "../SimpleAccount.sol";
import "./IBLSAccount.sol";
import "account-abstraction/core/Helpers.sol";
/**
* Minimal BLS-based account that uses an aggregated signature.
* The account must maintain its own BLS public key, and expose its trusted signature aggregator.
* Note that unlike the "standard" SimpleAccount, this account can't be called directly
* (normal SimpleAccount uses its "signer" address as both the ecrecover signer, and as a legitimate
* Ethereum sender address. Obviously, a BLS public key is not a valid Ethereum sender address.)
*/
contract BLSAccount is SimpleAccount, IBLSAccount {
address public immutable aggregator;
uint256[4] private publicKey;
// The constructor is used only for the "implementation" and only sets immutable values.
// Mutable value slots for proxy accounts are set by the 'initialize' function.
constructor(IEntryPoint anEntryPoint, address anAggregator) SimpleAccount(anEntryPoint) {
aggregator = anAggregator;
}
/**
* The initializer for the BLSAccount instance.
* @param aPublicKey public key from a BLS keypair that will have a full ownership and control of this account.
*/
function initialize(uint256[4] memory aPublicKey) public virtual initializer {
super._initialize(address(0));
_setBlsPublicKey(aPublicKey);
}
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
view
override
returns (uint256 validationData)
{
(userOp, userOpHash);
if (userOp.initCode.length != 0) {
// BLSSignatureAggregator.getUserOpPublicKey() assumes that during account creation, the public key is
// the suffix of the initCode.
// The account MUST validate it
bytes32 pubKeyHash = keccak256(abi.encode(getBlsPublicKey()));
require(keccak256(userOp.initCode[userOp.initCode.length - 128:]) == pubKeyHash, "wrong pubkey");
}
return _packValidationData(ValidationData(aggregator, 0, 0));
}
/**
* Allows the owner to set or change the BLS key.
* @param newPublicKey public key from a BLS keypair that will have a full ownership and control of this account.
*/
function setBlsPublicKey(uint256[4] memory newPublicKey) public onlyOwner {
_setBlsPublicKey(newPublicKey);
}
function _setBlsPublicKey(uint256[4] memory newPublicKey) internal {
emit PublicKeyChanged(publicKey, newPublicKey);
publicKey = newPublicKey;
}
/// @inheritdoc IBLSAccount
function getBlsPublicKey() public view override returns (uint256[4] memory) {
return publicKey;
}
}
- SimpleAccount를 상속하여 구현되었습니다.
- 신뢰하는 entryPoint 또는 aggregator에 의해서만 호출됩니다.
- initialize 함수에서 _setBlsPublicKey를 호출하여 계정을 생성할 때 스토리지에 공개 키를 등록하도록 합니다.
- 등록된 공개 키는 setBlsPublicKey 함수를 통해 변경될 수 있습니다.
- 서명을 검증하는 _validateSignature 함수에서는 서명을 검증하지 않고 aggregator의 주소를 반환합니다.
- BLS 서명 방식을 지원하는 EOA가 현재로서는 존재하지 않으므로 이 계정은 EOA에 의해 직접 호출될 수 없습니다.
BLSAccountFactory
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.23;
import "@openzeppelin/contracts/utils/Create2.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "account-abstraction/interfaces/IEntryPoint.sol";
import "./BLSAccount.sol";
/* solhint-disable no-inline-assembly */
/**
* Based on SimpleAccountFactory.
* Cannot be a subclass since both constructor and createAccount depend on the
* constructor and initializer of the actual account contract.
*/
contract BLSAccountFactory {
BLSAccount public immutable accountImplementation;
constructor(IEntryPoint entryPoint, address aggregator) {
accountImplementation = new BLSAccount(entryPoint, aggregator);
}
/**
* create an account, and return its address.
* returns the address even if the account is already deployed.
* Note that during UserOperation execution, this method is called only if the account is not deployed.
* This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation
* Also note that our BLSSignatureAggregator requires that the public key is the last parameter
*/
function createAccount(uint256 salt, uint256[4] calldata aPublicKey) public returns (BLSAccount) {
// the BLSSignatureAggregator depends on the public-key being the last 4 uint256 of msg.data.
uint256 slot;
assembly {
slot := aPublicKey
}
require(slot == msg.data.length - 128, "wrong pubkey offset");
address addr = getAddress(salt, aPublicKey);
uint256 codeSize = addr.code.length;
if (codeSize > 0) {
return BLSAccount(payable(addr));
}
return BLSAccount(
payable(
new ERC1967Proxy{salt: bytes32(salt)}(
address(accountImplementation), abi.encodeCall(BLSAccount.initialize, aPublicKey)
)
)
);
}
/**
* calculate the counterfactual address of this account as it would be returned by createAccount()
*/
function getAddress(uint256 salt, uint256[4] memory aPublicKey) public view returns (address) {
return Create2.computeAddress(
bytes32(salt),
keccak256(
abi.encodePacked(
type(ERC1967Proxy).creationCode,
abi.encode(address(accountImplementation), abi.encodeCall(BLSAccount.initialize, (aPublicKey)))
)
)
);
}
}
- 전반적으로 AccountFactory와 크게 다른 부분은 없으나, 함수의 파라미터로 계정 주인의 BLS 공개 키를 받게 되어 있습니다.
BLSSignatureAggregator
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.23;
import "account-abstraction/interfaces/IAggregator.sol";
import "account-abstraction/interfaces/IEntryPoint.sol";
import "account-abstraction/core/UserOperationLib.sol";
import {BLSOpen} from "./lib/BLSOpen.sol";
import "./IBLSAccount.sol";
import "./BLSHelper.sol";
/**
* A BLS-based signature aggregator, to validate aggregated signature of multiple UserOps if BLSAccount
*/
contract BLSSignatureAggregator is IAggregator {
using UserOperationLib for PackedUserOperation;
bytes32 public constant BLS_DOMAIN = keccak256("eip4337.bls.domain");
//copied from BLS.sol
uint256 public constant N = 21888242871839275222246405745257275088696311157297823662689037894645226208583;
address public immutable entryPoint;
constructor(address _entryPoint) {
entryPoint = _entryPoint;
}
/**
* return the public key of this account.
* @param userOp the UserOperation that we need the account's public key for.
* @return publicKey - the public key from a BLS keypair the Aggregator will use to verify this UserOp;
* normally public key will be queried from the deployed BLSAccount itself;
* the public key will be read from the 'initCode' if the account is not deployed yet;
*/
function getUserOpPublicKey(PackedUserOperation memory userOp) public view returns (uint256[4] memory publicKey) {
bytes memory initCode = userOp.initCode;
if (initCode.length > 0) {
publicKey = getTrailingPublicKey(initCode);
} else {
return IBLSAccount(userOp.sender).getBlsPublicKey{gas: 50000}();
}
}
/**
* return the trailing 4 words of input data
*/
function getTrailingPublicKey(bytes memory data) public pure returns (uint256[4] memory publicKey) {
uint256 len = data.length;
require(len > 32 * 4, "data too short for sig");
/* solhint-disable-next-line no-inline-assembly */
assembly {
// actual buffer starts at data+32, so last 128 bytes start at data+32+len-128 = data+len-96
let ofs := sub(add(data, len), 96)
mstore(publicKey, mload(ofs))
mstore(add(publicKey, 32), mload(add(ofs, 32)))
mstore(add(publicKey, 64), mload(add(ofs, 64)))
mstore(add(publicKey, 96), mload(add(ofs, 96)))
}
}
/// @inheritdoc IAggregator
function validateSignatures(PackedUserOperation[] calldata userOps, bytes calldata signature)
external
view
override
{
require(signature.length == 64, "BLS: invalid signature");
(uint256[2] memory blsSignature) = abi.decode(signature, (uint256[2]));
uint256 userOpsLen = userOps.length;
uint256[4][] memory blsPublicKeys = new uint256[4][](userOpsLen);
uint256[2][] memory messages = new uint256[2][](userOpsLen);
for (uint256 i = 0; i < userOpsLen; i++) {
PackedUserOperation memory userOp = userOps[i];
blsPublicKeys[i] = getUserOpPublicKey(userOp);
messages[i] = _userOpToMessage(userOp, _getPublicKeyHash(blsPublicKeys[i]));
}
require(BLSOpen.verifyMultiple(blsSignature, blsPublicKeys, messages), "BLS: validateSignatures failed");
}
/**
* get a hash of userOp
* NOTE: this hash is not the same as UserOperation.hash()
* (slightly less efficient, since it uses memory userOp)
*/
function internalUserOpHash(PackedUserOperation memory userOp) internal pure returns (bytes32) {
return keccak256(
abi.encode(
userOp.sender,
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.accountGasLimits,
userOp.preVerificationGas,
userOp.gasFees,
keccak256(userOp.paymasterAndData)
)
);
}
/**
* return the BLS "message" for the given UserOp.
* the account checks the signature over this value using its public key
*/
function userOpToMessage(PackedUserOperation memory userOp) public view returns (uint256[2] memory) {
bytes32 publicKeyHash = _getPublicKeyHash(getUserOpPublicKey(userOp));
return _userOpToMessage(userOp, publicKeyHash);
}
function _userOpToMessage(PackedUserOperation memory userOp, bytes32 publicKeyHash)
internal
view
returns (uint256[2] memory)
{
bytes32 userOpHash = _getUserOpHash(userOp, publicKeyHash);
return BLSOpen.hashToPoint(BLS_DOMAIN, abi.encodePacked(userOpHash));
}
function getUserOpHash(PackedUserOperation memory userOp) public view returns (bytes32) {
bytes32 publicKeyHash = _getPublicKeyHash(getUserOpPublicKey(userOp));
return _getUserOpHash(userOp, publicKeyHash);
}
function _getUserOpHash(PackedUserOperation memory userOp, bytes32 publicKeyHash) internal view returns (bytes32) {
return
keccak256(abi.encode(internalUserOpHash(userOp), publicKeyHash, address(this), block.chainid, entryPoint));
}
function _getPublicKeyHash(uint256[4] memory publicKey) internal pure returns (bytes32) {
return keccak256(abi.encode(publicKey));
}
/**
* validate signature of a single userOp
* This method is called after EntryPoint.simulateValidation() returns an aggregator.
* First it validates the signature over the userOp. then it return data to be used when creating the handleOps:
* @param userOp the userOperation received from the user.
* @return sigForUserOp the value to put into the signature field of the userOp when calling handleOps.
* (usually empty, unless account and aggregator support some kind of "multisig"
*/
function validateUserOpSignature(PackedUserOperation calldata userOp)
external
view
returns (bytes memory sigForUserOp)
{
uint256[2] memory signature = abi.decode(userOp.signature, (uint256[2]));
uint256[4] memory pubkey = getUserOpPublicKey(userOp);
uint256[2] memory message = _userOpToMessage(userOp, _getPublicKeyHash(pubkey));
require(BLSOpen.verifySingle(signature, pubkey, message), "BLS: wrong sig");
return "";
}
/**
* aggregate multiple signatures into a single value.
* This method is called off-chain to calculate the signature to pass with handleOps()
* bundler MAY use optimized custom code perform this aggregation
* @param userOps array of UserOperations to collect the signatures from.
* @return aggregatedSignature the aggregated signature
*/
function aggregateSignatures(PackedUserOperation[] calldata userOps)
external
pure
returns (bytes memory aggregatedSignature)
{
BLSHelper.XY[] memory points = new BLSHelper.XY[](userOps.length);
for (uint256 i = 0; i < points.length; i++) {
(uint256 x, uint256 y) = abi.decode(userOps[i].signature, (uint256, uint256));
points[i] = BLSHelper.XY(x, y);
}
BLSHelper.XY memory sum = BLSHelper.sum(points, N);
return abi.encode(sum.x, sum.y);
}
/**
* allow staking for this aggregator
* there is no limit on stake or delay, but it is not a problem, since it is a permissionless
* signature aggregator, which doesn't support unstaking.
* @param unstakeDelaySec - the required unstaked delay
*/
function addStake(uint32 unstakeDelaySec) external payable {
IEntryPoint(entryPoint).addStake{value: msg.value}(unstakeDelaySec);
}
}
- 신뢰하는 entryPoint에 의해서만 호출됩니다.
- getUserOpPublicKey 함수를 사용해 계정 소유자의 공개 키를 가져옵니다. (또는 getTrailingPublicKey 함수를 통해 initCode에서 추출)
- validateSignatures 함수를 통해 집계된 서명을 검증합니다.
- validateUserOpSignature 함수를 통해 단일 서명을 검증하는 것도 가능합니다.
- aggregateSignatures 함수를 통해 서명을 집계합니다.
- 위의 세 함수들은 모두 view 또는 pure 함수로, 오프체인에서 호출하여 서명을 집계하고 검증하기에 유용합니다.
테스트 설정
BLS 서명이 무엇인지 대략적으로 이해하는 것도 고비였는데 또 다른 벽에 부딪히고 말았습니다. 암만 찾아봐도 Foundry에서 BLS 개인 키와 공개 키를 생성하는 방법을 찾을 수가 없었습니다. 그래서 고민 끝에 내린 결론은 Hardhat을 사용해 Javascript로 BLS 개인 키와 공개 키를 생성하는 것이었습니다.
다음은 Foundry 프로젝트에서 Hardhat을 어떻게 가져와서 써야 되나 싶은 김에 간단하게 정리해 본 글입니다.
전반적인 테스트 환경 설정은 eth-infinitism 그룹의 account-abstraction 리포지토리를 참고했습니다. 물론 지금까지 참고했던 solidity 코드들도 포함해서 말이죠.
테스트
[CASE 1] 서명 집계
it("aggregateSignatures", async () => {
const sig1 = signer1.sign("0x1234"); // signer1의 개인키로 "0x1234"에 대한 서명을 생성한다.
const sig2 = signer2.sign("0x5678"); // signer2의 개인키로 "0x5678"에 대한 서명을 생성한다.
console.log("sig1", sig1); // 64 바이트 길이의 16진수 값 - m * privkey1
console.log("sig2", sig2); // 64 바이트 길이의 16진수 값 - m * privkey2
const aggSig = aggregate([sig1, sig2]); // sig1과 sig2를 집계한다.
console.log("aggSig", aggSig); // 32 바이트 * 2 크기의 배열 - [x, y] 형태의 집계된 서명
const offChainSigResult = hexConcat(aggSig); // 집계된 서명을 16진수 문자열로 변환한다.
console.log("offChainSigResult", offChainSigResult); // 64 바이트 길이의 16진수 값
// 서명을 포함한 UserOp을 생성한다.
const userOp1 = packUserOp({
...DefaultsForUserOp,
signature: hexConcat(sig1),
});
const userOp2 = packUserOp({
...DefaultsForUserOp,
signature: hexConcat(sig2),
});
// 온체인에서 집계된 서명을 생성한다.
const solidityAggResult = await blsAgg.aggregateSignatures([
userOp1,
userOp2,
]);
console.log("solidityAggResult", solidityAggResult); // 64 바이트 길이의 16진수 값
expect(solidityAggResult).to.equal(offChainSigResult); // 집계된 서명이 같은지 확인한다.
});
1. signer1의 개인키로 "0x1234"에 대한 서명을 생성하고, signer2의 개인키로 "0x5678"에 대한 서명을 생성합니다. 이 값들은 64 바이트 크기의 16진수 값입니다.
const sig1 = signer1.sign("0x1234"); // signer1의 개인키로 "0x1234"에 대한 서명을 생성한다.
const sig2 = signer2.sign("0x5678"); // signer2의 개인키로 "0x5678"에 대한 서명을 생성한다.
console.log("sig1", sig1); // 64 바이트 길이의 16진수 값 - m * privkey1
console.log("sig2", sig2); // 64 바이트 길이의 16진수 값 - m * privkey2
sig1 [
'0x0783b8bd30c38674c77ec4fe3182e563b6065b06f391d4529fe5e6082f25579c',
'0x2db88b34375e8a55e6f7ecbfa7392ebb83009141a377761556fcee8f13d80e7f'
]
sig2 [
'0x08a567abd8751234c22a1a35644aa9a949d15ab1d4224e9447588b04cea82dd9',
'0x0c5577ff3910692d191775ad34885b484633db23078bda4a8df663435fce3e6e'
]
2. 오프체인에서 서명을 집계합니다. 집계된 서명은 64 바이트 크기의 16진수 값입니다.
const aggSig = aggregate([sig1, sig2]); // sig1과 sig2를 집계한다.
console.log("aggSig", aggSig); // 32 바이트 * 2 크기의 배열 - [x, y] 형태의 집계된 서명
const offChainSigResult = hexConcat(aggSig); // 집계된 서명을 16진수 문자열로 변환한다.
console.log("offChainSigResult", offChainSigResult); // 64 바이트 길이의 16진수 값
aggSig [
'0x254432c775c1e80bc6e8b55a70b5c97d08c82e24f2963b3ae6a15ebd72e85af6',
'0x2dbe165f11dc37b27db682e6dd4ad6245152ce01d3d2e1397fb96926fcf48fbe'
]
offChainSigResult 0x254432c775c1e80bc6e8b55a70b5c97d08c82e24f2963b3ae6a15ebd72e85af62dbe165f11dc37b27db682e6dd4ad6245152ce01d3d2e1397fb96926fcf48fbe
3. 각각의 서명을 포함한 더미 UserOperation을 생성합니다. 이 값을 입력으로 온체인에서 집계 서명을 생성합니다.
// 서명을 포함한 UserOp을 생성한다.
const userOp1 = packUserOp({
...DefaultsForUserOp,
signature: hexConcat(sig1),
});
const userOp2 = packUserOp({
...DefaultsForUserOp,
signature: hexConcat(sig2),
});
// 온체인에서 집계된 서명을 생성한다.
const solidityAggResult = await blsAgg.aggregateSignatures([
userOp1,
userOp2,
]);
console.log("solidityAggResult", solidityAggResult); // 64 바이트 길이의 16진수 값
solidityAggResult 0x254432c775c1e80bc6e8b55a70b5c97d08c82e24f2963b3ae6a15ebd72e85af62dbe165f11dc37b27db682e6dd4ad6245152ce01d3d2e1397fb96926fcf48fbe
4. 오프체인에서 집계된 서명과 온체인에서 집계된 서명이 동일해야 합니다.
expect(solidityAggResult).to.equal(offChainSigResult); // 집계된 서명이 같은지 확인한다.
테스트
$ npx hardhat test --grep aggregateSignatures
No need to generate any newer typings.
BLS
sig1 [
'0x0783b8bd30c38674c77ec4fe3182e563b6065b06f391d4529fe5e6082f25579c',
'0x2db88b34375e8a55e6f7ecbfa7392ebb83009141a377761556fcee8f13d80e7f'
]
sig2 [
'0x08a567abd8751234c22a1a35644aa9a949d15ab1d4224e9447588b04cea82dd9',
'0x0c5577ff3910692d191775ad34885b484633db23078bda4a8df663435fce3e6e'
]
aggSig [
'0x254432c775c1e80bc6e8b55a70b5c97d08c82e24f2963b3ae6a15ebd72e85af6',
'0x2dbe165f11dc37b27db682e6dd4ad6245152ce01d3d2e1397fb96926fcf48fbe'
]
offChainSigResult 0x254432c775c1e80bc6e8b55a70b5c97d08c82e24f2963b3ae6a15ebd72e85af62dbe165f11dc37b27db682e6dd4ad6245152ce01d3d2e1397fb96926fcf48fbe
solidityAggResult 0x254432c775c1e80bc6e8b55a70b5c97d08c82e24f2963b3ae6a15ebd72e85af62dbe165f11dc37b27db682e6dd4ad6245152ce01d3d2e1397fb96926fcf48fbe
✔ aggregateSignatures (58ms)
1 passing (2s)
[CASE 2] 단일 UserOperation의 서명 검증
it("validateUserOpSignature", async () => {
// UserOp1을 생성한다.
const userOp1 = await fillAndPack(
{
sender: account1.address,
},
entrypoint
);
// UserOp1의 해시를 구한다.
const requestHash = await blsAgg.getUserOpHash(userOp1);
// UserOp1의 해시에 대한 서명을 생성한다.
const sigParts = signer1.sign(requestHash);
// UserOp1에 서명을 추가한다.
userOp1.signature = hexConcat(sigParts);
// UserOp1의 서명 길이가 130인지 확인한다.
expect(userOp1.signature.length).to.equal(130); // 0x + 64 * 2
// 오프체인에서 서명을 검증한다.
const verifier = new BlsVerifier(BLS_DOMAIN);
expect(verifier.verify(sigParts, signer1.pubkey, requestHash)).to.equal(true);
// 온체인에서 서명을 검증한다.
const ret = await blsAgg.validateUserOpSignature(userOp1);
expect(ret).to.equal("0x");
});
1. UserOperation을 생성하고 aggregator를 호출하여 메시지의 해시를 구합니다.
// UserOp1을 생성한다.
const userOp1 = await fillAndPack(
{
sender: account1.address,
},
entrypoint
);
// UserOp1의 해시를 구한다.
const requestHash = await blsAgg.getUserOpHash(userOp1);
2. 메시지의 해시에 대한 서명을 생성합니다. 서명은 길이가 64인 바이트열입니다.
// UserOp1의 해시에 대한 서명을 생성한다.
const sigParts = signer1.sign(requestHash);
// UserOp1에 서명을 추가한다.
userOp1.signature = hexConcat(sigParts);
// UserOp1의 서명 길이가 130인지 확인한다.
expect(userOp1.signature.length).to.equal(130); // 0x + 64 * 2
3. 오프체인에서의 서명 검증과 온체인에서의 서명 검증이 모두 유효해야 합니다.
// 오프체인에서 서명을 검증한다.
const verifier = new BlsVerifier(BLS_DOMAIN);
expect(verifier.verify(sigParts, signer1.pubkey, requestHash)).to.equal(
true
);
// 온체인에서 서명을 검증한다.
const ret = await blsAgg.validateUserOpSignature(userOp1);
expect(ret).to.equal("0x");
validateUserOpSignature 함수는 일반적으로 오프체인에서 시뮬레이션이 진행되는 과정에서 aggregator의 주소가 반환되었을 때 호출됩니다. 함수에서 반환되는 sigForUserOp는 handleOps를 호출하기 전에 userOp에 추가되어야 할 값을 나타냅니다. 이 값은 보통 비어있습니다. (다중 서명을 지원하는 경우 등에 사용)
function validateUserOpSignature(PackedUserOperation calldata userOp)
external
view
returns (bytes memory sigForUserOp)
{
uint256[2] memory signature = abi.decode(userOp.signature, (uint256[2]));
uint256[4] memory pubkey = getUserOpPublicKey(userOp);
uint256[2] memory message = _userOpToMessage(userOp, _getPublicKeyHash(pubkey));
require(BLSOpen.verifySingle(signature, pubkey, message), "BLS: wrong sig");
return "";
}
테스트
$ npx hardhat test --grep validateUserOpSignature
No need to generate any newer typings.
BLS
✔ validateUserOpSignature (82ms)
1 passing (3s)
[CASE 3] 여러 개의 UserOperation으로부터 집계된 서명 검증
it("validateSignatures", async function () {
this.timeout(30000);
// UserOp1을 생성한다.
const userOp1 = await fillAndPack(
{
sender: account1.address,
},
entrypoint
);
// UserOp1의 해시를 구한다.
const requestHash = await blsAgg.getUserOpHash(userOp1);
// UserOp1의 해시에 대한 서명을 생성한다.
const sig1 = signer1.sign(requestHash);
// UserOp1에 서명을 추가한다.
userOp1.signature = hexConcat(sig1);
// UserOp2을 생성한다.
const userOp2 = await fillAndPack(
{
sender: account2.address,
},
entrypoint
);
// UserOp2의 해시를 구한다.
const requestHash2 = await blsAgg.getUserOpHash(userOp2);
// UserOp2의 해시에 대한 서명을 생성한다.
const sig2 = signer2.sign(requestHash2);
// UserOp2에 서명을 추가한다.
userOp2.signature = hexConcat(sig2);
// 오프체인에서 두 서명을 집계한다.
const aggSig = aggregate([sig1, sig2]);
// 온체인에서 두 서명을 집계한다.
const aggregatedSig = await blsAgg.aggregateSignatures([userOp1, userOp2]);
// 두 집계된 서명이 같은지 확인한다.
expect(hexConcat(aggSig)).to.equal(aggregatedSig);
// 두 서명자의 공개키를 가져온다.
const pubkeys = [signer1.pubkey, signer2.pubkey];
// BLS 검증자를 생성한다.
const v = new BlsVerifier(BLS_DOMAIN);
// 오프체인에서 집계된 서명을 검증한다.
const now = Date.now();
expect(
v.verifyMultiple(aggSig, pubkeys, [requestHash, requestHash2])
).to.equal(true);
console.log("verifyMultiple (mcl code)", Date.now() - now, "ms");
// 온체인에서 집계된 서명을 검증한다.
const now2 = Date.now();
console.log(
"validateSignatures gas= ",
await blsAgg.estimateGas.validateSignatures(
[userOp1, userOp2],
aggregatedSig
)
);
console.log("validateSignatures (on-chain)", Date.now() - now2, "ms");
});
1. userOp1과 userOp2를 생성하고 각각의 서명을 생성합니다.
// UserOp1을 생성한다.
const userOp1 = await fillAndPack(
{
sender: account1.address,
},
entrypoint
);
// UserOp1의 해시를 구한다.
const requestHash = await blsAgg.getUserOpHash(userOp1);
// UserOp1의 해시에 대한 서명을 생성한다.
const sig1 = signer1.sign(requestHash);
// UserOp1에 서명을 추가한다.
userOp1.signature = hexConcat(sig1);
// UserOp2을 생성한다.
const userOp2 = await fillAndPack(
{
sender: account2.address,
},
entrypoint
);
// UserOp2의 해시를 구한다.
const requestHash2 = await blsAgg.getUserOpHash(userOp2);
// UserOp2의 해시에 대한 서명을 생성한다.
const sig2 = signer2.sign(requestHash2);
// UserOp2에 서명을 추가한다.
userOp2.signature = hexConcat(sig2);
2. 서명을 집계합니다. 오프체인과 온체인에서 집계한 결과가 동일해야 합니다.
// 오프체인에서 두 서명을 집계한다.
const aggSig = aggregate([sig1, sig2]);
// 온체인에서 두 서명을 집계한다.
const aggregatedSig = await blsAgg.aggregateSignatures([userOp1, userOp2]);
// 두 집계된 서명이 같은지 확인한다.
expect(hexConcat(aggSig)).to.equal(aggregatedSig);
3. 두 서명자의 공개키를 가져온 뒤, 오프체인과 온체인에서 각각 집계된 서명을 검증하고 검증에 걸린 시간(비용)을 출력합니다.
// 두 서명자의 공개키를 가져온다.
const pubkeys = [signer1.pubkey, signer2.pubkey];
// BLS 검증자를 생성한다.
const v = new BlsVerifier(BLS_DOMAIN);
// 오프체인에서 집계된 서명을 검증한다.
const now = Date.now();
expect(v.verifyMultiple(aggSig, pubkeys, [requestHash, requestHash2])).to.equal(
true
);
console.log("verifyMultiple (mcl code)", Date.now() - now, "ms");
// 온체인에서 집계된 서명을 검증한다.
const now2 = Date.now();
console.log(
"validateSignatures gas= ",
await blsAgg.estimateGas.validateSignatures([userOp1, userOp2], aggregatedSig)
);
console.log("validateSignatures (on-chain)", Date.now() - now2, "ms");
집계된 서명을 검증하는 validateSignatures 함수는 서명이 유효하지 않은 경우 바로 revert 되도록 설계되어 있습니다.
function validateSignatures(PackedUserOperation[] calldata userOps, bytes calldata signature) external view override {
require(signature.length == 64, "BLS: invalid signature");
(uint256[2] memory blsSignature) = abi.decode(signature, (uint256[2]));
uint256 userOpsLen = userOps.length;
uint256[4][] memory blsPublicKeys = new uint256[4][](userOpsLen);
uint256[2][] memory messages = new uint256[2][](userOpsLen);
for (uint256 i = 0; i < userOpsLen; i++) {
PackedUserOperation memory userOp = userOps[i];
blsPublicKeys[i] = getUserOpPublicKey(userOp);
messages[i] = _userOpToMessage(userOp, _getPublicKeyHash(blsPublicKeys[i]));
}
require(BLSOpen.verifyMultiple(blsSignature, blsPublicKeys, messages), "BLS: validateSignatures failed");
}
테스트
$ npx hardhat test --grep validateSignatures
No need to generate any newer typings.
BLS
verifyMultiple (mcl code) 10 ms
validateSignatures gas= BigNumber { value: "357945" }
validateSignatures (on-chain) 1230 ms
✔ validateSignatures (1321ms)
1 passing (4s)
[CASE 4] handleAggregatedOps : 집계된 서명으로 작업을 실행하는 경우
it("handleAggregatedOps", async function () {
this.timeout(30000);
// UserOp1을 생성한다.
const userOp1 = await fillAndPack(
{
sender: account1.address,
},
entrypoint
);
// UserOp1의 해시를 구한다.
const requestHash = await blsAgg.getUserOpHash(userOp1);
// UserOp1의 해시에 대한 서명을 생성한다.
const sig1 = signer1.sign(requestHash);
// UserOp1에 서명을 추가한다.
userOp1.signature = hexConcat(sig1);
// UserOp2을 생성한다.
const userOp2 = await fillAndPack(
{
sender: account2.address,
},
entrypoint
);
// UserOp2의 해시를 구한다.
const requestHash2 = await blsAgg.getUserOpHash(userOp2);
// UserOp2의 해시에 대한 서명을 생성한다.
const sig2 = signer2.sign(requestHash2);
// UserOp2에 서명을 추가한다.
userOp2.signature = hexConcat(sig2);
// 오프체인에서 두 서명을 집계한다.
const aggSig = aggregate([sig1, sig2]);
// 온체인에서 두 서명을 집계한다.
const aggregatedSig = await blsAgg.aggregateSignatures([userOp1, userOp2]);
// 두 집계된 서명이 같은지 확인한다.
expect(hexConcat(aggSig)).to.equal(aggregatedSig);
// 집계된 서명과 UserOp들을 Aggregator에 전달할 수 있는 형태로 변환한다.
const userOpsPerAgg: UserOpsPerAggregator[] = [
{
userOps: [userOp1, userOp2],
aggregator: blsAgg.address,
signature: aggregatedSig,
},
];
// BLS 계정에 자금을 추가한다. (수수료를 지불하기 위해)
await fund(account1.address);
await fund(account2.address);
const now = Date.now();
// handleAggregatedOps를 호출하여 UserOp들을 처리한다.
const tx = await entrypoint.handleAggregatedOps(userOpsPerAgg, beneficiary);
const receipt = await tx.wait();
console.log("handleAggregatedOps gas= ", receipt.gasUsed);
console.log("handleAggregatedOps", Date.now() - now, "ms");
});
1. 서명을 집계하고 UserOp들을 handleAggregateOps 함수에 전달할 수 있는 형태로 변환합니다. UserOpsPerAggregator는 지원되는 aggregator 별로 UserOp를 모아놓은 것인데, 테스트 상에서는 하나의 aggregator만 존재합니다.
// 집계된 서명과 UserOp들을 Aggregator에 전달할 수 있는 형태로 변환한다.
const userOpsPerAgg: UserOpsPerAggregator[] = [
{
userOps: [userOp1, userOp2],
aggregator: blsAgg.address,
signature: aggregatedSig,
},
];
2. 수수료를 납부하기 위해서 BLS 계정에 자금(1 이더)을 추가합니다.
// BLS 계정에 자금을 추가한다. (수수료를 지불하기 위해)
await fund(account1.address);
await fund(account2.address);
3. entryPoint의 handleAggregatedOps 함수를 호출합니다.
const now = Date.now();
// handleAggregatedOps를 호출하여 UserOp들을 처리한다.
const tx = await entrypoint.handleAggregatedOps(userOpsPerAgg, beneficiary);
const receipt = await tx.wait();
console.log("handleAggregatedOps gas= ", receipt.gasUsed);
console.log("handleAggregatedOps", Date.now() - now, "ms");
function handleAggregatedOps(
UserOpsPerAggregator[] calldata opsPerAggregator,
address payable beneficiary
) public nonReentrant {
uint256 opasLen = opsPerAggregator.length;
uint256 totalOps = 0;
for (uint256 i = 0; i < opasLen; i++) {
UserOpsPerAggregator calldata opa = opsPerAggregator[i];
PackedUserOperation[] calldata ops = opa.userOps;
IAggregator aggregator = opa.aggregator;
//address(1) is special marker of "signature error"
require(
address(aggregator) != address(1),
"AA96 invalid aggregator"
);
if (address(aggregator) != address(0)) {
// solhint-disable-next-line no-empty-blocks
try aggregator.validateSignatures(ops, opa.signature) {} catch {
revert SignatureValidationFailed(address(aggregator));
}
}
totalOps += ops.length;
}
UserOpInfo[] memory opInfos = new UserOpInfo[](totalOps);
uint256 opIndex = 0;
for (uint256 a = 0; a < opasLen; a++) {
UserOpsPerAggregator calldata opa = opsPerAggregator[a];
PackedUserOperation[] calldata ops = opa.userOps;
IAggregator aggregator = opa.aggregator;
uint256 opslen = ops.length;
for (uint256 i = 0; i < opslen; i++) {
UserOpInfo memory opInfo = opInfos[opIndex];
(
uint256 validationData,
uint256 paymasterValidationData
) = _validatePrepayment(opIndex, ops[i], opInfo);
_validateAccountAndPaymasterValidationData(
i,
validationData,
paymasterValidationData,
address(aggregator)
);
opIndex++;
}
}
emit BeforeExecution();
uint256 collected = 0;
opIndex = 0;
for (uint256 a = 0; a < opasLen; a++) {
UserOpsPerAggregator calldata opa = opsPerAggregator[a];
emit SignatureAggregatorChanged(address(opa.aggregator));
PackedUserOperation[] calldata ops = opa.userOps;
uint256 opslen = ops.length;
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(opIndex, ops[i], opInfos[opIndex]);
opIndex++;
}
}
emit SignatureAggregatorChanged(address(0));
_compensate(beneficiary, collected);
}
handleAggregatedOps 함수의 동작 과정은 다음과 같습니다.
- 각 aggregator에 대응하는 집계 서명을 검증합니다.
- UserOpsPerAggregator의 모든 UserOperation에 대해 검증 작업을 진행합니다.
- 이 때 sender의 validateUserOp 함수도 실행이 됩니다. 서명을 검증해놓고 왜 또 검증을 하는 걸까 싶은데
- BLSAccount의 validateUserOp 함수 자체는 복잡한 검증 과정이 포함되어 있지 않으므로 비용이 크게 발생하지 않을 뿐더러
- 반환되는 validationData에 aggregator의 주소와 검증 유효 시간이 들어 있어서 이러한 데이터를 사용하는 _validateAccountAndPaymasterValidationData와 같은 함수를 호출하기 전에 반드시 거쳐야 할 관문같은 것이라고 보여집니다.
- 이후의 실행 과정은 handleOps 함수와 동일합니다.
테스트
$ npx hardhat test --grep handleAggregatedOps
No need to generate any newer typings.
BLS
handleAggregatedOps gas= BigNumber { value: "544742" }
handleAggregatedOps 1739 ms
✔ handleAggregatedOps (1839ms)
1 passing (4s)
[CASE 5] aggregator를 지원하는 계정으로 handleOps를 호출하는 경우
it("handleOps", async function () {
this.timeout(30000);
const userOp1 = await fillAndPack(
{
sender: account1.address,
},
entrypoint
);
const requestHash = await blsAgg.getUserOpHash(userOp1);
const sig1 = signer1.sign(requestHash);
userOp1.signature = hexConcat(sig1);
const userOp2 = await fillAndPack(
{
sender: account2.address,
},
entrypoint
);
const requestHash2 = await blsAgg.getUserOpHash(userOp2);
const sig2 = signer2.sign(requestHash2);
userOp2.signature = hexConcat(sig2);
await fund(account1.address);
await fund(account2.address);
// handleOps 함수는 aggregator를 지원하는 계정을 지원하지 않는다.
await expect(
entrypoint.handleOps([userOp1, userOp2], beneficiary)
).to.be.revertedWith('FailedOp(0, "AA24 signature error")');
});
handleOps 함수를 aggregator를 지원하는 계정에 대한 UserOperation으로 호출하게 되면 검증 과정에서 revert됩니다.
// handleOps 함수는 aggregator를 지원하는 계정을 지원하지 않는다.
await expect(
entrypoint.handleOps([userOp1, userOp2], beneficiary)
).to.be.revertedWith('FailedOp(0, "AA24 signature error")');
그 이유는 다음과 같습니다.
BLSAccount에서 validateUserOp 함수에서 반환되는 validationData에는 aggregator의 주소가 포함되어 있습니다.
return _packValidationData(ValidationData(aggregator, 0, 0));
그리고 handleOps 함수에서는 validationData를 사용해 _validateAccountAndPaymasterValidationData 함수를 호출합니다.
_validateAccountAndPaymasterValidationData(
i,
validationData,
pmValidationData,
address(0)
);
이 때 validationData에서 추출한 aggregator의 주소는 유효한 주소인데 함수를 호출할 때 expectedAggregator로 전달된 값이 address(0)이기 때문에 "AA24 signature error"와 함께 함수 호출이 revert되게 됩니다.
function _validateAccountAndPaymasterValidationData(
uint256 opIndex,
uint256 validationData,
uint256 paymasterValidationData,
address expectedAggregator
) internal view {
(address aggregator, bool outOfTimeRange) = _getValidationData(
validationData
);
if (expectedAggregator != aggregator) {
revert FailedOp(opIndex, "AA24 signature error");
}
}
테스트
$ npx hardhat test --grep handleOps
No need to generate any newer typings.
BLS
✔ handleOps (91ms)
1 passing (2s)
정리
- 서명 집계를 사용함으로써 검증에 필요한 연산을 줄이고 가스 비용을 절감할 수 있습니다.
- 또한 다중 서명 계정 등의 구현을 통해 스마트 컨트랙트의 확장성을 향상시킬 수 있습니다.
- 그러나 공식적으로 이더리움의 EOA에서 사용하는 ECDSA 서명 방식을 사용하지 않는 경우에는 EOA를 통해 직접적으로 스마트 계정을 제어하기가 어려울 수 있습니다.
- 기술적 복잡도나 초기 설정 비용 등도 고려해야 할 요소입니다.
전체 코드
참조
'블록체인 > Ethereum' 카테고리의 다른 글
Transient Storage 이해하기 (0) | 2024.10.13 |
---|---|
ERC-4337: 계정 추상화 - 간단 정리 (0) | 2024.04.23 |
ERC-4337: 계정 추상화 - 테스트를 통한 Paymaster와 LegacyTokenPaymaster의 동작 이해 (0) | 2024.04.18 |
ERC-4337: 계정 추상화 - 테스트를 통한 Account Factory의 동작 이해 (0) | 2024.04.18 |
ERC-4337: 계정 추상화 - 테스트 수정 사항 (0) | 2024.04.17 |