Identity
AddressLink

AddressLink

AddressLink is the contract behind SuperheroID. It stores signed links from an æternity address to values in named provider namespaces (an X handle, a Nostr key, a preferred .chain name, a bio, a website), and the /api/address-links/… endpoints are the API in front of it.

What it stores

state = { providers  : map(string, address)
        , links      : map(address, map(string, string))
        , reverse    : map(string, map(string, address))
        , nonce_seq  : map(address, int) }

A link is a (subject, provider, value) triple. links is the forward index (every provider value an address has claimed), and reverse is the inverse, so a provider value resolves back to its owner. nonce_seq is a monotonic counter per subject: every signed message carries the current nonce, and the nonce advances on write, so a signature cannot be replayed.

Providers

A provider is a short string namespace: at most 10 characters, lowercase a to z only. The providers the Superhero backend runs:

Provider idHoldsAPI path
xAn X handle, lowercased, verified by OAuth/api/address-links/x/
nostrAn npub… key, verified by a signed kind-22242 event/api/address-links/nostr/
bioFree text, up to 200 characters/api/address-links/bio/
siteA hostname and path, scheme stripped/api/address-links/site/
prefaensA .chain name the address owns/api/address-links/prefered-aens-name/

Values are capped at 200 characters and may not contain a colon, because the signed message is colon-delimited. site therefore stores example.com/blog rather than https://example.com/blog, and a port is rejected.

Provider ownership

register_provider(provider) claims a namespace and get_provider_owner(provider) returns its owner. Only the owner may submit link and unlink calls for that namespace; the contract aborts with NOT_PROVIDER_OWNER otherwise.

Authority is therefore split in two, and both halves are required:

  • The user authorises the specific link by signing the message with their æternity key. The contract verifies that signature against the subject address.
  • The provider owner submits the transaction and pays for it. For every provider above, the owner is the Superhero backend wallet.

You cannot link an x handle to your own address by calling the contract yourself, even with a valid signature. You can register your own provider and run your own namespace on the same contract.

Entrypoints

Three parallel namespaces exist, differing only in what identifies the subject.

Address-keyed

EntrypointSignature
link(addr: address, provider: string, value: string, nonce: int, sig: signature) => unit
unlink(addr: address, provider: string, nonce: int, sig: signature) => unit
get_nonce(addr: address) => int
get_links(addr: address) => map(string, string)
get_link(addr: address, provider: string) => option(string)
resolve(provider: string, value: string) => option(address)

AENS-name-keyed ("principal")

EntrypointSignature
link_principal(principal: string, signer: address, provider: string, value: string, nonce: int, sig: signature) => unit
unlink_principal(principal: string, signer: address, provider: string, nonce: int, sig: signature) => unit
get_nonce_principal(principal: string, signer: address) => int
get_links_principal(principal: string, signer: address) => map(string, string)
get_link_principal(principal: string, signer: address, provider: string) => option(string)
resolve_signer(principal: string) => option(address)

The principal is an AENS name. The contract verifies ownership itself via AENSv2.lookup before accepting the link: a name that is not registered aborts with PRINCIPAL_NOT_FOUND, and a name the signer does not own aborts with PRINCIPAL_MISMATCH. The preferred-AENS-name link uses this namespace.

DID-keyed

EntrypointSignature
link_did(did: string, controller: address, provider: string, value: string, nonce: int, sig: signature) => unit
unlink_did(did: string, controller: address, provider: string, nonce: int, sig: signature) => unit
get_nonce_did(did: string, controller: address) => int
get_links_did(did: string, controller: address) => map(string, string)
get_link_did(did: string, controller: address, provider: string) => option(string)
resolve_did_controller(did: string) => option(address)

The did is an opaque string; the contract attaches no syntax or meaning to it. The Superhero providers use the address-keyed and principal-keyed namespaces, and the API indexes those two.

Events

ProviderRegistered(address, string), Link(address, string), Unlink(address, string), PrincipalLink(address, string), PrincipalUnlink(address, string).

The first argument is the subject address as an integer, and the payload string is provider:value for a link and provider: for an unlink. The indexer decodes the integer back to an ak_… address and splits the payload on its first colon.

The signed message

The backend builds the message from the contract's own nonce:

link:<address>:<provider>:<value>:<nonce>
unlink:<address>:<provider>:<nonce>

For a principal link the subject slot holds the AENS name instead of the address:

link:hero.chain:prefaens:hero.chain:0

The digest is the standard æternity personal-message construction: a 28-byte prefix (0x1a, aeternity Signed Message: plus a newline, then the body length 0x20) concatenated with the Blake2b-256 hash of the message, the whole 60 bytes hashed again. That is exactly what signMessage in @aeternity/aepp-sdk produces, so pass the claim's message string to signMessage and never hash it by hand. The signature goes on the wire as 128 lowercase hex characters, no 0x, no base64.

The same construction is used by delegated posting.

Error codes

AbortMeaning
INVALID_SIGNATUREThe Ed25519 check failed against the subject
INVALID_NONCEThe nonce moved between claim and submit; restart from claim
ALREADY_CLAIMEDThis provider is already linked to a different value for this subject
NOT_LINKED / NO_LINKSNothing to unlink
NOT_PROVIDER_OWNERThe submitting wallet does not own the provider namespace
PRINCIPAL_NOT_FOUNDThe AENS name is not registered
PRINCIPAL_MISMATCHThe AENS name is not owned by the signer
INVALID_PRINCIPALNot a well-formed AENS name

The backend maps each abort to a readable 400 message.

Configuration

The backend's address-link module is environment-driven:

VariablePurpose
ADDRESS_LINK_CONTRACT_ADDRESSThe deployed ct_… address
ADDRESS_LINK_SECRET_KEYThe provider wallet that submits and pays. Also the HMAC key for claim verification tokens.
ADDRESS_LINK_VERIFICATION_TTL_SECONDSClaim-token lifetime, default 300
ADDRESS_LINK_NOSTR_EVENT_MAX_AGE_SECONDSNostr proof-event freshness window, default 300

With either of the first two unset, every address-link read and write returns 503 Service Unavailable and nothing is indexed.

How a link reaches the API

The indexer scans link, unlink, link_principal and unlink_principal calls from height 1,273,691. On a live link transaction it decodes the call arguments directly (addr, provider, value), so the API reflects a link the moment the transaction is mined, without waiting for contract logs to be indexed.

The link lands in the account's links object, keyed by provider id:

{ "nostr": "npub1xyzabc…", "x": "superherocom",
  "bio": "Builder on Aeternity", "site": "example.com", "prefaens": "hero.chain" }

A nostr link also wakes the token-gated-rooms pipeline. See Chat.

The profile aggregate

GET /api/profile/{address} is served entirely from indexed data (includeOnChain is deprecated):

FieldSource
profile.x_usernamelinks.x, trimmed, lowercased, leading @ stripped
profile.biolinks.bio, falling back to the legacy profile field
profile.sitelinks.site
profile.prefered_aens_namelinks.prefaens
profile.chain_nameThe middleware's AENS name for the address, refreshed hourly
profile.fullname, avatarurl, username, chain_expires_atLegacy profile fields, read-only
public_nameprefered_aens_name, else chain_name, else username, else the address
followers_count, following_countThe social-graph counts, when that feature is enabled

Related