CIS-8: External Key Registry¶
Created |
May 21, 2026 |
|---|---|
Draft |
May 25, 2026 |
Supported versions |
Smart contract version 1 or newer
(Protocol version 4 or newer)
|
Standard identifier |
|
Requires |
Abstract¶
A standard interface for registering a public link between a Concordium account and an external public key used on another blockchain. A registration asserts that a Concordium account controls a specified external public key, proven by a cryptographic signature produced with that external key.
The standard is chain-agnostic: it supports external blockchains including Ethereum, Solana, Fetch.ai, and Cosmos-SDK chains using CAIP-style chain namespace identifiers, per-chain-family proof scheme identifiers, and chain-specific public key encodings.
Verifier-anchored assertions for non-cryptographic external identifiers such as platform handles or HTTPS endpoints are out of scope.
The interface defines the following:
Data structures for external key identifiers, ownership proofs, and registrations.
Entrypoints for registering, updating, revoking, and querying external key links.
Logged events for each mutating operation.
Standard rejection error codes.
Specification¶
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
General types and serialization¶
Note
Integers are encoded in little-endian unless stated otherwise.
String¶
A variable-length UTF-8 encoded string.
It is serialized as: 2 bytes for the length (n) of the string in little-endian, followed by n bytes for the UTF-8 encoding of the string:
String ::= (n: Byte2) (value: Byten)
Bytestring¶
A variable-length byte array.
It is serialized as: 2 bytes for the length (n) in little-endian, followed by n bytes:
Bytestring ::= (n: Byte2) (value: Byten)
AccountAddress¶
An address of a Concordium account.
It is serialized as 32 bytes:
AccountAddress ::= (address: Byte32)
ContractAddress¶
An address of a Concordium smart contract instance. It consists of an index and a subindex, both unsigned 64-bit integers.
It is serialized as: 8 bytes for the index (index) followed by 8 bytes for the subindex (subindex), both little-endian:
ContractAddress ::= (index: Byte8) (subindex: Byte8)
BlockHash¶
A 32-byte block hash identifying a Concordium block. Used in the canonical signed message to carry the genesis block hash and prevent cross-network replay.
It is serialized as 32 bytes:
BlockHash ::= (hash: Byte32)
Timestamp¶
A point in time given in milliseconds since Unix epoch, represented as an unsigned 64-bit integer.
It is serialized as 8 bytes in little-endian:
Timestamp ::= (milliseconds: Byte8)
ExternalKeyId¶
An identifier for a public key on an external blockchain, consisting of a CAIP-style chain namespace, a key type identifier, and the raw public key bytes.
The namespace field is a CAIP-style chain identifier (e.g., eip155:1, solana:mainnet, cosmos:fetchhub-4).
The key_type field identifies the public key format and encoding.
Standard values defined by this specification are:
secp256k1-compressed- 33 bytes; used by Ethereum and Cosmos chains.secp256k1-uncompressed- 65 bytes; alternate Ethereum encoding.ed25519- 32 bytes; used by Solana and Fetch.ai uagents.
The public_key field is the raw encoded public key bytes.
Encoding is determined by key_type.
It is serialized as a String (namespace), a String (key_type), and a Bytestring (public_key):
ExternalKeyId ::= (namespace: String) (key_type: String) (public_key: Bytestring)
Proof¶
A cryptographic ownership proof, produced by signing the canonical signed message with the external key being registered.
The scheme field is a proof scheme identifier (e.g., ethereum-personal-sign, solana-ed25519, cosmos-secp256k1, fetch-ai-ed25519).
The signature field contains the signature bytes as produced by that proof scheme.
It is serialized as a String (scheme) followed by a Bytestring (signature):
Proof ::= (scheme: String) (signature: Bytestring)
MetadataEntry¶
An application-defined key-value string pair associated with a registration.
It is serialized as a String (key) followed by a String (value):
MetadataEntry ::= (key: String) (value: String)
RegistrationStatus¶
The lifecycle status of a registration.
It is serialized as 1 byte with value 0 for Active and 1 for Revoked:
RegistrationStatus ::= (0: Byte) // Active
| (1: Byte) // Revoked
Registration¶
A complete record of an external key registration, including the linked Concordium account, the external key, the proof scheme used at registration time, application-defined metadata, the current status, and the time of the last status change.
It is serialized as an AccountAddress (owner), an ExternalKeyId (external_key), a String (proof_scheme), 2 bytes for the number of metadata entries (m), followed by m MetadataEntry records (metadata), a RegistrationStatus (status), and a Timestamp (last_updated):
Registration ::= (owner: AccountAddress) (external_key: ExternalKeyId) (proof_scheme: String)
(m: Byte2) (metadata: MetadataEntrym) (status: RegistrationStatus)
(last_updated: Timestamp)
Canonical signed message¶
The canonical signed message is the message that MUST be signed with the external private key as the ownership proof in a registerExternalKey call.
The message MUST NOT include a nonce or expiry timestamp.
Replay protection is provided by the Concordium transaction itself, which is bound to the sender account, a sequential nonce, and an expiry time at the protocol level.
Cross-contract and cross-network replay is prevented by the inclusion of the contract_address and concordium_genesis_hash fields.
The message bytes MUST be prefixed with the 18-byte ASCII domain separation tag CIS-8/v1/canonical.
It is serialized as the 18 raw ASCII bytes of the domain separation tag, followed by an AccountAddress (concordium_account), a ContractAddress (contract_address), a BlockHash (concordium_genesis_hash), a String (external_namespace), an ExternalKeyId (external_key), and a String (proof_scheme):
CanonicalSignedMessage ::= ("CIS-8/v1/canonical": Byte18)
(concordium_account: AccountAddress)
(contract_address: ContractAddress)
(concordium_genesis_hash: BlockHash)
(external_namespace: String)
(external_key: ExternalKeyId)
(proof_scheme: String)
Proof verification¶
This section specifies the proof schemes that a conformant CIS-8 contract SHOULD support.
A contract MUST reject with UnsupportedProofScheme for any scheme identifier it does not implement.
A contract MAY support additional proof schemes beyond those listed here.
ethereum-personal-sign¶
Algorithm: ECDSA over secp256k1.
Hash: Keccak-256.
Public key encoding: secp256k1 compressed (33 bytes) or uncompressed (65 bytes).
Message construction: Let
mbe the canonical signed message bytes. Construct"\x19Ethereum Signed Message:\n" + ascii(len(m)) + mand hash with Keccak-256.Signature: 65 bytes (r || s || v). Verify by public-key recovery and comparison against the supplied
public_key.
solana-ed25519¶
Algorithm: Ed25519.
Public key encoding: 32 bytes.
Signature: 64 bytes (R || S).
Message construction: Sign the canonical signed message bytes directly (the
CIS-8/v1/canonicaldomain prefix is included as part of the message).
cosmos-secp256k1¶
Algorithm: ECDSA over secp256k1.
Hash: SHA-256.
Public key encoding: secp256k1 compressed (33 bytes).
Message construction: Wrap the canonical signed message bytes in an ADR-036 off-chain envelope, then hash with SHA-256.
Signature: Standard ECDSA (r || s). Verify against the supplied compressed public key.
fetch-ai-ed25519¶
Algorithm: Ed25519.
Public key encoding: 32 bytes (Fetch.ai uagent ed25519 public key).
Signature: 64 bytes (R || S).
Message construction: Identical to solana-ed25519.
Logged events¶
The events defined by this specification are serialized using one byte to discriminate the different events. A custom event SHOULD NOT have a first byte colliding with any of the events defined by this specification.
If account B replaces account A as the active owner of an external key, the contract MUST emit
ExternalKeyRevoked for account A before emitting ExternalKeyRegistered for account B.
ExternalKeyRegistered¶
An ExternalKeyRegistered event MUST be logged whenever an external key is successfully registered or re-registered.
It is serialized as: first a byte with the value of 231, followed by the AccountAddress (owner) and the ExternalKeyId (external_key):
ExternalKeyRegistered ::= (231: Byte) (owner: AccountAddress) (external_key: ExternalKeyId)
ExternalKeyRevoked¶
An ExternalKeyRevoked event MUST be logged whenever an active registration is revoked, whether by an explicit revoke call or by displacement through a new registerExternalKey call.
It is serialized as: first a byte with the value of 232, followed by the AccountAddress (owner) and the ExternalKeyId (external_key):
ExternalKeyRevoked ::= (232: Byte) (owner: AccountAddress) (external_key: ExternalKeyId)
UpdateMetadata¶
An UpdateMetadata event MUST be logged whenever the metadata of an active registration is successfully updated.
It is serialized as: first a byte with the value of 233, followed by the AccountAddress (owner), the ExternalKeyId (external_key), 2 bytes for the number of metadata entries (m), and then m MetadataEntry records (metadata):
UpdateMetadata ::= (233: Byte) (owner: AccountAddress) (external_key: ExternalKeyId)
(m: Byte2) (metadata: MetadataEntrym)
Contract functions¶
A smart contract implementing this standard MUST export the following functions:
supports¶
As specified in CIS-0.
registerExternalKey¶
Register or replace the active owner of an external key with a cryptographic ownership proof. The proof MUST be signed with the external private key over the canonical signed message constructed from the transaction context and the supplied parameters.
If an active registration already exists for the supplied external key under a different owner, the previous registration is displaced: the contract MUST emit ExternalKeyRevoked for the displaced owner before emitting ExternalKeyRegistered for the new owner.
Parameter¶
The parameter consists of an ExternalKeyId (external_key), a Proof (proof), 2 bytes for the number of metadata entries (m), and then m MetadataEntry records (metadata):
RegisterExternalKeyParam ::= (external_key: ExternalKeyId) (proof: Proof) (m: Byte²) (metadata: MetadataEntryᵐ)
Requirements¶
The
proof.schemeMUST be supported by the contract; otherwise the contract MUST reject withUnsupportedProofScheme.The
external_key.key_typeMUST be supported by the contract; otherwise the contract MUST reject withUnsupportedKeyType.The
external_keyMUST be well-formed for its declaredkey_type; otherwise the contract MUST reject withMalformedExternalKey.The
proofMUST verify against the canonical signed message reconstructed from the transaction context and the supplied parameters; otherwise the contract MUST reject withInvalidProof.If the caller is already the active owner of the supplied external key the contract MUST reject with
AlreadyRegistered.If
metadataviolates implementation-defined limits the contract MUST reject withInvalidMetadata.On success the contract MUST emit ExternalKeyRegistered.
updateMetadata¶
Update the metadata of an existing active registration. The caller MUST be the current active owner of the registration.
Parameter¶
The parameter consists of an ExternalKeyId (external_key), 2 bytes for the number of metadata entries (m), and then m MetadataEntry records (metadata):
UpdateMetadataParam ::= (external_key: ExternalKeyId) (m: Byte2) (metadata: MetadataEntrym)
Requirements¶
The contract MUST reject with
NotRegisteredif no active registration exists for the supplied external key.The contract MUST reject with
Unauthorizedif the caller is not the active owner of the registration.If
metadataviolates implementation-defined limits the contract MUST reject withInvalidMetadata.On success the contract MUST emit UpdateMetadata.
revoke¶
Revoke an active registration. The caller MUST be the current active owner of the registration.
Parameter¶
The parameter consists of the ExternalKeyId (external_key) to revoke:
RevokeParam ::= (external_key: ExternalKeyId)
Requirements¶
The contract MUST reject with
NotRegisteredif no active registration exists for the supplied external key.The contract MUST reject with
Unauthorizedif the caller is not the active owner.On success the contract MUST emit ExternalKeyRevoked.
ownerOfKey¶
Query the active registration for a given external key identifier.
Parameter¶
The parameter consists of the ExternalKeyId (external_key) to query:
OwnerOfKeyParam ::= (external_key: ExternalKeyId)
Response¶
The response is 1 byte indicating whether an active registration was found.
If the value is 0, no active registration exists and no further bytes follow.
If the value is 1, the full Registration (registration) follows:
OwnerOfKeyResponse ::= (0: Byte) // not found
| (1: Byte) (registration: Registration) // found
Rejection errors¶
A smart contract following this specification MUST use the following error codes to reject under the described conditions:
Name |
Error code |
Description |
|---|---|---|
|
-7100 |
The supplied proof does not verify against the reconstructed canonical signed message. |
|
-7101 |
The proof scheme identifier is not supported by this contract. |
|
-7102 |
The external key identifier is malformed or its encoding does not match the declared |
|
-7103 |
The sender is not the active owner of the registration. |
|
-7104 |
The sender is already the active owner of the supplied external key. |
|
-7105 |
No active registration exists for the supplied identifier. |
|
-7106 |
The supplied identifier resolves ambiguously. |
|
-7107 |
The supplied |
|
-7108 |
The metadata violates implementation-defined limits or encoding requirements. |
Rejecting using an error code from the table above MUST only occur in a situation as described in the corresponding error description.
The smart contract implementing this specification MAY introduce custom error codes other than the ones specified in the table above.