replace할 때 기존 플러그인 내 데이터를 새로운 플러그인으로 옮길 수 있어야 한다. 이때, 사용자가 직접 플러그인 내 스토리지에 접근할 수 있도록 하는 것은 보안 상 위험한 결과를 낳을 수도 있기 때문에, 각 플러그인 내에 migration data를 반환하는 view
함수를 두어서 이를 활용한다.
각 플러그인은 semantic versioning을 따르는 버전을 가지고 있다. replacePlugin
이 적용될 수 있는 범위는, patch version이 바뀌는 업그레이드 혹은 다운그레이드로 정의한다. 이는 가스비 최적화, 버그 수정 등 사소한 수준의 업데이트만 허용한다는 것을 의미한다. 그런데, 버전은 각 플러그인 내에서 정의되기 때문에, 이를 악용하는 엣지 케이스들이 발생할 수 있다 (major 또는 minor change인데도 불구하고 patch update인 척하는 경우). 이에 따라 replacePlugin
에서 이를 확인해주어야 한다. 온체인에서 이 엣지 케이스를 식별하는 것은 거의 불가능에 가깝기 때문에, 미리 지정된 committee가 확인하는 등의 오프체인 support가 필요하다.
이를 위해 각 플러그인에 VersionRegistry를 정의할 수 있게끔 한다. 이는 다음과 같은 스펙을 가진다.
각 플러그인은 VersionRegistry의 owner만 등록할 수 있다.
어떤 플러그인의 새로운 버전을 등록할 때, 정해진 owner (혹은 committee) 가 해당 버전의 호환성을 오프체인에서 체크 후 등록하는 방식을 택한다. 이는 위에서 언급했던 보안적인 엣지 케이스를 방지하기 위함이다.
각 플러그인이 각자의 Registry를 사용할 수 있다.
하나의 글로벌 Registry를 사용해 모든 플러그인의 버전 관리를 사용하는 솔루션도 있을 수 있다. 그러나, 이는 다음과 같은 단점들이 존재한다.
중앙화에 대한 리스크
플러그인 개발자들의 자유도를 낮춤
각 플러그인을 구분하기 어려움.
Registry에서는 서로 호환되는 플러그인의 리스트나 맵핑을 유지하고 있을 텐데, 이를 구분하려면 플러그인의 이름 혹은 저자 등을 해시하는 등의 방법을 써야 할 것이다. 이때 플러그인의 이름으로 구분하는 방식을 사용한다면 같은 이름을 가진 다른 플러그인들은 등록을 하지 못하는 이슈가 있을 수 있다.
이에 따라 각 플러그인에서 각자가 원하는 형태의 Registry를 등록할 수 있게끔 한다. ERC-6900 ref implementation에 들어갈 코드는 이에 대한 최소한의 인터페이스와, 기본적인 형태의 VersionRegistry 코드가 될 것이다.
사용되는 버전 정보는 pluginMetadata
에서 가지고 온다.
이에 따라 Version string을 아래와 같은 version struct로 디코딩하는 함수가 필요할 것이다.
struct Version {
uint256 major;
uint256 minor;
uint256 patch;
}
replacePlugin
function replacePlugin(address oldPlugin, address newPlugin, bytes32 newManifestHash) external;
VersionRegistry
아래와 같은 인터페이스를 가진 VersionRegistry를 구현한다.
interface IVersionRegistry {
/// @notice Register a new plugin version in the registry.
/// @dev This function can be restricted to only be callable by the contract owner or a specific role.
/// @param plugin The address of the plugin to register.
function registerPlugin(address plugin) external;
/// @notice Retrieve the version information of a given plugin.
/// @param plugin The address of the plugin whose version information is being queried.
/// @return The version information of the plugin.
function getPluginVersion(address plugin) external view returns (Version memory);
/// @notice Checks if the given two plugins are compatible for the replacement.
/// @param oldPlugin The address of plugin to be replaced.
/// @param newPlugin The address of plugin replacing the existing plugin.
/// @return A boolean indicating the compatibility of two plugins.
function isPluginCompatible(address oldPlugin, address newPlugin) external view returns (bool);
}
각 플러그인마다 Registry를 배포 전에 등록한다. 이에 따라 아래와 같이 PluginManifest의 형태가 바뀌어야 한다.
struct PluginManifest {
// List of ERC-165 interface IDs to add to account to support introspection checks. This MUST NOT include
// IPlugin's interface ID.
bytes4[] interfaceIds;
// If this plugin depends on other plugins' validation functions, the interface IDs of those plugins MUST be
// provided here, with its position in the array matching the `dependencyIndex` members of `ManifestFunction`
// structs used in the manifest.
bytes4[] dependencyInterfaceIds;
// Execution functions defined in this plugin to be installed on the MSCA.
bytes4[] executionFunctions;
// Plugin execution functions already installed on the MSCA that this plugin will be able to call.
bytes4[] permittedExecutionSelectors;
// Boolean to indicate whether the plugin can call any external address.
bool permitAnyExternalAddress;
// Boolean to indicate whether the plugin needs access to spend native tokens of the account. If false, the
// plugin MUST still be able to spend up to the balance that it sends to the account in the same call.
bool canSpendNativeToken;
**address versionRegistry;**
ManifestExternalCallPermission[] permittedExternalCalls;
ManifestAssociatedFunction[] userOpValidationFunctions;
ManifestAssociatedFunction[] runtimeValidationFunctions;
ManifestAssociatedFunction[] preUserOpValidationHooks;
ManifestAssociatedFunction[] preRuntimeValidationHooks;
ManifestExecutionHook[] executionHooks;
}
Data Migration during replacement
아래와 같은 함수들을 IPlugin
내에 추가한다.
/// @notice Retrieves data for migrating from the old plugin to a new plugin.
/// @dev Called by the plugin manager during the plugin replacement process.
/// It should return all the necessary state information of the plugin in a serialized format.
/// In the case of SingleOwnerPlugin, it returns the owner's address.
/// @return bytes Migration data to migrate from old plugin to new plugin
function getDataForReplacement() external view returns (bytes memory);
/// @notice Cleans up the plugin data when the plugin is being replaced.
/// @dev This function is called during the plugin replacement process to allow the current (old) plugin
/// to clean up its data or state before being replaced. For the SingleOwnerPlugin, this might involve
/// resetting ownership information.
function onReplaceForOldPlugin() external;
/// @notice Initialize new plugin with migrated data.
/// @dev Called during the plugin replacement process. This function initializes the state of the new plugin
/// with the data provided. For SingleOwnerPlugin, it sets the new owner based on the migrated data.
/// @param migrationData Migrationdata from old plugin, exported form getDataForMigration() function.
function onReplaceForNewPlugin(bytes memory migrationData) external;
이때 onReplaceForNewPlugin
에 들어가는 migrationData
는 getDataForReplacement
에서 가져온 데이터이다.
우리는 현재 다음과 같은 부분에서 고민을 계속하고 있다.
replacePlugin
에서 저장된 값들의 주소를 변경하는 행위를 기존에 정의된 함수들을 써서 다음과 같은 방법을 통해 하고 있다.
_removeUserOpValidationFunction(
mv.executionSelector,
_resolveManifestFunction(
mv.associatedFunction, oldPlugin, dependencies, ManifestAssociatedFunctionType.NONE
)
);
_addUserOpValidationFunction(
mv.executionSelector,
_resolveManifestFunction(
mv.associatedFunction, newPlugin, dependencies, ManifestAssociatedFunctionType.NONE
)
);
이러한 방식보다 더 가스 효율적이고, 바이트코드를 덜 소모하는 방법을 찾고 있다. 현재 이러한 방식으로 구현하면, UpgradeableModularAccount의 크기가 24KB를 넘어가게 되는 문제가 존재한다.
플러그인 별로 각각 Registry를 연결할 수 있게끔 구현하는 것이 옳은지 고민하고 있다. 이러한 방식의 최대 문제점은, 플러그인 개발자가 플러그인 뿐만 아니라 VersionRegistry도 구현해야 할 수도 있다는 점이다. 이는 플러그인 개발 경험을 낮추는 문제로 이어질 수 있다.
한편 글로벌로 단 하나의 VersionRegistry를 사용하게 된다면, 위에서 언급했던 중앙화나 자유도의 문제가 발생할 수 있다. 우리는 현재 이 tradeoff를 고려해서 최적의 솔루션이 무엇인지 고민하고 있다.
현재 있는 코드에서 테스트를 진행하기 위해선, 레지스트리 주소를 미리 코드에 하드코딩해야 한다(Manifest에 정의하였기 때문). 현재는 BasePlugin
에다가 하드코딩을 하는 방식으로 코드를 작성하고 있는데, 이 코드가 프로덕션 레벨로 가면 플러그인 개발자들에게 혼란을 줄 수도 있을 것이다(예를 들어, 이 주소를 수정 안하고 플러그인을 배포한다거나 하는 문제). 깔끔하게 이 문제를 해결할 수 있는 방법이 없을까?