Managing linked wallets (there is no admin screen)

No admin page reads or writes wallet links. What the two user-side endpoints do, why a second account gets a 409, and the SQL to find, release or re-point a provider_user row safely.

11 min readUpdated 6 August 2026admin, provider-user, sql, 409, licence

There is no wallet-connect admin section, no wallet column on the users table, and no admin endpoint that reads or writes a wallet link. Grep backend/src/api/admin and the admin frontend for providerUser and you get nothing back. That is not an oversight you can work around with a permission — the screen does not exist.

So the first ticket that says "a customer linked the wrong address" or "someone else has my wallet" has to be answered from the database, and this page is what that looks like. Read it before you open a SQL client, because a provider_user row is a login credential: re-pointing one hands a stranger a working sign-in to somebody else's account.

What actually exists

Two endpoints, both scoped to the signed-in user's own session. Neither carries a permission key, because neither is reachable by an administrator acting on somebody else.

Links a wallet to the SIGNED-IN account after verifying a SIWE signature. Session-scoped.
Unlinks an address from the SIGNED-IN account. Session-scoped. NOT gated on the extension or the licence.

Both log under the WALLET module, as Connect wallet and Disconnect wallet. connect calls ensureWalletConnectAvailable() and disconnect deliberately does not — that is the escape hatch described in the licence gate below.

The one admin surface that shows anything

Admin → CRM → Users → open a user → Activity. The tab reads GET /api/admin/crm/user/{id}/activity?limit=25 (permission view.user) and renders the user's own activity log, which is where the wallet events land:

Activity type Title Severity Metadata
wallet.connected Wallet connected success address, chainId
wallet.disconnected Wallet disconnected warning address, verifiedBySignature
auth.login Signed in info full address; the description reads Wallet sign-in (0x1234…abcd)

The truncated address is in the description; the full address is in the row's metadata. This is a history, not a state view — it tells you what happened and when, not what is linked right now. Nothing on any admin screen tells you that.

admin/crm/kyc/application/[id] renders a Wallet address card that reads user.walletAddress. The backend route that feeds it does not select that column — its attributes list stops at profile — so the card renders its empty branch for every applicant on every install, including users who do have a wallet linked. Do not read it as evidence of anything.

Why a second account gets a 409

Answers 409 when the address belongs to someone else

This wallet address is already linked to a different account.

That message is verbatim, and the cause is a single-column index:

UNIQUE KEY `providerUserId` (`providerUserId`)

The uniqueness is on providerUserId alone — not on (provider, providerUserId) and not on (userId, providerUserId). Since the same column holds Google sub values under provider = 'GOOGLE', one address belongs to exactly one account platform-wide, forever, until the row is released.

The handler looks the address up on that indexed column, and answers three ways:

The address is Response
Already linked to this account 200 — Wallet already registered. Nothing is written
Linked to another account 409 — the message above
Not linked at all 200 — a row is created

A fourth path exists and produces the same 409: two requests can both pass the lookup and only one INSERT wins, so a SequelizeUniqueConstraintError is caught and re-answered as 409 rather than as a 500.

The lookup used to key on (providerUserId, userId) — two columns against a one-column index — so a collision missed the find, hit ER_DUP_ENTRY on the create, and was rewritten as Internal server error. A 500 on connect meant a duplicate address. It now means a genuine fault.

The table

`id`             char(36)                  NOT NULL          -- uuid, PK
`userId`         char(36)                  NOT NULL          -- FK user.id, ON DELETE CASCADE
`providerUserId` varchar(255)              NOT NULL          -- the address, LOWERCASED on write
`provider`       enum('GOOGLE','WALLET')   NOT NULL
`isPrimary`      tinyint(1)  DEFAULT NULL  -- TRUE or NULL. NEVER FALSE
`chainId`        int(11)     DEFAULT NULL  -- EIP-155 chain the signature was proven on
`verifiedAt`     datetime    DEFAULT NULL  -- when the SIWE signature was verified
`createdAt` / `updatedAt` / `deletedAt`                      -- paranoid: soft delete
UNIQUE KEY `providerUserId` (`providerUserId`)
UNIQUE KEY `providerUserPrimaryPerProvider` (`userId`,`provider`,`isPrimary`)

Five properties drive everything an operator has to reason about here.

providerUserId is lowercased by the model's column setter, and only for values matching ^0x[0-9a-fA-F]{40}$ — a Google sub survives byte for byte. Write lowercase in SQL. The column inherits the table's utf8mb4_unicode_ci collation so a checksummed needle will still match, but application code compares lowercased strings in memory in several places and those comparisons are case-sensitive.

isPrimary is TRUE or NULL, never FALSE. MySQL allows unlimited NULLs in a unique index, so UNIQUE(userId, provider, isPrimary) is what enforces "at most one primary link per user per provider" in the database rather than in code. Writing FALSE into a second row collides with every other FALSE.

The unique index does not honour deletedAt. A soft-deleted row would block its address forever, which is why the unlink handler rewrites providerUserId to deleted:<row id> before soft-deleting. Any release you perform by hand must do the same.

