Files
simplex-chat/apps/simplex-store-service/src/StoreService/Names/Store.hs
T

958 lines
40 KiB
Haskell

{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeOperators #-}
module StoreService.Names.Store
( NamePhase (..),
RegistrationPayment (..),
NameReveal (..),
NameRegistration (..),
NewRegistration (..),
PaidOrder (..),
RegistrationRefused (..),
VersionWrite (..),
LiveOrder (..),
OrderTerms (..),
PrepaidCode (..),
NewPrepaidCode (..),
CodeHashTaken (..),
TxFor (..),
TxStatus (..),
NameTransaction (..),
NewTransaction (..),
RecordChange (..),
EditState (..),
RecordEdit (..),
NewRecordEdit (..),
findLimit,
orderHash,
orderLive,
newRegistrationId,
methodText,
textMethod,
createPaidRegistration,
createCodeRegistration,
getRegistration,
getRegistrationByOrderHash,
getLiveOrders,
closeOverdue,
updateRegistration,
retargetRegistration,
findRegistrations,
isLabelRegistering,
activeRegistrations,
unpaidCleanupCandidates,
deleteUnpaidRegistration,
failedCodeOrdersBefore,
insertPrepaidCode,
getPrepaidCode,
getPrepaidCodeById,
reservePrepaidCode,
releasePrepaidCode,
spendPrepaidCode,
revokePrepaidCode,
insertTransaction,
recordTransactionOutcome,
pendingTransactions,
registrationTransactions,
editTransactions,
insertRecordEdit,
getRecordEdit,
getRecordEditByBody,
getRecordEditByNonce,
settleRecordEdit,
relayedEditTimes,
)
where
import qualified Control.Exception as E
import Control.Monad (when)
import Crypto.Number.Serialize (i2ospOf_)
import Data.ByteArray.Encoding (Base (Base16), convertToBase)
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.Int (Int64)
import Data.Maybe (isJust, listToMaybe)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (UTCTime)
import Data.Word (Word64)
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), InvoiceId (..), InvoiceStatus (..), ServicePaymentMethod (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), FromField (..), ToField (..), fromTextField_)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Crypto (sha256Hash)
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature)
import Simplex.Messaging.Encoding.String (TextEncoding (..), strDecode)
import Simplex.Messaging.Eth.Address (Address, unAddress)
import Simplex.Messaging.Eth.EIP712 (serializeSignature)
import Simplex.Messaging.SimplexName (LabelHash (..))
import Simplex.Messaging.Util (maybeFirstRow, tshow)
import StoreService.Names.Abi (hex)
import StoreService.Names.Chain (TxHash (..))
import StoreService.Names.Phases (RegistrationId (..))
import StoreService.Names.Signatures (OrderFields (..), parseSignature)
import StoreService.Store (RevokeResult (..))
import StoreService.Store.Invoices (StoreDecodeError (..), executeChanging, invoiceStatusText, lockInvoice, newInvoiceId, truncateToSecond, unfundedQuery)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
import Database.SQLite.Simple.QQ (sql)
#endif
data NamePhase
= PUnpaid
| PQueued
| PCommitting
| PCommitted
| PRegistering
| PRegistered
| PTaken
| PFailed
| PCodeIssued
deriving (Eq, Show, Enum, Bounded)
instance TextEncoding NamePhase where
textEncode = \case
PUnpaid -> "unpaid"
PQueued -> "queued"
PCommitting -> "committing"
PCommitted -> "committed"
PRegistering -> "registering"
PRegistered -> "registered"
PTaken -> "taken"
PFailed -> "failed"
PCodeIssued -> "code_issued"
textDecode t = lookup t [(textEncode p, p) | p <- [minBound .. maxBound]]
instance FromField NamePhase where fromField = fromTextField_ textDecode
instance ToField NamePhase where toField = toField . textEncode
data RegistrationPayment
= RPInvoice ServicePaymentMethod InvoiceId
| RPCode Int64
deriving (Eq, Show)
-- | The name, links and owner's intent, all set by an accepted reveal and cleared together.
data NameReveal = NameReveal
{ rvLabel :: Text,
rvSecret :: ByteString,
rvContactLink :: Maybe Text,
rvChannelLink :: Maybe Text,
rvNonce :: Word64,
rvDeadline :: Word64,
rvSignature :: RecoverableSignature
}
deriving (Eq, Show)
data NameRegistration = NameRegistration
{ nrId :: RegistrationId,
nrVersion :: Int64,
nrLabelHash :: LabelHash,
nrLength :: Int,
nrDuration :: Word64,
nrOwner :: Address,
nrCommitment :: ByteString,
nrSealed :: Text,
nrPhase :: NamePhase,
nrYears :: Int,
nrPrice :: Int,
nrPayment :: RegistrationPayment,
nrReveal :: Maybe NameReveal,
nrCommitTx :: Maybe TxHash,
nrCommitBlock :: Maybe Word64,
nrRegisterTx :: Maybe TxHash,
nrRegisterBlock :: Maybe Word64,
nrRevealAfter :: Maybe Word64,
nrRevealBefore :: Maybe Word64,
nrExpiresAt :: Maybe Word64,
nrIssuedCodeId :: Maybe Int64,
nrRegisteredAt :: Maybe UTCTime,
nrFailedAt :: Maybe UTCTime,
nrError :: Maybe Text,
nrLive :: Bool,
nrOrderHash :: ByteString,
nrMoves :: Int,
nrLinksRefusedAt :: Maybe UTCTime,
nrCreatedAt :: UTCTime,
nrUpdatedAt :: UTCTime
}
deriving (Eq, Show)
data NewRegistration = NewRegistration
{ nrgId :: RegistrationId,
nrgOwner :: Address,
nrgOrder :: OrderFields,
nrgYears :: Int
}
-- | The invoice row is inserted by the caller, in the same transaction and before the registration.
data PaidOrder = PaidOrder
{ poMethod :: ServicePaymentMethod,
poPrice :: Int,
poInvoiceId :: InvoiceId,
poProviderRef :: Text
}
data RegistrationRefused = RRLiveOrder | RRCodeNotReservable
deriving (Eq, Show)
-- | NotLive: the write would make a closed order live. LiveOrderExists: another live order holds its commitment, or its owner and label hash.
-- NotOverdue: closeOverdue found the order's invoice no longer open, or holding money.
data VersionWrite = Written Int64 | VersionChanged | NotLive | LiveOrderExists | NotOverdue
deriving (Eq, Show)
-- | A live order an order request meets; overdue when its open invoice is past its expiry time.
data LiveOrder = LiveOrder
{ loRegistration :: NameRegistration,
loOverdue :: Bool
}
deriving (Eq, Show)
-- | What an order pays with, as its hash covers it.
data OrderTerms = OTInvoice ServicePaymentMethod Int | OTCode ByteString
data PrepaidCode = PrepaidCode
{ pcId :: Int64,
pcCodeHash :: ByteString,
pcMinLength :: Int,
pcYears :: Int,
pcPaymentStatus :: BadgeCodePaymentStatus,
pcReservedBy :: Maybe RegistrationId,
pcReservedAt :: Maybe UTCTime,
pcSpentAt :: Maybe UTCTime,
pcRevokedAt :: Maybe UTCTime,
pcExpiresAt :: Maybe UTCTime,
pcCreatedAt :: UTCTime
}
deriving (Eq, Show)
data NewPrepaidCode = NewPrepaidCode
{ npcCodeHash :: ByteString,
npcMinLength :: Int,
npcYears :: Int,
npcPaymentStatus :: BadgeCodePaymentStatus,
npcExpiresAt :: Maybe UTCTime
}
data CodeHashTaken = CodeHashTaken
deriving (Eq, Show)
data TxFor = TxCommit RegistrationId | TxRegister RegistrationId | TxEdit Int64
deriving (Eq, Show)
-- | A dropped transaction has no receipt and its registrar nonce is used.
data TxStatus = TSPending | TSMined | TSReverted | TSDropped
deriving (Eq, Show, Enum, Bounded)
instance TextEncoding TxStatus where
textEncode = \case
TSPending -> "pending"
TSMined -> "mined"
TSReverted -> "reverted"
TSDropped -> "dropped"
textDecode t = lookup t [(textEncode s, s) | s <- [minBound .. maxBound]]
instance FromField TxStatus where fromField = fromTextField_ textDecode
instance ToField TxStatus where toField = toField . textEncode
data NameTransaction = NameTransaction
{ ntId :: Int64,
ntFor :: TxFor,
ntRaw :: ByteString,
ntHash :: TxHash,
ntNonce :: Word64,
ntSentAt :: UTCTime,
ntMinedBlock :: Maybe Word64,
ntStatus :: TxStatus
}
deriving (Eq, Show)
data NewTransaction = NewTransaction
{ ntxRaw :: ByteString,
ntxHash :: TxHash,
ntxNonce :: Word64
}
-- | A text record set to a value, "" removing it, or every record cleared.
data RecordChange = RCSetText Text Text | RCClear
deriving (Eq, Show)
data EditState = ESPending | ESMined (Maybe TxHash) | ESReverted (Maybe Text)
deriving (Eq, Show)
data RecordEdit = RecordEdit
{ reId :: Int64,
reLabelHash :: LabelHash,
reOwner :: Address,
reNonce :: Word64,
reChange :: RecordChange,
reDeadline :: Word64,
reSignature :: RecoverableSignature,
reBodyHash :: ByteString,
reState :: EditState,
reCreatedAt :: UTCTime
}
deriving (Eq, Show)
data NewRecordEdit = NewRecordEdit
{ nreLabelHash :: LabelHash,
nreOwner :: Address,
nreNonce :: Word64,
nreChange :: RecordChange,
nreDeadline :: Word64,
nreSignature :: RecoverableSignature,
nreBodyHash :: ByteString
}
findLimit :: Int
findLimit = 50
initialVersion :: Int64
initialVersion = 1
orderHashFieldBytes :: Int
orderHashFieldBytes = 4
-- | Each field is length-prefixed, so no two different orders encode the same.
orderHash :: Address -> OrderFields -> OrderTerms -> ByteString
orderHash owner OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed} terms =
sha256Hash . B.concat . map field $ [tag, lh, num ofLength, num ofDuration, unAddress owner, ofCommitment, encodeUtf8 ofSealed] <> payment
where
(tag, payment) = case terms of
OTInvoice method price -> ("name-invoice", [encodeUtf8 $ methodText method, num price])
OTCode codeHash -> ("name-code", [codeHash])
field bs = i2ospOf_ orderHashFieldBytes (toInteger $ B.length bs) <> bs
num :: Show a => a -> ByteString
num = encodeUtf8 . tshow
newRegistrationId :: IO RegistrationId
newRegistrationId = (\(InvoiceId t) -> RegistrationId t) <$> newInvoiceId
methodText :: ServicePaymentMethod -> Text
methodText = \case
SPMCard CPStripe -> "card"
SPMCrypto CCBtc -> "btc"
SPMCrypto CCXmr -> "xmr"
textMethod :: Text -> Maybe ServicePaymentMethod
textMethod t = lookup t [(methodText m, m) | m <- [SPMCard CPStripe, SPMCrypto CCBtc, SPMCrypto CCXmr]]
createPaidRegistration :: DB.Connection -> NewRegistration -> PaidOrder -> UTCTime -> IO (Either RegistrationRefused ())
createPaidRegistration db new@NewRegistration {nrgId = RegistrationId rid, nrgOwner, nrgOrder} PaidOrder {poMethod, poPrice, poInvoiceId = InvoiceId invId, poProviderRef} now' = do
let now = truncateToSecond now'
hash = orderHash nrgOwner nrgOrder (OTInvoice poMethod poPrice)
inserted <- insertRegistration db new PUnpaid poPrice (Just poMethod) Nothing hash now
when inserted $
DB.execute
db
"INSERT INTO sx_badge_service_name_invoices (invoice_id, registration_id, provider_ref, created_at) VALUES (?,?,?,?)"
(invId, rid, poProviderRef, now)
pure $ if inserted then Right () else Left RRLiveOrder
-- | Reserving the code is the conditional update of the transaction that creates the registration.
createCodeRegistration :: DB.Connection -> NewRegistration -> PrepaidCode -> UTCTime -> IO (Either RegistrationRefused ())
createCodeRegistration db new@NewRegistration {nrgId = regId@(RegistrationId rid), nrgOwner, nrgOrder} PrepaidCode {pcId, pcCodeHash} now' = do
let now = truncateToSecond now'
inserted <- insertRegistration db new PQueued 0 Nothing (Just pcId) (orderHash nrgOwner nrgOrder (OTCode pcCodeHash)) now
if not inserted
then pure $ Left RRLiveOrder
else
reservePrepaidCode db pcId regId now >>= \case
True -> pure $ Right ()
False -> do
DB.execute db "DELETE FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid)
pure $ Left RRCodeNotReservable
-- | False when the registration would be a second live one for its commitment, or for its owner and label hash.
insertRegistration :: DB.Connection -> NewRegistration -> NamePhase -> Int -> Maybe ServicePaymentMethod -> Maybe Int64 -> ByteString -> UTCTime -> IO Bool
insertRegistration db NewRegistration {nrgId = RegistrationId rid, nrgOwner, nrgOrder, nrgYears} phase price method codeId hash now =
(> 0)
<$> executeChanging
db
[sql|
INSERT INTO sx_badge_service_name_registrations
(registration_id, version, label_hash, length, duration, owner, commitment, sealed, phase, years,
price, method, name_prepaid_code_id, live, order_hash, moves, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?, ?,?,?,?,?,?,?,?)
ON CONFLICT DO NOTHING
|]
( (rid, initialVersion, Binary lh, ofLength, int64 ofDuration, Binary $ unAddress nrgOwner, Binary ofCommitment, ofSealed, phase, nrgYears)
:. (price, methodText <$> method, codeId, BI True, Binary hash, 0 :: Int, now, now)
)
where
OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed} = nrgOrder
registrationColumns :: Query
registrationColumns =
[sql|
SELECT r.registration_id, r.version, r.label_hash, r.length, r.duration, r.owner, r.commitment, r.sealed, r.phase, r.years,
r.price, r.method, ni.invoice_id, r.name_prepaid_code_id, r.label, r.secret, r.contact_link, r.channel_link, r.intent_nonce, r.intent_deadline,
r.intent_signature, r.commit_tx, r.commit_block, r.register_tx, r.register_block, r.reveal_after, r.reveal_before, r.expires_at, r.issued_code_id, r.registered_at,
r.failed_at, r.error, r.live, r.order_hash, r.moves, r.links_refused_at, r.created_at, r.updated_at
|]
registrationFrom :: Query
registrationFrom =
" "
<> [sql|
FROM sx_badge_service_name_registrations r
LEFT JOIN sx_badge_service_name_invoices ni ON ni.registration_id = r.registration_id
|]
selectRegistrations :: Query
selectRegistrations = registrationColumns <> registrationFrom
type RegistrationRow =
(Text, Int64, Binary ByteString, Int, Int64, Binary ByteString, Binary ByteString, Text, NamePhase, Int)
:. (Int, Maybe Text, Maybe Text, Maybe Int64, Maybe Text, Maybe (Binary ByteString), Maybe Text, Maybe Text, Maybe Int64, Maybe Int64)
:. (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int64, Maybe (Binary ByteString), Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime)
:. (Maybe UTCTime, Maybe Text, BoolInt, Binary ByteString, Int, Maybe UTCTime, UTCTime, UTCTime)
toRegistration :: RegistrationRow -> Either Text NameRegistration
toRegistration
( (rid, nrVersion, Binary lh, nrLength, duration, Binary owner, Binary nrCommitment, nrSealed, nrPhase, nrYears)
:. (nrPrice, method_, invoiceId_, codeId_, label_, secret_, nrContactLink, nrChannelLink, nonce_, deadline_)
:. (signature_, commitTx, commitBlock, registerTx, registerBlock, revealAfter, revealBefore, expiresAt, nrIssuedCodeId, nrRegisteredAt)
:. (nrFailedAt, nrError, BI nrLive, Binary nrOrderHash, nrMoves, nrLinksRefusedAt, nrCreatedAt, nrUpdatedAt)
) = do
nrOwner <- decodeAddress owner
nrPayment <- case (method_, invoiceId_, codeId_) of
(Just m, Just invId, Nothing) -> (`RPInvoice` InvoiceId invId) <$> note ("name_registrations.method: " <> m) (textMethod m)
(Nothing, Nothing, Just codeId) -> Right $ RPCode codeId
_ -> Left $ "name_registrations payment: " <> rid
nrReveal <- case (label_, secret_, nonce_, deadline_, signature_) of
(Nothing, Nothing, Nothing, Nothing, Nothing) -> Right Nothing
(Just rvLabel, Just (Binary rvSecret), Just nonce, Just deadline, Just (Binary sig)) -> do
rvSignature <- decodeSignature sig
Right $ Just NameReveal {rvLabel, rvSecret, rvContactLink = nrContactLink, rvChannelLink = nrChannelLink, rvNonce = word64 nonce, rvDeadline = word64 deadline, rvSignature}
_ -> Left $ "name_registrations reveal: " <> rid
pure
NameRegistration
{ nrId = RegistrationId rid,
nrVersion,
nrLabelHash = LabelHash lh,
nrLength,
nrDuration = word64 duration,
nrOwner,
nrCommitment,
nrSealed,
nrPhase,
nrYears,
nrPrice,
nrPayment,
nrReveal,
nrCommitTx = txHash <$> commitTx,
nrCommitBlock = word64 <$> commitBlock,
nrRegisterTx = txHash <$> registerTx,
nrRegisterBlock = word64 <$> registerBlock,
nrRevealAfter = word64 <$> revealAfter,
nrRevealBefore = word64 <$> revealBefore,
nrExpiresAt = word64 <$> expiresAt,
nrIssuedCodeId,
nrRegisteredAt,
nrFailedAt,
nrError,
nrLive,
nrOrderHash,
nrMoves,
nrLinksRefusedAt,
nrCreatedAt,
nrUpdatedAt
}
getRegistration :: DB.Connection -> RegistrationId -> IO (Maybe NameRegistration)
getRegistration db (RegistrationId rid) =
firstDecoded toRegistration $ DB.query db (selectRegistrations <> " WHERE r.registration_id = ?") (Only rid)
-- | The newest stored order, live or not, with exactly these fields.
getRegistrationByOrderHash :: DB.Connection -> ByteString -> IO (Maybe NameRegistration)
getRegistrationByOrderHash db hash =
firstDecoded toRegistration $
DB.query db (selectRegistrations <> " WHERE r.order_hash = ? ORDER BY r.created_at DESC, r.registration_id DESC LIMIT 1") (Only $ Binary hash)
-- | Overdue whatever status the poller has marked, as long as the invoice is not paid and holds no money, as the sweep decides.
getLiveOrders :: DB.Connection -> ByteString -> Address -> LabelHash -> UTCTime -> IO [LiveOrder]
getLiveOrders db commitment owner (LabelHash lh) now =
DB.query
db
( registrationColumns
<> ", CASE WHEN r.phase = ? AND i.status <> ? AND i.expires_at < ?"
<> unfunded
<> " THEN 1 ELSE 0 END"
<> registrationFrom
<> " LEFT JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id"
<> " WHERE r.live = 1 AND (r.commitment = ? OR (r.owner = ? AND r.label_hash = ?))"
)
(PUnpaid, invoiceStatusText ISPaid, truncateToSecond now, Binary commitment, Binary $ unAddress owner, Binary lh)
>>= decodeAll toLiveOrder
where
toLiveOrder (row :. Only (BI loOverdue)) = (\loRegistration -> LiveOrder {loRegistration, loOverdue}) <$> toRegistration row
-- | The sweep's own check, on the invoice joined as i.
unfunded :: Query
unfunded = unfundedQuery "i.invoice_id"
-- | Run in the transaction that inserts the next order, so the unique constraints let it in. The invoice is written before
-- the registration, as settlement, cancel and the sweep write them; a registration write that loses after it leaves the
-- caller to roll back.
closeOverdue :: DB.Connection -> NameRegistration -> UTCTime -> IO VersionWrite
closeOverdue db r@NameRegistration {nrId = RegistrationId rid, nrVersion, nrPayment} now = do
-- locked first, so the funding check below, a later statement, sees a payment a concurrent settlement committed
mapM_ (\invId -> lockInvoice db invId now) $ case nrPayment of
RPInvoice _ invId -> Just invId
RPCode _ -> Nothing
expired <-
executeChanging
db
( [sql|
UPDATE sx_badge_service_invoices SET status = ?, updated_at = ?
WHERE status = ? AND invoice_id IN (
SELECT i.invoice_id FROM sx_badge_service_name_invoices ni
JOIN sx_badge_service_name_registrations r ON r.registration_id = ni.registration_id
JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id
WHERE ni.registration_id = ? AND r.version = ? AND r.live = 1
|]
<> unfunded
<> " )"
)
(invoiceStatusText ISExpired, truncateToSecond now, invoiceStatusText ISOpen, rid, nrVersion)
if expired > 0
then updateRegistration db r {nrLive = False} now
else
DB.query db "SELECT version FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid) >>= \case
[Only version] | version == nrVersion -> pure NotOverdue
_ -> pure VersionChanged
-- | Whether an order can still progress to a registration for its name. The unique live indexes allow one such order per commitment,
-- and one per owner and label hash, so a wallet can order a name again once its registration is done.
-- An unpaid order is live while its invoice is open, which nrLive carries; a paid order that lost its name can move to another one.
orderLive :: NameRegistration -> Bool
orderLive NameRegistration {nrPhase, nrPayment, nrLive} = case nrPhase of
PUnpaid -> nrLive
PQueued -> True
PCommitting -> True
PCommitted -> True
PRegistering -> True
PFailed -> True
PTaken -> case nrPayment of
RPInvoice {} -> True
RPCode _ -> False
PRegistered -> False
PCodeIssued -> False
-- | Writes every changeable field of a registration read at its nrVersion, unless it changed since, with live as orderLive decides.
-- A closed order is never made live again, and a write that would make the order meet another live one changes nothing.
updateRegistration :: DB.Connection -> NameRegistration -> UTCTime -> IO VersionWrite
updateRegistration db r now = do
changed <-
executeChanging
db
[sql|
UPDATE sx_badge_service_name_registrations
SET version = version + 1, label_hash = ?, length = ?, duration = ?, commitment = ?, sealed = ?, phase = ?, price = ?,
label = ?, secret = ?, contact_link = ?, channel_link = ?, intent_nonce = ?, intent_deadline = ?, intent_signature = ?,
commit_tx = ?, commit_block = ?, register_tx = ?, register_block = ?, reveal_after = ?, reveal_before = ?, expires_at = ?,
issued_code_id = ?, registered_at = ?, failed_at = ?, error = ?, live = ?, moves = ?, links_refused_at = ?, updated_at = ?
WHERE registration_id = ? AND version = ?
AND (? = 0 OR (live = 1 AND NOT EXISTS (
SELECT 1 FROM sx_badge_service_name_registrations o
WHERE o.live = 1 AND o.registration_id <> ? AND (o.commitment = ? OR (o.owner = ? AND o.label_hash = ?))
)))
|]
( (Binary lh, nrLength, int64 nrDuration, Binary nrCommitment, nrSealed, nrPhase, nrPrice)
:. (rvLabel <$> nrReveal, Binary . rvSecret <$> nrReveal, rvContactLink =<< nrReveal, rvChannelLink =<< nrReveal, int64 . rvNonce <$> nrReveal, int64 . rvDeadline <$> nrReveal, Binary . serializeSignature . rvSignature <$> nrReveal)
:. (txHashField <$> nrCommitTx, int64 <$> nrCommitBlock, txHashField <$> nrRegisterTx, int64 <$> nrRegisterBlock, int64 <$> nrRevealAfter, int64 <$> nrRevealBefore, int64 <$> nrExpiresAt)
:. (nrIssuedCodeId, truncateToSecond <$> nrRegisteredAt, truncateToSecond <$> nrFailedAt, nrError, BI live, nrMoves, truncateToSecond <$> nrLinksRefusedAt, truncateToSecond now)
:. (rid, nrVersion, BI live, rid, Binary nrCommitment, Binary $ unAddress nrOwner, Binary lh)
)
if changed > 0 then pure $ Written (nrVersion + 1) else refusal
where
NameRegistration {nrId = RegistrationId rid, nrVersion, nrLabelHash = LabelHash lh, nrLength, nrDuration, nrOwner, nrCommitment, nrSealed, nrPhase, nrPrice, nrReveal} = r
NameRegistration {nrCommitTx, nrCommitBlock, nrRegisterTx, nrRegisterBlock, nrRevealAfter, nrRevealBefore, nrExpiresAt} = r
NameRegistration {nrIssuedCodeId, nrRegisteredAt, nrFailedAt, nrError, nrMoves, nrLinksRefusedAt} = r
live = orderLive r
refusal =
DB.query db "SELECT version, live FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid) >>= \case
[(version, BI storedLive)] | version == nrVersion -> pure $ if storedLive then LiveOrderExists else NotLive
_ -> pure VersionChanged
-- | Another name, or other links: queued again with the reveal, transactions, reveal times and link refusal cleared.
-- The order hash stays the original order's, so a resend of that order still finds it.
retargetRegistration :: DB.Connection -> NameRegistration -> OrderFields -> Int -> UTCTime -> IO VersionWrite
retargetRegistration db r OrderFields {ofLabelHash, ofLength, ofDuration, ofCommitment, ofSealed} price =
updateRegistration
db
r
{ nrLabelHash = ofLabelHash,
nrLength = ofLength,
nrDuration = ofDuration,
nrCommitment = ofCommitment,
nrSealed = ofSealed,
nrPrice = price,
nrPhase = PQueued,
nrMoves = nrMoves r + 1,
nrReveal = Nothing,
nrCommitTx = Nothing,
nrCommitBlock = Nothing,
nrRegisterTx = Nothing,
nrRegisterBlock = Nothing,
nrRevealAfter = Nothing,
nrRevealBefore = Nothing,
nrLinksRefusedAt = Nothing,
nrFailedAt = Nothing,
nrError = Nothing
}
-- | Newest first, at most findLimit.
findRegistrations :: DB.Connection -> Address -> IO [NameRegistration]
findRegistrations db owner =
DB.query db (selectRegistrations <> " WHERE r.owner = ? ORDER BY r.created_at DESC, r.registration_id DESC LIMIT ?") (Binary $ unAddress owner, findLimit)
>>= decodeAll toRegistration
isLabelRegistering :: DB.Connection -> LabelHash -> IO Bool
isLabelRegistering db (LabelHash lh) =
not . null
<$> ( DB.query db "SELECT 1 FROM sx_badge_service_name_registrations WHERE label_hash = ? AND phase = ? LIMIT 1" (Binary lh, PRegistering) ::
IO [Only Int]
)
-- | The registrations a worker moves on, oldest first.
activeRegistrations :: DB.Connection -> IO [NameRegistration]
activeRegistrations db =
DB.query db (selectRegistrations <> " WHERE r.phase IN (?,?,?,?) ORDER BY r.created_at, r.registration_id") (PQueued, PCommitting, PCommitted, PRegistering)
>>= decodeAll toRegistration
-- | Unpaid orders whose invoice expired before the cutoff, without a recorded payment.
unpaidCleanupCandidates :: DB.Connection -> UTCTime -> IO [(RegistrationId, InvoiceId)]
unpaidCleanupCandidates db expiredBefore =
map (\(rid, invId) -> (RegistrationId rid, InvoiceId invId))
<$> DB.query
db
[sql|
SELECT r.registration_id, ni.invoice_id
FROM sx_badge_service_name_registrations r
JOIN sx_badge_service_name_invoices ni ON ni.registration_id = r.registration_id
JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id
WHERE r.phase = ? AND i.expires_at < ?
AND NOT EXISTS (SELECT 1 FROM sx_badge_service_payments p WHERE p.invoice_id = ni.invoice_id)
ORDER BY i.expires_at, r.registration_id
|]
(PUnpaid, truncateToSecond expiredBefore)
-- | Deletes an unpaid registration and its invoice, unless a payment was recorded for it.
deleteUnpaidRegistration :: DB.Connection -> RegistrationId -> IO Bool
deleteUnpaidRegistration db (RegistrationId rid) = do
invoiceIds <- DB.query db "SELECT invoice_id FROM sx_badge_service_name_invoices WHERE registration_id = ?" (Only rid)
deleted <-
executeChanging
db
[sql|
DELETE FROM sx_badge_service_name_registrations
WHERE registration_id = ? AND phase = ?
AND NOT EXISTS (
SELECT 1 FROM sx_badge_service_name_invoices ni
JOIN sx_badge_service_payments p ON p.invoice_id = ni.invoice_id
WHERE ni.registration_id = ?
)
|]
(rid, PUnpaid, rid)
when (deleted > 0) $
mapM_ (DB.execute db "DELETE FROM sx_badge_service_invoices WHERE invoice_id = ?" . Only . fromOnly) (invoiceIds :: [Only Text])
pure $ deleted > 0
-- | Code orders failed before the cutoff that still hold their unspent code.
failedCodeOrdersBefore :: DB.Connection -> UTCTime -> IO [(RegistrationId, Int64)]
failedCodeOrdersBefore db cutoff =
map (\(rid, codeId) -> (RegistrationId rid, codeId))
<$> DB.query
db
[sql|
SELECT r.registration_id, c.name_prepaid_code_id
FROM sx_badge_service_name_registrations r
JOIN sx_badge_service_name_prepaid_codes c ON c.reserved_by = r.registration_id
WHERE r.phase = ? AND r.failed_at < ? AND c.spent_at IS NULL
ORDER BY r.failed_at, r.registration_id
|]
(PFailed, truncateToSecond cutoff)
insertPrepaidCode :: DB.Connection -> NewPrepaidCode -> UTCTime -> IO (Either CodeHashTaken Int64)
insertPrepaidCode db NewPrepaidCode {npcCodeHash, npcMinLength, npcYears, npcPaymentStatus, npcExpiresAt} now = do
inserted <-
executeChanging
db
[sql|
INSERT INTO sx_badge_service_name_prepaid_codes (code_hash, min_length, years, code_payment_status, expires_at, created_at)
VALUES (?,?,?,?,?,?)
ON CONFLICT DO NOTHING
|]
(Binary npcCodeHash, npcMinLength, npcYears, npcPaymentStatus, truncateToSecond <$> npcExpiresAt, truncateToSecond now)
if inserted > 0 then Right <$> insertedRowId db else pure $ Left CodeHashTaken
prepaidCodeColumns :: Query
prepaidCodeColumns =
[sql|
SELECT name_prepaid_code_id, code_hash, min_length, years, code_payment_status, reserved_by, reserved_at, spent_at, revoked_at, expires_at, created_at
FROM sx_badge_service_name_prepaid_codes
|]
type PrepaidCodeRow = (Int64, Binary ByteString, Int, Int, BadgeCodePaymentStatus, Maybe Text, Maybe UTCTime, Maybe UTCTime, Maybe UTCTime, Maybe UTCTime) :. Only UTCTime
toPrepaidCode :: PrepaidCodeRow -> PrepaidCode
toPrepaidCode ((pcId, Binary pcCodeHash, pcMinLength, pcYears, pcPaymentStatus, reservedBy, pcReservedAt, pcSpentAt, pcRevokedAt, pcExpiresAt) :. Only pcCreatedAt) =
PrepaidCode {pcId, pcCodeHash, pcMinLength, pcYears, pcPaymentStatus, pcReservedBy = RegistrationId <$> reservedBy, pcReservedAt, pcSpentAt, pcRevokedAt, pcExpiresAt, pcCreatedAt}
getPrepaidCode :: DB.Connection -> ByteString -> IO (Maybe PrepaidCode)
getPrepaidCode db codeHash =
maybeFirstRow toPrepaidCode $ DB.query db (prepaidCodeColumns <> " WHERE code_hash = ?") (Only $ Binary codeHash)
getPrepaidCodeById :: DB.Connection -> Int64 -> IO (Maybe PrepaidCode)
getPrepaidCodeById db codeId =
maybeFirstRow toPrepaidCode $ DB.query db (prepaidCodeColumns <> " WHERE name_prepaid_code_id = ?") (Only codeId)
-- | Only an unused code: not reserved, spent or revoked, paid or free, and not expired.
reservePrepaidCode :: DB.Connection -> Int64 -> RegistrationId -> UTCTime -> IO Bool
reservePrepaidCode db codeId (RegistrationId rid) now' =
(> 0)
<$> executeChanging
db
[sql|
UPDATE sx_badge_service_name_prepaid_codes SET reserved_by = ?, reserved_at = ?
WHERE name_prepaid_code_id = ? AND reserved_by IS NULL AND spent_at IS NULL AND revoked_at IS NULL
AND code_payment_status <> ? AND (expires_at IS NULL OR expires_at > ?)
|]
(rid, now, codeId, CPSUnpaid, now)
where
now = truncateToSecond now'
releasePrepaidCode :: DB.Connection -> Int64 -> RegistrationId -> IO Bool
releasePrepaidCode db codeId (RegistrationId rid) =
(> 0)
<$> executeChanging
db
[sql|
UPDATE sx_badge_service_name_prepaid_codes SET reserved_by = NULL, reserved_at = NULL
WHERE name_prepaid_code_id = ? AND reserved_by = ? AND spent_at IS NULL
|]
(codeId, rid)
-- | A spent code keeps reserved_by, naming the registration it paid for.
spendPrepaidCode :: DB.Connection -> Int64 -> RegistrationId -> UTCTime -> IO Bool
spendPrepaidCode db codeId (RegistrationId rid) now =
(> 0)
<$> executeChanging
db
"UPDATE sx_badge_service_name_prepaid_codes SET spent_at = ? WHERE name_prepaid_code_id = ? AND reserved_by = ? AND spent_at IS NULL"
(truncateToSecond now, codeId, rid)
-- | A reserved code can be revoked, and its order keeps it; a spent one cannot.
revokePrepaidCode :: DB.Connection -> ByteString -> UTCTime -> IO RevokeResult
revokePrepaidCode db codeHash now = do
revoked <-
executeChanging
db
"UPDATE sx_badge_service_name_prepaid_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND spent_at IS NULL"
(truncateToSecond now, Binary codeHash)
maybe NoSuchCode (result revoked) <$> getPrepaidCode db codeHash
where
result revoked PrepaidCode {pcId, pcRevokedAt}
| revoked > 0 = Revoked pcId
| isJust pcRevokedAt = AlreadyRevoked pcId
| otherwise = AlreadyRedeemed
txKind :: TxFor -> (Text, Maybe Text, Maybe Int64)
txKind = \case
TxCommit (RegistrationId rid) -> ("commit", Just rid, Nothing)
TxRegister (RegistrationId rid) -> ("register", Just rid, Nothing)
TxEdit editId -> ("edit", Nothing, Just editId)
-- | Saved once signed and before it is sent, so a restart can rebroadcast it.
insertTransaction :: DB.Connection -> TxFor -> NewTransaction -> UTCTime -> IO Int64
insertTransaction db for NewTransaction {ntxRaw, ntxHash, ntxNonce} now = do
let (kind, rid, editId) = txKind for
DB.execute
db
[sql|
INSERT INTO sx_badge_service_name_transactions
(registration_id, name_record_edit_id, kind, raw, tx_hash, registrar_nonce, sent_at, status)
VALUES (?,?,?,?,?,?,?,?)
|]
(rid, editId, kind, Binary ntxRaw, txHashField ntxHash, int64 ntxNonce, truncateToSecond now, TSPending)
insertedRowId db
-- | Moves a transaction on from the status the caller read, so an outcome is never overwritten unseen; False when it is not in that status.
recordTransactionOutcome :: DB.Connection -> TxHash -> TxStatus -> TxStatus -> Maybe Word64 -> IO Bool
recordTransactionOutcome db hash from status block =
(> 0)
<$> executeChanging
db
"UPDATE sx_badge_service_name_transactions SET status = ?, mined_block = ? WHERE tx_hash = ? AND status = ?"
(status, int64 <$> block, txHashField hash, from)
transactionColumns :: Query
transactionColumns =
[sql|
SELECT name_transaction_id, kind, registration_id, name_record_edit_id, raw, tx_hash, registrar_nonce, sent_at, mined_block, status
FROM sx_badge_service_name_transactions
|]
type TransactionRow = (Int64, Text, Maybe Text, Maybe Int64, Binary ByteString, Binary ByteString, Int64, UTCTime, Maybe Int64, TxStatus)
toTransaction :: TransactionRow -> Either Text NameTransaction
toTransaction (ntId, kind, rid_, editId_, Binary ntRaw, hash, nonce, ntSentAt, block, ntStatus) = do
ntFor <- case (kind, rid_, editId_) of
("commit", Just rid, Nothing) -> Right $ TxCommit $ RegistrationId rid
("register", Just rid, Nothing) -> Right $ TxRegister $ RegistrationId rid
("edit", Nothing, Just editId) -> Right $ TxEdit editId
_ -> Left $ "name_transactions.kind: " <> kind
pure NameTransaction {ntId, ntFor, ntRaw, ntHash = txHash hash, ntNonce = word64 nonce, ntSentAt, ntMinedBlock = word64 <$> block, ntStatus}
-- | Those without a receipt, in nonce order, for the sender to resume.
pendingTransactions :: DB.Connection -> IO [NameTransaction]
pendingTransactions db =
DB.query db (transactionColumns <> " WHERE status = ? ORDER BY registrar_nonce, name_transaction_id") (Only TSPending)
>>= decodeAll toTransaction
registrationTransactions :: DB.Connection -> RegistrationId -> IO [NameTransaction]
registrationTransactions db (RegistrationId rid) =
DB.query db (transactionColumns <> " WHERE registration_id = ? ORDER BY name_transaction_id") (Only rid)
>>= decodeAll toTransaction
editTransactions :: DB.Connection -> Int64 -> IO [NameTransaction]
editTransactions db editId =
DB.query db (transactionColumns <> " WHERE name_record_edit_id = ? ORDER BY name_transaction_id") (Only editId)
>>= decodeAll toTransaction
pendingEdit :: Text
pendingEdit = "pending"
minedEdit :: Text
minedEdit = "mined"
revertedEdit :: Text
revertedEdit = "reverted"
-- | Nothing when a pending or mined edit already holds the owner's nonce.
insertRecordEdit :: DB.Connection -> NewRecordEdit -> UTCTime -> IO (Maybe Int64)
insertRecordEdit db NewRecordEdit {nreLabelHash = LabelHash lh, nreOwner, nreNonce, nreChange, nreDeadline, nreSignature, nreBodyHash} now = do
let (key, value) = case nreChange of
RCSetText k v -> (Just k, Just v)
RCClear -> (Nothing, Nothing)
inserted <-
executeChanging
db
[sql|
INSERT INTO sx_badge_service_name_record_edits
(label_hash, owner, nonce, record_key, record_value, deadline, signature, body_hash, state, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT DO NOTHING
|]
( (Binary lh, Binary $ unAddress nreOwner, int64 nreNonce, key, value, int64 nreDeadline, Binary $ serializeSignature nreSignature, Binary nreBodyHash)
:. (pendingEdit, truncateToSecond now)
)
if inserted > 0 then Just <$> insertedRowId db else pure Nothing
recordEditColumns :: Query
recordEditColumns =
[sql|
SELECT name_record_edit_id, label_hash, owner, nonce, record_key, record_value, deadline, signature, body_hash, state, revert, mined_tx, created_at
FROM sx_badge_service_name_record_edits
|]
type RecordEditRow =
(Int64, Binary ByteString, Binary ByteString, Int64, Maybe Text, Maybe Text, Int64, Binary ByteString, Binary ByteString, Text)
:. (Maybe Text, Maybe (Binary ByteString), UTCTime)
toRecordEdit :: RecordEditRow -> Either Text RecordEdit
toRecordEdit ((reId, Binary lh, Binary owner, nonce, key_, value_, deadline, Binary sig, Binary reBodyHash, state) :. (revert, minedTx, reCreatedAt)) = do
reOwner <- decodeAddress owner
reSignature <- decodeSignature sig
reChange <- case (key_, value_) of
(Just key, Just value) -> Right $ RCSetText key value
(Nothing, Nothing) -> Right RCClear
_ -> Left $ "name_record_edits record: " <> tshow reId
reState <- decodeState
pure RecordEdit {reId, reLabelHash = LabelHash lh, reOwner, reNonce = word64 nonce, reChange, reDeadline = word64 deadline, reSignature, reBodyHash, reState, reCreatedAt}
where
decodeState
| state == pendingEdit = Right ESPending
| state == minedEdit = Right $ ESMined $ txHash <$> minedTx
| state == revertedEdit = Right $ ESReverted revert
| otherwise = Left $ "name_record_edits.state: " <> state
getRecordEdit :: DB.Connection -> Int64 -> IO (Maybe RecordEdit)
getRecordEdit db editId =
firstDecoded toRecordEdit $ DB.query db (recordEditColumns <> " WHERE name_record_edit_id = ?") (Only editId)
-- | The newest edit sent with this signed body.
getRecordEditByBody :: DB.Connection -> ByteString -> IO (Maybe RecordEdit)
getRecordEditByBody db bodyHash =
firstDecoded toRecordEdit $
DB.query db (recordEditColumns <> " WHERE body_hash = ? ORDER BY name_record_edit_id DESC LIMIT 1") (Only $ Binary bodyHash)
-- | The pending or mined edit holding the owner's nonce.
getRecordEditByNonce :: DB.Connection -> Address -> Word64 -> IO (Maybe RecordEdit)
getRecordEditByNonce db owner nonce =
firstDecoded toRecordEdit $
DB.query db (recordEditColumns <> " WHERE owner = ? AND nonce = ? AND state IN (?,?)") (Binary $ unAddress owner, int64 nonce, pendingEdit, minedEdit)
-- | Settles a pending edit; False when it was settled already.
settleRecordEdit :: DB.Connection -> Int64 -> EditState -> IO Bool
settleRecordEdit db editId state =
(> 0)
<$> executeChanging
db
"UPDATE sx_badge_service_name_record_edits SET state = ?, revert = ?, mined_tx = ? WHERE name_record_edit_id = ? AND state = ?"
(stateText, revert, txHashField <$> minedTx, editId, pendingEdit)
where
(stateText, revert, minedTx) = case state of
ESPending -> (pendingEdit, Nothing, Nothing)
ESMined tx -> (minedEdit, Nothing, tx)
ESReverted name -> (revertedEdit, name, Nothing)
-- | When each edit relayed for the name since the given time was sent, oldest first.
relayedEditTimes :: DB.Connection -> LabelHash -> UTCTime -> IO [UTCTime]
relayedEditTimes db (LabelHash lh) since =
map fromOnly
<$> DB.query
db
"SELECT created_at FROM sx_badge_service_name_record_edits WHERE label_hash = ? AND created_at > ? ORDER BY created_at"
(Binary lh, truncateToSecond since)
int64 :: Word64 -> Int64
int64 = fromIntegral
txHash :: Binary ByteString -> TxHash
txHash (Binary h) = TxHash h
txHashField :: TxHash -> Binary ByteString
txHashField (TxHash h) = Binary h
word64 :: Int64 -> Word64
word64 = fromIntegral
-- | Address has no exported constructor; its parser takes lowercase hex without the EIP-55 check.
decodeAddress :: ByteString -> Either Text Address
decodeAddress bs = either (const $ Left "address column") Right $ strDecode $ convertToBase Base16 bs
decodeSignature :: ByteString -> Either Text RecoverableSignature
decodeSignature bs = either (Left . ("signature column: " <>) . tshow) Right $ parseSignature $ hex bs
note :: Text -> Maybe a -> Either Text a
note err = maybe (Left err) Right
decodeAll :: (row -> Either Text a) -> [row] -> IO [a]
decodeAll decode = either (E.throwIO . StoreDecodeError) pure . traverse decode
firstDecoded :: (row -> Either Text a) -> IO [row] -> IO (Maybe a)
firstDecoded decode rows = rows >>= fmap listToMaybe . decodeAll decode . take 1