跳至主要内容

POST /users/me/wallet — 已登入 Twitch 使用者綁定 MetaMask

狀態:待實作

背景

目前 POST /auth/web3/nonce + POST /auth/web3/verify 是「用錢包登入」的流程。已用 Twitch 登入的使用者沒有辦法把 MetaMask 錢包綁到自己的帳號,導致 ClaimService.Claim() 呼叫 resolveWalletAddress() 時找不到 provider='web3' 的 AuthProvider,回傳 ErrClaimWalletNotLinked

Demo 階段的 wallet_linker helper 已在 #243 移除,正式綁定流程需補上。


設計決策

問題決策
每個 user 幾個錢包?只允許一個;綁新的自動取代舊的
nonce 端點複用現有 POST /auth/web3/nonce(public),binding 本身需 JWT
舊 web3 row 處理方式Soft delete(保留歷史),restore 而非 insert 若同 user 重綁同一地址
SIWE helper 位置抽到 services/siwe.go,package-level unexported,auth_serviceuser_service 共用

API 規格

前置:取 nonce(現有端點,不動)

POST /api/v1/auth/web3/nonce
Content-Type: application/json

{"address": "0xAbCd..."}

→ 200 {"success":true,"data":{"nonce":"<hex64>"}}

新端點:綁定錢包

POST /api/v1/users/me/wallet
Authorization: Bearer <access_token>
Content-Type: application/json

{
"address": "0xAbCd...", // EIP-55 or lowercase, 後端統一 checksum
"nonce": "<hex64>",
"signature": "0x<hex130>"
}

→ 200 {"success":true,"data":{"address":"0xAbCd..."}} // checksummed

Error mapping

情況HTTPerror 欄位
address 格式不合法400invalid wallet address
nonce 不存在或過期401invalid or expired nonce
簽名驗證失敗401invalid wallet signature
address 已被其他 user 綁定409wallet already linked to another account
DB 錯誤500internal server error

資料庫變更

migration 014:將全域 unique index 改為 partial unique index

現有 001_init.sql 有:

UNIQUE (provider, provider_id)

Soft delete 後舊 row 的 deleted_at IS NOT NULL,insert 新 row 時會踩到這個 constraint。需新增 migration:

-- backend/migrations/014_auth_provider_partial_unique.sql

-- 移除全域 unique constraint
ALTER TABLE auth_providers DROP CONSTRAINT IF EXISTS auth_providers_provider_provider_id_key;

-- 改為 partial unique index(只約束 active row)
CREATE UNIQUE INDEX IF NOT EXISTS
idx_auth_providers_provider_provider_id_active
ON auth_providers(provider, provider_id)
WHERE deleted_at IS NULL;

PostgreSQL 支援 partial index;專案其他 migration 已有先例。

實作前確認 DB 中沒有重複的 active (provider, provider_id),否則 CREATE UNIQUE INDEX 會失敗。 不要使用 CONCURRENTLY(migration runner 若包 transaction 會報錯;目前 spec 未用,OK)。

SQLite(測試環境)

SQLite 支援 partial unique index(WHERE clause)。backend/internal/services/testutil_test.go 已有先例(idx_watch_sessions_active_user_channel)。測試 helper 應同步新增:

CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_providers_provider_provider_id_active
ON auth_providers (provider, provider_id)
WHERE deleted_at IS NULL;

Implementation Checklist

  • 新增 backend/migrations/014_auth_provider_partial_unique.sql
  • 更新 SQLite test helper schema(補同等 partial unique index)
  • 新增 services/siwe.go,搬移 siweMessage / verifyEthSignature
  • 修改 services/auth_service.go,移除原 helper 定義,保留呼叫
  • 實作 UserService.LinkWallet
  • 更新 backend/internal/handlers/swagger_types.go,新增 WalletResponse
  • 實作 UserHandler.LinkWallet
  • 更新 router/router.go
  • services/user_service_test.go test cases
  • handlers/user_handler_test.go test cases

新增 / 修改物件清單

0. backend/migrations/014_auth_provider_partial_unique.sql(新增)

ALTER TABLE auth_providers
DROP CONSTRAINT IF EXISTS auth_providers_provider_provider_id_key;

CREATE UNIQUE INDEX IF NOT EXISTS
idx_auth_providers_provider_provider_id_active
ON auth_providers(provider, provider_id)
WHERE deleted_at IS NULL;
  • 執行前確認 DB 中無重複 active (provider, provider_id),否則 index 建立失敗
  • 不使用 CONCURRENTLY

test helper(services/testutil_test.go)同步新增:

CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_providers_provider_provider_id_active
ON auth_providers (provider, provider_id)
WHERE deleted_at IS NULL;

1a. backend/internal/services/siwe.go(新增)

Package-level unexported helpers,auth_service.gouser_service.go 同在 services package,可直接呼叫。

func siweMessage(address, nonce string) string
func verifyEthSignature(message, sigHex, expectedAddress string) bool

1b. backend/internal/services/auth_service.go(修改)

  • 移除 siweMessageverifyEthSignature 的原始定義(Go build 不允許同 package 重複定義,移除前先確認 siwe.go 已建立)
  • Web3Verify() 內的呼叫不變(同 package,直接可用)
  • 移除因此產生的 unused imports;預期至少檢查 github.com/ethereum/go-ethereum/cryptofmtencoding/hex 在其他函式仍有使用,不會被移除)

