Files
zeekay bd850a1b38 feat: complete 5-chain verifier core (TS) + Go port + IAM plan
TS verifiers: EVM, Solana, TON (ton_proof), XRP (secp256k1+ed25519), Bitcoin
(legacy + BIP-322 simple P2WPKH/P2TR) — 61 tests green, typecheck clean.

Go port (go/walletconnect): EVM + Solana via github.com/luxfi/crypto (NO
go-ethereum/ava-labs), TON/XRP/Bitcoin stubs fail-closed — 23 tests green.

IAM integration plan + drafts (integrations/iam/): canonical /v1/iam/web3/
{nonce,verify}, single-use nonce store, multi-wallet WalletLink. Found current
IAM web3 login verifies NO signature (security hole this closes).
2026-06-22 22:47:36 -07:00

159 lines
5.5 KiB
Plaintext

// DRAFT — destination: github.com/hanzoai/iam/object/web3_store.go
//
// Persistence for multi-chain wallet login:
// - Web3Nonce : single-use, expiring login challenges (anti-replay).
// - WalletLink: the (chain,address) -> user mapping (one user, N wallets).
//
// Register both in object/ormer table sync (the slice passed to
// ormer.Engine.Sync2(...) / the AutoMigrate list). DO NOT apply yet.
package object
import (
"time"
"github.com/hanzoai/iam/util"
)
// ----------------------------------------------------------------------------
// Web3Nonce — single-use login nonce.
//
// Minted by GET /v1/iam/web3/nonce, burned by POST /v1/iam/web3/verify. The burn
// is a conditional UPDATE (used=false -> used=true) so two concurrent verifies of
// the same captured proof cannot both win: exactly one UPDATE affects a row.
// ----------------------------------------------------------------------------
type Web3Nonce struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"` // the nonce string itself
Chain string `xorm:"varchar(20)" json:"chain"`
Address string `xorm:"varchar(128)" json:"address"`
Domain string `xorm:"varchar(128)" json:"domain"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
ExpireTime string `xorm:"varchar(100)" json:"expireTime"`
Used bool `xorm:"bool" json:"used"`
}
func AddWeb3Nonce(n *Web3Nonce) (bool, error) {
affected, err := ormer.Engine.Insert(n)
if err != nil {
return false, err
}
return affected != 0, nil
}
// BurnWeb3Nonce atomically consumes a nonce. Returns the row iff it existed, was
// unused, and is unexpired; otherwise (nil, nil). The conditional UPDATE is the
// replay guard — never split "read then write".
func BurnWeb3Nonce(nonce string) (*Web3Nonce, error) {
rec := &Web3Nonce{}
existed, err := ormer.Engine.Where("name = ?", nonce).Get(rec)
if err != nil {
return nil, err
}
if !existed || rec.Used {
return nil, nil
}
if exp, err := time.Parse(time.RFC3339, rec.ExpireTime); err == nil {
if time.Now().After(exp) {
return nil, nil
}
}
// Conditional, single-row UPDATE: used=false -> true. affected==0 means
// another request already burned it (lost the race) -> treat as invalid.
affected, err := ormer.Engine.
Where("owner = ? AND name = ? AND used = ?", rec.Owner, rec.Name, false).
Cols("used").
Update(&Web3Nonce{Used: true})
if err != nil {
return nil, err
}
if affected == 0 {
return nil, nil
}
rec.Used = true
return rec, nil
}
// PurgeExpiredWeb3Nonces is a janitor for a periodic task (optional).
func PurgeExpiredWeb3Nonces() (int64, error) {
return ormer.Engine.Where("expire_time < ?", time.Now().Format(time.RFC3339)).Delete(&Web3Nonce{})
}
// ----------------------------------------------------------------------------
// WalletLink — the (chain,address) -> user binding.
//
// One user can own many links (EVM + Solana + BTC + TON + XRP, and several
// addresses per chain). The (chain,address) pair is globally unique, so a given
// wallet can be bound to at most one identity — this is what stops a wallet from
// hijacking someone else's account.
//
// Why a side table and not user columns: the legacy model stored one address in
// one varchar per provider (user.web3onboard). That cannot represent N wallets on
// M chains. Values, not places: a link is its own value; the user is referenced,
// not mutated.
// ----------------------------------------------------------------------------
type WalletLink struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"` // organization
User string `xorm:"varchar(100) notnull pk" json:"user"` // user.Name
Chain string `xorm:"varchar(20) notnull pk" json:"chain"`
Address string `xorm:"varchar(128) notnull pk index" json:"address"`
Scheme string `xorm:"varchar(40)" json:"scheme"`
PublicKey string `xorm:"varchar(256)" json:"publicKey"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
}
// GetWalletLink resolves a verified (chain,address) to its link row, scoped to the
// application's organization. Returns (nil, nil) when no wallet is on file.
//
// NOTE: address is compared as the verifier canonicalizes it. For EVM the verifier
// returns a lowercased 0x address, so store + look up lowercased (do the
// ToLower in the caller or here) to keep EVM case-insensitive.
func GetWalletLink(organization, chain, address string) (*WalletLink, error) {
if chain == "" || address == "" {
return nil, nil
}
link := &WalletLink{}
existed, err := ormer.Engine.
Where("owner = ? AND chain = ? AND address = ?", organization, chain, address).
Get(link)
if err != nil {
return nil, err
}
if !existed {
return nil, nil
}
return link, nil
}
func AddWalletLink(link *WalletLink) (bool, error) {
if link.CreatedTime == "" {
link.CreatedTime = util.GetCurrentTime()
}
affected, err := ormer.Engine.Insert(link)
if err != nil {
return false, err
}
return affected != 0, nil
}
func DeleteWalletLink(link *WalletLink) (bool, error) {
affected, err := ormer.Engine.
Where("owner = ? AND user = ? AND chain = ? AND address = ?",
link.Owner, link.User, link.Chain, link.Address).
Delete(&WalletLink{})
if err != nil {
return false, err
}
return affected != 0, nil
}
// GetWalletLinksByUser lists every wallet a user has linked (for account UI).
func GetWalletLinksByUser(owner, user string) ([]*WalletLink, error) {
links := []*WalletLink{}
err := ormer.Engine.Where("owner = ? AND user = ?", owner, user).Find(&links)
return links, err
}