Multiple wallets per account are allowed. Nothing caps the count, and any linked address signs the user in.

onDelete: CASCADE is on the foreign key, but user deletion is soft. See deleting a user.

user.walletAddress is a mirror with exactly one writer

user.walletAddress and user.walletProvider are populated — but not by anything you can reach.

Denormalised copy of the PRIMARY wallet link. Written only by the providerUser afterSave/afterDestroy hooks.

The providerUser model's afterSave hook writes the primary link's address into user.walletAddress and the literal string WALLETCONNECT into user.walletProvider. afterDestroy promotes the next-oldest surviving WALLET link to primary — which re-enters afterSave and rewrites the mirror — or clears both columns when the last link goes. Both hooks also delete the user:<id>:profile Redis key, best-effort.

Every other writer is refused. beforeUpdate and beforeBulkUpdate on the user model throw unless the caller passes context: { source: "providerUserMirror" }:

user.walletAddress is a mirror of providerUser and may only be changed by the SIWE link flow.

That is why walletAddress is in the admin CRM read schema and absent from userUpdateSchema — an admin cannot type one in, and an attempt through the API would be rejected by the model before it reached the column.

The mirror holds the primary link only. A user with two linked wallets has two provider_user rows and one user.walletAddress, so any count, export or segment built on the user column undercounts and, for the second address, reports the wrong one.

The guard is a Sequelize hook, not a database trigger, so a direct SQL UPDATE bypasses it silently. If you repair rows by hand you own keeping the mirror correct — the repair statement below is the one to run.

Before you touch SQL

Wallet sign-in matches the signed address against this table and issues the same session a password login would. Re-pointing a row to another userId gives the holder of that private key a working, permanent login to that account — no email loop, no password, no second factor beyond whatever the account already has.

Treat it exactly as you would treat resetting somebody's password on their behalf: prove control first, take a backup, do it in a transaction, and write down who asked.

user.walletAddress is also read as a payout target by the NFT Marketplace on-chain flows. A mirror you leave stale after a manual edit points a settlement at the wrong address.

Prove the customer controls the address

You cannot verify a signature by looking at the database, but you can make the platform do it for you. The signal is which error comes back.

  1. Ask them to attempt a wallet sign-in with the address in question, from the login modal's Sign in with wallet.

  2. Read the failure. 401 "Wallet address not recognized" means the signature verified and there is no row for that address — they control the key and the address is free. That is the green light. Any other failure is not: a 401 Signature verification failed proves nothing, and a 403 is the licence gate rather than the address.

  3. If they are already signed in another way, the cleaner proof is for them to link it themselves at /user/profile?tab=wallet. A 409 there tells you the address is taken and by then you know they hold the key.

  4. Only then consider a manual change — and only to release or move a row the customer has just proven they can sign for.

Sign-in and nonce both sit on the shared strict rate limiter — five requests per fifteen minutes per IP, and one complete attempt costs two of them. Do not ask a customer to "just try again a few times".

The SQL

Take a backup first. Every statement below assumes MySQL and a session you can roll back.

Read: who owns an address

SELECT u.id AS userId, u.email, u.status, u.deletedAt AS userDeletedAt,
       p.id AS linkId, p.isPrimary, p.chainId, p.verifiedAt, p.createdAt
FROM provider_user p
JOIN `user` u ON u.id = p.userId
WHERE p.provider = 'WALLET'
  AND p.providerUserId = LOWER('0xAbCdEf0123456789AbCdEf0123456789AbCdEf01')
  AND p.deletedAt IS NULL;

No row and the address is free. A row with a non-null userDeletedAt is the case in deleting a user.

SELECT p.id AS linkId, p.providerUserId, p.isPrimary, p.chainId,
       p.verifiedAt, p.createdAt, p.deletedAt
FROM provider_user p
JOIN `user` u ON u.id = p.userId
WHERE u.email = 'customer@example.com'
  AND p.provider = 'WALLET'
ORDER BY p.createdAt;

The mirror repair

Run this for every userId you touch, as the last step of any change. It re-derives user.walletAddress from whatever is primary, and clears it when nothing is.

UPDATE `user` u
LEFT JOIN provider_user p
       ON p.userId = u.id
      AND p.provider = 'WALLET'
      AND p.isPrimary = TRUE
      AND p.deletedAt IS NULL
SET u.walletAddress  = p.providerUserId,
    u.walletProvider = IF(p.id IS NULL, NULL, 'WALLETCONNECT')
WHERE u.id = '<user-uuid>';

Then clear the profile cache for that user, or the old address keeps being served from Redis: DEL user:<user-uuid>:profile.

Release an address

This is what the customer's own Disconnect Wallet button does. Do it by hand only when they cannot sign in to press it.

START TRANSACTION;

SET @link  := (SELECT id FROM provider_user
               WHERE provider = 'WALLET'
                 AND providerUserId = LOWER('0x…')
                 AND deletedAt IS NULL);
SET @owner := (SELECT userId FROM provider_user WHERE id = @link);

