UBNet Follow Protocol

API version: 6.4.1 · SDK version: 6.5.3 · Docs version: 6.5.3

This page defines how current UBNet clients publish, project, read, and export follow relationships. A follow is a public, signed statement made by one UBNet profile about another UBNet profile.

Use the SDK Event contract Projection rules Read and export SDK reference Documentation home

Relationship identity

The logical relationship key has four parts:

(follower_wallet, follower_profile_name, following_wallet, following_profile_name)

Wallet addresses and profile names are normalized before projection. The Default profile is stored as an empty profile name. Named profiles are independent identities, so one wallet can follow the same target from multiple profiles or follow multiple profiles belonging to the same target wallet.

Publishing with the browser SDK

const client = UBNet.createClient({
  apiBase: window.location.origin,
  walletProvider: window.ethereum,
  profileName: "research"
});

await client.profiles.follow(
  actorWallet,
  targetWallet,
  { profile_name: "news" }
);

await client.profiles.unfollow(
  actorWallet,
  targetWallet,
  { profile_name: "news" }
);

The client's active profileName selects the follower profile. The option passed to follow() or unfollow() selects the target profile. The target may also be written as 0x…@news; an explicit profile_name option takes precedence.

Both methods require an EIP-1193 wallet provider and request a personal_sign signature. The target does not sign or approve the relationship.

Signed event contract

The current SDK creates a Spasm 2.0 event body using UBNet schema version 3.0.0. A follow body has this shape; values shown are illustrative:

{
  "type": "SpasmEventBodyV2",
  "timestamp": 1780000000000,
  "authors": [{
    "addresses": [{
      "value": "0xfollower…",
      "format": { "name": "ethereum-pubkey" }
    }]
  }],
  "protocol": { "name": "spasm", "version": "2.0.0" },
  "schema": { "name": "ubnet-follow", "version": "3.0.0" },
  "action": "follow",
  "profile_name": "research",
  "target_wallet": "0xtarget…",
  "target_profile_name": "news"
}

An unfollow uses action: "unfollow" and schema ubnet-unfollow; its identity fields are otherwise the same. The body is serialized and wrapped as:

{
  "type": "SpasmEventBodySignedClosedV2",
  "signedString": "{...serialized event body...}",
  "signature": "0x…",
  "signer": "0xfollower…"
}

The SDK submits that envelope to POST /api/events/submit. A follow is public metadata: do not place private information in profile names or additional event fields.

Projection and conflict rules

  1. The indexer extracts the author wallet, actor profile, target wallet, and target profile.
  2. follow sets the relationship's projected active value to true.
  3. unfollow sets active to false. It does not delete the signed follow history.
  4. The projection stores the latest event ID and signed event timestamp for the four-part relationship key.
  5. An event updates the projection only when its timestamp is equal to or newer than the stored timestamp. Older events cannot reverse newer projected state. If timestamps are equal, the event processed last wins.

Repeated follow events do not create duplicate projected relationships. Public follower/following reads return only rows whose projected state is active.

Reading followers and following

const followers = await client.profiles.followers(wallet, {
  profile: "news",
  limit: 50,
  cursor: null
});

const following = await client.profiles.following(wallet, {
  profile: "news",
  limit: 50,
  cursor: null
});

These call GET /api/users/:wallet/followers and GET /api/users/:wallet/following. The profile query accepts name, profile, or profile_name. The default page size is 50 and the maximum is 100.

{
  "followers": [
    {
      "follower_wallet": "0x…",
      "follower_profile_name": "research",
      "following_wallet": "0x…",
      "following_profile_name": "news",
      "active": true,
      "latest_event_id": "spasmid01…",
      "updated_at": 1780000000000
    }
  ],
  "has_more": true,
  "next_cursor": "opaque-base64url-cursor"
}

The following response uses the same row fields under following. Results are ordered by newest projected update, then by target wallet and target profile. Treat next_cursor as opaque and pass it unchanged to retrieve the next page.

Portable follow-list export

const exportData = await client.profiles.followExport(wallet, {
  profile: "research"
});

GET /api/users/:wallet/follow-export returns all active relationships for the selected profile:

{
  "type": "ubnet-follow-list-v2",
  "wallet": "0x…",
  "profile_name": "research",
  "exported_at": "2026-09-27T00:00:00.000Z",
  "following_profiles": [
    { "wallet": "0x…", "profile_name": "news" }
  ],
  "follower_profiles": [
    { "wallet": "0x…", "profile_name": "reader" }
  ]
}

Use following_profiles and follower_profiles so each relationship retains its complete profile identity. Importing an export means publishing new signed follow events from the importing profile; the export itself is not an authorization token and does not recreate someone else's signatures.

Integration rules