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 id | Holds | API path |
|---|---|---|
x | An X handle, lowercased, verified by OAuth | /api/address-links/x/ |
nostr | An npub… key, verified by a signed kind-22242 event | /api/address-links/nostr/ |
bio | Free text, up to 200 characters | /api/address-links/bio/ |
site | A hostname and path, scheme stripped | /api/address-links/site/ |
prefaens | A .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
| Entrypoint | Signature |
|---|---|
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")
| Entrypoint | Signature |
|---|---|
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
| Entrypoint | Signature |
|---|---|
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:0The 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
| Abort | Meaning |
|---|---|
INVALID_SIGNATURE | The Ed25519 check failed against the subject |
INVALID_NONCE | The nonce moved between claim and submit; restart from claim |
ALREADY_CLAIMED | This provider is already linked to a different value for this subject |
NOT_LINKED / NO_LINKS | Nothing to unlink |
NOT_PROVIDER_OWNER | The submitting wallet does not own the provider namespace |
PRINCIPAL_NOT_FOUND | The AENS name is not registered |
PRINCIPAL_MISMATCH | The AENS name is not owned by the signer |
INVALID_PRINCIPAL | Not 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:
| Variable | Purpose |
|---|---|
ADDRESS_LINK_CONTRACT_ADDRESS | The deployed ct_… address |
ADDRESS_LINK_SECRET_KEY | The provider wallet that submits and pays. Also the HMAC key for claim verification tokens. |
ADDRESS_LINK_VERIFICATION_TTL_SECONDS | Claim-token lifetime, default 300 |
ADDRESS_LINK_NOSTR_EVENT_MAX_AGE_SECONDS | Nostr 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):
| Field | Source |
|---|---|
profile.x_username | links.x, trimmed, lowercased, leading @ stripped |
profile.bio | links.bio, falling back to the legacy profile field |
profile.site | links.site |
profile.prefered_aens_name | links.prefaens |
profile.chain_name | The middleware's AENS name for the address, refreshed hourly |
profile.fullname, avatarurl, username, chain_expires_at | Legacy profile fields, read-only |
public_name | prefered_aens_name, else chain_name, else username, else the address |
followers_count, following_count | The social-graph counts, when that feature is enabled |
Related
- SuperheroID: the product-level view of what an identity links.
- Verifying Your X Account: the
xprovider's claim-sign-submit flow end to end. - Profiles & Accounts: where the linked values surface on screen.
- Chat: the
nostrprovider, and the room pipeline that consumes it. - Delegated & Gasless Posting: the same signed-message construction in the posting contract.
- Sophia standard library (opens in a new tab):
the reference for
Crypto.verify_sigand the Blake2b primitives.