-- 1. Blank the address BEFORE soft-deleting. The unique index ignores
--    deletedAt, so leaving it in place locks the address permanently.
UPDATE provider_user
   SET providerUserId = CONCAT('deleted:', id),
       isPrimary      = NULL,
       deletedAt      = NOW(),
       updatedAt      = NOW()
 WHERE id = @link;

-- 2. Promote the next-oldest surviving link, if the user has one.
UPDATE provider_user
   SET isPrimary = TRUE, updatedAt = NOW()
 WHERE userId = @owner AND provider = 'WALLET' AND deletedAt IS NULL
 ORDER BY createdAt ASC
 LIMIT 1;

COMMIT;

Then run the mirror repair for @owner, and delete their profile cache key.

Only after the steps in prove the customer controls the address.

START TRANSACTION;

-- Clear the flag first: UNIQUE(userId, provider, isPrimary) will reject the
-- move if the destination account already has a primary WALLET link.
UPDATE provider_user SET isPrimary = NULL, updatedAt = NOW()
 WHERE id = '<link-id>';

UPDATE provider_user SET userId = '<destination-user-uuid>', updatedAt = NOW()
 WHERE id = '<link-id>';

-- If the destination has no other WALLET link, make this one primary.
UPDATE provider_user SET isPrimary = TRUE, updatedAt = NOW()
 WHERE id = '<link-id>'
   AND NOT EXISTS (
     SELECT 1 FROM (SELECT * FROM provider_user) x
      WHERE x.userId = '<destination-user-uuid>'
        AND x.provider = 'WALLET'
        AND x.isPrimary = TRUE
        AND x.deletedAt IS NULL
   );

COMMIT;

Run the mirror repair and the cache delete for both user ids — the one that lost the link and the one that gained it.

The safer alternative, whenever the customer can still sign in: have them unlink it from /user/profile?tab=wallet and have the new owner link it from theirs. Two clicks, both hook-driven, no mirror to repair.

Deleting a user does not release their address

The admin Delete action on Admin → CRM → Users is a soft delete — the user model is paranoid, so handleSingleDelete sets deletedAt and the database-level ON DELETE CASCADE never fires. The provider_user row survives with a live providerUserId, so the address stays locked to a deleted account and nobody can ever link it again.

The row action that does release it is Permanent delete (the same delete.user permission, plus a typed confirmation, and the backend additionally refuses anyone who is not a Super Admin). That issues ?force=true, the row is really removed, and the cascade takes the provider_user rows with it.

It destroys the account, not the link. Use the release statement above, which removes one credential and leaves the customer's record, balances and history intact.

The licence gate disables sign-in for everyone

ensureWalletConnectAvailable() runs on the nonce endpoint, on wallet sign-in and on profile connect. It checks two things:

  1. The extension cache holds wallet_connect. That cache only ever contains rows with status = true and is cleared when an admin toggles an extension, so the switch on /admin/system/extension takes effect without a restart. Missing → 403 "Wallet authentication is not enabled on this platform".

  2. The file lic/37548018.lic exists in the project root — <root>/lic/<the extension row's productId>.lic, resolved by stripping a trailing backend from the process's working directory. Missing → 403 "Wallet Connect extension license is not activated".

Wallet Connect has no route prefix of its own — its endpoints live under /api/auth and /api/user/profile, both exempt from the route-prefix licence gate so that login survives a core licence re-activation. The check therefore runs inside the handlers, and it reads the disk on every call.

A deploy, a container rebuild or a restore that does not carry the lic/ directory across leaves the extension reading Enabled on the admin screen while every wallet sign-in on the platform returns 403. Nothing on any dashboard reports it. Add lic/ to whatever you copy between releases, and after any migration confirm with an unauthenticated GET /api/auth/login/nonce — a 32-hex string means the gate is open, a 403 means it is not.

Unlinking is deliberately outside the gate. POST /api/user/profile/wallet/disconnect never calls ensureWalletConnectAvailable(), so a user can always detach an address from their account even on an install where the addon is switched off or the licence has lapsed.

The unlink handler reads one settings key:

When on, unlinking requires a fresh SIWE signature over the address being removed. Read via CacheManager.getSettingBool with a hard-coded default of false.

There is no field for it on any settings screen and no seeded row, so it is off on every install. Turning it on means inserting the row into the settings table yourself, and settings are cached in two layers — a hand-written row is not seen until the cache is cleared, which saving anything on Admin → System → Settings does, and so does a backend restart.

With it on, the disconnect body must carry message and signature alongside address, the signed address must equal the address being unlinked (401 "Signature does not match the address being unlinked."), and the nonce is consumed the same way connect consumes it. It is defence in depth — the route is already session- and CSRF-scoped — and it costs the customer a wallet prompt to remove their own credential. Leave it off unless you have a reason.

What you cannot do from any screen

  • See who has a wallet linked, or search users by address.
  • Link, unlink or re-point a wallet on a customer's behalf.
  • Force a specific link to be the primary one.
  • Prevent a particular address from being linked, or ban one.
  • Disable wallet sign-in while leaving linking on, or the reverse — the extension toggle governs both.

Everything on that list is a database operation today. If you find yourself doing any of them more than occasionally, that is the signal to reconsider the process, not to write a faster query.