Files
simplex-chat/tests/Bots/StoreService/NamesOrdersTests.hs
T

1665 lines
93 KiB
Haskell

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Bots.StoreService.NamesOrdersTests
( namesOrdersTests,
Ctx (..),
Order (..),
withOrders,
withOrdersVia,
withOrdersOn,
withOrdersCancelling,
withSettings,
configured,
aliceOrder,
commitmentOf,
fieldsOf,
orderTime,
placeOrder,
placed,
post,
settleNow,
settled,
invoiceRegistration,
registrationOf,
keyA,
keyB,
ownerAText,
ownerBText,
yearsDuration,
codeRoutes,
issueCode,
registered,
registrationById,
registrationsOf,
field,
advance,
refusedWith,
malformedTerms,
listName,
bakers,
bumpedTerms,
newerTerms,
olderTerms,
communityTerms,
termsChanged,
)
where
import Bots.StoreService.ConfigTests (termsUrl, termsVersion)
import Bots.StoreService.FakeBTCPay (FakeBTCPay (..), setInvoiceState, withFakeBTCPay)
import Bots.StoreService.FakeEthNode
import Bots.StoreService.NamesContractsTests (alice, answerRegistered, answerRegistrar, nameExpiresCall, noncesCall, registrarOf, rentPriceAnswer, rentPriceCall, reservedNamesCall, unhex)
import Bots.StoreService.NamesStoreTests (btc, insertInvoiceRow, newReg, order, ownerA, ownerB, paidOrder, placeCode, placePaid, t0)
import Bots.StoreService.WebTests
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, concurrently, wait)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM (TVar, atomically, check, modifyTVar', newTVarIO, readTVar)
import qualified Control.Exception as E
import Control.Arrow ((&&&))
import Control.Monad (forM_, void)
import Crypto.Random (ChaChaDRG)
import Data.Aeson ((.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)
import Data.List (sort)
import qualified Data.Map.Strict as M
import Data.Maybe (fromJust, fromMaybe, isJust, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Data.Word (Word64)
import Network.HTTP.Client (defaultManagerSettings, newManager, responseBody)
import Network.HTTP.Types (hContentType)
import Network.Wai (Application, requestHeaders)
import qualified Network.Wai.Handler.Warp as Warp
import Simplex.Chat.Badges.Code (NameCode (..), formatNameCode, namePrepaidCodeHash, randomNamePrepaidCode)
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), ServicePaymentDestination (..), ServicePaymentMethod (..))
import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection, withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.Secp256k1 (Secp256k1PrivateKey, mkPrivateKey, signRecoverable)
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Eth.EIP712 (serializeSignature)
import Simplex.Messaging.SimplexName (LabelHash (..), SimplexTLD (..), labelHash)
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
import StoreService.Config (ChainConfig (..), NamesConfig (..), ServiceConfig (..), TermsConfig (..))
import StoreService.Names.Abi (hex, uint256)
import StoreService.Names.Chain (newChainClient)
import StoreService.Names.Contracts (newContracts)
import StoreService.Names.Codes (CodeRefusal (..))
import StoreService.Names.Orders (OrdersEnv (..))
import qualified StoreService.Names.Orders as Orders
import StoreService.Names.Phases (RegistrationId (..), awaitChange, newPhases)
import StoreService.Names.Signatures (OrderFields (..), orderMessage, personalDigest)
import StoreService.Names.Store
import StoreService.Names.Turns (newTurns, withCodeTurn)
import StoreService.Names.View (ViewReads (..), readViewChain, registrationView, viewJSON)
import StoreService.Orders (cancelOpenInvoice, expireOverdue, settleOrder)
import StoreService.Poller (cleanupInterval, cleanupReadsPerPass, cleanupUnpaid, drainHints, newPollerEnv, newReadHints, queueReadHint, runOnePass, unpaidRetention)
import StoreService.Providers.BTCPay (btcpayProvider)
import StoreService.Providers (Funded (..), OrderDraft (..), PaymentSignal (..), Provider (..), ProviderError (..), ProviderInvoice (..))
import StoreService.Store.Invoices (InvoicePayment (..), InvoiceProduct (..), InvoiceRow (..), OverdueInvoice (..), ProviderRefConflict (..), getInvoice, getInvoiceByProviderRef, overdueInvoices, unpaidRefs)
import StoreService.Waiters (Waiters, newWaiters)
import StoreService.Web.Http (codeLimit)
import StoreService.Web.Names (Route (..), namesApp)
import StoreService.Web.Names.Codes (codeCheckHandler)
import StoreService.Web.Names.Orders (codeOrderHandler)
import StoreService.Web.Server (WebEnv (..), newWebEnv, webApp, webSettings)
import System.Directory (createDirectoryIfMissing)
import System.Timeout (timeout)
import Test.Hspec
import UnliftIO.Temporary (withTempDirectory)
namesOrdersTests :: Spec
namesOrdersTests = describe "StoreService names: orders" $ do
describe "paid orders" $ do
it "places an order as an unpaid live registration and answers the name invoice view" testPaidOrder
it "accepts the order the webapp's wallet signed, as viem signs it" testViemOrder
it "answers an exact resend with its invoice, also with a new timestamp and signature, and after the timestamp window" testExactResend
it "answers an exact resend after its order went past unpaid, before any chain read" testResendPastUnpaid
it "answers an exact resend of an expired order with its expired invoice" testResendExpired
it "reads an exact resend's unpaid invoice past its expiry as expired, and its lapsed commitment as failed, writing neither" testResendReadsTime
it "refuses a resend differing in any one field, or a new commitment, with a bare order_exists" testOneFieldDiffers
it "refuses an order carrying another wallet's commitment without disclosing anything" testForeignCommitment
it "refuses a bad signature, or another signer's" testBadSignature
it "refuses a stale timestamp on a new order" testStaleTimestamp
it "lets one of two concurrent orders for one owner and name win, the other never reaching the provider" testConcurrentOrders
it "cancels the provider invoice of an order whose insert lost to another with the same commitment" testLostInsertCancels
it "cancels the provider invoice and logs it when the order's insert fails" testInsertFailureCancels
it "checks the duration within the slack, before and after the term start" testDurationSlack
it "answers catalog_changed with the chain price" testCatalogChanged
it "answers name_taken for a registered, reserved or being registered name" testNameTaken
it "answers name_check_unavailable when a status or price read fails" testNameCheckUnavailable
it "refuses sealed missing or over 11095 characters and a body over 16384 bytes, accepting the longest order" testSizes
it "refuses malformed fields, an unknown kind, and an order outside 6-63 letters" testFormats
it "accepts orders for one name from two wallets" testTwoWallets
it "answers after the name's turn, so a slow reader of one answer holds no other order for the name, paid or by code" testSlowReaderHoldsNoTurn
describe "code orders" $ do
it "places a code order as a queued live registration holding its code, answering only its id" testCodeOrder
it "answers an exact resend with its id in any phase, the code spent or not, before any chain read" testCodeResend
it "refuses a resend differing in any one field, the code included, with a bare order_exists" testCodeOneFieldDiffers
it "refuses a bad signature, or another signer's, after the code check" testCodeBadSignature
it "refuses a stale timestamp on a new code order" testCodeStaleTimestamp
it "refuses a code order meeting an open invoice for the name with order_exists" testCodeMeetsInvoice
it "closes an overdue paid order of the same wallet and name, cancelling its invoice after" testCodeClosesOverdue
it "refuses each code that cannot pay with its tag, the state before the cover" testCodeRefusals
it "checks the duration for the code's years" testCodeYears
it "answers name_taken and name_check_unavailable before the code's state and cover, leaving the code unreserved" testCodeNameChecks
it "refuses sealed over 11095 characters, a body over 16384 bytes and malformed fields, accepting the longest order" testCodeSizes
it "runs code orders in the code's turn, one of two spending one code for two names winning, the other answered code_reserved" testConcurrentCodeOrders
it "answers the code's refusal when its reservation fails in the insert, writing nothing" testCodeNotReservable
it "counts code orders and code checks against one code limit" testCodeLimitShared
describe "terms" $ do
it "answers terms_changed with the current terms and community for an old version or the wrong variant, listed or not, creating nothing" testTermsChanged
it "accepts the community terms for a listed name as Appendix C signs them, storing them" testCommunityTerms
it "answers an exact resend after the version changed, and takes the same fields under the new terms as another order" testResendAfterBump
it "checks the terms after the existing orders, the timestamp and the duration, before the chain and the price" testTermsCheckOrder
it "answers terms_changed for a code order under old terms, listed or not, leaving the code unreserved, and accepts the community terms" testCodeTermsChanged
it "answers a code order's exact resend after the version changed" testCodeResendAfterBump
it "checks a code order's terms after the timestamp, before the chain and the code's state" testCodeTermsCheckOrder
describe "closed orders" $ do
it "accepts a new order of the same wallet and name after a cancel" testOrderAfterCancel
it "closes an overdue order of the same wallet and name before the poller marks it, cancelling its invoice after" testOverdueClosed
it "records a payment of the closed order as a late payment, leaving the newer order alone" testLatePaymentAfterNewOrder
it "keeps an overdue order whose invoice holds money, refusing the next order, and settles it" testFundedOverdueKept
it "alerts on a late payment as BTCPay reports it for an expired, cancelled or swept invoice, once" testLateBTCPayPayments
it "alerts once on money read while open when the provider then closes the invoice, also after a restart" testOneLateAlert
it "answers cancel with funded, not_open, paid when settlement won, and expired with the payment read meanwhile" testCancelAnswers
it "alerts once when a cancel expires an invoice a payment landed on while the provider was told" testCancelAlertsFunded
it "leaves a late payment of a cancelled invoice unpaid and alerts" testLatePayment
it "queues an order settled while open and publishes its version" testSettlementQueues
it "takes the invoice's row before recording a payment that moves no status" testSettlementLocksInvoice
it "closes the order of an invoice the provider closed or the sweep expired" testExpiryCloses
it "sweeps each invoice on its own, skipping one whose order cannot be read" testSweepSkipsBadRow
it "keeps out of a pass's sweep only the invoices whose read or settlement failed" testPassSweepsAroundFailures
it "cleans up unpaid orders after 30 days, reading the provider first" testCleanup
it "reads at most a pass's share of the provider in a cleanup, carrying the rest to the next pass" testCleanupCapped
it "answers 503 for a name invoice while names are off, cancelling nothing" testNamesOff
describe "invoice side" $ do
it "finds an invoice by provider ref in either table, refusing a ref both hold" testProviderRefs
it "reads and sweeps name invoices with the badge ones" testPollerQueries
it "agrees between the sweep and the overdue close on which invoices hold money" testFundedAgreement
describe "registration view" $ do
it "has the contract's fields" testViewFields
it "has the community entry of its label hash, null for an unlisted name" testViewCommunity
it "has the terms the order signed, null for an order stored before orders signed them" testViewTerms
it "reads the intent nonce in committed and nothing in taken" testViewReads
it "leaves a failed intentNonce read null and named in unread, reading nothing in taken" testViewUnread
it "reads the order's balance, held until its holder's invoice expires and free past it, and the cents its order took from another's" testViewBalance
-- fixtures
-- 21 October 2026 09:15 UTC, the webapp's signed order vector's timestamp
orderTime :: UTCTime
orderTime = posixSecondsToUTCTime 1792574100
termStart :: UTCTime
termStart = posixSecondsToUTCTime 1798761600
yearSeconds :: Word64
yearSeconds = 31536000
slackSeconds :: Int
slackSeconds = 3600
seconds :: UTCTime -> Word64
seconds = floor . utcTimeToPOSIXSeconds
-- | What the page sends: 2 paid years and the free days until the term starts.
durationAt :: UTCTime -> Word64
durationAt = yearsDuration 2
yearsDuration :: Word64 -> UTCTime -> Word64
yearsDuration years now = years * yearSeconds + fromIntegral (max 0 (toInteger (seconds termStart) - toInteger (seconds now)))
-- the webapp's wallet, BIP-39 entropy 6f2d1c9a4b7e3f80d15a2c6e9b04f7a3, accounts 0 and 1 as viem derives them
keyA, keyB :: Secp256k1PrivateKey
keyA = privateKey "14bb03a88d47f77e17be83d8fe479ab982ac8b18b5f4bdec7e40ffccb1af9325"
keyB = privateKey "e8068cb3aaf53e5c46b40aefd8b78480453a8265d158df06174ba0e49bf0f130"
privateKey :: ByteString -> Secp256k1PrivateKey
privateKey = either error id . mkPrivateKey . BA.convert . unhex
ownerAText, ownerBText :: Text
ownerAText = "0x014f9c09CcEeDEF1B713110d04D3a07d5B5D9b73"
ownerBText = "0x39ad11775d14FC8c9118Bd94f374DF3c7ad5655d"
data Order = Order
{ oLabel :: Text,
oOwner :: Text,
oKey :: Secp256k1PrivateKey,
oCommitment :: ByteString,
oSealed :: Text,
oDuration :: Word64,
oMethod :: Text,
oPrice :: Int,
oTerms :: Text,
oTimestamp :: Word64
}
aliceOrder :: Order
aliceOrder =
Order
{ oLabel = "alicebakery",
oOwner = ownerAText,
oKey = keyA,
oCommitment = commitmentOf 1,
oSealed = "sealed-alicebakery-1",
oDuration = durationAt orderTime,
oMethod = "btc",
oPrice = alicePrice,
oTerms = termsVersion,
oTimestamp = seconds orderTime
}
-- the launch price of 11 letters for 2 years
alicePrice :: Int
alicePrice = 10000
commitmentOf :: Int -> ByteString
commitmentOf n = C.sha256Hash $ BC.pack $ "commitment " <> show n
fieldsOf :: Order -> OrderFields
fieldsOf Order {oLabel, oCommitment, oSealed, oDuration, oTerms} =
OrderFields {ofLabelHash = labelHash oLabel, ofLength = T.length oLabel, ofDuration = oDuration, ofCommitment = oCommitment, ofSealed = oSealed, ofAgreedTerms = oTerms}
signedBody :: TVar ChaChaDRG -> Order -> IO J.Object
signedBody g o@Order {oKey, oTimestamp} = do
sig <- either error id <$> signRecoverable g oKey (personalDigest $ orderMessage (fieldsOf o) oTimestamp)
pure $ withSignature (hex $ serializeSignature sig) (unsignedBody o)
unsignedBody :: Order -> J.Object
unsignedBody o@Order {oOwner, oMethod, oPrice, oTimestamp} =
KM.fromList
[ ("kind", "name"),
("labelHash", J.String $ hex lh),
("length", J.toJSON ofLength),
("duration", J.toJSON ofDuration),
("owner", J.String oOwner),
("commitment", J.String $ hex ofCommitment),
("sealed", J.String ofSealed),
("method", J.String oMethod),
("price", J.toJSON oPrice),
("terms", J.String ofAgreedTerms),
("timestamp", J.toJSON oTimestamp)
]
where
OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed, ofAgreedTerms} = fieldsOf o
withSignature :: Text -> J.Object -> J.Object
withSignature sig = KM.insert "signature" (J.String sig)
data Ctx = Ctx
{ cStore :: DBStore,
-- | Another store over the same database, as a second process opens one.
cOpenStore :: IO DBStore,
cNode :: FakeEthNode,
cOrders :: OrdersEnv,
cWeb :: WebEnv,
cClient :: WebClient,
cStub :: IORef StubState,
cClock :: IORef UTCTime,
cRandom :: TVar ChaChaDRG,
cIps :: IORef Int
}
testNamesConfig :: FakeEthNode -> NamesConfig
testNamesConfig node =
NamesConfig
{ nTLD = TLDSimplex,
nYears = 2,
nTermStart = termStart,
nTermSlackSeconds = slackSeconds,
nRevealMarginSeconds = 900,
nMaxOrderMoves = 3,
nTerms = TermsConfig {tVersion = termsVersion, tUrl = termsUrl},
nCommunity = M.empty,
nChain = fakeChainConfig node
}
-- | Each invoice the stub creates gets its own provider ref, and a card invoice its client secret.
freshRefs :: IORef Int -> Provider -> Provider
freshRefs counter p = p {pCreateInvoice = \method draft -> pCreateInvoice p method draft >>= traverse fresh}
where
fresh pinv = do
n <- atomicModifyIORef' counter (\i -> (i + 1, i))
let ref = "pref-" <> tshow n
pure $ case pProvider p of
PPStripe -> pinv {piProviderRef = ref, piDestination = SPDCard CPStripe ("pi_secret_" <> tshow n)}
_ -> pinv {piProviderRef = ref}
withOrders :: (Ctx -> IO a) -> IO a
withOrders = withOrdersVia id
-- | With the providers changed as a test needs them.
withOrdersVia :: (Provider -> Provider) -> (Ctx -> IO a) -> IO a
withOrdersVia via action = withServiceStores $ \open -> withOrdersOn open via action
-- | Over the stores the action opens.
withOrdersOn :: IO DBStore -> (Provider -> Provider) -> (Ctx -> IO a) -> IO a
withOrdersOn cOpenStore via action = cOpenStore >>= \cStore -> withFakeEthNode $ \cNode -> do
cRandom <- C.newRandom
ct <- newContracts TLDSimplex =<< newChainClient cRandom (fakeChainConfig cNode)
turns <- newTurns
phases <- newPhases
cClock <- newIORef orderTime
cStub <- newIORef (newStubState (Right sampleProviderInvoice))
counter <- newIORef (1 :: Int)
cIps <- newIORef 1
answerAvailable cNode
createDirectoryIfMissing True "tests/tmp"
withTempDirectory "tests/tmp" "names-orders" $ \root -> do
staticDir <- prepareStaticDir root
let cfg = (testServiceConfig staticDir True) {btcpay = Just testBTCPayConfig}
cOrders = OrdersEnv {oeStore = cStore, oeConfig = testNamesConfig cNode, oeContracts = ct, oeTurns = turns, oePhases = phases, oeNow = readIORef cClock}
crypto = via $ freshRefs counter (stubProvider cStub)
card = via $ freshRefs counter (stubProvider cStub) {pProvider = PPStripe}
waiters <- newWaiters
hints <- newReadHints
built <- newWebEnv cStore cfg waiters hints [crypto, card] Nothing
let cWeb = built {weOrders = Just cOrders}
mgr <- newManager defaultManagerSettings
Warp.testWithApplicationSettings (webSettings (listener cfg)) (pure $ ordersApp cWeb cOrders) $ \port ->
action Ctx {cStore, cOpenStore, cNode, cOrders, cWeb, cClient = (mgr, "http://127.0.0.1:" <> show port), cStub, cClock, cRandom, cIps}
-- | The context's store, chain, providers and clock with its names settings changed, as after a restart.
configured :: (NamesConfig -> NamesConfig) -> Ctx -> Ctx
configured change ctx@Ctx {cOrders, cWeb} = ctx {cOrders = orders, cWeb = cWeb {weOrders = Just orders}}
where
orders = cOrders {oeConfig = change (oeConfig cOrders)}
-- | Another server of the orders routes over the context with its names settings changed.
withSettings :: (NamesConfig -> NamesConfig) -> Ctx -> (Ctx -> IO a) -> IO a
withSettings change ctx action = do
let ctx'@Ctx {cOrders, cWeb} = configured change ctx
mgr <- newManager defaultManagerSettings
Warp.testWithApplicationSettings (webSettings (listener $ weConfig cWeb)) (pure $ ordersApp cWeb cOrders) $ \port ->
action ctx' {cClient = (mgr, "http://127.0.0.1:" <> show port)}
ordersApp :: WebEnv -> OrdersEnv -> Application
ordersApp web orders req respond = fromMaybe (webApp web req respond) (namesApp (weHttp web) (codeRoutes orders (weProviders web) (weWaiters web)) req respond)
-- | The routes as namesRoutes registers them, since a NamesEnv needs a running chat controller.
codeRoutes :: OrdersEnv -> [Provider] -> Waiters -> [Text] -> Maybe Route
codeRoutes orders providers waiters = \case
["api", "name", "register"] -> Just Route {rMethod = "POST", rLimit = codeLimit, rHandler = codeOrderHandler orders providers waiters}
["api", "code", "check"] -> Just Route {rMethod = "POST", rLimit = codeLimit, rHandler = codeCheckHandler (oeStore orders)}
_ -> Nothing
-- | alicebakery and dynamis available, at the launch prices for 2 years.
answerAvailable :: FakeEthNode -> IO ()
answerAvailable node = do
answerRegistrar node
setBlock node 100 (seconds orderTime)
forM_ [nameExpiresCall, dynamisExpiresCall] $ \call -> answerCall node (registrarOf node) call $ Returns $ uint256 (0 :: Int)
forM_ [reservedNamesCall, dynamisReservedCall] $ \call -> answerCall node fakeController call $ Returns $ uint256 (0 :: Int)
answerCall node fakeController (rentPriceCall 11) $ Returns $ rentPriceAnswer 10000
answerCall node fakeController (rentPriceCall 7) $ Returns $ rentPriceAnswer 20000
-- viem: nameExpires and reservedNames of labelhash("dynamis")
dynamisExpiresCall, dynamisReservedCall :: ByteString
dynamisExpiresCall = unhex "d6e4fa8678b602f03f164366531da963764c1f2fdfd588d0487daf2aa261ca799e5ffe33"
dynamisReservedCall = unhex "389c074878b602f03f164366531da963764c1f2fdfd588d0487daf2aa261ca799e5ffe33"
-- | Each request from its own client address, so the create limit counts none twice.
post :: Ctx -> String -> LB.ByteString -> IO (Int, Maybe J.Object)
post Ctx {cClient, cIps} path body = do
n <- atomicModifyIORef' cIps (\i -> (i + 1, i))
let ip = "198.51." <> show (n `div` 250) <> "." <> show (n `mod` 250 + 1)
r <- webRequestBody cClient "POST" path [(hContentType, "application/json"), ("X-Forwarded-For", BC.pack ip)] body
pure (statusOf r, J.decode (responseBody r))
postOrder :: Ctx -> J.Object -> IO (Int, Maybe J.Object)
postOrder ctx = post ctx "/api/invoice" . J.encode
placeOrder :: Ctx -> Order -> IO (Int, Maybe J.Object)
placeOrder ctx o = signedBody (cRandom ctx) o >>= postOrder ctx
-- | A placed order's invoice id, failing the test unless the order was accepted.
placed :: Ctx -> Order -> IO (InvoiceId, J.Object)
placed ctx o =
placeOrder ctx o >>= \case
(200, Just v) | Just (J.String invId) <- KM.lookup "invoiceId" v -> pure (InvoiceId invId, v)
other -> error $ "order refused: " <> show other
errorOnly :: Text -> Maybe J.Object
errorOnly tag = Just $ KM.fromList [("error", J.String tag)]
refusedWith :: Int -> Text -> (Int, Maybe J.Object)
refusedWith status tag = (status, errorOnly tag)
registrationOf :: J.Object -> J.Object
registrationOf v = case KM.lookup "registration" v of
Just (J.Object r) -> r
_ -> error "no registration in the view"
field :: Text -> J.Object -> Maybe J.Value
field k = KM.lookup (K.fromText k)
advance :: Ctx -> NominalDiffTime -> IO ()
advance Ctx {cClock} by = modifyIORef' cClock (addUTCTime by)
registrationsOf :: Ctx -> Address -> IO [NameRegistration]
registrationsOf Ctx {cStore, cClock} owner = readIORef cClock >>= \now -> map fst <$> withTransaction cStore (\db -> findRegistrations db owner now)
invoiceRegistration :: Ctx -> InvoiceId -> IO NameRegistration
invoiceRegistration Ctx {cStore} invId =
getInvoice cStore invId >>= \case
Just InvoiceRow {irProduct = IPName rid} -> withTransaction cStore $ \db -> fromJust <$> getRegistration db rid
other -> error $ "not a name invoice: " <> show other
settleNow :: Ctx -> InvoiceId -> PaymentSignal -> IO (Either Text InvoiceStatus)
settleNow Ctx {cStore, cWeb, cOrders, cClock} invId signal = readIORef cClock >>= settleOrder cStore (weWaiters cWeb) (Just $ oePhases cOrders) invId signal
settled :: PaymentSignal
settled = SigSettled (rcv 10000 (Just "0.00156")) orderTime
textOf :: Text -> Maybe J.Object -> Text
textOf k v = case field k =<< v of
Just (J.String t) -> t
other -> error $ T.unpack k <> ": " <> show other
objectOf :: J.Value -> J.Object
objectOf = \case
J.Object o -> o
v -> error $ "not an object: " <> show v
-- tests
testPaidOrder :: IO ()
testPaidOrder = withOrders $ \ctx -> do
(invId, v) <- placed ctx aliceOrder
sort (KM.keys v)
`shouldBe` sort ["invoiceId", "status", "amount", "currency", "expiresAt", "kind", "registration", "address", "cryptoAmount", "cryptoCurrency", "requiredConfirmations"]
field "kind" v `shouldBe` Just "name"
field "status" v `shouldBe` Just "open"
field "amount" v `shouldBe` Just (J.Number 10000)
field "currency" v `shouldBe` Just "usd"
field "expiresAt" v `shouldBe` Just (J.toJSON $ addUTCTime (45 * 60) orderTime)
let r = registrationOf v
field "phase" r `shouldBe` Just "unpaid"
field "version" r `shouldBe` Just (J.Number 1)
field "owner" r `shouldBe` Just (J.String ownerAText)
field "labelHash" r `shouldBe` Just (J.String $ hex $ (\(LabelHash h) -> h) alice)
field "price" r `shouldBe` Just (J.Number 10000)
field "duration" r `shouldBe` Just (J.toJSON $ durationAt orderTime)
field "terms" r `shouldBe` Just (J.String termsVersion)
stubCalls (cStub ctx) `shouldReturn` [StubCreate (SPMCrypto CCBtc) OrderDraft {odAmount = CurrencyAmount 10000, odCurrency = "usd"}]
reg <- invoiceRegistration ctx invId
(nrPhase reg, nrLive reg, nrVersion reg, nrYears reg, nrPrice reg, nrPayment reg) `shouldBe` (PUnpaid, True, 1, 2, 10000, RPInvoice (SPMCrypto CCBtc) invId)
nrOrderHash reg `shouldBe` orderHash ownerA (fieldsOf aliceOrder) (OTInvoice (SPMCrypto CCBtc) 10000)
nrAgreedTerms reg `shouldBe` termsVersion
-- | The body of the contract's Appendix C order vector, with viem's signature over it (NamesSignatureTests).
testViemOrder :: IO ()
testViemOrder = withOrders $ \ctx0 -> withSettings vectorTermStart ctx0 $ \ctx -> do
writeIORef (cClock ctx) (posixSecondsToUTCTime $ fromIntegral $ oTimestamp vectorOrder)
signedBody (cRandom ctx) vectorOrder >>= (`shouldBe` withSignature vectorSignature (unsignedBody vectorOrder))
(status, v) <- postOrder ctx $ withSignature vectorSignature (unsignedBody vectorOrder)
status `shouldBe` 200
(field "phase" . registrationOf =<< v) `shouldBe` Just "unpaid"
-- | Appendix C's order: alicebakery with no links, signed with the terms version.
vectorOrder :: Order
vectorOrder =
aliceOrder
{ oCommitment = unhex "5e62a4cb7da596cb38169a6a552b5f69db2acee279b53be6693a78e03c3dfaa5",
oSealed = "AAAAAAAAAAAAAAAACWgxqMk-UKfpra_Zh6vc4KNBR6SX1WYpGb4COoWCCbM0bisYCH14WLzghp8hqi_-KyXSi4qf_DAdP_M4bFetD0s",
oDuration = 69292800,
oTimestamp = 1792828800
}
vectorSignature :: Text
vectorSignature = "0x17107b11494e25098efc348aca64a2469ffb832bcc79dc57377a1b26a4004b6701ad73648f50af8b39d4079820a3de7e681ddf15f8cf15b5cff02b4a48cf1fe61c"
-- | Appendix C's duration is the page's at 21 October 2026 00:00 UTC, before its timestamp,
-- so the term starts later by as much for the vector to pass the duration check at its timestamp.
vectorTermStart :: NamesConfig -> NamesConfig
vectorTermStart cfg = cfg {nTermStart = posixSecondsToUTCTime $ fromIntegral $ oTimestamp vectorOrder + oDuration vectorOrder - 2 * yearSeconds}
testExactResend :: IO ()
testExactResend = withOrders $ \ctx -> do
body <- signedBody (cRandom ctx) aliceOrder
(invId, first') <- placed ctx aliceOrder
advance ctx 60
resigned <- placed ctx aliceOrder {oTimestamp = seconds orderTime + 60}
resigned `shouldBe` (invId, first')
advance ctx 600
postOrder ctx body `shouldReturn` (200, Just first')
length <$> stubCalls (cStub ctx) `shouldReturn` 1
length <$> registrationsOf ctx ownerA `shouldReturn` 1
testResendPastUnpaid :: IO ()
testResendPastUnpaid = withOrders $ \ctx -> do
(invId, _) <- placed ctx aliceOrder
settleNow ctx invId settled `shouldReturn` Right ISPaid
setFault (cNode ctx) "eth_call" HttpFailure
(status, v) <- placeOrder ctx aliceOrder {oTimestamp = seconds orderTime + 1}
status `shouldBe` 200
(field "invoiceId" =<< v) `shouldBe` Just (J.String $ (\(InvoiceId t) -> t) invId)
(field "status" =<< v) `shouldBe` Just "paid"
(field "phase" . registrationOf =<< v) `shouldBe` Just "queued"
testResendExpired :: IO ()
testResendExpired = withOrders $ \ctx -> do
(InvoiceId invId, _) <- placed ctx aliceOrder
fst <$> post ctx ("/api/invoice/" <> T.unpack invId <> "/cancel") "" `shouldReturn` 200
(status, v) <- placeOrder ctx aliceOrder
status `shouldBe` 200
(field "invoiceId" =<< v) `shouldBe` Just (J.String invId)
(field "status" =<< v) `shouldBe` Just "expired"
testResendReadsTime :: IO ()
testResendReadsTime = do
withOrders $ \ctx -> do
(invId, _) <- placed ctx aliceOrder
advance ctx (45 * 60 + 1)
(status, v) <- placeOrder ctx aliceOrder
(status, field "status" =<< v, field "phase" . registrationOf =<< v) `shouldBe` (200, Just "expired", Just "unpaid")
fmap irStatus <$> getInvoice (cStore ctx) invId `shouldReturn` Just ISOpen
withOrders $ \ctx@Ctx {cStore, cClock} -> do
(invId, _) <- placed ctx aliceOrder
settleNow ctx invId settled `shouldReturn` Right ISPaid
r <- invoiceRegistration ctx invId
now <- readIORef cClock
let lapse = seconds now + 600
Written version <- withTransaction cStore $ \db ->
updateRegistration db r {nrPhase = PCommitted, nrRevealAfter = Just (lapse - 3600), nrRevealBefore = Just lapse} now
let resend = (\(status, v) -> (status, field "status" =<< v, (field "phase" &&& field "error") . registrationOf <$> v)) <$> placeOrder ctx aliceOrder
resend `shouldReturn` (200, Just "paid", Just (Just "committed", Just J.Null))
advance ctx 601
resend `shouldReturn` (200, Just "paid", Just (Just "failed", Just "The name was not signed in time"))
(nrPhase &&& nrVersion) <$> invoiceRegistration ctx invId `shouldReturn` (PCommitted, version)
testOneFieldDiffers :: IO ()
testOneFieldDiffers = withOrders $ \ctx -> do
void $ placed ctx aliceOrder
let variants =
[ aliceOrder {oLabel = "dynamis"},
aliceOrder {oCommitment = commitmentOf 2},
aliceOrder {oSealed = "sealed-alicebakery-2"},
aliceOrder {oDuration = oDuration aliceOrder + 1},
aliceOrder {oMethod = "xmr"},
aliceOrder {oMethod = "card"},
aliceOrder {oPrice = alicePrice + 1},
aliceOrder {oTerms = termsVersion <> "/community"}
]
forM_ variants $ \o -> placeOrder ctx o `shouldReturn` refusedWith 409 "order_exists"
length <$> stubCalls (cStub ctx) `shouldReturn` 1
testForeignCommitment :: IO ()
testForeignCommitment = withOrders $ \ctx -> do
void $ placed ctx aliceOrder
placeOrder ctx aliceOrder {oOwner = ownerBText, oKey = keyB, oLabel = "dynamis", oPrice = 20000}
`shouldReturn` refusedWith 409 "order_exists"
placeOrder ctx aliceOrder {oOwner = ownerBText, oKey = keyB}
`shouldReturn` refusedWith 409 "order_exists"
length <$> registrationsOf ctx ownerB `shouldReturn` 0
testBadSignature :: IO ()
testBadSignature = withOrders $ \ctx -> do
byB <- signedBody (cRandom ctx) aliceOrder {oKey = keyB}
postOrder ctx byB `shouldReturn` refusedWith 400 "bad_request"
good <- signedBody (cRandom ctx) aliceOrder
sig <- case KM.lookup "signature" good of
Just (J.String t) -> pure t
other -> error $ "signature: " <> show other
let highS = "0x" <> T.replicate 32 "11" <> "ff" <> T.replicate 31 "11" <> "1b"
forM_ [T.toUpper sig, T.dropEnd 2 sig <> "1d", highS, ""] $ \bad ->
postOrder ctx (withSignature bad good) `shouldReturn` refusedWith 400 "bad_request"
postOrder ctx (KM.delete "signature" good) `shouldReturn` refusedWith 400 "bad_request"
stubCalls (cStub ctx) `shouldReturn` []
testStaleTimestamp :: IO ()
testStaleTimestamp = withOrders $ \ctx -> do
let at offset = aliceOrder {oTimestamp = fromIntegral (toInteger (seconds orderTime) + offset)}
forM_ [-601, 601] $ \offset -> placeOrder ctx (at offset) `shouldReturn` refusedWith 400 "bad_request"
-- near the top of Word64, where a sum in the timestamp's own type would wrap
placeOrder ctx aliceOrder {oTimestamp = maxBound} `shouldReturn` refusedWith 400 "bad_request"
fst <$> placeOrder ctx (at (-600)) `shouldReturn` 200
-- | The provider is slow, so without the name's turn both orders would pass the existing-order check and create an invoice.
testConcurrentOrders :: IO ()
testConcurrentOrders = withOrdersVia (\p -> p {pCreateInvoice = \m d -> threadDelay 300000 >> pCreateInvoice p m d}) $ \ctx -> do
(a, b) <- concurrently (placeOrder ctx aliceOrder) (placeOrder ctx aliceOrder {oCommitment = commitmentOf 2})
sort [fst a, fst b] `shouldBe` [200, 409]
filter ((== 409) . fst) [a, b] `shouldBe` [refusedWith 409 "order_exists"]
length <$> registrationsOf ctx ownerA `shouldReturn` 1
length <$> stubCalls (cStub ctx) `shouldReturn` 1
-- | Two names take two turns, and both creates are let through together, so both orders reach the insert.
testLostInsertCancels :: IO ()
testLostInsertCancels = do
arrived <- newTVarIO (0 :: Int)
let meet = atomically (modifyTVar' arrived (+ 1)) >> atomically (readTVar arrived >>= check . (>= 2))
withOrdersVia (\p -> p {pCreateInvoice = \m d -> meet >> pCreateInvoice p m d}) $ \ctx -> do
(a, b) <- concurrently (placeOrder ctx aliceOrder) (placeOrder ctx aliceOrder {oLabel = "dynamis", oPrice = 20000})
sort [fst a, fst b] `shouldBe` [200, 409]
filter ((== 409) . fst) [a, b] `shouldBe` [refusedWith 409 "order_exists"]
[(_, winner)] <- pure $ filter ((== 200) . fst) [a, b]
Just InvoiceRow {irProviderRef = won} <- getInvoice (cStore ctx) (InvoiceId $ textOf "invoiceId" winner)
calls <- stubCalls (cStub ctx)
let created = length [() | StubCreate {} <- calls]
cancelled = [r | StubCancel r <- calls]
created `shouldBe` 2
length cancelled `shouldBe` 1
cancelled `shouldNotBe` [won]
-- | The stub gives every invoice one ref, so the second order's insert breaks the unique ref.
testInsertFailureCancels :: IO ()
testInsertFailureCancels = withOrdersVia (\p -> p {pCreateInvoice = \m d -> fmap (\i -> i {piProviderRef = "pref-same"}) <$> pCreateInvoice p m d}) $ \ctx -> do
void $ placed ctx aliceOrder
-- a listener that answers the handler's exception instead of rethrowing it into the test
mgr <- newManager defaultManagerSettings
logs <- Warp.withApplication (pure $ webApp $ cWeb ctx) $ \port -> do
let answering = ctx {cClient = (mgr, "http://127.0.0.1:" <> show port)}
capturingStderr $ fst <$> placeOrder answering aliceOrder {oLabel = "dynamis", oPrice = 20000, oCommitment = commitmentOf 2} `shouldReturn` 500
logs `shouldSatisfy` T.isInfixOf "invoice pref-same is cancelled at the provider"
calls <- stubCalls (cStub ctx)
[r | StubCancel r <- calls] `shouldBe` ["pref-same"]
testDurationSlack :: IO ()
testDurationSlack = withOrders $ \ctx -> do
let slack = fromIntegral slackSeconds
withDuration n d = aliceOrder {oCommitment = commitmentOf n, oDuration = d}
early = durationAt orderTime
placeOrder ctx (withDuration 2 (early + slack + 1)) `shouldReturn` refusedWith 400 "bad_request"
placeOrder ctx (withDuration 3 (early - slack - 1)) `shouldReturn` refusedWith 400 "bad_request"
fst <$> placeOrder ctx (withDuration 4 (early + slack)) `shouldReturn` 200
-- after the term start no free days are left, and fewer than the paid years are refused
let later = addUTCTime 86400 termStart
afterStart n d = (withDuration n d) {oLabel = "dynamis", oPrice = 20000, oTimestamp = seconds later}
writeIORef (cClock ctx) later
durationAt later `shouldBe` 2 * yearSeconds
placeOrder ctx (afterStart 5 (2 * yearSeconds - 1)) `shouldReturn` refusedWith 400 "bad_request"
placeOrder ctx (afterStart 6 (2 * yearSeconds + slack + 1)) `shouldReturn` refusedWith 400 "bad_request"
fst <$> placeOrder ctx (afterStart 7 (2 * yearSeconds + slack)) `shouldReturn` 200
testCatalogChanged :: IO ()
testCatalogChanged = withOrders $ \ctx -> do
placeOrder ctx aliceOrder {oPrice = 9000} `shouldReturn` (409, Just $ KM.fromList [("error", "catalog_changed"), ("price", J.Number 10000)])
stubCalls (cStub ctx) `shouldReturn` []
testNameTaken :: IO ()
testNameTaken = withOrders $ \ctx@Ctx {cNode = node} -> do
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (1 :: Int)
placeOrder ctx aliceOrder `shouldReturn` refusedWith 409 "name_taken"
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int)
answerRegistered node (cResolverOf node)
answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (seconds orderTime + yearSeconds)
placeOrder ctx aliceOrder `shouldReturn` refusedWith 409 "name_taken"
answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int)
-- another wallet's order of the name being registered here
Right () <- placePaid (cStore ctx) "other" ownerB (order "alicebakery" 9) (addUTCTime 3600 t0)
other <- withTransaction (cStore ctx) $ \db -> fromJust <$> getRegistration db (RegistrationId "other")
Written _ <- withTransaction (cStore ctx) $ \db -> updateRegistration db other {nrPhase = PRegistering} t0
placeOrder ctx aliceOrder `shouldReturn` refusedWith 409 "name_taken"
stubCalls (cStub ctx) `shouldReturn` []
where
cResolverOf = cResolver . fakeChainConfig
testNameCheckUnavailable :: IO ()
testNameCheckUnavailable = withOrders $ \ctx@Ctx {cNode = node} -> do
setFault node "eth_getBlockByNumber" HttpFailure
placeOrder ctx aliceOrder `shouldReturn` refusedWith 503 "name_check_unavailable"
clearFault node "eth_getBlockByNumber"
answerCall node fakeController (rentPriceCall 11) $ Reverts ""
placeOrder ctx aliceOrder `shouldReturn` refusedWith 503 "name_check_unavailable"
stubCalls (cStub ctx) `shouldReturn` []
testSizes :: IO ()
testSizes = withOrders $ \ctx -> do
missing <- KM.delete "sealed" <$> signedBody (cRandom ctx) aliceOrder
postOrder ctx missing `shouldReturn` refusedWith 400 "bad_request"
placeOrder ctx aliceOrder {oSealed = T.replicate 11096 "a"} `shouldReturn` refusedWith 400 "bad_request"
padded <- KM.insert "padding" (J.String $ T.replicate 16384 "a") <$> signedBody (cRandom ctx) aliceOrder
postOrder ctx padded `shouldReturn` refusedWith 400 "bad_request"
longest <- signedBody (cRandom ctx) aliceOrder {oSealed = T.replicate 11095 "a"}
LB.length (J.encode longest) `shouldSatisfy` (< 16384)
fst <$> postOrder ctx longest `shouldReturn` 200
testFormats :: IO ()
testFormats = withOrders $ \ctx -> do
good <- signedBody (cRandom ctx) aliceOrder
let with k v = KM.insert k v good
lowerOwner = J.String $ T.toLower ownerAText
forM_
[ with "labelHash" (J.String $ T.toUpper $ hex $ (\(LabelHash h) -> h) alice),
with "commitment" (J.String "0x1234"),
with "owner" lowerOwner,
with "method" "eth",
with "price" (J.Number 100.5),
with "duration" (J.Number (-1)),
with "timestamp" "1792574100",
with "length" (J.Number 1e30),
KM.delete "terms" good,
with "terms" (J.Number 20261023),
KM.delete "kind" good,
with "kind" "badge"
]
$ \body -> postOrder ctx body `shouldReturn` refusedWith 400 "bad_request"
forM_ malformedTerms $ \t -> placeOrder ctx aliceOrder {oTerms = t} `shouldReturn` refusedWith 400 "bad_request"
forM_ [5, 64] $ \len -> postOrder ctx (with "length" (J.Number len)) `shouldReturn` refusedWith 400 "invalid_name"
stubCalls (cStub ctx) `shouldReturn` []
-- | The name on the operator's community list.
listName :: Text -> NamesConfig -> NamesConfig
listName label cfg = cfg {nCommunity = M.insert lh bakers (nCommunity cfg)}
where
LabelHash lh = labelHash label
bakers :: Text
bakers = "bakers and bakeries in Portugal"
-- | The operator's next terms version, after a restart.
bumpedTerms :: NamesConfig -> NamesConfig
bumpedTerms cfg@NamesConfig {nTerms} = cfg {nTerms = nTerms {tVersion = newerTerms}}
newerTerms, olderTerms :: Text
newerTerms = "2026-11-01"
olderTerms = "2026-10-01"
communityTerms :: Text -> Text
communityTerms version = version <> "/community"
-- | terms_changed with the current version, the terms' URL and the name's community entry.
termsChanged :: Text -> Maybe Text -> (Int, Maybe J.Object)
termsChanged version community =
( 409,
Just $
KM.fromList
[ ("error", "terms_changed"),
("terms", J.object ["version" .= version, "url" .= termsUrl]),
("community", maybe J.Null (\t -> J.object ["for" .= t]) community)
]
)
-- | Signed as sent, so only their format refuses them.
malformedTerms :: [Text]
malformedTerms = ["", T.replicate 49 "a", "2026-10-23 community", "2026-10-23+community", "2026-10-23/communit\233"]
testTwoWallets :: IO ()
testTwoWallets = withOrders $ \ctx -> do
(a, _) <- placed ctx aliceOrder
(b, _) <- placed ctx aliceOrder {oOwner = ownerBText, oKey = keyB, oCommitment = commitmentOf 2}
a `shouldNotBe` b
mapM (fmap nrLive . invoiceRegistration ctx) [a, b] `shouldReturn` [True, True]
testTermsChanged :: IO ()
testTermsChanged = withOrders $ \ctx0 -> withSettings (listName "dynamis") ctx0 $ \ctx -> do
let dynamis = aliceOrder {oLabel = "dynamis", oCommitment = commitmentOf 2, oPrice = 20000}
-- 48 characters pass the format and meet the terms check
forM_ [olderTerms, communityTerms termsVersion, communityTerms olderTerms, T.replicate 48 "a"] $ \t ->
placeOrder ctx aliceOrder {oTerms = t} `shouldReturn` termsChanged termsVersion Nothing
forM_ [termsVersion, olderTerms, communityTerms olderTerms] $ \t ->
placeOrder ctx dynamis {oTerms = t} `shouldReturn` termsChanged termsVersion (Just bakers)
stubCalls (cStub ctx) `shouldReturn` []
registrationsOf ctx ownerA `shouldReturn` []
(invId, v) <- placed ctx dynamis {oTerms = communityTerms termsVersion}
field "terms" (registrationOf v) `shouldBe` Just (J.String $ communityTerms termsVersion)
nrAgreedTerms <$> invoiceRegistration ctx invId `shouldReturn` communityTerms termsVersion
testCommunityTerms :: IO ()
testCommunityTerms = withOrders $ \ctx0 -> withSettings (listName "alicebakery" . vectorTermStart) ctx0 $ \ctx -> do
writeIORef (cClock ctx) (posixSecondsToUTCTime $ fromIntegral $ oTimestamp vectorOrder)
postOrder ctx (withSignature vectorSignature $ unsignedBody vectorOrder) `shouldReturn` termsChanged termsVersion (Just bakers)
let community = vectorOrder {oTerms = communityTerms termsVersion}
communitySignature = "0x03eccb591b79ab4e3e6f22ccbeb77a6ac32c6dad8cb318557836258c390bc31d78852a3279c6c8cc2ef8d1025641d45ba4101f54dfd84c264deb2ab391c1cbc61c"
signedBody (cRandom ctx) community >>= (`shouldBe` withSignature communitySignature (unsignedBody community))
(status, v) <- postOrder ctx $ withSignature communitySignature (unsignedBody community)
(status, field "terms" . registrationOf =<< v, field "community" . registrationOf =<< v)
`shouldBe` (200, Just (J.String $ communityTerms termsVersion), Just (J.object ["for" .= bakers]))
testResendAfterBump :: IO ()
testResendAfterBump = withOrders $ \ctx0 -> do
body <- signedBody (cRandom ctx0) aliceOrder
(invId@(InvoiceId invText), view) <- placed ctx0 aliceOrder
withSettings bumpedTerms ctx0 $ \ctx -> do
advance ctx 60
let later = aliceOrder {oTimestamp = seconds orderTime + 60}
postOrder ctx body `shouldReturn` (200, Just view)
placed ctx later `shouldReturn` (invId, view)
placeOrder ctx later {oTerms = newerTerms} `shouldReturn` refusedWith 409 "order_exists"
fst <$> post ctx ("/api/invoice/" <> T.unpack invText <> "/cancel") "" `shouldReturn` 200
(newer, _) <- placed ctx later {oTerms = newerTerms}
newer `shouldNotBe` invId
[n, o] <- mapM (invoiceRegistration ctx) [newer, invId]
(nrAgreedTerms n, nrAgreedTerms o) `shouldBe` (newerTerms, termsVersion)
nrOrderHash n `shouldNotBe` nrOrderHash o
(status, v) <- postOrder ctx body
(status, field "invoiceId" =<< v, field "status" =<< v) `shouldBe` (200, Just (J.String invText), Just "expired")
testTermsCheckOrder :: IO ()
testTermsCheckOrder = withOrders $ \ctx@Ctx {cNode = node} -> do
let old = aliceOrder {oTerms = olderTerms}
void $ placed ctx aliceOrder {oOwner = ownerBText, oKey = keyB, oCommitment = commitmentOf 5}
placeOrder ctx old {oOwner = ownerBText, oKey = keyB, oCommitment = commitmentOf 6} `shouldReturn` refusedWith 409 "order_exists"
placeOrder ctx old {oTimestamp = seconds orderTime + 601} `shouldReturn` refusedWith 400 "bad_request"
placeOrder ctx old {oDuration = oDuration old + fromIntegral slackSeconds + 1} `shouldReturn` refusedWith 400 "bad_request"
placeOrder ctx old {oPrice = 9000} `shouldReturn` termsChanged termsVersion Nothing
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (1 :: Int)
placeOrder ctx old `shouldReturn` termsChanged termsVersion Nothing
setFault node "eth_call" HttpFailure
placeOrder ctx old `shouldReturn` termsChanged termsVersion Nothing
length <$> stubCalls (cStub ctx) `shouldReturn` 1
-- code orders
-- | A prepaid code as the operator issues it, with its text as the page sends it.
issueCode :: Ctx -> Int -> Int -> (NewPrepaidCode -> NewPrepaidCode) -> IO (Text, PrepaidCode)
issueCode Ctx {cStore, cRandom} len years change = do
code <- randomNamePrepaidCode cRandom len
let new = change NewPrepaidCode {npcCodeHash = namePrepaidCodeHash code, npcMinLength = len, npcYears = years, npcPaymentStatus = CPSPaid, npcExpiresAt = Nothing}
stored <- withTransaction cStore $ \db ->
insertPrepaidCode db new orderTime >>= \case
Right codeId -> fromJust <$> getPrepaidCodeById db codeId
Left e -> error $ "code not issued: " <> show e
pure (formatNameCode $ NCPrepaid code, stored)
unissuedCode :: Ctx -> Int -> IO Text
unissuedCode Ctx {cRandom} len = formatNameCode . NCPrepaid <$> randomNamePrepaidCode cRandom len
readCode :: Ctx -> PrepaidCode -> IO PrepaidCode
readCode Ctx {cStore} PrepaidCode {pcId} = withTransaction cStore $ \db -> fromJust <$> getPrepaidCodeById db pcId
-- | The body of /api/name/register: the order's fields and the code, without a kind, method or price.
signedCodeBody :: Ctx -> Text -> Order -> IO J.Object
signedCodeBody ctx code o = KM.insert "code" (J.String code) . KM.delete "kind" . KM.delete "method" . KM.delete "price" <$> signedBody (cRandom ctx) o
postRegister :: Ctx -> J.Object -> IO (Int, Maybe J.Object)
postRegister ctx = post ctx "/api/name/register" . J.encode
register :: Ctx -> Text -> Order -> IO (Int, Maybe J.Object)
register ctx code o = signedCodeBody ctx code o >>= postRegister ctx
-- | A code order's registration id, failing the test unless the order was accepted with that alone.
registered :: Ctx -> Text -> Order -> IO RegistrationId
registered ctx code o =
register ctx code o >>= \case
(200, Just v) | [("registrationId", J.String rid)] <- KM.toList v -> pure $ RegistrationId rid
other -> error $ "code order refused: " <> show other
registrationAnswer :: RegistrationId -> (Int, Maybe J.Object)
registrationAnswer (RegistrationId rid) = (200, Just $ KM.fromList [("registrationId", J.String rid)])
registrationById :: Ctx -> RegistrationId -> IO NameRegistration
registrationById Ctx {cStore} rid = withTransaction cStore $ \db -> fromJust <$> getRegistration db rid
testCodeOrder :: IO ()
testCodeOrder = withOrders $ \ctx -> do
(code, issued@PrepaidCode {pcId, pcCodeHash}) <- issueCode ctx 8 2 id
rid <- registered ctx code aliceOrder
r <- registrationById ctx rid
(nrPhase r, nrLive r, nrVersion r, nrYears r, nrPrice r, nrPayment r) `shouldBe` (PQueued, True, 1, 2, 0, RPCode pcId)
(nrOwner r, nrDuration r, nrCommitment r, nrSealed r, nrAgreedTerms r) `shouldBe` (ownerA, durationAt orderTime, oCommitment aliceOrder, oSealed aliceOrder, termsVersion)
nrOrderHash r `shouldBe` orderHash ownerA (fieldsOf aliceOrder) (OTCode pcCodeHash)
held <- readCode ctx issued
(pcReservedBy held, pcReservedAt held, pcSpentAt held) `shouldBe` (Just rid, Just orderTime, Nothing)
stubCalls (cStub ctx) `shouldReturn` []
testCodeResend :: IO ()
testCodeResend = withOrders $ \ctx -> do
(code, PrepaidCode {pcId}) <- issueCode ctx 8 2 id
body <- signedCodeBody ctx code aliceOrder
rid <- registered ctx code aliceOrder
advance ctx 60
register ctx code aliceOrder {oTimestamp = seconds orderTime + 60} `shouldReturn` registrationAnswer rid
r <- registrationById ctx rid
now <- readIORef (cClock ctx)
withTransaction (cStore ctx) $ \db -> do
updateRegistration db r {nrPhase = PRegistered} now `shouldReturn` Written 2
spendPrepaidCode db pcId rid now `shouldReturn` True
advance ctx 3600
setFault (cNode ctx) "eth_call" HttpFailure
postRegister ctx body `shouldReturn` registrationAnswer rid
register ctx code aliceOrder {oTimestamp = seconds orderTime + 3660} `shouldReturn` registrationAnswer rid
length <$> registrationsOf ctx ownerA `shouldReturn` 1
testCodeTermsChanged :: IO ()
testCodeTermsChanged = withOrders $ \ctx0 -> withSettings (listName "dynamis") ctx0 $ \ctx -> do
(code, issued) <- issueCode ctx 7 2 id
let dynamis = aliceOrder {oLabel = "dynamis", oCommitment = commitmentOf 2}
forM_ [olderTerms, communityTerms termsVersion] $ \t ->
register ctx code aliceOrder {oTerms = t} `shouldReturn` termsChanged termsVersion Nothing
forM_ [termsVersion, communityTerms olderTerms] $ \t ->
register ctx code dynamis {oTerms = t} `shouldReturn` termsChanged termsVersion (Just bakers)
pcReservedBy <$> readCode ctx issued `shouldReturn` Nothing
rid <- registered ctx code dynamis {oTerms = communityTerms termsVersion}
nrAgreedTerms <$> registrationById ctx rid `shouldReturn` communityTerms termsVersion
pcReservedBy <$> readCode ctx issued `shouldReturn` Just rid
testCodeResendAfterBump :: IO ()
testCodeResendAfterBump = withOrders $ \ctx0 -> do
(code, _) <- issueCode ctx0 8 2 id
rid <- registered ctx0 code aliceOrder
withSettings bumpedTerms ctx0 $ \ctx -> do
advance ctx 60
register ctx code aliceOrder `shouldReturn` registrationAnswer rid
register ctx code aliceOrder {oTimestamp = seconds orderTime + 60} `shouldReturn` registrationAnswer rid
register ctx code aliceOrder {oTerms = newerTerms, oTimestamp = seconds orderTime + 60} `shouldReturn` refusedWith 409 "order_exists"
testCodeTermsCheckOrder :: IO ()
testCodeTermsCheckOrder = withOrders $ \ctx@Ctx {cStore, cNode = node} -> do
let old = aliceOrder {oTerms = olderTerms}
(code, _) <- issueCode ctx 8 2 id
register ctx code old {oTimestamp = seconds orderTime + 601} `shouldReturn` refusedWith 400 "bad_request"
register ctx code old {oDuration = oDuration old + fromIntegral slackSeconds + 1} `shouldReturn` refusedWith 400 "bad_request"
(reserved, reservedCode) <- issueCode ctx 8 2 id
placeCode cStore "holder" ownerB (order "holderone" 1) reservedCode `shouldReturn` Right ()
(long, _) <- issueCode ctx 12 2 id
(unpaid, _) <- issueCode ctx 8 2 $ \c -> c {npcPaymentStatus = CPSUnpaid}
forM_ [reserved, long, unpaid] $ \c -> register ctx c old `shouldReturn` termsChanged termsVersion Nothing
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (1 :: Int)
register ctx code old `shouldReturn` termsChanged termsVersion Nothing
setFault node "eth_call" HttpFailure
register ctx code old `shouldReturn` termsChanged termsVersion Nothing
length <$> registrationsOf ctx ownerA `shouldReturn` 0
testCodeOneFieldDiffers :: IO ()
testCodeOneFieldDiffers = withOrders $ \ctx -> do
(code, _) <- issueCode ctx 7 2 id
(other, otherCode) <- issueCode ctx 8 2 id
void $ registered ctx code aliceOrder
let variants =
[ (code, aliceOrder {oLabel = "dynamis"}),
(code, aliceOrder {oCommitment = commitmentOf 2}),
(code, aliceOrder {oSealed = "sealed-alicebakery-2"}),
(code, aliceOrder {oDuration = oDuration aliceOrder + 1}),
(code, aliceOrder {oTerms = termsVersion <> "/community"}),
(other, aliceOrder)
]
forM_ variants $ \(c, o) -> register ctx c o `shouldReturn` refusedWith 409 "order_exists"
-- the same fields as a paid order are another order
placeOrder ctx aliceOrder `shouldReturn` refusedWith 409 "order_exists"
pcReservedBy <$> readCode ctx otherCode `shouldReturn` Nothing
length <$> registrationsOf ctx ownerA `shouldReturn` 1
testCodeBadSignature :: IO ()
testCodeBadSignature = withOrders $ \ctx -> do
(code, issued) <- issueCode ctx 8 2 id
register ctx code aliceOrder {oKey = keyB} `shouldReturn` refusedWith 400 "bad_request"
good <- signedCodeBody ctx code aliceOrder
let highS = "0x" <> T.replicate 32 "11" <> "ff" <> T.replicate 31 "11" <> "1b"
forM_ [highS, ""] $ \bad -> postRegister ctx (withSignature bad good) `shouldReturn` refusedWith 400 "bad_request"
postRegister ctx (KM.delete "signature" good) `shouldReturn` refusedWith 400 "bad_request"
unknown <- unissuedCode ctx 8
register ctx unknown aliceOrder {oKey = keyB} `shouldReturn` refusedWith 400 "code_invalid"
pcReservedBy <$> readCode ctx issued `shouldReturn` Nothing
length <$> registrationsOf ctx ownerA `shouldReturn` 0
testCodeStaleTimestamp :: IO ()
testCodeStaleTimestamp = withOrders $ \ctx -> do
(code, _) <- issueCode ctx 8 2 id
let at offset = aliceOrder {oTimestamp = fromIntegral (toInteger (seconds orderTime) + offset)}
forM_ [-601, 601] $ \offset -> register ctx code (at offset) `shouldReturn` refusedWith 400 "bad_request"
register ctx code aliceOrder {oTimestamp = maxBound} `shouldReturn` refusedWith 400 "bad_request"
void $ registered ctx code (at (-600))
testCodeMeetsInvoice :: IO ()
testCodeMeetsInvoice = withOrders $ \ctx -> do
(code, issued) <- issueCode ctx 8 2 id
void $ placed ctx aliceOrder
register ctx code aliceOrder `shouldReturn` refusedWith 409 "order_exists"
register ctx code aliceOrder {oCommitment = commitmentOf 2} `shouldReturn` refusedWith 409 "order_exists"
-- another wallet sending the open order's commitment
register ctx code aliceOrder {oOwner = ownerBText, oKey = keyB} `shouldReturn` refusedWith 409 "order_exists"
pcReservedBy <$> readCode ctx issued `shouldReturn` Nothing
length <$> registrationsOf ctx ownerA `shouldReturn` 1
length <$> registrationsOf ctx ownerB `shouldReturn` 0
testCodeClosesOverdue :: IO ()
testCodeClosesOverdue = withOrders $ \ctx -> do
(code, _) <- issueCode ctx 8 2 id
(short, _) <- issueCode ctx 12 2 id
(oldInv, _) <- placed ctx aliceOrder {oMethod = "card"}
-- past the card invoice's 60 minutes, before any sweep
advance ctx (61 * 60)
now <- readIORef (cClock ctx)
let next = aliceOrder {oCommitment = commitmentOf 2, oTimestamp = seconds now, oDuration = durationAt now}
register ctx short next `shouldReturn` refusedWith 409 "code_not_covering"
nrLive <$> invoiceRegistration ctx oldInv `shouldReturn` True
rid <- registered ctx code next
Just oldRow <- getInvoice (cStore ctx) oldInv
irStatus oldRow `shouldBe` ISExpired
old <- invoiceRegistration ctx oldInv
(nrPhase old, nrLive old, nrVersion old) `shouldBe` (PUnpaid, False, 2)
(nrPhase &&& nrLive) <$> registrationById ctx rid `shouldReturn` (PQueued, True)
calls <- stubCalls (cStub ctx)
[c | c@StubCancel {} <- calls] `shouldBe` [StubCancel (irProviderRef oldRow)]
testCodeRefusals :: IO ()
testCodeRefusals = withOrders $ \ctx@Ctx {cStore} -> do
unknown <- unissuedCode ctx 8
let typo = T.init unknown <> (if T.last unknown == '0' then "1" else "0")
ownerCode = "SN-DWPHS-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5"
forM_ [unknown, typo, ownerCode, "not a code"] $ \c -> register ctx c aliceOrder `shouldReturn` refusedWith 400 "code_invalid"
(revoked, revokedCode) <- issueCode ctx 8 2 id
withTransaction cStore $ \db -> void $ revokePrepaidCode db (pcCodeHash revokedCode) orderTime
register ctx revoked aliceOrder `shouldReturn` refusedWith 400 "code_invalid"
(reserved, reservedCode) <- issueCode ctx 8 2 id
placeCode cStore "holder" ownerB (order "holderone" 1) reservedCode `shouldReturn` Right ()
register ctx reserved aliceOrder `shouldReturn` refusedWith 409 "code_reserved"
(used, usedCode) <- issueCode ctx 8 2 id
placeCode cStore "spender" ownerB (order "spendertwo" 2) usedCode `shouldReturn` Right ()
withTransaction cStore (\db -> spendPrepaidCode db (pcId usedCode) (RegistrationId "spender") t0) `shouldReturn` True
register ctx used aliceOrder `shouldReturn` refusedWith 409 "code_used"
(expired, _) <- issueCode ctx 8 2 $ \c -> c {npcExpiresAt = Just orderTime}
register ctx expired aliceOrder `shouldReturn` refusedWith 409 "code_expired"
(unpaid, _) <- issueCode ctx 8 2 $ \c -> c {npcPaymentStatus = CPSUnpaid}
register ctx unpaid aliceOrder `shouldReturn` refusedWith 409 "payment_pending"
(long, _) <- issueCode ctx 12 2 id
register ctx long aliceOrder `shouldReturn` refusedWith 409 "code_not_covering"
(longUnpaid, _) <- issueCode ctx 12 2 $ \c -> c {npcPaymentStatus = CPSUnpaid}
register ctx longUnpaid aliceOrder `shouldReturn` refusedWith 409 "payment_pending"
length <$> registrationsOf ctx ownerA `shouldReturn` 0
testCodeYears :: IO ()
testCodeYears = withOrders $ \ctx -> do
(code, _) <- issueCode ctx 8 5 id
register ctx code aliceOrder `shouldReturn` refusedWith 400 "bad_request"
rid <- registered ctx code aliceOrder {oDuration = yearsDuration 5 orderTime}
(nrYears &&& nrDuration) <$> registrationById ctx rid `shouldReturn` (5, yearsDuration 5 orderTime)
testCodeNameChecks :: IO ()
testCodeNameChecks = withOrders $ \ctx@Ctx {cNode = node} -> do
(code, issued) <- issueCode ctx 8 2 id
(long, _) <- issueCode ctx 12 2 id
let unreserved = pcReservedBy <$> readCode ctx issued `shouldReturn` Nothing
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (1 :: Int)
register ctx code aliceOrder `shouldReturn` refusedWith 409 "name_taken"
register ctx long aliceOrder `shouldReturn` refusedWith 409 "name_taken"
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int)
answerRegistered node (cResolver $ fakeChainConfig node)
answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (seconds orderTime + yearSeconds)
register ctx code aliceOrder `shouldReturn` refusedWith 409 "name_taken"
register ctx long aliceOrder `shouldReturn` refusedWith 409 "name_taken"
unreserved
answerCall node (registrarOf node) nameExpiresCall $ Returns $ uint256 (0 :: Int)
setFault node "eth_call" HttpFailure
register ctx code aliceOrder `shouldReturn` refusedWith 503 "name_check_unavailable"
register ctx long aliceOrder `shouldReturn` refusedWith 503 "name_check_unavailable"
unreserved
length <$> registrationsOf ctx ownerA `shouldReturn` 0
clearFault node "eth_call"
void $ registered ctx code aliceOrder
testCodeSizes :: IO ()
testCodeSizes = withOrders $ \ctx -> do
(code, _) <- issueCode ctx 8 2 id
register ctx code aliceOrder {oSealed = T.replicate 11096 "a"} `shouldReturn` refusedWith 400 "bad_request"
padded <- KM.insert "padding" (J.String $ T.replicate 16384 "a") <$> signedCodeBody ctx code aliceOrder
postRegister ctx padded `shouldReturn` refusedWith 400 "bad_request"
good <- signedCodeBody ctx code aliceOrder
let with k v = KM.insert k v good
forM_
[ KM.delete "sealed" good,
KM.delete "code" good,
with "code" (J.Number 8),
with "labelHash" (J.String $ T.toUpper $ hex $ (\(LabelHash h) -> h) alice),
with "owner" (J.String $ T.toLower ownerAText),
with "timestamp" "1792574100",
KM.delete "terms" good
]
$ \body -> postRegister ctx body `shouldReturn` refusedWith 400 "bad_request"
forM_ malformedTerms $ \t -> register ctx code aliceOrder {oTerms = t} `shouldReturn` refusedWith 400 "bad_request"
forM_ ["[]", "{"] $ \body -> post ctx "/api/name/register" body `shouldReturn` (400, errorOnly "bad_request")
-- the formats and the length come before the code
forM_ [5, 64] $ \len -> postRegister ctx (KM.insert "code" "not a code" $ with "length" (J.Number len)) `shouldReturn` refusedWith 400 "invalid_name"
longest <- signedCodeBody ctx code aliceOrder {oSealed = T.replicate 11095 "a"}
LB.length (J.encode longest) `shouldSatisfy` (< 16384)
fst <$> postRegister ctx longest `shouldReturn` 200
-- | Both orders wait while the test holds the code's turn, so neither checks or reserves the code outside it.
testConcurrentCodeOrders :: IO ()
testConcurrentCodeOrders = withOrders $ \ctx -> do
(code, issued@PrepaidCode {pcCodeHash}) <- issueCode ctx 7 2 id
entered <- newEmptyMVar
release <- newEmptyMVar
holder <- async $ withCodeTurn (oeTurns $ cOrders ctx) pcCodeHash $ putMVar entered () >> takeMVar release
takeMVar entered
orders <- async $ concurrently (register ctx code aliceOrder) (register ctx code aliceOrder {oLabel = "dynamis", oCommitment = commitmentOf 2})
timeout 300000 (wait orders) >>= (`shouldSatisfy` isNothing)
pcReservedBy <$> readCode ctx issued `shouldReturn` Nothing
putMVar release ()
wait holder
(a, b) <- wait orders
sort [fst a, fst b] `shouldBe` [200, 409]
filter ((== 409) . fst) [a, b] `shouldBe` [refusedWith 409 "code_reserved"]
[(_, winner)] <- pure $ filter ((== 200) . fst) [a, b]
pcReservedBy <$> readCode ctx issued `shouldReturn` Just (RegistrationId $ textOf "registrationId" winner)
length <$> registrationsOf ctx ownerA `shouldReturn` 1
-- | A writer outside this process, which takes no turn here, reserves or revokes the code between the check and the insert.
testCodeNotReservable :: IO ()
testCodeNotReservable = withOrders $ \ctx@Ctx {cStore} -> do
(_, reservedLate) <- issueCode ctx 8 2 id
placeCode cStore "outsider" ownerB (order "holderone" 1) reservedLate `shouldReturn` Right ()
(_, revokedLate) <- issueCode ctx 8 2 id
withTransaction cStore $ \db -> void $ revokePrepaidCode db (pcCodeHash revokedLate) orderTime
req <- fromJust . Orders.parseOrderRequest <$> signedBody (cRandom ctx) aliceOrder
let new = NewRegistration {nrgId = RegistrationId "late", nrgOwner = ownerA, nrgOrder = fieldsOf aliceOrder, nrgYears = 2}
forM_ [(reservedLate, CRReserved), (revokedLate, CRInvalid)] $ \(stale, refusal) -> do
placedOrder <- Orders.placeOrder (cOrders ctx) req [] (Orders.codeOrderInsert new stale)
either Just (const Nothing) placedOrder `shouldBe` Just (Orders.ORCode refusal)
length <$> registrationsOf ctx ownerA `shouldReturn` 0
-- | One client's code checks and code orders share its 10 a minute; another client is not limited by them.
testCodeLimitShared :: IO ()
testCodeLimitShared = withOrders $ \ctx@Ctx {cClient} -> do
(code, _) <- issueCode ctx 8 2 id
body <- J.encode <$> signedCodeBody ctx code aliceOrder
let from path b = statusOf <$> webRequestBody cClient "POST" path [(hContentType, "application/json"), ("X-Forwarded-For", "203.0.113.7")] b
codeCheck = from "/api/code/check" (J.encode $ J.object ["code" .= code])
codeOrder = from "/api/name/register" body
sequence (concat $ replicate 5 [codeCheck, codeOrder]) `shouldReturn` replicate 10 200
codeCheck `shouldReturn` 429
codeOrder `shouldReturn` 429
fst <$> register ctx code aliceOrder `shouldReturn` 200
-- | A response held before it is written, as for a client that reads it slowly, must not hold the name's turn.
testSlowReaderHoldsNoTurn :: IO ()
testSlowReaderHoldsNoTurn = withOrders $ \ctx@Ctx {cWeb, cOrders} -> do
(codeA, _) <- issueCode ctx 7 2 id
(codeB, _) <- issueCode ctx 7 2 id
writing <- newEmptyMVar
release <- newEmptyMVar
let app = ordersApp cWeb cOrders
slow req respond
| isJust (lookup slowReader $ requestHeaders req) = app req $ \r -> putMVar writing () >> takeMVar release >> respond r
| otherwise = app req respond
mgr <- newManager defaultManagerSettings
Warp.testWithApplication (pure slow) $ \port -> do
let c = ctx {cClient = (mgr, "http://127.0.0.1:" <> show port)}
slowPost path body = statusOf <$> webRequestBody (cClient c) "POST" path [(hContentType, "application/json"), (slowReader, "1")] (J.encode body)
whileHeld first second = do
held <- async first
takeMVar writing
timeout 2000000 second >>= (`shouldSatisfy` maybe False ((== 200) . fst))
putMVar release ()
wait held `shouldReturn` 200
byB o = o {oOwner = ownerBText, oKey = keyB, oCommitment = commitmentOf 2}
whileHeld (signedBody (cRandom c) aliceOrder >>= slowPost "/api/invoice") (placeOrder c $ byB aliceOrder)
let dynamis = aliceOrder {oLabel = "dynamis", oCommitment = commitmentOf 3}
whileHeld (signedCodeBody c codeA dynamis >>= slowPost "/api/name/register") (register c codeB (byB dynamis) {oCommitment = commitmentOf 4})
where
slowReader = "x-slow-reader"
testOrderAfterCancel :: IO ()
testOrderAfterCancel = withOrders $ \ctx -> do
(invId@(InvoiceId invText), _) <- placed ctx aliceOrder
(status, v) <- post ctx ("/api/invoice/" <> T.unpack invText <> "/cancel") ""
status `shouldBe` 200
(field "status" =<< v) `shouldBe` Just "expired"
(field "version" . registrationOf =<< v) `shouldBe` Just (J.Number 2)
old <- invoiceRegistration ctx invId
nrLive old `shouldBe` False
(newInvId, _) <- placed ctx aliceOrder {oCommitment = commitmentOf 2, oTimestamp = seconds orderTime + 1}
newInvId `shouldNotBe` invId
nrLive <$> invoiceRegistration ctx newInvId `shouldReturn` True
testOverdueClosed :: IO ()
testOverdueClosed = withOrders $ \ctx -> do
(oldInv, _) <- placed ctx aliceOrder {oMethod = "card"}
-- a card invoice expires after the session's 60 minutes; this is past it, before any sweep
advance ctx (61 * 60)
now <- readIORef (cClock ctx)
-- a refused order closes nothing
placeOrder ctx aliceOrder {oCommitment = commitmentOf 2, oTimestamp = seconds now} `shouldReturn` refusedWith 400 "bad_request"
nrLive <$> invoiceRegistration ctx oldInv `shouldReturn` True
(newInv, _) <- placed ctx aliceOrder {oMethod = "card", oCommitment = commitmentOf 2, oTimestamp = seconds now, oDuration = durationAt now}
Just oldRow <- getInvoice (cStore ctx) oldInv
irStatus oldRow `shouldBe` ISExpired
old <- invoiceRegistration ctx oldInv
(nrPhase old, nrLive old, nrVersion old) `shouldBe` (PUnpaid, False, 2)
nrLive <$> invoiceRegistration ctx newInv `shouldReturn` True
calls <- stubCalls (cStub ctx)
[c | c@StubCancel {} <- calls] `shouldBe` [StubCancel (irProviderRef oldRow)]
testLatePaymentAfterNewOrder :: IO ()
testLatePaymentAfterNewOrder = withOrders $ \ctx -> do
(oldInv, _) <- placed ctx aliceOrder
advance ctx (46 * 60)
now <- readIORef (cClock ctx)
(newInv, _) <- placed ctx aliceOrder {oCommitment = commitmentOf 2, oTimestamp = seconds now, oDuration = durationAt now}
logs <- capturingStderr $ settleNow ctx oldInv settled `shouldReturn` Right ISPaid
logs `shouldSatisfy` T.isInfixOf "credited to its balance"
old <- invoiceRegistration ctx oldInv
(nrPhase old, nrLive old) `shouldBe` (PUnpaid, False)
new <- invoiceRegistration ctx newInv
(nrPhase new, nrLive new, nrVersion new) `shouldBe` (PUnpaid, True, 1)
settleNow ctx newInv settled `shouldReturn` Right ISPaid
(nrPhase <$> invoiceRegistration ctx newInv) `shouldReturn` PQueued
-- | Paid in full at minute 44 of 45 and confirming after; a second tab's order at minute 46 must not refund it.
testFundedOverdueKept :: IO ()
testFundedOverdueKept = withOrders $ \ctx -> do
(inv, _) <- placed ctx aliceOrder
advance ctx (44 * 60)
settleNow ctx inv (SigFunded (rcv 10000 (Just "0.00156")) PaidInFull) `shouldReturn` Right ISOpen
advance ctx (2 * 60)
now <- readIORef (cClock ctx)
placeOrder ctx aliceOrder {oCommitment = commitmentOf 2, oTimestamp = seconds now, oDuration = durationAt now} `shouldReturn` refusedWith 409 "order_exists"
r <- invoiceRegistration ctx inv
(nrLive r, nrPhase r) `shouldBe` (True, PUnpaid)
withTransaction (cStore ctx) (\db -> closeOverdue db r now) `shouldReturn` Nothing
(irStatus <$>) <$> getInvoice (cStore ctx) inv `shouldReturn` Just ISOpen
settleNow ctx inv settled `shouldReturn` Right ISPaid
nrPhase <$> invoiceRegistration ctx inv `shouldReturn` PQueued
-- | The real BTCPay adapter against the fake: a payment into an expired invoice reads Expired with PaidLate,
-- into a cancelled one Invalid, and into one only we closed New and paid in part.
testLateBTCPayPayments :: IO ()
testLateBTCPayPayments = withFakeBTCPay $ \fake -> do
btcpay <- btcpayProvider (fbConfig fake)
withOrdersVia (const btcpay) $ \ctx@Ctx {cStore = st, cWeb} -> do
poller <- newPollerEnv st (weWaiters cWeb) (Just $ oePhases $ cOrders ctx) (weHints cWeb) (weProviders cWeb) (poll $ weConfig cWeb)
let refOf inv = maybe (error "no invoice") irProviderRef <$> getInvoice st inv
readAt ref = queueReadHint (weHints cWeb) ref >> drainHints poller
paidLate ref fields = capturingStderr $ setInvoiceState fake ref fields >> readAt ref
alerted (InvoiceId t) logs = T.isInfixOf ("invoice " <> t <> " received money after registration") logs
later = addUTCTime 3600 orderTime
(expired, _) <- placed ctx aliceOrder
expiredRef <- refOf expired
setInvoiceState fake expiredRef ["status" .= ("Expired" :: Text)]
readAt expiredRef
(irStatus <$>) <$> getInvoice st expired `shouldReturn` Just ISExpired
paidLate expiredRef ["additionalStatus" .= ("PaidLate" :: Text), "paymentMethodPaid" .= ("0.00050000" :: Text)] >>= (`shouldSatisfy` alerted expired)
capturingStderr (readAt expiredRef) >>= (`shouldNotSatisfy` alerted expired)
(cancelled, _) <- placed ctx aliceOrder {oLabel = "dynamis", oPrice = 20000, oCommitment = commitmentOf 2}
cancelledRef <- refOf cancelled
cancelOpenInvoice st (weWaiters cWeb) (Just $ oePhases $ cOrders ctx) cancelled later `shouldReturn` True
paidLate cancelledRef ["status" .= ("Invalid" :: Text), "paymentMethodPaid" .= ("0.00078000" :: Text)] >>= (`shouldSatisfy` alerted cancelled)
(swept, _) <- placed ctx aliceOrder {oOwner = ownerBText, oKey = keyB, oCommitment = commitmentOf 3}
sweptRef <- refOf swept
expireOverdue st (weWaiters cWeb) (Just $ oePhases $ cOrders ctx) later later [swept] `shouldReturn` [swept]
paidLate sweptRef ["paymentMethodPaid" .= ("0.00025000" :: Text)] >>= (`shouldSatisfy` alerted swept)
forM_ [expired, cancelled, swept] $ \inv -> do
r <- invoiceRegistration ctx inv
(nrPhase r, nrLive r) `shouldBe` (PUnpaid, False)
-- | Paid in part while open, then expired by BTCPay with the same figures: one late payment line, none from a restarted poller.
testOneLateAlert :: IO ()
testOneLateAlert = withOrders $ \ctx@Ctx {cStore = st, cWeb} -> do
(inv, _) <- placed ctx aliceOrder
Just InvoiceRow {irProviderRef = ref} <- getInvoice st inv
let paid = rcv 5000 (Just "0.00078")
newPoller = newPollerEnv st (weWaiters cWeb) (Just $ oePhases $ cOrders ctx) (weHints cWeb) (weProviders cWeb) (poll $ weConfig cWeb)
readWith poller = capturingStderr $ queueReadHint (weHints cWeb) ref >> drainHints poller
InvoiceId invText = inv
refunds = length . T.breakOnAll ("invoice " <> invText <> " received money after")
poller <- newPoller
setSignals (cStub ctx) [(ref, SigFunded paid PaidInPart)]
refunds <$> readWith poller `shouldReturn` 0
setSignals (cStub ctx) [(ref, SigClosed paid)]
logs <- readWith poller
refunds logs `shouldBe` 1
logs `shouldSatisfy` T.isInfixOf "it stays unpaid, and its money is credited to its balance once confirmed, else alerted"
restarted <- newPoller
refunds <$> readWith restarted `shouldReturn` 0
(nrPhase &&& nrLive) <$> invoiceRegistration ctx inv `shouldReturn` (PUnpaid, False)
testCancelAnswers :: IO ()
testCancelAnswers = withOrders $ \ctx -> do
let cancel = cancelAt ctx
(funded, _) <- placed ctx aliceOrder
settleNow ctx funded (SigFunded (rcv 5000 (Just "0.00078")) PaidInPart) `shouldReturn` Right ISOpen
cancel funded `shouldReturn` refusedWith 409 "funded"
(other, _) <- placed ctx aliceOrder {oLabel = "dynamis", oPrice = 20000, oCommitment = commitmentOf 2}
fst <$> cancel other `shouldReturn` 200
cancel other `shouldReturn` refusedWith 409 "not_open"
cancel (InvoiceId "no-such-invoice") `shouldReturn` refusedWith 404 "not_found"
-- the provider settles the invoice, or reads a payment into it, while taking the cancel
withOrdersCancelling settled $ \ctx' -> do
(invId, _) <- placed ctx' aliceOrder
(status, v) <- cancelAt ctx' invId
(status, field "status" =<< v, field "phase" . registrationOf =<< v) `shouldBe` (200, Just "paid", Just "queued")
withOrdersCancelling (SigFunded (rcv 0 Nothing) PaidInFull) $ \ctx' -> do
(invId, _) <- placed ctx' aliceOrder
(status, v) <- cancelAt ctx' invId
(status, field "status" =<< v, field "paidInFull" =<< v) `shouldBe` (200, Just "expired", Just (J.Bool True))
r <- invoiceRegistration ctx' invId
(nrLive r, nrVersion r) `shouldBe` (False, 2)
where
cancelAt c (InvoiceId t) = post c ("/api/invoice/" <> T.unpack t <> "/cancel") ""
-- | Cancel takes the invoice row and decides on the payment it then reads, as settlement does.
testCancelAlertsFunded :: IO ()
testCancelAlertsFunded = withOrdersCancelling (SigFunded paid PaidInPart) $ \ctx -> do
(invId, _) <- placed ctx aliceOrder
logs <- capturingStderr $ do
(status, v) <- post ctx ("/api/invoice/" <> T.unpack (invoiceText invId) <> "/cancel") ""
(status, field "status" =<< v, field "cryptoAmountPaid" =<< v) `shouldBe` (200, Just "expired", Just "0.00078")
length (T.breakOnAll (alertOf invId) logs) `shouldBe` 1
capturingStderr (settleNow ctx invId (SigClosed paid) `shouldReturn` Right ISExpired) >>= (`shouldNotSatisfy` T.isInfixOf (alertOf invId))
(nrPhase &&& nrLive) <$> invoiceRegistration ctx invId `shouldReturn` (PUnpaid, False)
where
paid = rcv 5000 (Just "0.00078")
invoiceText (InvoiceId t) = t
alertOf invId = "invoice " <> invoiceText invId <> " received money after"
testNamesOff :: IO ()
testNamesOff = withOrders $ \ctx -> do
(invId@(InvoiceId t), _) <- placed ctx aliceOrder
mgr <- newManager defaultManagerSettings
Warp.testWithApplication (pure $ webApp (cWeb ctx) {weOrders = Nothing}) $ \port -> do
let off = ctx {cClient = (mgr, "http://127.0.0.1:" <> show port)}
logs <- capturingStderr $ do
r <- webGet (cClient off) ("/api/invoice/" <> T.unpack t)
(statusOf r, J.decode (responseBody r)) `shouldBe` refusedWith 503 "provider_unavailable"
post off ("/api/invoice/" <> T.unpack t <> "/cancel") "" `shouldReturn` refusedWith 503 "provider_unavailable"
logs `shouldSatisfy` T.isInfixOf ("invoice " <> t <> " is a name's, and this service sells no names")
calls <- stubCalls (cStub ctx)
[c | c@StubCancel {} <- calls] `shouldBe` []
(irStatus <$>) <$> getInvoice (cStore ctx) invId `shouldReturn` Just ISOpen
-- | The provider's cancel settles the invoice with the signal before it answers.
withOrdersCancelling :: PaymentSignal -> (Ctx -> IO a) -> IO a
withOrdersCancelling signal action = withOrders $ \ctx -> do
let Ctx {cWeb} = ctx
cancelling p = p {pCancelInvoice = \ref -> settleRef ctx ref >> pCancelInvoice p ref}
web = cWeb {weProviders = map cancelling (weProviders cWeb)}
mgr <- newManager defaultManagerSettings
Warp.testWithApplication (pure (webApp web)) $ \port -> action ctx {cWeb = web, cClient = (mgr, "http://127.0.0.1:" <> show port)}
where
settleRef ctx ref =
getInvoiceByProviderRef (cStore ctx) ref >>= mapM_ (\InvoiceRow {irInvoiceId} -> settleNow ctx irInvoiceId signal)
testLatePayment :: IO ()
testLatePayment = withOrders $ \ctx -> do
(invId@(InvoiceId t), _) <- placed ctx aliceOrder
fst <$> post ctx ("/api/invoice/" <> T.unpack t <> "/cancel") "" `shouldReturn` 200
closed <- invoiceRegistration ctx invId
logs <- capturingStderr $ settleNow ctx invId settled `shouldReturn` Right ISPaid
logs `shouldSatisfy` T.isInfixOf ("registration " <> (\(RegistrationId r) -> r) (nrId closed) <> " closed; it stays unpaid")
invoiceRegistration ctx invId `shouldReturn` closed
r <- webGet (cClient ctx) ("/api/invoice/" <> T.unpack t)
v <- jsonObject r
(field "status" v, field "phase" (registrationOf v)) `shouldBe` (Just "paid", Just "unpaid")
testSettlementQueues :: IO ()
testSettlementQueues = withOrders $ \ctx -> do
(invId, _) <- placed ctx aliceOrder
NameRegistration {nrId} <- invoiceRegistration ctx invId
parked <- newEmptyMVar
-- the version read is pinned, so only a publish can wake the waiter
waiter <- async $ awaitChange (oePhases $ cOrders ctx) nrId (putMVar parked () >> pure 1) 1 10000000
takeMVar parked
settleNow ctx invId settled `shouldReturn` Right ISPaid
timeout 2000000 (wait waiter) `shouldReturn` Just True
r <- invoiceRegistration ctx invId
(nrPhase r, nrLive r, nrVersion r) `shouldBe` (PQueued, True, 2)
-- | Postgres serializes the payment with a concurrent overdue close only on this row; SQLite shows that it is written.
testSettlementLocksInvoice :: IO ()
testSettlementLocksInvoice = withOrders $ \ctx -> do
(inv@(InvoiceId t), _) <- placed ctx aliceOrder
advance ctx 60
settleNow ctx inv (SigFunded (rcv 10000 (Just "0.00156")) PaidInFull) `shouldReturn` Right ISOpen
now <- readIORef (cClock ctx)
withConnection (cStore ctx) (\db -> DB.query db "SELECT updated_at, status FROM sx_badge_service_invoices WHERE invoice_id = ?" [t])
`shouldReturn` [(now, "open" :: Text)]
testExpiryCloses :: IO ()
testExpiryCloses = withOrders $ \ctx -> do
(closedByProvider, _) <- placed ctx aliceOrder
settleNow ctx closedByProvider (SigClosed (rcv 0 Nothing)) `shouldReturn` Right ISExpired
r <- invoiceRegistration ctx closedByProvider
(nrPhase r, nrLive r, nrVersion r) `shouldBe` (PUnpaid, False, 2)
(swept, _) <- placed ctx aliceOrder {oLabel = "dynamis", oPrice = 20000, oCommitment = commitmentOf 2}
let later = addUTCTime 3600 orderTime
expireOverdue (cStore ctx) (weWaiters $ cWeb ctx) (Just $ oePhases $ cOrders ctx) later later [swept] `shouldReturn` [swept]
s <- invoiceRegistration ctx swept
(nrPhase s, nrLive s, nrVersion s) `shouldBe` (PUnpaid, False, 2)
testSweepSkipsBadRow :: IO ()
testSweepSkipsBadRow = withOrders $ \ctx@Ctx {cStore = st} -> do
(bad, _) <- placed ctx aliceOrder
(good, _) <- placed ctx aliceOrder {oLabel = "dynamis", oPrice = 20000, oCommitment = commitmentOf 2}
NameRegistration {nrId = RegistrationId badId} <- invoiceRegistration ctx bad
withConnection st $ \db -> DB.execute db "UPDATE sx_badge_service_name_registrations SET phase = 'lost' WHERE registration_id = ? AND phase = ?" (badId, "unpaid" :: Text)
let later = addUTCTime 3600 orderTime
logs <- capturingStderr $ expireOverdue st (weWaiters $ cWeb ctx) (Just $ oePhases $ cOrders ctx) later later [bad, good] `shouldReturn` [good]
logs `shouldSatisfy` T.isInfixOf ("invoice " <> invoiceText bad)
(irStatus <$>) <$> getInvoice st bad `shouldReturn` Just ISOpen
r <- invoiceRegistration ctx good
(nrLive r, nrVersion r) `shouldBe` (False, 2)
where
invoiceText (InvoiceId t) = t
-- | The poller reads by the wall clock, so the orders are placed two hours ago by it.
testPassSweepsAroundFailures :: IO ()
testPassSweepsAroundFailures = do
unreadable <- newIORef ""
let failing p = p {pReadInvoice = \r -> readIORef unreadable >>= \u -> if r == u then pure (Left $ ProviderError "down") else pReadInvoice p r}
withOrdersVia failing $ \ctx@Ctx {cStore = st, cWeb} -> do
placedAt <- addUTCTime (-7200) <$> getCurrentTime
writeIORef (cClock ctx) placedAt
let at' o = o {oTimestamp = seconds placedAt, oDuration = durationAt placedAt}
refOf inv = maybe (error "no invoice") irProviderRef <$> getInvoice st inv
(poisoned, _) <- placed ctx $ at' aliceOrder
(unread, _) <- placed ctx $ at' aliceOrder {oLabel = "dynamis", oPrice = 20000, oCommitment = commitmentOf 2}
(good, _) <- placed ctx $ at' aliceOrder {oOwner = ownerBText, oKey = keyB, oCommitment = commitmentOf 3}
NameRegistration {nrId = RegistrationId poisonedId} <- invoiceRegistration ctx poisoned
withConnection st $ \db -> DB.execute db "UPDATE sx_badge_service_name_registrations SET phase = 'lost' WHERE registration_id = ? AND phase = ?" (poisonedId, "unpaid" :: Text)
poisonedRef <- refOf poisoned
refOf unread >>= writeIORef unreadable
setSignals (cStub ctx) [(poisonedRef, SigClosed (rcv 0 Nothing))]
poller <- newPollerEnv st (weWaiters cWeb) (Just $ oePhases $ cOrders ctx) (weHints cWeb) (weProviders cWeb) (poll $ weConfig cWeb)
void $ capturingStderr $ runOnePass poller
mapM (fmap (irStatus <$>) . getInvoice st) [poisoned, unread, good] `shouldReturn` map Just [ISOpen, ISOpen, ISExpired]
testCleanup :: IO ()
testCleanup = withOrders $ \ctx -> testCleanupIn ctx
testCleanupIn :: Ctx -> IO ()
testCleanupIn ctx@Ctx {cStore = st} = do
let expired = t0
due = addUTCTime (unpaidRetention + 1) expired
place rid n = placePaid st rid ownerA (order (T.pack ("alicebakery" <> show n)) n) expired `shouldReturn` Right ()
mapM_ (uncurry place) [("gone", 1), ("funded", 2), ("late", 3), ("unread", 4)]
placePaid st "recent" ownerA (order "alicebakery5" 5) (addUTCTime 3600 due) `shouldReturn` Right ()
settleOrder st (weWaiters $ cWeb ctx) Nothing (InvoiceId "inv-funded") (SigFunded (rcv 100 (Just "0.00001")) PaidInPart) t0 `shouldReturn` Right ISOpen
setSignals (cStub ctx) [(ref "late", SigClosed (rcv 5000 (Just "0.00078")))]
failing <- newIORef False
let reading p = p {pReadInvoice = \r -> readIORef failing >>= \f -> if f && r == ref "unread" then pure (Left $ ProviderError "down") else pReadInvoice p r}
providers = map reading (weProviders $ cWeb ctx)
writeIORef failing True
poller <- newPollerEnv st (weWaiters $ cWeb ctx) Nothing (weHints $ cWeb ctx) providers (poll $ weConfig $ cWeb ctx)
logs <- capturingStderr $ cleanupUnpaid poller due
let present rid = isJust <$> withTransaction st (`getRegistration` RegistrationId rid)
mapM present ["gone", "funded", "late", "unread", "recent"] `shouldReturn` [False, True, True, True, True]
isNothing <$> getInvoice st (InvoiceId "inv-gone") `shouldReturn` True
Just InvoiceRow {irPayment = latePayment} <- getInvoice st (InvoiceId "inv-late")
(ipCryptoPaid <$> latePayment) `shouldBe` Just (Just "0.00078")
logs `shouldSatisfy` T.isInfixOf "registration late is kept with its balance, because invoice inv-late received a payment"
logs `shouldSatisfy` T.isInfixOf "registration unread is kept until the next run"
reads' <- stubReads
sort reads' `shouldBe` [ref "gone", ref "late"]
writeIORef failing False
cleanupUnpaid poller (addUTCTime cleanupInterval due)
mapM present ["unread", "recent"] `shouldReturn` [False, True]
drop (length reads') <$> stubReads `shouldReturn` [ref "unread"]
where
stubReads = (\calls -> [r | StubRead r <- calls]) <$> stubCalls (cStub ctx)
ref rid = "ref-inv-" <> rid
testCleanupCapped :: IO ()
testCleanupCapped = withOrders $ \ctx@Ctx {cStore = st} -> do
let extra = 3
count = cleanupReadsPerPass + extra
due = addUTCTime (unpaidRetention + 1) t0
reads' = (\calls -> length [() | StubRead _ <- calls]) <$> stubCalls (cStub ctx)
forM_ [1 .. count] $ \n -> placePaid st ("old" <> tshow n) ownerA (order ("oldname" <> tshow n) (fromIntegral n)) t0 `shouldReturn` Right ()
poller <- newPollerEnv st (weWaiters $ cWeb ctx) Nothing (weHints $ cWeb ctx) (weProviders $ cWeb ctx) (poll $ weConfig $ cWeb ctx)
cleanupUnpaid poller due
reads' `shouldReturn` cleanupReadsPerPass
cleanupUnpaid poller (addUTCTime 1 due)
reads' `shouldReturn` count
length <$> registrationsOf ctx ownerA `shouldReturn` 0
testProviderRefs :: IO ()
testProviderRefs = withServiceStore $ \st -> do
Right () <- placePaid st "r1" ownerA (order "alicebakery" 1) (addUTCTime 3600 t0)
getInvoiceByProviderRef st "ref-inv-r1" >>= \case
Just InvoiceRow {irInvoiceId, irProduct} -> (irInvoiceId, irProduct) `shouldBe` (InvoiceId "inv-r1", IPName (RegistrationId "r1"))
Nothing -> expectationFailure "the name invoice was not found by its ref"
withConnection st $ \db ->
DB.execute
db
"INSERT INTO sx_badge_service_invoices (invoice_id, provider, price, amount, currency, expires_at, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
("badge-1" :: Text, "crypto" :: Text, 500 :: Int, 500 :: Int, "usd" :: Text, t0, "open" :: Text, t0, t0)
withConnection st $ \db -> do
DB.execute db "INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) VALUES (?,?,?,?,?)" (DB.Binary ("hash" :: ByteString), "supporter" :: Text, 1 :: Int, "unpaid" :: Text, t0)
DB.execute db "INSERT INTO sx_badge_service_badge_prices (price_id, badge_type, month_price, currency, status, created_at) VALUES (?,?,?,?,?,?)" ("p1" :: Text, "supporter" :: Text, 500 :: Int, "usd" :: Text, "active" :: Text, t0)
DB.execute db "INSERT INTO sx_badge_service_badge_code_invoices (invoice_id, badge_code_id, price_id, provider_ref, created_at) VALUES (?, (SELECT badge_code_id FROM sx_badge_service_badge_codes), ?, ?, ?)" ("badge-1" :: Text, "p1" :: Text, "ref-inv-r1" :: Text, t0)
E.try (getInvoiceByProviderRef st "ref-inv-r1") `shouldReturn` Left (ProviderRefConflict "ref-inv-r1")
testPollerQueries :: IO ()
testPollerQueries = withServiceStore $ \st -> do
Right () <- placePaid st "r1" ownerA (order "alicebakery" 1) (addUTCTime 3600 t0)
unpaidRefs st (addUTCTime (-60) t0) `shouldReturn` [("crypto", "ref-inv-r1")]
map oiProviderRef <$> overdueInvoices st (addUTCTime 7200 t0) `shouldReturn` ["ref-inv-r1"]
overdueInvoices st t0 `shouldReturn` []
testFundedAgreement :: IO ()
testFundedAgreement = withServiceStore $ \st -> do
waiters <- newWaiters
let now = addUTCTime 3600 t0
shapes =
[ ("amount", Just (SigFunded (rcv 100 Nothing) PaidInPart), True),
("crypto", Just (SigFunded (rcv 0 (Just "0.00001")) PaidInPart), True),
("verdict", Just (SigFunded (rcv 0 Nothing) PaidInFull), True),
("nothing", Nothing, False)
]
forM_ (zip [1 ..] shapes) $ \(n, (rid, signal, _)) -> do
placePaid st rid ownerA (order ("fundname" <> rid) n) t0 `shouldReturn` Right ()
forM_ signal $ \sig -> void $ settleOrder st waiters Nothing (InvoiceId $ "inv-" <> rid) sig t0
listed <- map oiInvoiceId <$> overdueInvoices st now
forM_ (zip [1 ..] shapes) $ \(n, (rid, _, funded)) -> do
let o = order ("fundname" <> rid) n
(InvoiceId ("inv-" <> rid) `elem` listed) `shouldBe` not funded
[LiveOrder {loRegistration, loOverdue}] <- withTransaction st $ \db -> getLiveOrders db (ofCommitment o) ownerA (ofLabelHash o) now
(rid, loOverdue) `shouldBe` (rid, not funded)
closed <- withTransaction st $ \db -> closeOverdue db loRegistration now
(rid, closed) `shouldBe` (rid, if funded then Nothing else Just (nrVersion loRegistration + 1))
testViewFields :: IO ()
testViewFields = withServiceStore $ \st -> withFakeEthNode $ \node -> do
Right () <- placePaid st "r1" ownerA (order "alicebakery" 1) (addUTCTime 3600 t0)
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "r1")
let v = objectOf $ viewJSON (testNamesConfig node) r Nothing Nothing ViewReads {vrIntentNonce = Nothing, vrUnread = []}
sort (KM.keys v)
`shouldBe` sort
[ "id", "version", "moves", "linksRefused", "phase", "labelHash", "label", "owner", "commitment", "sealed", "resolver", "sponsor", "chainId", "tld",
"years", "duration", "price", "fromBalance", "balance", "latePayment", "commitTx", "registerTx", "revealAfter", "revealBefore", "intentNonce", "unread", "expiresAt", "error", "community", "terms"
]
field "latePayment" v `shouldBe` Just J.Null
let waiting w = field "latePayment" $ objectOf $ viewJSON (testNamesConfig node) r Nothing (Just w) ViewReads {vrIntentNonce = Nothing, vrUnread = []}
map waiting [LWConfirming, LWCrediting, LWSupport] `shouldBe` map (\t -> Just $ J.object ["waiting" .= (t :: Text)]) ["confirming", "crediting", "support"]
map (`field` v) ["id", "phase", "owner", "tld", "chainId", "linksRefused", "label", "unread", "sponsor", "fromBalance", "balance", "community", "terms"]
`shouldBe` map
Just
[ "r1",
"unpaid",
J.String ownerAText,
"simplex",
J.Number 31337,
J.Bool False,
J.Null,
J.Array mempty,
J.String $ safeDecodeUtf8 $ strEncode fakeSponsor,
J.Number 0,
J.Null,
J.Null,
J.String termsVersion
]
testViewCommunity :: IO ()
testViewCommunity = withServiceStore $ \st -> withFakeEthNode $ \node -> do
Right () <- placePaid st "r1" ownerA (order "alicebakery" 1) (addUTCTime 3600 t0)
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "r1")
let LabelHash aliceHash = labelHash "alicebakery"
listing :: [(ByteString, Text)] -> Maybe J.Value
listing entries = field "community" $ objectOf $ viewJSON (testNamesConfig node) {nCommunity = M.fromList entries} r Nothing Nothing ViewReads {vrIntentNonce = Nothing, vrUnread = []}
listing [(aliceHash, "bakers and bakeries in Lisbon")] `shouldBe` Just (J.object ["for" .= ("bakers and bakeries in Lisbon" :: Text)])
let LabelHash bobHash = labelHash "bobsbakery"
listing [(bobHash, "bakers of Porto")] `shouldBe` Just J.Null
testViewTerms :: IO ()
testViewTerms = withServiceStore $ \st -> withFakeEthNode $ \node -> do
Right () <- placePaid st "r1" ownerA (order "alicebakery" 1) {ofAgreedTerms = "2026-10-23/community"} (addUTCTime 3600 t0)
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "r1")
let terms :: NameRegistration -> Maybe J.Value
terms r' = field "terms" $ objectOf $ viewJSON (testNamesConfig node) r' Nothing Nothing ViewReads {vrIntentNonce = Nothing, vrUnread = []}
terms r `shouldBe` Just "2026-10-23/community"
terms r {nrAgreedTerms = ""} `shouldBe` Just J.Null
testViewReads :: IO ()
testViewReads = withServiceStore $ \st -> withFakeEthNode $ \node -> do
g <- C.newRandom
ct <- newContracts TLDSimplex =<< newChainClient g (fakeChainConfig node)
answerCall node fakeSponsor noncesCall $ Returns $ uint256 (4 :: Int)
Right () <- placePaid st "paid" ownerA (order "alicebakery" 1) (addUTCTime 3600 t0)
let inPhase phase = do
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "paid")
Written _ <- withTransaction st $ \db -> updateRegistration db r {nrPhase = phase} t0
withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "paid")
committed <- inPhase PCommitted
readViewChain ct committed `shouldReturn` ViewReads (Just 4) []
taken <- inPhase PTaken
readViewChain ct taken `shouldReturn` ViewReads Nothing []
testViewUnread :: IO ()
testViewUnread = withServiceStore $ \st -> withFakeEthNode $ \node -> do
g <- C.newRandom
ct <- newContracts TLDSimplex =<< newChainClient g (fakeChainConfig node)
setFault node "eth_call" HttpFailure
Right () <- placePaid st "paid" ownerA (order "alicebakery" 1) (addUTCTime 3600 t0)
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "paid")
readViewChain ct r {nrPhase = PCommitted} `shouldReturn` ViewReads Nothing ["intentNonce"]
readViewChain ct r {nrPhase = PTaken} `shouldReturn` ViewReads Nothing []
let v = objectOf $ viewJSON (testNamesConfig node) r {nrPhase = PCommitted} Nothing Nothing (ViewReads Nothing ["intentNonce"])
(field "intentNonce" v, field "unread" v) `shouldBe` (Just J.Null, Just (J.toJSON ["intentNonce" :: Text]))
testViewBalance :: IO ()
testViewBalance = withServiceStore $ \st -> withFakeEthNode $ \node -> do
g <- C.newRandom
ct <- newContracts TLDSimplex =<< newChainClient g (fakeChainConfig node)
let holderExpiry = addUTCTime 3600 t0
viewOf :: Text -> UTCTime -> IO J.Object
viewOf rid now = do
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId rid)
objectOf <$> registrationView st (testNamesConfig node) ct now r
balanceOf :: J.Object -> (Maybe J.Value, Maybe J.Value)
balanceOf v = (field "balance" v, field "fromBalance" v)
balance :: Int -> Bool -> Maybe UTCTime -> Int -> J.Value
balance amount held heldUntil refunded = J.object ["amount" .= amount, "held" .= held, "heldUntil" .= heldUntil, "refunded" .= refunded]
Right () <- placePaid st "source" ownerA (order "alicebakery" 1) holderExpiry
withTransaction st $ \db -> creditBalance db (RegistrationId "source") 15000 t0
balanceOf <$> viewOf "source" t0 `shouldReturn` (Just $ balance 15000 False Nothing 0, Just $ J.Number 0)
Right () <- withTransaction st $ \db -> do
insertInvoiceRow db "inv-holder" holderExpiry "open"
let terms = OTBalance (RegistrationId "source") 20000 15000 (Just btc)
createPaidRegistration db (newReg "holder" ownerA (order "bobsbakery" 2)) terms (paidOrder "inv-holder") t0
balanceOf <$> viewOf "source" t0 `shouldReturn` (Just $ balance 15000 True (Just holderExpiry) 0, Just $ J.Number 0)
balanceOf <$> viewOf "source" (addUTCTime 1 holderExpiry) `shouldReturn` (Just $ balance 15000 False Nothing 0, Just $ J.Number 0)
balanceOf <$> viewOf "holder" t0 `shouldReturn` (Just J.Null, Just $ J.Number 15000)
version <- nrVersion <$> withTransaction st (\db -> fromJust <$> getRegistration db (RegistrationId "source"))
withTransaction st (\db -> releaseHeldBalance db (RegistrationId "holder") t0) `shouldReturn` True
withTransaction st (\db -> refundBalance db (RegistrationId "source") 15000 t0) `shouldReturn` True
v <- viewOf "source" t0
balanceOf v `shouldBe` (Just $ balance 0 False Nothing 15000, Just $ J.Number 0)
field "version" v `shouldBe` Just (J.toJSON version)