Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/evm-module/abi/Profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@
"name": "NodeIdAlreadyExists",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint72",
"name": "identityId",
"type": "uint72"
},
{
"internalType": "bytes",
"name": "expected",
"type": "bytes"
},
{
"internalType": "bytes",
"name": "provided",
"type": "bytes"
}
],
"name": "NodeIdShardingMismatch",
"type": "error"
},
{
"inputs": [
{
Expand Down Expand Up @@ -144,6 +165,17 @@
"name": "OperatorFeeOutOfRange",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint72",
"name": "identityId",
"type": "uint72"
}
],
"name": "ProfileAlreadyExists",
"type": "error"
},
{
"inputs": [
{
Expand Down Expand Up @@ -367,6 +399,34 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "operationalWallet",
"type": "address"
},
{
"internalType": "string",
"name": "nodeName",
"type": "string"
},
{
"internalType": "bytes",
"name": "nodeId",
"type": "bytes"
},
{
"internalType": "uint16",
"name": "initialOperatorFee",
"type": "uint16"
}
],
"name": "recreateProfile",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
Expand All @@ -380,6 +440,19 @@
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "shardingTableStorage",
"outputs": [
{
"internalType": "contract ShardingTableStorage",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "status",
Expand Down
81 changes: 80 additions & 1 deletion packages/evm-module/contracts/Profile.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {ProfileStorage} from "./storage/ProfileStorage.sol";
import {WhitelistStorage} from "./storage/WhitelistStorage.sol";
import {Chronos} from "./storage/Chronos.sol";
import {ConvictionStakingStorage} from "./storage/ConvictionStakingStorage.sol";
import {ShardingTableStorage} from "./storage/ShardingTableStorage.sol";
import {ShardingTableLib} from "./libraries/ShardingTableLib.sol";
import {ContractStatus} from "./abstract/ContractStatus.sol";
import {IInitializable} from "./interfaces/IInitializable.sol";
import {INamed} from "./interfaces/INamed.sol";
Expand All @@ -24,7 +26,12 @@ contract Profile is INamed, IVersioned, ContractStatus, IInitializable {
// Network State Registry (RFC 04 v0.3 / Issue #461). Multiaddrs were
// briefly added on a prior revision but are not stored on Profile —
// they live in per-round attestation KCs instead (RFC 04 §5.2).
string private constant _VERSION = "1.2.0";
// Bumped 1.2.0 -> 1.3.0: adds recreateProfile, an admin-only recovery
// entry point that re-attaches a Profile to an existing identityId
// (testnet ProfileStorage-redeploy recovery). The id is reused so the
// surviving staking/conviction/sharding state stays addressable. See
// docs/adr/0001-recreate-profile-admin-only.md.
string private constant _VERSION = "1.3.0";

Ask public askContract;
Identity public identityContract;
Expand All @@ -39,6 +46,9 @@ contract Profile is INamed, IVersioned, ContractStatus, IInitializable {
// Profile reads `isOperatorFeeClaimedForEpoch` in `updateOperatorFee`
// to gate fee changes on prior-epoch fee claims being fully settled.
ConvictionStakingStorage public convictionStakingStorage;
// recreate-profile-recovery 0001 — read-only: recreateProfile checks the
// recovered nodeId against any surviving sharding-table entry.
ShardingTableStorage public shardingTableStorage;

// solhint-disable-next-line no-empty-blocks
constructor(address hubAddress) ContractStatus(hubAddress) {}
Expand Down Expand Up @@ -72,6 +82,7 @@ contract Profile is INamed, IVersioned, ContractStatus, IInitializable {
whitelistStorage = WhitelistStorage(hub.getContractAddress("WhitelistStorage"));
chronos = Chronos(hub.getContractAddress("Chronos"));
convictionStakingStorage = ConvictionStakingStorage(hub.getContractAddress("ConvictionStakingStorage"));
shardingTableStorage = ShardingTableStorage(hub.getContractAddress("ShardingTableStorage"));
}

function name() external pure virtual override returns (string memory) {
Expand Down Expand Up @@ -123,6 +134,74 @@ contract Profile is INamed, IVersioned, ContractStatus, IInitializable {
ps.createProfile(identityId, nodeName, nodeId, initialOperatorFee);
}

// recreate-profile-recovery 0001 — re-attach a Profile to an Identity
// that survived a ProfileStorage redeploy. The caller passes the node
// operational wallet (operators know this; the numeric identityId is
// internal and often unknown), and the contract resolves the id via
// IdentityStorage. Admin-only (ADR 0001): unlike genesis createProfile,
// the resolved identityId may already carry third-party delegated
// stake, so an operational key must not be able to re-price the
// operator fee — _checkAdmin enforces the admin key after resolution
// (a zero/unknown wallet resolves to id 0, which has no admin and
// reverts). The identityId is reused — no new identity is minted — so
// id-keyed staking/conviction/sharding state stays addressable.
function recreateProfile(
Comment thread
zsculac marked this conversation as resolved.
address operationalWallet,
string calldata nodeName,
bytes calldata nodeId,
Comment thread
zsculac marked this conversation as resolved.
uint16 initialOperatorFee
Comment thread
zsculac marked this conversation as resolved.
Comment thread
zsculac marked this conversation as resolved.
) external onlyWhitelisted {
uint72 identityId = identityStorage.getIdentityId(operationalWallet);
_checkAdmin(identityId);

// ShardingTableStorage survived the ProfileStorage redeploy and
// caches nodeId per identityId. If this node is still in the ring,
// the recovered nodeId MUST match the surviving entry — otherwise
// ProfileStorage and the sharding table would disagree about the
// same identityId (consumers would see a stale ring node). Read-only:
// ring state is not rewritten here (out of scope — ADR 0001).
ShardingTableStorage sts = shardingTableStorage;
if (sts.nodeExists(identityId)) {
bytes memory ringNodeId = sts.getNode(identityId).nodeId;
if (keccak256(nodeId) != keccak256(ringNodeId)) {
revert ProfileLib.NodeIdShardingMismatch(identityId, ringNodeId, nodeId);
}
}

ProfileStorage ps = profileStorage;

if (ps.profileExists(identityId)) {
revert ProfileLib.ProfileAlreadyExists(identityId);
}
if (bytes(nodeName).length == 0) {
revert ProfileLib.EmptyNodeName();
}
if (ps.isNameTaken(nodeName)) {
Comment thread
zsculac marked this conversation as resolved.
Comment thread
zsculac marked this conversation as resolved.
revert ProfileLib.NodeNameAlreadyExists(nodeName);
}
if (nodeId.length == 0) {
revert ProfileLib.EmptyNodeId();
}
if (ps.nodeIdsList(nodeId)) {
revert ProfileLib.NodeIdAlreadyExists(nodeId);
}
if (initialOperatorFee > parametersStorage.maxOperatorFee()) {
revert ProfileLib.OperatorFeeOutOfRange(initialOperatorFee);
}

ps.createProfile(identityId, nodeName, nodeId, initialOperatorFee);
Comment thread
zsculac marked this conversation as resolved.
Comment thread
zsculac marked this conversation as resolved.
Comment thread
zsculac marked this conversation as resolved.
Comment thread
zsculac marked this conversation as resolved.

// ShardingTable survived a ProfileStorage-only redeploy: if this
// node is already in the ring, Ask's active-set / pricing
// aggregates (recomputed from ProfileStorage.getAsk per ring node)
// are stale until something recomputes. Trigger it now so the
// recovered node's contribution is consistent. Genesis
// createProfile has no ring entry, so it never needs this.
if (sts.nodeExists(identityId)) {
Comment thread
zsculac marked this conversation as resolved.
askContract.recalculateActiveSet();
}
}

function addOperationalWallets(
uint72 identityId,
address[] calldata operationalWallets
Expand Down
2 changes: 2 additions & 0 deletions packages/evm-module/contracts/libraries/ProfileLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ library ProfileLib {
error AskUpdateOnCooldown(uint72 identityId, uint256 cooldownEnd);
error NoOperatorFees(uint72 identityId);
error ProfileDoesntExist(uint72 identityId);
error ProfileAlreadyExists(uint72 identityId);
error NodeIdShardingMismatch(uint72 identityId, bytes expected, bytes provided);
error NoPendingNodeAsk();
error NoPendingOperatorFee();
error InvalidOperatorFee();
Expand Down
4 changes: 4 additions & 0 deletions packages/evm-module/deploy/active/025_deploy_profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ func.dependencies = [
'ParametersStorage',
'ProfileStorage',
'WhitelistStorage',
// recreate-profile-recovery 0001 — recreateProfile reads
// ShardingTableStorage to keep the recovered nodeId consistent with any
// surviving sharding-table entry for the same identityId.
'ShardingTableStorage',
'Ask',
// D13 — Profile.initialize() reads `isOperatorFeeClaimedForEpoch` via CSS
// after the DelegatorsInfo redirect. V6/V8 DelegatorsInfo migrators
Expand Down
79 changes: 79 additions & 0 deletions packages/evm-module/docs/adr/0001-recreate-profile-admin-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# ADR 0001 — recreateProfile is admin-only

- Status: Accepted
- Scope: `packages/evm-module` · `Profile.recreateProfile`
- Related: PRD "Recreate Profile for an existing Identity (testnet recovery)"

## Context

After a testnet `ProfileStorage` redeploy, some nodes have an on-chain
`Identity` but no `Profile`. `recreateProfile` re-attaches a `Profile`
to such an `Identity`, reusing the existing `identityId` so that the
surviving `identityId`-keyed staking, conviction and sharding state
stays addressable.

Genesis `createProfile` is whitelist-gated and called by an
**Operational** key on a brand-new identity with zero stake. `Recreate`
is different: it acts on an `identityId` that may already carry
third-party delegated stake, and it sets the initial operator fee.

## Decision

`recreateProfile(address operationalWallet, …)` is gated `onlyWhitelisted`
and resolves + enforces the admin check in-body:

- The caller passes the node's **Operational wallet** — operators know this
(it is the node's running key); the numeric `identityId` is internal and
often unknown. The contract resolves
`identityId = IdentityStorage.getIdentityId(operationalWallet)`.
- It then calls `_checkAdmin(identityId)`: `msg.sender` must hold that
identity's **Admin** key. Authorization is the Admin key, exactly as
before — the operational wallet is only an *identifier*, never the
authorizer. A zero/unknown wallet resolves to `id 0` (no admin) and
reverts, which also proves the `Identity` exists.
- The existing whitelist gate is preserved.

(Original draft took `uint72 identityId` directly, gated
`onlyAdmin(identityId)`. Changed to the operational-wallet form for
operator ergonomics — admins rarely know the numeric id — without
weakening authorization: the admin key is still enforced, just after
in-body resolution rather than via the modifier.)

## Rationale

An Operational ("hot") key must not be able to set the operator fee on a
stake-bearing node. A compromised hot key could otherwise re-price the
node's operator fee against its delegators. Restricting recovery to the
Admin key removes that vector while still letting honest operators
recover their nodes with a single transaction.

## Consequences

- Operators must control each bricked Identity's original Admin key to
recover (operationally verified, off-chain).
- If testnet whitelisting is enabled, the bricked identities' **Admin**
wallets must be whitelisted before recovery — the genesis flow
whitelisted the Operational caller instead.
- **Sharding-table consistency (enforced).** `ShardingTableStorage`
survives a ProfileStorage-only redeploy and caches `nodeId` per
`identityId`. `recreateProfile` therefore *reverts* (`NodeIdShardingMismatch`)
if the node is still in the ring (`nodeExists(identityId)`) and the
supplied `nodeId` differs from the surviving ring entry. This is a
**read-only** check — recovery deliberately does **not** rewrite ring
state (out of scope; would touch ShardingTable). Honest recovery (same
node, same `nodeId`) is unaffected; only a divergent `nodeId` is refused.

## Known limitations

- **Operator-fee history is not recoverable.** The pre-redeploy operator-fee
schedule was Profile-resident and is gone. `recreateProfile` seeds a
single fresh fee at recovery time (like genesis `createProfile`). For any
**unclaimed pre-recovery epochs**, `StakingV10._claim` resolves the
historical split via `getOperatorFeePercentageByTimestampReverse`, which
now falls back to the new recovery-time fee — i.e. recovery can
retroactively change reward splits for those epochs. This is **accepted
as a known testnet limitation**: the data is unrecoverable on-chain, and
a real (mainnet) event of this kind would be handled by a state
migration, not this recovery path. Operationally: settle/claim all
pre-recovery epochs before recovering, or accept reward drift for
unclaimed ones.
Loading
Loading