import { getAddress, type Address } from 'viem' import type { EthagentIdentity } from '../../../storage/config.js' import { createWalletRestoreAccessChallenge, type WalletChallengePurpose, } from '../../continuity/envelope.js' import { writeAgentCardFile, } from '../../continuity/storage.js' import { syncAgentCardManifest } from '../../continuity/skills/publicSkillsSync.js' import { recordPublishedContinuitySnapshot } from '../../continuity/snapshots.js' import { addToIpfs, DEFAULT_IPFS_API_URL, isPinataUploadUrl } from '../../storage/ipfs.js' import { createErc8004PublicClient, encodeSetAgentUri, preflightSetAgentUri, withEthagentPointers, type Erc8004RegistryConfig, } from '../../registry/erc8004.js' import { encodeRotateAgentURI } from '../../registry/vault.js' import { resolveValidatedPinataJwt, savePinataJwt } from '../../storage/pinataJwt.js' import { openBrowserWalletSession, prepareTransactionGasFee, requestBrowserWalletSignatureAndTransaction, type WalletPurpose, } from '../../wallet/browserWallet.js' import type { ProfileUpdates, Step } from '../reducer.js' import { acquireTxGuard, releaseTxGuard } from '../shared/txGuard.js' import type { EffectCallbacks } from '../shared/effects/types.js' import { awaitConfirmedReceipt } from '../shared/effects/receipts.js' import { assertVerifiedPin, deriveAgentName, prepareProfileStateForSave, readEnsOkFromState, } from '../shared/effects/profilePrep.js' import { appendResolverSyncWarning, markCurrentContinuityFilesPublished, resolverSyncWarningMessage, syncVaultOperatorsAfterOwnerSave, } from '../shared/effects/sync.js' import { assertSnapshotSaveSignerAuthorized, createContinuityEnvelopeForSave, expectedAccountForSnapshotSave, operatorsPointerFromState, ownerAddressForSnapshotSave, snapshotSaveWalletRole, walletRestoreAccessContext, } from '../continuity/snapshot.js' import { rebackupCompletionMessage } from '../continuity/effects.js' import { assertVaultSignerCanRotateAgentUri, prepareOperatorProfileArtifacts, } from './operatorSave.js' type AgentCardMetadata = NonNullable type PublicProfilePreparedTransaction = { ownerAddress: Address agentUri: string metadataCid: string agentCard: AgentCardMetadata identity: EthagentIdentity agentCardJson: string } export async function runPublicProfilePreflight( identity: EthagentIdentity, registry: Erc8004RegistryConfig, callbacks: EffectCallbacks, profileUpdates?: ProfileUpdates, returnTo: Step = { kind: 'continuity-public' }, vaultAddress?: Address, ): Promise { const apiUrl = DEFAULT_IPFS_API_URL let jwt: string | undefined try { jwt = isPinataUploadUrl(apiUrl) ? await resolveValidatedPinataJwt() : undefined } catch (err: unknown) { callbacks.onStep({ kind: 'public-profile-storage', identity, registry, error: (err as Error).message, profileUpdates, returnTo, ...(vaultAddress ? { vaultAddress } : {}) }) return } if (isPinataUploadUrl(apiUrl) && !jwt) { callbacks.onStep({ kind: 'public-profile-storage', identity, registry, profileUpdates, returnTo, ...(vaultAddress ? { vaultAddress } : {}) }) return } callbacks.onStep({ kind: 'public-profile-signing', identity, registry, pinataJwt: jwt, profileUpdates, returnTo, ...(vaultAddress ? { vaultAddress } : {}) }) } export async function runPublicProfileSigning( step: Extract, callbacks: EffectCallbacks, ): Promise { acquireTxGuard('public-profile') try { return await runPublicProfileSigningInner(step, callbacks) } finally { releaseTxGuard('public-profile') } } async function runPublicProfileSigningInner( step: Extract, callbacks: EffectCallbacks, ): Promise { if (snapshotSaveWalletRole(step.identity, step.profileUpdates) === 'operator') { await runOperatorWalletPublicProfileSave(step, callbacks) return } const snapshotOwner = ownerAddressForSnapshotSave(step.identity, step.profileUpdates) const walletAccess = walletRestoreAccessContext(step.identity, step.registry, step.profileUpdates, snapshotOwner) const expectedSigner = expectedAccountForSnapshotSave(step.identity, step.profileUpdates, walletAccess) const profileRole = snapshotSaveWalletRole(step.identity, step.profileUpdates) const profilePurpose: WalletPurpose = profileRole === 'operator' ? 'update-profile-operator' : profileRole === 'owner' ? 'update-profile-owner' : 'update-profile-connected' const result = await requestBrowserWalletSignatureAndTransaction({ chainId: step.registry.chainId, messageForAccount: account => `Authorize public profile update for agent #${step.identity.agentId ?? 'unknown'} (${account})`, onReady: callbacks.onWalletReady, purpose: profilePurpose, ...(expectedSigner ? { expectedAccount: expectedSigner } : {}), prepareTransaction: async wallet => { if (!step.identity.agentId) throw new Error('Cannot update public profile: identity is missing an agent token ID') assertSnapshotSaveSignerAuthorized(step.identity, step.profileUpdates, wallet.account, snapshotOwner, walletAccess) const { state, nextName, nextDescription, nextEnsName, uploadedImageUri, } = await prepareProfileStateForSave({ identity: step.identity, registry: step.registry, profileUpdates: step.profileUpdates, pinataJwt: step.pinataJwt, ownerAddress: snapshotOwner, walletAccount: getAddress(wallet.account), includeLastBackedUpAt: false, }) const nextIdentityForFiles: EthagentIdentity = { ...step.identity, state } const agentCardJson = await syncAgentCardManifest(nextIdentityForFiles) const agentCardPin = await addToIpfs(DEFAULT_IPFS_API_URL, agentCardJson, fetch, { pinataJwt: step.pinataJwt }) assertVerifiedPin(agentCardPin) const updatedAt = new Date().toISOString() const agentCard: AgentCardMetadata = { cid: agentCardPin.cid, updatedAt, status: 'pinned', } const existingBackup = step.identity.backup ? { cid: step.identity.backup.cid, ...(step.identity.backup.envelopeVersion ? { envelopeVersion: step.identity.backup.envelopeVersion } : {}), ...(step.identity.backup.createdAt ? { createdAt: step.identity.backup.createdAt } : {}), } : undefined const registration = withEthagentPointers({ type: 'https://eips.ethereum.org/EIPS/eip-8004#registration-v1', name: nextName ?? deriveAgentName(step.identity), ...(nextDescription ? { description: nextDescription } : {}), ...(uploadedImageUri ? { image: uploadedImageUri } : {}), }, { backup: existingBackup, publicDiscovery: { agentCardCid: agentCard.cid, updatedAt, }, registration: { chainId: step.registry.chainId, identityRegistryAddress: step.registry.identityRegistryAddress, agentId: step.identity.agentId, }, ensName: nextEnsName, operators: operatorsPointerFromState(state, nextEnsName), ownerAddress: snapshotOwner, }) const metadataPin = await addToIpfs(DEFAULT_IPFS_API_URL, JSON.stringify(registration, null, 2), fetch, { pinataJwt: step.pinataJwt }) assertVerifiedPin(metadataPin) const metadataCid = metadataPin.cid const agentUri = `ipfs://${metadataCid}` const agentId = BigInt(step.identity.agentId) await preflightSetAgentUri({ ...step.registry, account: wallet.account, agentId, newUri: agentUri, }) const directCall = { to: step.registry.identityRegistryAddress, data: encodeSetAgentUri({ agentId, newUri: agentUri }), } const gasFee = await prepareTransactionGasFee({ client: createErc8004PublicClient(step.registry), account: getAddress(wallet.account), to: directCall.to, data: directCall.data, }) return { to: directCall.to, data: directCall.data, ...gasFee, prepared: { ownerAddress: snapshotOwner, agentUri, metadataCid, agentCard, identity: { ...step.identity, state }, agentCardJson, }, } }, }) const client = createErc8004PublicClient(step.registry) await awaitConfirmedReceipt(client, result.txHash, 'Owner-wallet profile save', { kind: 'public-profile', chainId: step.registry.chainId }) const nextIdentity: EthagentIdentity = { ...result.prepared.identity, source: 'erc8004', address: getAddress(result.prepared.ownerAddress), ownerAddress: getAddress(result.prepared.ownerAddress), chainId: step.registry.chainId, rpcUrl: step.registry.rpcUrl, identityRegistryAddress: step.registry.identityRegistryAddress, agentUri: result.prepared.agentUri, metadataCid: result.prepared.metadataCid, agentCard: result.prepared.agentCard, } await writeAgentCardFile(nextIdentity, result.prepared.agentCardJson) await markCurrentContinuityFilesPublished(nextIdentity) const resolverSyncWarning = await syncVaultOperatorsAfterOwnerSave({ beforeIdentity: step.identity, afterIdentity: nextIdentity, registry: step.registry, callbacks, }).then(() => null).catch(err => resolverSyncWarningMessage(err)) const completionMessage = appendResolverSyncWarning( rebackupCompletionMessage( step.profileUpdates, step.identity, readEnsOkFromState(nextIdentity.state), ), resolverSyncWarning, ) await callbacks.onIdentityComplete(nextIdentity, completionMessage, 'update') } async function runOperatorWalletPublicProfileSave( step: Extract, callbacks: EffectCallbacks, ): Promise { if (!step.identity.agentId) throw new Error('Cannot update public profile: identity is missing an agent token ID') if (!step.vaultAddress) { throw new Error('Operator-wallet profile updates require a Vault. Set one up via Custody Mode (choose Advanced), or connect the owner wallet to update the profile.') } const snapshotOwner = ownerAddressForSnapshotSave(step.identity, step.profileUpdates) const walletAccess = walletRestoreAccessContext(step.identity, step.registry, step.profileUpdates, snapshotOwner) if (!walletAccess) throw new Error('Operator-wallet profile updates require wallet restore access context') const challengePurpose: WalletChallengePurpose = 'restore-operator' await runOperatorWalletVaultPublicProfileSave({ step, callbacks, snapshotOwner, walletAccess, challengePurpose, vaultAddress: step.vaultAddress, }) } type WalletAccessContext = NonNullable> async function runOperatorWalletVaultPublicProfileSave(args: { step: Extract callbacks: EffectCallbacks snapshotOwner: Address walletAccess: WalletAccessContext challengePurpose: WalletChallengePurpose vaultAddress: Address }): Promise { const { step, callbacks, snapshotOwner, walletAccess, challengePurpose, vaultAddress } = args const session = await openBrowserWalletSession({ onReady: callbacks.onWalletReady }) try { const wallet = await session.requestSignature({ chainId: step.registry.chainId, messageForAccount: account => createWalletRestoreAccessChallenge({ token: walletAccess.token, ownerAddress: snapshotOwner, walletAddress: account, accessEpoch: walletAccess.accessEpoch, purpose: challengePurpose, }), purpose: 'update-profile-operator', flowId: 'public-profile-vault', flowStep: 1, }) assertSnapshotSaveSignerAuthorized(step.identity, step.profileUpdates, wallet.account, snapshotOwner, walletAccess) await assertVaultSignerCanRotateAgentUri({ registry: step.registry, vaultAddress, agentId: BigInt(step.identity.agentId!), signer: wallet.account, }) const prepared = await prepareOperatorProfileArtifacts({ step, wallet, snapshotOwner, walletAccess, challengePurpose, }) const vaultCall = encodeRotateAgentURI({ registry: getAddress(step.registry.identityRegistryAddress), agentId: BigInt(step.identity.agentId!), newURI: prepared.agentUri, vaultAddress, }) const gasFee = await prepareTransactionGasFee({ client: createErc8004PublicClient(step.registry), account: getAddress(wallet.account), to: vaultCall.to, data: vaultCall.data, }) const vaultTx = await session.sendTransaction({ chainId: step.registry.chainId, expectedAccount: wallet.account, to: vaultCall.to, data: vaultCall.data, ...gasFee, purpose: 'rotate-agent-uri-vault-operator', flowId: 'public-profile-vault', flowStep: 2, }) const client = createErc8004PublicClient(step.registry) await awaitConfirmedReceipt( client, vaultTx.txHash, 'Operator public profile URI rotation through vault', { kind: 'public-profile', chainId: step.registry.chainId }, ) const nextIdentity: EthagentIdentity = { ...prepared.nextIdentity, backup: prepared.nextIdentity.backup ? { ...prepared.nextIdentity.backup, txHash: vaultTx.txHash } : prepared.nextIdentity.backup, } await writeAgentCardFile(nextIdentity, prepared.agentCardJson) await markCurrentContinuityFilesPublished(nextIdentity).catch(() => null) await recordPublishedContinuitySnapshot({ identity: nextIdentity, label: 'operator-wallet vaulted profile update', }).catch(() => null) await callbacks.onIdentityComplete( nextIdentity, 'Profile updated. ERC-8004 metadata published through the Vault.', 'update', ) } finally { await session.close().catch(() => null) callbacks.onWalletReady(null) } } export async function runPublicProfileStorageSubmit( input: string, step: Extract, callbacks: EffectCallbacks, ): Promise { const { jwt: pinataJwt } = await savePinataJwt(input) callbacks.onStep({ kind: 'public-profile-signing', identity: step.identity, registry: step.registry, pinataJwt, profileUpdates: step.profileUpdates, returnTo: step.returnTo, ...(step.vaultAddress ? { vaultAddress: step.vaultAddress } : {}) }) }