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.
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.
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
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.
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.
-
Ask them to attempt a wallet sign-in with the address in question, from the login modal's Sign in with wallet.
-
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. -
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. -
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.
Move a link to another account
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:
-
The extension cache holds
wallet_connect. That cache only ever contains rows withstatus = trueand is cleared when an admin toggles an extension, so the switch on/admin/system/extensiontakes effect without a restart. Missing → 403 "Wallet authentication is not enabled on this platform". -
The file
lic/37548018.licexists in the project root —<root>/lic/<the extension row's productId>.lic, resolved by stripping a trailingbackendfrom 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.
An undocumented setting: requiring a signature to unlink
The unlink handler reads one settings key:
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.
Related
- Linking a wallet — the customer-side flow the row comes from.
- The sign-in flow — what a linked row is checked against.
- SIWE message validation — every 400, 401 and 500 string the link and login endpoints emit.
- Wallets, chains and endpoints.