2. backend/internal/services/user_service.go(新增方法)

type LinkWalletInput struct {
Address string `json:"address" binding:"required"`
Nonce string `json:"nonce" binding:"required"`
Signature string `json:"signature" binding:"required"`
}

func (s *UserService) LinkWallet(userID uuid.UUID, input LinkWalletInput) (string, error)

LinkWallet 內部流程(transaction 外 → transaction 內):

[transaction 外]
1. common.IsHexAddress(input.Address) → false → ErrInvalidWalletAddress
2. checksumAddr = common.HexToAddress(input.Address).Hex()
3. lookupAddr = strings.ToLower(checksumAddr) // canonical: checksum 後再 lower
4. db.Where("nonce=? AND address=?", input.Nonce, lookupAddr).First(&nonceRecord)
→ not found or expired → ErrInvalidNonce
5. msg = siweMessage(lookupAddr, input.Nonce)
verifyEthSignature(msg, input.Signature, lookupAddr) → false → ErrInvalidSignature

[BEGIN TRANSACTION]
6. result = db.Where("nonce=? AND address=?", ...).Delete(&Web3Nonce{})
result.RowsAffected != 1 → ErrInvalidNonce // 防止並發重放
7. db.Where("provider='web3' AND provider_id=? AND deleted_at IS NULL AND user_id != ?",
checksumAddr, userID).
Count(&count)
→ count > 0 → ErrProviderLinked

8. db.Where("user_id=? AND provider='web3' AND deleted_at IS NULL", userID).
Update("deleted_at", now) // soft delete 目前 active web3 row(若有,含同 address)

9. db.Unscoped().
Where("user_id=? AND provider='web3' AND provider_id=?", userID, checksumAddr).
First(&ap)
// step 8 之後,若找到此 row,它一定是 soft-deleted 狀態(剛被 soft delete 或更早前的)
→ 找到 → db.Unscoped().Model(&ap).Update("deleted_at", nil) // restore
→ 找不到 → db.Create(&AuthProvider{provider:'web3', provider_id: checksumAddr})
// Create / restore 若遇到 partial unique index 衝突(race),轉 ErrProviderLinked → 409

[COMMIT]
10. return checksumAddr, nil

新增 sentinel errors(在 user_service.go 宣告):

ErrInvalidWalletAddress = errors.New("invalid wallet address")

沿用現有:ErrInvalidNonceErrInvalidSignatureErrProviderLinked(已在 auth_service.go,同 package 可直接用)

3. backend/internal/handlers/swagger_types.go(修改)

新增:

type WalletResponse struct {
Address string `json:"address"`
}

UserResponseProvidersResponse 等現有 swagger type 放在同一檔案。

4. backend/internal/handlers/user_handler.go(新增方法)

// LinkWallet godoc
// @Summary Bind a MetaMask wallet address to the current user
// @Tags users
// @Accept json
// @Produce json
// @Param body body services.LinkWalletInput true "address + nonce + signature"
// @Success 200 {object} Response{data=WalletResponse}
// @Failure 400 {object} Response
// @Failure 401 {object} Response
// @Failure 409 {object} Response
// @Security BearerAuth
// @Router /users/me/wallet [post]
func (h *UserHandler) LinkWallet(c *gin.Context)
  • MustClaims(c) 取 userID
  • 呼叫 userSvc.LinkWallet(userID, input)
  • error switch:
    • ErrInvalidWalletAddressbadRequest
    • ErrInvalidNonceunauthorized
    • ErrInvalidSignatureunauthorized
    • ErrProviderLinkedconflict("wallet already linked to another account")
    • default → internal

5. backend/internal/router/router.go(修改)

protected group 新增:

protected.POST("users/me/wallet", userH.LinkWallet)

測試規格

services/user_service_test.go(新增 test cases)

Case預期結果
合法 address + nonce + 簽名,首次綁定成功,AuthProvider insert,checksumAddr 回傳
合法,已有 active web3 row → 取代舊 row soft deleted,新 row insert
同 user 重綁同一地址(soft-deleted row 存在)restore deleted_at = NULL,不 insert 新 row
nonce 不存在ErrInvalidNonce
nonce 已過期ErrInvalidNonce
成功綁定後重用同一 nonceErrInvalidNonce(nonce 已被 consume)
簽名錯誤ErrInvalidSignature
address 已被其他 user 綁定ErrProviderLinked
Create / restore 遇 active unique index 衝突(race)ErrProviderLinked(不掉到 500)
address 格式不合法ErrInvalidWalletAddress

handlers/user_handler_test.go(新增 test cases)

HTTP 層測試,驗證 status code 與 response body,不重複 service 邏輯。


本票明確不做

  • GET /users/me/wallet(查詢已綁錢包)
  • 解綁走現有 DELETE /auth/providers/web3,不改動
  • 前端(tachimint / dashboard)串接
  • 不修改 ClaimService.resolveWalletAddress()
  • 不改 POST /auth/web3/noncePOST /auth/web3/verify