Skip to content
44 changes: 43 additions & 1 deletion packages/evm-module/contracts/Profile.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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 Down Expand Up @@ -123,6 +128,43 @@ 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. Admin-only (ADR 0001):
// unlike genesis createProfile, the supplied identityId may already
// carry third-party delegated stake, so an operational key must not
// be able to re-price the operator fee. 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.
uint72 identityId,
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 onlyAdmin(identityId) {
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.
}

function addOperationalWallets(
uint72 identityId,
address[] calldata operationalWallets
Expand Down
1 change: 1 addition & 0 deletions packages/evm-module/contracts/libraries/ProfileLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ library ProfileLib {
error AskUpdateOnCooldown(uint72 identityId, uint256 cooldownEnd);
error NoOperatorFees(uint72 identityId);
error ProfileDoesntExist(uint72 identityId);
error ProfileAlreadyExists(uint72 identityId);
error NoPendingNodeAsk();
error NoPendingOperatorFee();
error InvalidOperatorFee();
Expand Down
45 changes: 45 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,45 @@
# 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` is gated `onlyWhitelisted onlyAdmin(identityId)`.

- The caller must hold the supplied identity's **Admin** key. This both
authorizes the caller and proves the `Identity` exists.
- The signature takes `identityId` explicitly because Admin keys have no
reverse lookup, mirroring other admin-gated, id-parameterised profile
mutations (`updateOperatorFee`, `addOperationalWallets`).
- The existing whitelist gate is preserved.

## 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.
93 changes: 93 additions & 0 deletions packages/evm-module/test/integration/Profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
ParametersStorage,
DelegatorsInfo,
ShardingTable,
ConvictionStakingStorage,
IdentityStorage,
} from '../../typechain';
import { createKnowledgeCollection } from '../helpers/kc-helpers';
import { createProfile } from '../helpers/profile-helpers';
Expand Down Expand Up @@ -1098,3 +1100,94 @@ describe('Profile Contract', () => {
});
});
});

/* ───────── recreate-profile-recovery 0001 — id-keyed state ───────── */

describe('@integration Profile recreate preserves id-keyed state', () => {
const fixtureRecreate = deployments.createFixture(async () => {
await hre.deployments.fixture(['Profile']);
const signers = await hre.ethers.getSigners();
const contracts = {
hub: await hre.ethers.getContract<Hub>('Hub'),
profile: await hre.ethers.getContract<Profile>('Profile'),
profileStorage:
await hre.ethers.getContract<ProfileStorage>('ProfileStorage'),
identityStorage:
await hre.ethers.getContract<IdentityStorage>('IdentityStorage'),
stakingStorage:
await hre.ethers.getContract<StakingStorage>('StakingStorage'),
convictionStakingStorage:
await hre.ethers.getContract<ConvictionStakingStorage>(
'ConvictionStakingStorage',
),
};
await contracts.hub.setContractAddress('HubOwner', signers[0].address);
return { ...contracts, signers };
});

it('staking/conviction state pre-seeded under a bricked identityId survives recreate', async () => {
const {
profile,
profileStorage,
identityStorage,
stakingStorage,
convictionStakingStorage,
signers,
} = await fixtureRecreate();

const operational = signers[0];
const admin = signers[1];
const nodeId =
'0x07f38512786964d9e70453371e7c98975d284100d44bd68dab67fe00b525cb66';

// Mint identity 1 + profile, then wipe ONLY the profile — the testnet
// ProfileStorage-redeploy state: Identity + id-keyed state survive.
await profile
.connect(operational)
.createProfile(admin.address, [], 'Recover Node', nodeId, 1000);
const identityId = await identityStorage.getIdentityId(
operational.address,
);

const seededStake = hre.ethers.parseEther('12345');
const seededOperatorFee = hre.ethers.parseEther('67');
await stakingStorage
Comment thread
zsculac marked this conversation as resolved.
Outdated
.connect(signers[0])
.setNodeStake(identityId, seededStake);
await convictionStakingStorage
.connect(signers[0])
.setOperatorFeeBalance(identityId, seededOperatorFee);

await profileStorage.connect(signers[0]).deleteProfile(identityId);
expect(await profileStorage.profileExists(identityId)).to.equal(false);
expect(await stakingStorage.getNodeStake(identityId)).to.equal(
seededStake,
);
expect(
await convictionStakingStorage.getOperatorFeeBalance(identityId),
).to.equal(seededOperatorFee);

const lastIdBefore = await identityStorage.lastIdentityId();

await expect(
profile
.connect(admin)
.recreateProfile(identityId, 'Recover Node', nodeId, 1000),
).to.not.be.reverted;

// Profile restored under the SAME id; no new identity minted; the
// pre-seeded id-keyed state is still addressable and unchanged.
expect(await profileStorage.profileExists(identityId)).to.equal(true);
expect(await profileStorage.getNodeId(identityId)).to.equal(nodeId);
expect(await identityStorage.lastIdentityId()).to.equal(lastIdBefore);
expect(
await identityStorage.getIdentityId(operational.address),
).to.equal(identityId);
expect(await stakingStorage.getNodeStake(identityId)).to.equal(
seededStake,
);
expect(
await convictionStakingStorage.getOperatorFeeBalance(identityId),
).to.equal(seededOperatorFee);
});
});
Loading
Loading