Compare commits
23 Commits
483ba9f2a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7fa097eab | ||
|
|
2eafcbfd68 | ||
|
|
c250ed2f80 | ||
|
|
bf75302e07 | ||
|
|
b0f07f63ff | ||
|
|
8af6fd585e | ||
|
|
ca39e8bad9 | ||
|
|
1da3b6760e | ||
|
|
e70258c54d | ||
|
|
f097721304 | ||
|
|
3bdb518f9c | ||
|
|
c12640cf52 | ||
|
|
038550b6ff | ||
|
|
5c3393b989 | ||
|
|
5210266fd6 | ||
|
|
48a9120fe4 | ||
|
|
dee3becd47 | ||
|
|
f2c02e2bde | ||
|
|
2a2a1db8de | ||
|
|
41c1bb2fdf | ||
|
|
8045e37c16 | ||
|
|
8fccbb2a9a | ||
|
|
9413537251 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
*.log
|
||||||
|
coverage.out
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Build the server as a static binary with the client embedded in it, then ship
|
||||||
|
# it on a minimal base. The result is one file with no runtime dependencies.
|
||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/arcade ./cmd/arcade
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=build /out/arcade /arcade
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nonroot:nonroot
|
||||||
|
ENTRYPOINT ["/arcade"]
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
58
Makefile
Normal file
58
Makefile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.PHONY: help test test-race test-e2e cover db-up db-reset run build lint
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "test run unit and integration tests"
|
||||||
|
@echo "test-race run tests under the race detector"
|
||||||
|
@echo "test-e2e run end-to-end tests against a live server"
|
||||||
|
@echo "cover report coverage per package"
|
||||||
|
@echo "db-up start postgres and redis"
|
||||||
|
@echo "db-reset destroy and recreate the database"
|
||||||
|
@echo "run run the server with the dev faucet enabled"
|
||||||
|
@echo "build build the server binary"
|
||||||
|
|
||||||
|
test: db-up
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
test-race: db-up
|
||||||
|
go test ./pkg/... -race -count=1
|
||||||
|
|
||||||
|
# The end-to-end suite drives a live server; start one first.
|
||||||
|
test-e2e: db-up build
|
||||||
|
@echo "start the server with 'make run' in another shell, then:"
|
||||||
|
@echo " ARCADE_E2E=http://localhost:8080 go test ./cmd/arcade/ -v"
|
||||||
|
|
||||||
|
cover: db-up
|
||||||
|
go test ./pkg/... -count=1 -coverprofile=coverage.out
|
||||||
|
go tool cover -func=coverage.out | tail -1
|
||||||
|
|
||||||
|
db-up:
|
||||||
|
@docker compose up -d postgres redis >/dev/null
|
||||||
|
@for i in $$(seq 1 40); do \
|
||||||
|
docker compose exec -T postgres pg_isready -U arcade >/dev/null 2>&1 && exit 0; \
|
||||||
|
sleep 1; \
|
||||||
|
done; echo "postgres did not become ready" >&2; exit 1
|
||||||
|
|
||||||
|
# The ledger is append-only, so tests that mint large amounts steadily consume
|
||||||
|
# the bridge's headroom. This wipes the volume and reapplies every migration.
|
||||||
|
db-reset:
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up -d postgres
|
||||||
|
@for i in $$(seq 1 40); do \
|
||||||
|
docker compose exec -T postgres pg_isready -U arcade >/dev/null 2>&1 && break; \
|
||||||
|
sleep 1; \
|
||||||
|
done
|
||||||
|
@# Migrations are mounted into docker-entrypoint-initdb.d and run
|
||||||
|
@# automatically on a fresh volume, so there is nothing to apply here.
|
||||||
|
@docker compose exec -T postgres psql -q -U arcade -d arcade \
|
||||||
|
-c "SELECT count(*) AS tables FROM information_schema.tables WHERE table_schema='public'"
|
||||||
|
@echo "database reset"
|
||||||
|
|
||||||
|
run: db-up
|
||||||
|
ARCADE_DEV_FAUCET=1 go run ./cmd/arcade
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/qa ./cmd/arcade
|
||||||
|
|
||||||
|
lint:
|
||||||
|
go vet ./...
|
||||||
|
gofmt -l .
|
||||||
169
README.md
Normal file
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Quantum Arcade
|
||||||
|
|
||||||
|
A private physics arcade for one Linux box and the people on your network.
|
||||||
|
Drop-in crash rounds, instant scratch tickets, a real double-entry ledger, and
|
||||||
|
outcomes any player can verify on their own phone.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
Three shared crash games — a rocket fighting gravity, a decaying orbit, and a
|
||||||
|
stacking tower — plus instant scratch tickets to play between rounds. Set an
|
||||||
|
auto cash-out target before the round and it closes at exactly your number, or
|
||||||
|
ride it and tap out by hand. Identity is a keypair your browser generates;
|
||||||
|
there is no account, no email, and no password.
|
||||||
|
|
||||||
|
The house edge is **1%** on everything. That is better than essentially
|
||||||
|
anything commercial, and it is deliberate: this is a game among friends, not a
|
||||||
|
revenue stream.
|
||||||
|
|
||||||
|
Everything is API-first — the browser client uses the same endpoints anyone
|
||||||
|
else can. Write a bot in twenty lines; see [docs/API.md](docs/API.md).
|
||||||
|
|
||||||
|
Everything runs on one machine: one Go binary with the client embedded,
|
||||||
|
PostgreSQL, and Redis.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<your-box>:8080` from any phone on the network.
|
||||||
|
|
||||||
|
To play with test funds before Lightning is wired up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCADE_DEV_FAUCET=1 docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The faucet mints through the same ledger path a real deposit uses, so the code
|
||||||
|
under test is the production code. Leave it off otherwise.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/arcade
|
||||||
|
```
|
||||||
|
|
||||||
|
End-to-end tests need a live server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCADE_DEV_FAUCET=1 go run ./cmd/arcade &
|
||||||
|
ARCADE_E2E=http://localhost:8080 go test ./cmd/arcade/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## How fairness works
|
||||||
|
|
||||||
|
Before betting opens, the server generates a random seed and publishes
|
||||||
|
`SHA-256(seed)`. It is now committed and cannot change its mind.
|
||||||
|
|
||||||
|
The client seed is built from the public keys of everyone who joined the round.
|
||||||
|
The operator does not choose who plays, so it cannot steer the outcome even
|
||||||
|
knowing its own seed.
|
||||||
|
|
||||||
|
The crash point is `HMAC-SHA256(serverSeed, clientSeed || nonce)`, run through
|
||||||
|
the simulation. After settlement the seed is published, and the Verify tab
|
||||||
|
recomputes the whole chain in your browser — it asks the server only for the
|
||||||
|
published values, never for a verdict.
|
||||||
|
|
||||||
|
Scratch tickets use the identical pipeline, and their odds tables are generated
|
||||||
|
from the same data structure that produces outcomes, so the published odds
|
||||||
|
cannot drift from reality. A test asserts observed frequencies and empirical
|
||||||
|
return against the published figures across two million plays.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
One binary, with enforced internal boundaries:
|
||||||
|
|
||||||
|
| Package | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `pkg/fixed` | Q32.32 fixed-point arithmetic; no floats, so results are identical everywhere |
|
||||||
|
| `pkg/sim` | Deterministic RNG and the crash curve |
|
||||||
|
| `pkg/fair` | Commit-reveal protocol and verification proofs |
|
||||||
|
| `pkg/ledger` | Append-only double-entry accounting |
|
||||||
|
| `pkg/scratch` | Scratch tickets and their published odds |
|
||||||
|
| `pkg/identity` | Keypair sign-in via signed challenge |
|
||||||
|
| `pkg/room` | Round lifecycle, auto cash-out, live broadcast |
|
||||||
|
|
||||||
|
Nine services on one machine would buy latency and 3am debugging, so this is
|
||||||
|
one process. Modules talk through interfaces only; extracting one into its own
|
||||||
|
service later is a transport change, not a rewrite.
|
||||||
|
|
||||||
|
### Ledger invariants
|
||||||
|
|
||||||
|
Enforced in the application and again by database constraints and triggers:
|
||||||
|
|
||||||
|
- every transaction's postings sum to exactly zero
|
||||||
|
- no account may go negative, except the Lightning bridge, whose negative
|
||||||
|
balance is by definition what is owed to players
|
||||||
|
- rows are never updated or deleted; corrections are compensating entries
|
||||||
|
|
||||||
|
`GET /api/health` sums every account. It must return zero. Anything else means
|
||||||
|
the books are corrupt.
|
||||||
|
|
||||||
|
### Round timing
|
||||||
|
|
||||||
|
The multiplier follows `m(t) = 1/(1 - t/T)²`, which diverges at exactly 60
|
||||||
|
seconds. No round can run longer, however extreme the crash point, and the
|
||||||
|
climb visibly accelerates as it goes — which is where the tension comes from.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # unit and integration
|
||||||
|
make test-race # under the race detector
|
||||||
|
make cover # coverage per package
|
||||||
|
make db-reset # wipe the ledger; it is append-only and accumulates
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured capacity: **25,000 concurrent connections on one 4-core instance**,
|
||||||
|
zero failures, 586MB RSS. Bet throughput is the real ceiling at ~230/sec —
|
||||||
|
see [docs/SCALING.md](docs/SCALING.md).
|
||||||
|
|
||||||
|
Coverage sits around 85%, and the tests found and pinned three real money
|
||||||
|
bugs: a posting set that minted 18 quintillion millisatoshis by wrapping the
|
||||||
|
zero-sum check, a crash point that overflowed negative at the rarest seed, and
|
||||||
|
scratch tables that advertised 98% while paying 56%.
|
||||||
|
|
||||||
|
## Scaling
|
||||||
|
|
||||||
|
The app is stateless: clone the VM and boot it. An instance generates its own
|
||||||
|
identity, finds its peers through Redis, and campaigns for the games it will
|
||||||
|
drive. Exactly one instance runs a given game's rounds; the rest relay its
|
||||||
|
frames and forward bets to it.
|
||||||
|
|
||||||
|
An instance dying is not a special case — its lease expires and a survivor
|
||||||
|
takes over. Measured at six seconds, unattended, after a `kill -9`.
|
||||||
|
|
||||||
|
Clone the **app** VM only. PostgreSQL and Redis stay on one shared machine;
|
||||||
|
cloning those gives every instance its own ledger and they share nothing.
|
||||||
|
Full topology in [docs/SCALING.md](docs/SCALING.md).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built and tested:
|
||||||
|
|
||||||
|
- fixed-point deterministic core, ledger, commit-reveal fairness
|
||||||
|
- three crash games with live multiplayer rounds
|
||||||
|
- two scratch tickets with verified-honest odds
|
||||||
|
- keypair identity, peer-to-peer transfers, transaction history
|
||||||
|
- auto cash-out targets that pay your exact number
|
||||||
|
- in-browser verifier
|
||||||
|
- a documented public API, good enough to write bots against
|
||||||
|
|
||||||
|
Not yet built:
|
||||||
|
|
||||||
|
- **Lightning deposits and withdrawals.** The bridge account and ledger paths
|
||||||
|
exist; the node integration does not. The dev faucet stands in for now.
|
||||||
|
- Tournaments and scheduled events
|
||||||
|
- Operator dashboard
|
||||||
|
- In-memory bet reservation, which is what would lift the ~230 bets/sec ceiling
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This is built to run on a private network among people who know each other.
|
||||||
|
It is not hardened for, and should not be exposed to, the public internet.
|
||||||
|
Doing so would make it a public real-money gambling service, which carries
|
||||||
|
licensing, KYC, and AML obligations this codebase does not address.
|
||||||
385
cmd/arcade/admin.go
Normal file
385
cmd/arcade/admin.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fees"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin surfaces total operational visibility: every account, every
|
||||||
|
// transaction, treasury position, revenue, risk, and system health.
|
||||||
|
//
|
||||||
|
// It is deliberately read-only over game outcomes. The operator can see
|
||||||
|
// everything — including seeds, once revealed — but there is no control that
|
||||||
|
// changes a crash point or discloses a sealed seed mid-round. Such a control
|
||||||
|
// would make the fairness proof a lie, and the proof is the product. Money,
|
||||||
|
// accounts, withdrawals, and configuration are all operable.
|
||||||
|
|
||||||
|
// adminAuth gates the admin surface on a token supplied out of band.
|
||||||
|
//
|
||||||
|
// The comparison is constant-time and the token is never logged. If
|
||||||
|
// ARCADE_ADMIN_TOKEN is unset the admin surface is not mounted at all, so a
|
||||||
|
// default deployment has no admin endpoint to attack.
|
||||||
|
func adminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
want := os.Getenv("ARCADE_ADMIN_TOKEN")
|
||||||
|
wantHash := sha256.Sum256([]byte(want))
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got := bearer(r)
|
||||||
|
if got == "" {
|
||||||
|
got = r.URL.Query().Get("token") // convenience for the panel itself
|
||||||
|
}
|
||||||
|
gotHash := sha256.Sum256([]byte(got))
|
||||||
|
if subtle.ConstantTimeCompare(wantHash[:], gotHash[:]) != 1 {
|
||||||
|
// Do not distinguish "no token" from "wrong token".
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthorised")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The admin surface must never be cached by a proxy or a browser.
|
||||||
|
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) routesAdmin(mux *http.ServeMux) {
|
||||||
|
if os.Getenv("ARCADE_ADMIN_TOKEN") == "" {
|
||||||
|
return // no token configured: no admin surface exists
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /admin/api/overview", adminAuth(s.adminOverview))
|
||||||
|
mux.HandleFunc("GET /admin/api/players", adminAuth(s.adminPlayers))
|
||||||
|
mux.HandleFunc("GET /admin/api/transactions", adminAuth(s.adminTransactions))
|
||||||
|
mux.HandleFunc("GET /admin/api/rounds", adminAuth(s.adminRounds))
|
||||||
|
mux.HandleFunc("GET /admin/api/revenue", adminAuth(s.adminRevenue))
|
||||||
|
mux.HandleFunc("GET /admin/api/risk", adminAuth(s.adminRisk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminOverview is the headline position: what the house holds, what it owes,
|
||||||
|
// what it has earned, and whether the books balance.
|
||||||
|
func (s *server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
house, err := s.ledger.AccountByName(ctx, "house_pot")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
housePot, _ := s.ledger.Balance(ctx, house)
|
||||||
|
issued, _ := s.ledger.TotalIssued(ctx)
|
||||||
|
conservation, _ := s.ledger.ConservationCheck(ctx)
|
||||||
|
|
||||||
|
var playerCount, activeToday int64
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM accounts WHERE kind = 'player'`).Scan(&playerCount)
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(DISTINCT account_id) FROM postings
|
||||||
|
WHERE created_at > now() - interval '24 hours'`).Scan(&activeToday)
|
||||||
|
|
||||||
|
var owedToPlayers int64
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(SUM(b.balance_msat), 0)
|
||||||
|
FROM account_balances b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE a.kind = 'player'`).Scan(&owedToPlayers)
|
||||||
|
|
||||||
|
// Fees collected, all time and today.
|
||||||
|
var feesAllTime, feesToday int64
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(SUM(p.amount_msat), 0)
|
||||||
|
FROM postings p JOIN transactions t ON t.id = p.transaction_id
|
||||||
|
WHERE t.kind = 'operating_fee' AND p.account_id = $1`, house).Scan(&feesAllTime)
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(SUM(p.amount_msat), 0)
|
||||||
|
FROM postings p JOIN transactions t ON t.id = p.transaction_id
|
||||||
|
WHERE t.kind = 'operating_fee' AND p.account_id = $1
|
||||||
|
AND p.created_at > now() - interval '24 hours'`, house).Scan(&feesToday)
|
||||||
|
|
||||||
|
var wagered24h, paid24h int64
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(SUM(stake_msat), 0) FROM bets
|
||||||
|
WHERE placed_at > now() - interval '24 hours'`).Scan(&wagered24h)
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(SUM(payout_msat), 0) FROM bets
|
||||||
|
WHERE settled_at > now() - interval '24 hours'`).Scan(&paid24h)
|
||||||
|
|
||||||
|
members, _ := s.node.Members(ctx)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"house_pot_msat": housePot,
|
||||||
|
"owed_to_players": owedToPlayers,
|
||||||
|
"total_issued_msat": issued,
|
||||||
|
"conservation_msat": conservation,
|
||||||
|
"books_balanced": conservation == 0,
|
||||||
|
"players_total": playerCount,
|
||||||
|
"players_active_24h": activeToday,
|
||||||
|
"fees_all_time_msat": feesAllTime,
|
||||||
|
"fees_24h_msat": feesToday,
|
||||||
|
"wagered_24h_msat": wagered24h,
|
||||||
|
"paid_out_24h_msat": paid24h,
|
||||||
|
"gross_margin_24h": wagered24h - paid24h,
|
||||||
|
"instances": len(members),
|
||||||
|
"generated_at": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminPlayers lists accounts with their position and activity.
|
||||||
|
func (s *server) adminPlayers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := s.pool.Query(r.Context(), `
|
||||||
|
SELECT a.id,
|
||||||
|
COALESCE(a.nickname, ''),
|
||||||
|
encode(a.pubkey, 'hex'),
|
||||||
|
a.created_at,
|
||||||
|
COALESCE(bal.balance_msat, 0),
|
||||||
|
COALESCE(st.bets, 0),
|
||||||
|
COALESCE(st.wagered, 0),
|
||||||
|
COALESCE(st.won, 0),
|
||||||
|
st.last_seen
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN account_balances bal ON bal.account_id = a.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT account_id,
|
||||||
|
count(*) AS bets,
|
||||||
|
COALESCE(SUM(stake_msat), 0) AS wagered,
|
||||||
|
COALESCE(SUM(payout_msat), 0) AS won,
|
||||||
|
max(placed_at) AS last_seen
|
||||||
|
FROM bets GROUP BY account_id
|
||||||
|
) st ON st.account_id = a.id
|
||||||
|
WHERE a.kind = 'player'
|
||||||
|
ORDER BY COALESCE(bal.balance_msat, 0) DESC
|
||||||
|
LIMIT 500`)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type player struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Pubkey string `json:"pubkey"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
Bets int64 `json:"bets"`
|
||||||
|
WageredMsat int64 `json:"wagered_msat"`
|
||||||
|
WonMsat int64 `json:"won_msat"`
|
||||||
|
NetMsat int64 `json:"net_msat"`
|
||||||
|
LastSeen *time.Time `json:"last_seen"`
|
||||||
|
}
|
||||||
|
out := []player{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p player
|
||||||
|
if err := rows.Scan(&p.ID, &p.Nickname, &p.Pubkey, &p.CreatedAt,
|
||||||
|
&p.BalanceMsat, &p.Bets, &p.WageredMsat, &p.WonMsat, &p.LastSeen); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.NetMsat = p.WonMsat - p.WageredMsat
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"players": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminTransactions is the raw ledger feed.
|
||||||
|
func (s *server) adminTransactions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := s.pool.Query(r.Context(), `
|
||||||
|
SELECT p.id, t.kind, t.round_id, p.account_id,
|
||||||
|
COALESCE(a.nickname, ''), p.amount_msat,
|
||||||
|
p.balance_before, p.balance_after, p.created_at
|
||||||
|
FROM postings p
|
||||||
|
JOIN transactions t ON t.id = p.transaction_id
|
||||||
|
JOIN accounts a ON a.id = p.account_id
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
LIMIT 300`)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
RoundID *int64 `json:"round_id"`
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
AmountMsat int64 `json:"amount_msat"`
|
||||||
|
BalanceBefore int64 `json:"balance_before"`
|
||||||
|
BalanceAfter int64 `json:"balance_after"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
out := []entry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e entry
|
||||||
|
if err := rows.Scan(&e.ID, &e.Kind, &e.RoundID, &e.AccountID, &e.Nickname,
|
||||||
|
&e.AmountMsat, &e.BalanceBefore, &e.BalanceAfter, &e.CreatedAt); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"transactions": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRounds shows recent rounds with their economics and fairness record.
|
||||||
|
func (s *server) adminRounds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := s.pool.Query(r.Context(), `
|
||||||
|
SELECT r.id, r.game, r.crash_point, r.settled_at, r.voided_at,
|
||||||
|
encode(r.commitment, 'hex'),
|
||||||
|
CASE WHEN r.server_seed IS NULL THEN NULL
|
||||||
|
ELSE encode(r.server_seed, 'hex') END,
|
||||||
|
COALESCE(b.players, 0), COALESCE(b.staked, 0),
|
||||||
|
COALESCE(b.paid, 0), COALESCE(b.rake, 0)
|
||||||
|
FROM rounds r
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT round_id, count(*) AS players,
|
||||||
|
SUM(stake_msat) AS staked,
|
||||||
|
COALESCE(SUM(payout_msat), 0) AS paid,
|
||||||
|
COALESCE(SUM(rake_msat + rounding_msat), 0) AS rake
|
||||||
|
FROM bets GROUP BY round_id
|
||||||
|
) b ON b.round_id = r.id
|
||||||
|
ORDER BY r.id DESC
|
||||||
|
LIMIT 100`)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type round struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
CrashPoint *int64 `json:"crash_point"`
|
||||||
|
SettledAt *time.Time `json:"settled_at"`
|
||||||
|
VoidedAt *time.Time `json:"voided_at"`
|
||||||
|
Commitment string `json:"commitment"`
|
||||||
|
ServerSeed *string `json:"server_seed"`
|
||||||
|
Players int64 `json:"players"`
|
||||||
|
StakedMsat int64 `json:"staked_msat"`
|
||||||
|
PaidMsat int64 `json:"paid_msat"`
|
||||||
|
RakeMsat int64 `json:"rake_msat"`
|
||||||
|
HouseMsat int64 `json:"house_result_msat"`
|
||||||
|
}
|
||||||
|
out := []round{}
|
||||||
|
for rows.Next() {
|
||||||
|
var rd round
|
||||||
|
if err := rows.Scan(&rd.ID, &rd.Game, &rd.CrashPoint, &rd.SettledAt, &rd.VoidedAt,
|
||||||
|
&rd.Commitment, &rd.ServerSeed, &rd.Players, &rd.StakedMsat,
|
||||||
|
&rd.PaidMsat, &rd.RakeMsat); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rd.HouseMsat = rd.StakedMsat - rd.PaidMsat
|
||||||
|
out = append(out, rd)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"rounds": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRevenue breaks earnings down by source and by day.
|
||||||
|
func (s *server) adminRevenue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
house, err := s.ledger.AccountByName(ctx, "house_pot")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT date_trunc('day', p.created_at) AS day,
|
||||||
|
COALESCE(SUM(p.amount_msat) FILTER (WHERE t.kind = 'bet'), 0),
|
||||||
|
COALESCE(SUM(-p.amount_msat) FILTER (WHERE t.kind = 'payout'), 0),
|
||||||
|
COALESCE(SUM(p.amount_msat) FILTER (WHERE t.kind = 'operating_fee'), 0),
|
||||||
|
COALESCE(SUM(p.amount_msat) FILTER (WHERE t.kind LIKE 'scratch%'), 0)
|
||||||
|
FROM postings p
|
||||||
|
JOIN transactions t ON t.id = p.transaction_id
|
||||||
|
WHERE p.account_id = $1
|
||||||
|
AND p.created_at > now() - interval '30 days'
|
||||||
|
GROUP BY 1 ORDER BY 1`, house)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type day struct {
|
||||||
|
Day time.Time `json:"day"`
|
||||||
|
StakesIn int64 `json:"stakes_in_msat"`
|
||||||
|
PaidOut int64 `json:"paid_out_msat"`
|
||||||
|
Fees int64 `json:"fees_msat"`
|
||||||
|
Scratch int64 `json:"scratch_net_msat"`
|
||||||
|
NetMsat int64 `json:"net_msat"`
|
||||||
|
}
|
||||||
|
out := []day{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d day
|
||||||
|
if err := rows.Scan(&d.Day, &d.StakesIn, &d.PaidOut, &d.Fees, &d.Scratch); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.NetMsat = d.StakesIn - d.PaidOut + d.Fees
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
sch := fees.DefaultSchedule()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"daily": out,
|
||||||
|
"fee_schedule": sch.Describe(9900),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRisk surfaces what an operator needs to notice: outsized winners,
|
||||||
|
// unresolved rounds, and pending withdrawals.
|
||||||
|
func (s *server) adminRisk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
type winner struct {
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
NetMsat int64 `json:"net_msat"`
|
||||||
|
Bets int64 `json:"bets"`
|
||||||
|
}
|
||||||
|
winners := []winner{}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT b.account_id, COALESCE(a.nickname, ''),
|
||||||
|
SUM(COALESCE(b.payout_msat, 0)) - SUM(b.stake_msat) AS net,
|
||||||
|
count(*)
|
||||||
|
FROM bets b JOIN accounts a ON a.id = b.account_id
|
||||||
|
GROUP BY b.account_id, a.nickname
|
||||||
|
HAVING SUM(COALESCE(b.payout_msat, 0)) - SUM(b.stake_msat) > 0
|
||||||
|
ORDER BY net DESC LIMIT 20`)
|
||||||
|
if err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var v winner
|
||||||
|
if err := rows.Scan(&v.AccountID, &v.Nickname, &v.NetMsat, &v.Bets); err == nil {
|
||||||
|
winners = append(winners, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolved, pendingWithdrawals, needsApproval int64
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM rounds
|
||||||
|
WHERE settled_at IS NULL AND voided_at IS NULL
|
||||||
|
AND opened_at < now() - interval '2 minutes'`).Scan(&unresolved)
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM lightning_withdrawals
|
||||||
|
WHERE status IN ('queued', 'sending')`).Scan(&pendingWithdrawals)
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM lightning_withdrawals
|
||||||
|
WHERE status = 'needs_approval'`).Scan(&needsApproval)
|
||||||
|
|
||||||
|
conservation, _ := s.ledger.ConservationCheck(ctx)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"top_winners": winners,
|
||||||
|
"unresolved_rounds": unresolved,
|
||||||
|
"pending_withdrawals": pendingWithdrawals,
|
||||||
|
"withdrawals_to_review": needsApproval,
|
||||||
|
"conservation_msat": conservation,
|
||||||
|
"books_balanced": conservation == 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
602
cmd/arcade/e2e_test.go
Normal file
602
cmd/arcade/e2e_test.go
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests drive a running server. Start it with:
|
||||||
|
//
|
||||||
|
// ARCADE_DEV_FAUCET=1 go run ./cmd/arcade
|
||||||
|
//
|
||||||
|
// and run with ARCADE_E2E=http://localhost:8080. They are skipped otherwise so
|
||||||
|
// that `go test ./...` stays green without a live server.
|
||||||
|
func baseURL(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
u := os.Getenv("ARCADE_E2E")
|
||||||
|
if u == "" {
|
||||||
|
t.Skip("set ARCADE_E2E to run end-to-end tests")
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
t *testing.T
|
||||||
|
base string
|
||||||
|
token string
|
||||||
|
pub ed25519.PublicKey
|
||||||
|
priv ed25519.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(t *testing.T) *client {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &client{t: t, base: baseURL(t), pub: pub, priv: priv}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) do(method, path string, body, out any) int {
|
||||||
|
c.t.Helper()
|
||||||
|
var buf io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
buf = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, c.base+path, buf)
|
||||||
|
if err != nil {
|
||||||
|
c.t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if out != nil {
|
||||||
|
_ = json.NewDecoder(res.Body).Decode(out)
|
||||||
|
}
|
||||||
|
return res.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) signIn(nickname string) {
|
||||||
|
c.t.Helper()
|
||||||
|
pubHex := hex.EncodeToString(c.pub)
|
||||||
|
|
||||||
|
var chal struct{ Challenge string }
|
||||||
|
if code := c.do("POST", "/api/auth/challenge",
|
||||||
|
map[string]string{"pubkey": pubHex}, &chal); code != 200 {
|
||||||
|
c.t.Fatalf("challenge failed: %d", code)
|
||||||
|
}
|
||||||
|
nonce, _ := hex.DecodeString(chal.Challenge)
|
||||||
|
sig := ed25519.Sign(c.priv, nonce)
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if code := c.do("POST", "/api/auth/verify", map[string]string{
|
||||||
|
"pubkey": pubHex, "signature": hex.EncodeToString(sig), "nickname": nickname,
|
||||||
|
}, &res); code != 200 {
|
||||||
|
c.t.Fatalf("verify failed: %d", code)
|
||||||
|
}
|
||||||
|
c.token = res.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) fund(msat int64) int64 {
|
||||||
|
c.t.Helper()
|
||||||
|
var res struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
if code := c.do("POST", "/api/dev/faucet",
|
||||||
|
map[string]int64{"amount_msat": msat}, &res); code != 200 {
|
||||||
|
c.t.Fatalf("faucet failed: %d (is ARCADE_DEV_FAUCET=1 set?)", code)
|
||||||
|
}
|
||||||
|
return res.BalanceMsat
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInAndFund(t *testing.T) {
|
||||||
|
c := newClient(t)
|
||||||
|
c.signIn("tester")
|
||||||
|
if bal := c.fund(50_000_000); bal < 50_000_000 {
|
||||||
|
t.Fatalf("balance after faucet = %d", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthenticatedRequestsRejected(t *testing.T) {
|
||||||
|
c := newClient(t)
|
||||||
|
var out map[string]any
|
||||||
|
if code := c.do("GET", "/api/balance", nil, &out); code != 401 {
|
||||||
|
t.Fatalf("unauthenticated balance returned %d, want 401", code)
|
||||||
|
}
|
||||||
|
if code := c.do("POST", "/api/bet",
|
||||||
|
map[string]any{"game": "rocket", "stake_msat": 1000}, &out); code != 401 {
|
||||||
|
t.Fatalf("unauthenticated bet returned %d, want 401", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotBetMoreThanBalance(t *testing.T) {
|
||||||
|
c := newClient(t)
|
||||||
|
c.signIn("broke")
|
||||||
|
// No faucet call: balance is zero.
|
||||||
|
var out map[string]any
|
||||||
|
code := c.do("POST", "/api/bet",
|
||||||
|
map[string]any{"game": "rocket", "stake_msat": 1_000_000}, &out)
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("betting without funds returned %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play a full round: wait for a betting window, bet, and confirm the stake left
|
||||||
|
// the balance and the round eventually settles and reveals its seed.
|
||||||
|
func TestFullRoundLifecycleAndVerification(t *testing.T) {
|
||||||
|
c := newClient(t)
|
||||||
|
c.signIn("player")
|
||||||
|
c.fund(50_000_000)
|
||||||
|
|
||||||
|
const stake = 1_000_000
|
||||||
|
var roundID int64
|
||||||
|
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var games struct {
|
||||||
|
Rooms []struct {
|
||||||
|
RoundID int64 `json:"round_id"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"rooms"`
|
||||||
|
}
|
||||||
|
c.do("GET", "/api/games", nil, &games)
|
||||||
|
|
||||||
|
for _, rm := range games.Rooms {
|
||||||
|
if rm.Game != "rocket" || rm.State != "betting_open" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if code := c.do("POST", "/api/bet", map[string]any{
|
||||||
|
"game": "rocket", "stake_msat": stake, "nickname": "player",
|
||||||
|
}, &res); code == 200 {
|
||||||
|
roundID = rm.RoundID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roundID != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if roundID == 0 {
|
||||||
|
t.Fatal("never managed to place a bet within 90s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the round to settle and expose its proof.
|
||||||
|
var proof struct {
|
||||||
|
Commitment string `json:"commitment"`
|
||||||
|
ServerSeed string `json:"server_seed"`
|
||||||
|
ClientSeed string `json:"client_seed"`
|
||||||
|
Nonce int64 `json:"nonce"`
|
||||||
|
Participants []string `json:"participants"`
|
||||||
|
}
|
||||||
|
settled := false
|
||||||
|
deadline = time.Now().Add(90 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if code := c.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
|
||||||
|
settled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !settled {
|
||||||
|
t.Fatal("round never settled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The revealed seed must match the commitment published before betting.
|
||||||
|
seed, err := hex.DecodeString(proof.ServerSeed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(seed)
|
||||||
|
if hex.EncodeToString(sum[:]) != proof.Commitment {
|
||||||
|
t.Fatalf("commitment mismatch:\n published %s\n actual %s",
|
||||||
|
proof.Commitment, hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
if len(proof.Participants) == 0 {
|
||||||
|
t.Fatal("settled round lists no participants")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The books must balance at all times, which the health endpoint reports.
|
||||||
|
func TestLedgerStaysBalanced(t *testing.T) {
|
||||||
|
c := newClient(t)
|
||||||
|
var health struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LedgerSumMsat int64 `json:"ledger_sum_msat"`
|
||||||
|
}
|
||||||
|
if code := c.do("GET", "/api/health", nil, &health); code != 200 {
|
||||||
|
t.Fatalf("health returned %d", code)
|
||||||
|
}
|
||||||
|
if health.LedgerSumMsat != 0 {
|
||||||
|
t.Fatalf("ledger does not balance: sum = %d", health.LedgerSumMsat)
|
||||||
|
}
|
||||||
|
if health.Status != "ok" {
|
||||||
|
t.Fatalf("health status = %q", health.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScratchTicketPlaysAndPays(t *testing.T) {
|
||||||
|
c := newClient(t)
|
||||||
|
c.signIn("scratcher")
|
||||||
|
start := c.fund(100_000_000)
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Outcome struct {
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
PayoutMsat int64 `json:"payout_msat"`
|
||||||
|
Cells []int `json:"cells"`
|
||||||
|
} `json:"outcome"`
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
const stake = 1_000_000
|
||||||
|
if code := c.do("POST", "/api/scratch/play",
|
||||||
|
map[string]any{"ticket_id": "nebula-nine", "stake_msat": stake}, &res); code != 200 {
|
||||||
|
t.Fatalf("scratch play returned %d", code)
|
||||||
|
}
|
||||||
|
if len(res.Outcome.Cells) != 9 {
|
||||||
|
t.Fatalf("got %d cells, want 9", len(res.Outcome.Cells))
|
||||||
|
}
|
||||||
|
want := start - stake + res.Outcome.PayoutMsat
|
||||||
|
if res.BalanceMsat != want {
|
||||||
|
t.Fatalf("balance = %d, want %d (start %d, stake %d, payout %d)",
|
||||||
|
res.BalanceMsat, want, start, stake, res.Outcome.PayoutMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int64) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// An auto cash-out target must be accepted by the API and reflected in the
|
||||||
|
// round, and an invalid one must be refused before any money moves.
|
||||||
|
func TestAutoCashOutThroughTheAPI(t *testing.T) {
|
||||||
|
c := newClient(t)
|
||||||
|
c.signIn("autoplayer")
|
||||||
|
start := c.fund(50_000_000)
|
||||||
|
|
||||||
|
var out map[string]any
|
||||||
|
// A target at or below 1.00 is meaningless and must be rejected.
|
||||||
|
code := c.do("POST", "/api/bet", map[string]any{
|
||||||
|
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1.0,
|
||||||
|
}, &out)
|
||||||
|
if code == 200 {
|
||||||
|
t.Fatal("a 1.00x auto cash-out target was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absurd target must be refused rather than overflowing the conversion.
|
||||||
|
code = c.do("POST", "/api/bet", map[string]any{
|
||||||
|
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1e12,
|
||||||
|
}, &out)
|
||||||
|
if code == 200 {
|
||||||
|
t.Fatal("an absurd auto cash-out target was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither rejection may have moved money.
|
||||||
|
var bal struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
c.do("GET", "/api/balance", nil, &bal)
|
||||||
|
if bal.BalanceMsat != start {
|
||||||
|
t.Fatalf("balance = %d after rejected bets, want %d", bal.BalanceMsat, start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sensible target should be accepted during a betting window.
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
placed := false
|
||||||
|
for time.Now().Before(deadline) && !placed {
|
||||||
|
var games struct {
|
||||||
|
Rooms []struct {
|
||||||
|
Game string `json:"game"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"rooms"`
|
||||||
|
}
|
||||||
|
c.do("GET", "/api/games", nil, &games)
|
||||||
|
for _, rm := range games.Rooms {
|
||||||
|
if rm.Game == "rocket" && rm.State == "betting_open" {
|
||||||
|
var res map[string]any
|
||||||
|
if code := c.do("POST", "/api/bet", map[string]any{
|
||||||
|
"game": "rocket", "stake_msat": 1_000_000,
|
||||||
|
"auto_cashout": 2.5, "nickname": "autoplayer",
|
||||||
|
}, &res); code == 200 {
|
||||||
|
placed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !placed {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !placed {
|
||||||
|
t.Fatal("could not place an auto cash-out bet within 90s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The complete journey a real player takes, in one test: arrive with no
|
||||||
|
// account, get funded, play both games, watch the ledger explain every change,
|
||||||
|
// move sats to a friend, and verify a round independently.
|
||||||
|
//
|
||||||
|
// Each step asserts against the ledger rather than against the API's own
|
||||||
|
// summary, so a bug that reports success while losing money fails here.
|
||||||
|
func TestFullPlayerJourney(t *testing.T) {
|
||||||
|
alice := newClient(t)
|
||||||
|
bob := newClient(t)
|
||||||
|
|
||||||
|
// 1. Arrive. No account exists; a keypair is the whole sign-up.
|
||||||
|
alice.signIn("alice")
|
||||||
|
bob.signIn("bob")
|
||||||
|
|
||||||
|
var bal struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/balance", nil, &bal)
|
||||||
|
if bal.BalanceMsat != 0 {
|
||||||
|
t.Fatalf("a brand new player started with %d msat", bal.BalanceMsat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get funded.
|
||||||
|
const funded = 50_000_000
|
||||||
|
if got := alice.fund(funded); got != funded {
|
||||||
|
t.Fatalf("balance after funding = %d, want %d", got, funded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Scratch a ticket. The balance must move by exactly stake and payout.
|
||||||
|
var sc struct {
|
||||||
|
Outcome struct {
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
PayoutMsat int64 `json:"payout_msat"`
|
||||||
|
Cells []int `json:"cells"`
|
||||||
|
} `json:"outcome"`
|
||||||
|
Proof struct {
|
||||||
|
Commitment string `json:"commitment"`
|
||||||
|
ServerSeed string `json:"server_seed"`
|
||||||
|
} `json:"proof"`
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
const scratchStake = 1_000_000
|
||||||
|
if code := alice.do("POST", "/api/scratch/play",
|
||||||
|
map[string]any{"ticket_id": "nebula-nine", "stake_msat": scratchStake}, &sc); code != 200 {
|
||||||
|
t.Fatalf("scratch play returned %d", code)
|
||||||
|
}
|
||||||
|
wantAfterScratch := int64(funded) - scratchStake + sc.Outcome.PayoutMsat
|
||||||
|
if sc.BalanceMsat != wantAfterScratch {
|
||||||
|
t.Fatalf("balance after scratch = %d, want %d", sc.BalanceMsat, wantAfterScratch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scratch proof must verify against its own seed.
|
||||||
|
seed, err := hex.DecodeString(sc.Proof.ServerSeed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(seed)
|
||||||
|
if hex.EncodeToString(sum[:]) != sc.Proof.Commitment {
|
||||||
|
t.Fatal("scratch proof does not verify against its own commitment")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Play a crash round with an auto cash-out target.
|
||||||
|
const stake = 2_000_000
|
||||||
|
var roundID int64
|
||||||
|
beforeRound := sc.BalanceMsat
|
||||||
|
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && roundID == 0 {
|
||||||
|
var games struct {
|
||||||
|
Rooms []struct {
|
||||||
|
RoundID int64 `json:"round_id"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"rooms"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/games", nil, &games)
|
||||||
|
for _, rm := range games.Rooms {
|
||||||
|
if rm.Game != "rocket" || rm.State != "betting_open" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
if code := alice.do("POST", "/api/bet", map[string]any{
|
||||||
|
"game": "rocket", "stake_msat": stake,
|
||||||
|
"auto_cashout": 1.5, "nickname": "alice",
|
||||||
|
}, &res); code == 200 {
|
||||||
|
roundID = rm.RoundID
|
||||||
|
// The stake must leave immediately, not at settlement.
|
||||||
|
if res.BalanceMsat != beforeRound-stake {
|
||||||
|
t.Fatalf("balance after bet = %d, want %d",
|
||||||
|
res.BalanceMsat, beforeRound-stake)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roundID == 0 {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roundID == 0 {
|
||||||
|
t.Fatal("could not join a round within 90s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Wait for settlement and check the outcome is consistent.
|
||||||
|
var proof struct {
|
||||||
|
Commitment string `json:"commitment"`
|
||||||
|
ServerSeed string `json:"server_seed"`
|
||||||
|
ClientSeed string `json:"client_seed"`
|
||||||
|
Nonce int64 `json:"nonce"`
|
||||||
|
CrashPoint *int64 `json:"crash_point"`
|
||||||
|
Participants []string `json:"participants"`
|
||||||
|
}
|
||||||
|
settled := false
|
||||||
|
deadline = time.Now().Add(120 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if code := alice.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
|
||||||
|
settled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !settled {
|
||||||
|
t.Fatal("the round never settled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify the round independently, the way the client does.
|
||||||
|
roundSeed, err := hex.DecodeString(proof.ServerSeed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rs := sha256.Sum256(roundSeed)
|
||||||
|
if hex.EncodeToString(rs[:]) != proof.Commitment {
|
||||||
|
t.Fatal("settled round does not match its published commitment")
|
||||||
|
}
|
||||||
|
if proof.CrashPoint == nil {
|
||||||
|
t.Fatal("a settled round published no crash point")
|
||||||
|
}
|
||||||
|
crash := float64(*proof.CrashPoint) / 4294967296.0
|
||||||
|
|
||||||
|
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
|
||||||
|
// reached the target, nothing otherwise — net of the disclosed fees.
|
||||||
|
var after struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the expected payout from the published fee schedule rather than
|
||||||
|
// hardcoding it. A test that hardcodes the net would silently stop
|
||||||
|
// checking anything the moment the operator changed the rake.
|
||||||
|
var sched struct {
|
||||||
|
Schedule struct {
|
||||||
|
RakePercent string `json:"rake_percent"`
|
||||||
|
RoundingUnit string `json:"rounding_unit"`
|
||||||
|
} `json:"schedule"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/fees", nil, &sched)
|
||||||
|
|
||||||
|
var rakePct float64
|
||||||
|
fmt.Sscanf(sched.Schedule.RakePercent, "%f%%", &rakePct)
|
||||||
|
var roundUnit int64
|
||||||
|
fmt.Sscanf(sched.Schedule.RoundingUnit, "%d msat", &roundUnit)
|
||||||
|
if roundUnit < 1 {
|
||||||
|
roundUnit = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
gross := int64(stake) * 3 / 2
|
||||||
|
rake := int64(float64(gross) * rakePct / 100)
|
||||||
|
net := (gross - rake) / roundUnit * roundUnit
|
||||||
|
|
||||||
|
// Settlement posts a moment after the reveal; poll briefly.
|
||||||
|
wantWin := beforeRound - stake + net
|
||||||
|
wantLose := beforeRound - stake
|
||||||
|
ok := false
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
alice.do("GET", "/api/balance", nil, &after)
|
||||||
|
if after.BalanceMsat == wantWin || after.BalanceMsat == wantLose {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("balance %d is neither the win (%d) nor the loss (%d) outcome",
|
||||||
|
after.BalanceMsat, wantWin, wantLose)
|
||||||
|
}
|
||||||
|
if crash >= 1.5 && after.BalanceMsat != wantWin {
|
||||||
|
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d "+
|
||||||
|
"(gross %d, rake %d, net %d)",
|
||||||
|
crash, after.BalanceMsat, wantWin, gross, rake, net)
|
||||||
|
}
|
||||||
|
if crash < 1.5 && after.BalanceMsat != wantLose {
|
||||||
|
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
||||||
|
crash, after.BalanceMsat, wantLose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Every balance change must be explained by the ledger.
|
||||||
|
var hist struct {
|
||||||
|
Entries []struct {
|
||||||
|
Kind string `json:"Kind"`
|
||||||
|
AmountMsat int64 `json:"AmountMsat"`
|
||||||
|
BalanceBefore int64 `json:"BalanceBefore"`
|
||||||
|
BalanceAfter int64 `json:"BalanceAfter"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/history", nil, &hist)
|
||||||
|
if len(hist.Entries) < 3 {
|
||||||
|
t.Fatalf("history has %d entries; expected at least deposit, scratch, bet",
|
||||||
|
len(hist.Entries))
|
||||||
|
}
|
||||||
|
// History is newest-first; walking backwards, each entry's before must be
|
||||||
|
// the previous entry's after.
|
||||||
|
for i := 0; i < len(hist.Entries)-1; i++ {
|
||||||
|
newer, older := hist.Entries[i], hist.Entries[i+1]
|
||||||
|
if newer.BalanceBefore != older.BalanceAfter {
|
||||||
|
t.Fatalf("ledger history is not continuous: %s starts at %d but the "+
|
||||||
|
"preceding %s ended at %d",
|
||||||
|
newer.Kind, newer.BalanceBefore, older.Kind, older.BalanceAfter)
|
||||||
|
}
|
||||||
|
if newer.BalanceAfter != newer.BalanceBefore+newer.AmountMsat {
|
||||||
|
t.Fatalf("%s entry does not add up: %d + %d != %d",
|
||||||
|
newer.Kind, newer.BalanceBefore, newer.AmountMsat, newer.BalanceAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Send sats to a friend; both sides must move by the same amount.
|
||||||
|
bobBefore := bob.fund(1)
|
||||||
|
aliceBefore := after.BalanceMsat
|
||||||
|
const gift = 500_000
|
||||||
|
var xfer struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
if code := alice.do("POST", "/api/transfer", map[string]any{
|
||||||
|
"to_pubkey": hex.EncodeToString(bob.pub), "amount_msat": gift,
|
||||||
|
}, &xfer); code != 200 {
|
||||||
|
t.Fatalf("transfer returned %d", code)
|
||||||
|
}
|
||||||
|
if xfer.BalanceMsat != aliceBefore-gift {
|
||||||
|
t.Fatalf("sender balance = %d, want %d", xfer.BalanceMsat, aliceBefore-gift)
|
||||||
|
}
|
||||||
|
var bobAfter struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
bob.do("GET", "/api/balance", nil, &bobAfter)
|
||||||
|
if bobAfter.BalanceMsat != bobBefore+gift {
|
||||||
|
t.Fatalf("recipient balance = %d, want %d", bobAfter.BalanceMsat, bobBefore+gift)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. The books must still balance to zero after all of it.
|
||||||
|
var health struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LedgerSumMsat int64 `json:"ledger_sum_msat"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/health", nil, &health)
|
||||||
|
if health.LedgerSumMsat != 0 {
|
||||||
|
t.Fatalf("after a full journey the books are off by %d msat", health.LedgerSumMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
cmd/arcade/forward.go
Normal file
95
cmd/arcade/forward.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forwardClient is deliberately short-tempered. A bet or cash-out is only
|
||||||
|
// useful while the round is still open, so a request that cannot be delivered
|
||||||
|
// promptly should fail loudly rather than land after the moment has passed.
|
||||||
|
var forwardClient = &http.Client{Timeout: 3 * time.Second}
|
||||||
|
|
||||||
|
// forwardToLeader proxies a mutation to whichever instance drives the game.
|
||||||
|
//
|
||||||
|
// Only one instance holds a game's authoritative round state — who is in, at
|
||||||
|
// what stake, and at which tick each cash-out landed. Serving a bet from a
|
||||||
|
// follower's idle copy would either fail or, worse, create a second version of
|
||||||
|
// the round. So the follower relays the request and returns the leader's
|
||||||
|
// answer verbatim.
|
||||||
|
//
|
||||||
|
// The caller's Authorization header travels with it. That works because
|
||||||
|
// sessions live in Redis, so the leader can validate a token issued by any
|
||||||
|
// instance in the fleet.
|
||||||
|
//
|
||||||
|
// It reports whether the request was handled here.
|
||||||
|
func (s *server) forwardToLeader(w http.ResponseWriter, r *http.Request, game string, body []byte) bool {
|
||||||
|
hub, ok := s.hubs[game]
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusNotFound, "no such game")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if hub.Leading() {
|
||||||
|
return false // this instance owns the round; handle it locally
|
||||||
|
}
|
||||||
|
|
||||||
|
leader, err := s.node.LeaderOf(r.Context(), game)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "cannot locate the game leader")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if leader.Address == "" {
|
||||||
|
// Between leaders: a lease has expired and the next campaign has not
|
||||||
|
// landed yet. This resolves within a couple of seconds on its own.
|
||||||
|
writeErr(w, http.StatusServiceUnavailable,
|
||||||
|
"this game is changing hands; try again in a moment")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://%s%s", leader.Address, r.URL.Path)
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
// Mark the hop so a routing mistake shows up as an explicit loop error
|
||||||
|
// rather than as instances bouncing a request between themselves.
|
||||||
|
if r.Header.Get("X-Arcade-Forwarded") != "" {
|
||||||
|
writeErr(w, http.StatusLoopDetected,
|
||||||
|
"request was forwarded twice; the cluster disagrees about the leader")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Arcade-Forwarded", s.node.ID)
|
||||||
|
|
||||||
|
res, err := forwardClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable,
|
||||||
|
"the instance running this game did not respond")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadGateway, "truncated response from the game leader")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(res.StatusCode)
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBody buffers a request body so it can be both parsed locally and
|
||||||
|
// forwarded if this instance turns out not to own the game.
|
||||||
|
func readBody(r *http.Request) ([]byte, error) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
return io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
}
|
||||||
271
cmd/arcade/hub.go
Normal file
271
cmd/arcade/hub.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/cluster"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/room"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gameHub owns one game across the cluster.
|
||||||
|
//
|
||||||
|
// Exactly one instance leads a game: it runs the round loop, drives the
|
||||||
|
// simulation, settles to the ledger, and publishes each frame. Every other
|
||||||
|
// instance relays those frames to its own connected clients. Clients cannot
|
||||||
|
// tell the difference, and neither can the ledger.
|
||||||
|
//
|
||||||
|
// Roles are renegotiated on a timer rather than agreed once, so an instance
|
||||||
|
// disappearing is not a special case — its lease simply stops being renewed
|
||||||
|
// and the next campaign hands the game to someone else.
|
||||||
|
type gameHub struct {
|
||||||
|
game string
|
||||||
|
room *room.Room
|
||||||
|
node *cluster.Node
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
leading bool
|
||||||
|
subs map[chan []byte]struct{}
|
||||||
|
|
||||||
|
// lastFrame is the most recent frame this instance saw, whether it
|
||||||
|
// produced it or relayed it. A follower's own room object sits idle, so
|
||||||
|
// this — not the local room — is what any read of "current state" must
|
||||||
|
// use, or a follower would report a permanently settled game.
|
||||||
|
lastFrame []byte
|
||||||
|
|
||||||
|
// cancelLead stops the leader's round loop when leadership is lost.
|
||||||
|
cancelLead context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGameHub(game string, r *room.Room, node *cluster.Node) *gameHub {
|
||||||
|
return &gameHub{
|
||||||
|
game: game,
|
||||||
|
room: r,
|
||||||
|
node: node,
|
||||||
|
subs: make(map[chan []byte]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns frames for this game, whether this instance is producing
|
||||||
|
// them or relaying them.
|
||||||
|
func (h *gameHub) Subscribe() (<-chan []byte, func()) {
|
||||||
|
ch := make(chan []byte, 4)
|
||||||
|
h.mu.Lock()
|
||||||
|
h.subs[ch] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
return ch, func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.subs, ch)
|
||||||
|
close(ch)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fanout delivers a frame to this instance's own clients. A client that has
|
||||||
|
// stopped reading is skipped rather than allowed to stall the game.
|
||||||
|
func (h *gameHub) fanout(payload []byte) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.lastFrame = payload
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for ch := range h.subs {
|
||||||
|
select {
|
||||||
|
case ch <- payload:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastFrame returns the most recent state this instance knows about, and
|
||||||
|
// whether it has seen one yet.
|
||||||
|
func (h *gameHub) LastFrame() ([]byte, bool) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return h.lastFrame, len(h.lastFrame) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leading reports whether this instance currently drives the game.
|
||||||
|
func (h *gameHub) Leading() bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return h.leading
|
||||||
|
}
|
||||||
|
|
||||||
|
// supervise campaigns for the game and switches roles as leadership moves.
|
||||||
|
func (h *gameHub) supervise(ctx context.Context) {
|
||||||
|
// Followers hold a subscription to the cluster's frame channel. It is torn
|
||||||
|
// down on promotion so a leader never relays its own frames back to itself.
|
||||||
|
var stopRelay func()
|
||||||
|
defer func() {
|
||||||
|
if stopRelay != nil {
|
||||||
|
stopRelay()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(cluster.RenewInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
h.demote()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
var held bool
|
||||||
|
var err error
|
||||||
|
if h.Leading() {
|
||||||
|
held, err = h.node.Renew(ctx, h.game)
|
||||||
|
} else {
|
||||||
|
held, err = h.node.Campaign(ctx, h.game)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Redis is unreachable. A current leader keeps running rather than
|
||||||
|
// abandoning a round mid-flight; its lease will expire and another
|
||||||
|
// instance will take over if the outage outlasts it.
|
||||||
|
log.Printf("hub %s: coordination error: %v", h.game, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case held && !h.Leading():
|
||||||
|
if stopRelay != nil {
|
||||||
|
stopRelay()
|
||||||
|
stopRelay = nil
|
||||||
|
}
|
||||||
|
h.promote(ctx)
|
||||||
|
|
||||||
|
case !held && h.Leading():
|
||||||
|
h.demote()
|
||||||
|
stopRelay = h.startRelay(ctx)
|
||||||
|
|
||||||
|
case !held && stopRelay == nil:
|
||||||
|
// Follower with no relay yet — subscribe so clients see the game.
|
||||||
|
stopRelay = h.startRelay(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// promote starts driving the game on this instance.
|
||||||
|
func (h *gameHub) promote(ctx context.Context) {
|
||||||
|
leadCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.leading = true
|
||||||
|
h.cancelLead = cancel
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("hub %s: leading", h.game)
|
||||||
|
|
||||||
|
// Forward the room's frames both to this instance's clients and to peers.
|
||||||
|
frames, unsubscribe := h.room.Subscribe()
|
||||||
|
go func() {
|
||||||
|
defer unsubscribe()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-leadCtx.Done():
|
||||||
|
return
|
||||||
|
case payload, ok := <-frames:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.fanout(payload)
|
||||||
|
if err := h.node.PublishFrame(leadCtx, h.game, payload); err != nil &&
|
||||||
|
!errors.Is(err, context.Canceled) {
|
||||||
|
log.Printf("hub %s: publishing frame: %v", h.game, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := h.room.Run(leadCtx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.Printf("hub %s: round loop stopped: %v", h.game, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// demote stops driving the game.
|
||||||
|
func (h *gameHub) demote() {
|
||||||
|
h.mu.Lock()
|
||||||
|
wasLeading := h.leading
|
||||||
|
h.leading = false
|
||||||
|
cancel := h.cancelLead
|
||||||
|
h.cancelLead = nil
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if wasLeading {
|
||||||
|
log.Printf("hub %s: no longer leading", h.game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRelay subscribes to the leader's frames and passes them to this
|
||||||
|
// instance's clients.
|
||||||
|
func (h *gameHub) startRelay(ctx context.Context) func() {
|
||||||
|
frames, unsubscribe := h.node.SubscribeFrames(ctx, h.game)
|
||||||
|
relayCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-relayCtx.Done():
|
||||||
|
return
|
||||||
|
case payload, ok := <-frames:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.fanout(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
cancel()
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advertiseAddr is how peers reach this instance.
|
||||||
|
//
|
||||||
|
// It prefers an explicit setting, then the first non-loopback address it can
|
||||||
|
// find — so a cloned VM that gets its address from DHCP advertises correctly
|
||||||
|
// without being told what it is.
|
||||||
|
func advertiseAddr() string {
|
||||||
|
if v := os.Getenv("ARCADE_ADVERTISE"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
port := os.Getenv("ARCADE_ADDR")
|
||||||
|
if port == "" {
|
||||||
|
port = ":8080"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(port, ":") {
|
||||||
|
if _, p, err := net.SplitHostPort(port); err == nil {
|
||||||
|
port = ":" + p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return "127.0.0.1" + port
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
ipnet, ok := a.(*net.IPNet)
|
||||||
|
if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ipnet.IP.String() + port
|
||||||
|
}
|
||||||
|
return "127.0.0.1" + port
|
||||||
|
}
|
||||||
139
cmd/arcade/lnurl.go
Normal file
139
cmd/arcade/lnurl.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LNURL-withdraw endpoints.
|
||||||
|
//
|
||||||
|
// Two of these are called by the player's wallet, not by the arcade's own
|
||||||
|
// client, so they follow the LNURL specification's shapes rather than this
|
||||||
|
// project's conventions: a bare JSON object with a status field, and no
|
||||||
|
// bearer token, because a wallet has none. The single-use k1 is the
|
||||||
|
// authorisation.
|
||||||
|
|
||||||
|
func (s *server) routesLNURL(mux *http.ServeMux) {
|
||||||
|
// Called by the arcade's own client, authenticated as normal.
|
||||||
|
mux.HandleFunc("POST /api/withdraw/code", s.handleWithdrawCode)
|
||||||
|
|
||||||
|
// Called by the player's wallet after scanning. No session exists here.
|
||||||
|
mux.HandleFunc("GET /lnurl/withdraw", s.handleLNURLTerms)
|
||||||
|
mux.HandleFunc("GET /lnurl/withdraw/callback", s.handleLNURLCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWithdrawCode issues a scannable cash-out code.
|
||||||
|
//
|
||||||
|
// The funds are debited when the code is issued, not when it is redeemed. A
|
||||||
|
// code is an authorisation to pull an exact amount, and leaving the balance
|
||||||
|
// spendable while a code is outstanding would let a player cash out and then
|
||||||
|
// bet the same sats before the wallet claims them.
|
||||||
|
func (s *server) handleWithdrawCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ln == nil || s.lnurl == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "sign in first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AmountSat int64 `json:"amount_sats"`
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "could not read request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil || req.AmountSat < 1 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
amountMsat := req.AmountSat * 1000
|
||||||
|
|
||||||
|
// Take the money now. If the code is never scanned, the sweep below
|
||||||
|
// returns it.
|
||||||
|
if _, err := s.ledger.Withdraw(r.Context(), accountID, amountMsat); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, k1, err := s.lnurl.Issue(accountID, amountMsat)
|
||||||
|
if err != nil {
|
||||||
|
// Could not issue: give the money straight back rather than holding it
|
||||||
|
// against a code that does not exist.
|
||||||
|
if _, rerr := s.ledger.Deposit(r.Context(), accountID, amountMsat); rerr != nil {
|
||||||
|
log.Printf("lnurl: CRITICAL: debited %d msat from %d but could not "+
|
||||||
|
"issue a code or refund: %v / %v", amountMsat, accountID, err, rerr)
|
||||||
|
}
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), accountID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"lnurl": code,
|
||||||
|
"k1": k1,
|
||||||
|
"amount_sats": req.AmountSat,
|
||||||
|
"balance_msat": bal,
|
||||||
|
"expires_in": int(lnurl.TokenTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLNURLTerms is what the wallet fetches after scanning.
|
||||||
|
func (s *server) handleLNURLTerms(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.lnurl == nil {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
terms, err := s.lnurl.Describe(r.URL.Query().Get("k1"))
|
||||||
|
if err != nil {
|
||||||
|
// LNURL errors are 200s with a status field; a wallet shows the reason
|
||||||
|
// to the player, whereas an HTTP error shows nothing useful.
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, terms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLNURLCallback is where the wallet delivers its invoice.
|
||||||
|
func (s *server) handleLNURLCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ln == nil || s.lnurl == nil {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
k1 := r.URL.Query().Get("k1")
|
||||||
|
invoice := r.URL.Query().Get("pr")
|
||||||
|
if k1 == "" || invoice == "" {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("k1 and pr are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the token before paying. A token left valid after a successful
|
||||||
|
// payment could be replayed for the same amount again.
|
||||||
|
token, err := s.lnurl.Redeem(r.Context(), k1)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The balance was already debited when the code was issued, so this queues
|
||||||
|
// the payment against funds the arcade is holding rather than the player's
|
||||||
|
// balance. Crediting first and withdrawing again would double-charge.
|
||||||
|
if _, err := s.ln.PayHeld(r.Context(), token.AccountID, invoice, token.AmountMsat); err != nil {
|
||||||
|
// Payment failed: hand the authorisation back so the player can retry
|
||||||
|
// rather than losing the cash-out silently.
|
||||||
|
s.lnurl.Restore(token)
|
||||||
|
log.Printf("lnurl: payment for account %d failed: %v", token.AccountID, err)
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("payment failed; try scanning again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.OK())
|
||||||
|
}
|
||||||
1064
cmd/arcade/main.go
Normal file
1064
cmd/arcade/main.go
Normal file
File diff suppressed because it is too large
Load Diff
113
cmd/arcade/static/admin.css
Normal file
113
cmd/arcade/static/admin.css
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/* Operations console.
|
||||||
|
*
|
||||||
|
* Denser than the player interface on purpose: an operator is reading tables,
|
||||||
|
* not playing a game. Same palette, but amber is reserved for money the house
|
||||||
|
* holds and red for anything that needs a decision. */
|
||||||
|
|
||||||
|
body.admin { font-size: 13px; }
|
||||||
|
body.admin .filigree { opacity: 0.25; }
|
||||||
|
|
||||||
|
.wrap { max-width: 1400px; margin: 0 auto; padding: 14px 14px 60px; }
|
||||||
|
|
||||||
|
.livedot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: var(--green); margin-left: 10px;
|
||||||
|
box-shadow: 0 0 10px var(--green);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||||
|
.livedot.stale { background: var(--red); box-shadow: 0 0 10px var(--red); }
|
||||||
|
|
||||||
|
/* Six tiles across on a wide screen, two on a phone. */
|
||||||
|
.tiles.ops { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
@media (min-width: 700px) { .tiles.ops { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
@media (min-width: 1100px) { .tiles.ops { grid-template-columns: repeat(6, 1fr); } }
|
||||||
|
|
||||||
|
#tile-books.bad { border-color: var(--red); }
|
||||||
|
#tile-books.bad .tile-value { color: var(--red); }
|
||||||
|
|
||||||
|
.opsnav { display: flex; gap: 4px; margin: 14px 0 12px; flex-wrap: wrap; }
|
||||||
|
.opsnav button {
|
||||||
|
font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
padding: 8px 14px; color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.opsnav button.on {
|
||||||
|
color: var(--green); border-color: var(--green); background: #00291c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid2 { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
@media (min-width: 900px) { .grid2 { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
|
.chart.tall { height: 190px; }
|
||||||
|
|
||||||
|
/* ---------- data tables ---------- */
|
||||||
|
|
||||||
|
.tablewrap { overflow-x: auto; margin-top: 8px; }
|
||||||
|
|
||||||
|
table.data {
|
||||||
|
width: 100%; border-collapse: collapse; font-size: 11.5px;
|
||||||
|
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.data th {
|
||||||
|
text-align: left; padding: 6px 10px 6px 0;
|
||||||
|
font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); font-weight: 400;
|
||||||
|
border-bottom: 1px solid var(--green-ghost);
|
||||||
|
position: sticky; top: 0; background: #00120c;
|
||||||
|
}
|
||||||
|
table.data td {
|
||||||
|
padding: 6px 10px 6px 0;
|
||||||
|
border-bottom: 1px solid #06231a;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
table.data tr:hover td { background: #00ff9c0a; }
|
||||||
|
|
||||||
|
td.num { text-align: right; padding-right: 18px; }
|
||||||
|
td.pos { color: var(--amber); }
|
||||||
|
td.neg { color: var(--green-dim); }
|
||||||
|
td.dim { color: var(--green-dim); }
|
||||||
|
td.mono { font-size: 10px; color: var(--green-dim); }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block; padding: 2px 6px; border-radius: 2px; font-size: 9.5px;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
border: 1px solid var(--green-ghost); color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.pill.fee { color: var(--amber); border-color: #4a3300; }
|
||||||
|
.pill.payout { color: var(--magenta); border-color: #4a0f2c; }
|
||||||
|
.pill.void { color: var(--red); border-color: #4a1119; }
|
||||||
|
|
||||||
|
/* ---------- key/value grid ---------- */
|
||||||
|
|
||||||
|
.kvgrid {
|
||||||
|
display: grid; grid-template-columns: 1fr; gap: 1px;
|
||||||
|
background: var(--green-ghost); border: 1px solid var(--green-ghost);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
@media (min-width: 700px) { .kvgrid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
.kvgrid > div { background: #00120c; padding: 10px 12px; }
|
||||||
|
.kvgrid .k {
|
||||||
|
display: block; font-size: 9px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--green-dim); margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.kvgrid .v { font-size: 15px; color: var(--amber); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ---------- risk flags ---------- */
|
||||||
|
|
||||||
|
.flags { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||||
|
.flag {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||||
|
border: 1px solid var(--green-ghost); border-radius: 3px; background: #00120c;
|
||||||
|
}
|
||||||
|
.flag .n {
|
||||||
|
font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 46px;
|
||||||
|
}
|
||||||
|
.flag .t { font-size: 12px; color: var(--green-dim); }
|
||||||
|
.flag.ok .n { color: var(--green); }
|
||||||
|
.flag.warn { border-color: #4a3300; }
|
||||||
|
.flag.warn .n { color: var(--amber); }
|
||||||
|
.flag.bad { border-color: var(--red); }
|
||||||
|
.flag.bad .n { color: var(--red); }
|
||||||
|
|
||||||
|
#player-filter { max-width: 340px; }
|
||||||
193
cmd/arcade/static/admin.html
Normal file
193
cmd/arcade/static/admin.html
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>QA :: OPERATIONS</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<link rel="stylesheet" href="/admin.css">
|
||||||
|
</head>
|
||||||
|
<body class="admin">
|
||||||
|
|
||||||
|
<svg class="filigree" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="0.7">
|
||||||
|
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
|
||||||
|
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
|
||||||
|
<circle cx="30" cy="26" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Gate. The token is held in memory only; it is never written to storage,
|
||||||
|
so closing the tab ends the session. -->
|
||||||
|
<section class="panel center" id="gate">
|
||||||
|
<h1>OPERATIONS</h1>
|
||||||
|
<p class="muted small">Restricted. Access is logged.</p>
|
||||||
|
<input id="token" type="password" placeholder="operator token" autocomplete="off">
|
||||||
|
<button class="primary" id="unlock">Authenticate</button>
|
||||||
|
<p class="fineprint" id="gate-msg"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="console" hidden>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">QUANTUM<span>OPS</span></div>
|
||||||
|
<div class="livedot" id="livedot" title="auto-refreshing"></div>
|
||||||
|
<div class="balance">
|
||||||
|
<span class="label">house pot</span>
|
||||||
|
<span class="value" id="hdr-pot">—</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Headline position -->
|
||||||
|
<section class="wrap">
|
||||||
|
<div class="tiles ops">
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">house pot</span>
|
||||||
|
<span class="tile-value" id="k-pot">—</span>
|
||||||
|
<span class="tile-sub">operator funds</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">owed to players</span>
|
||||||
|
<span class="tile-value" id="k-owed">—</span>
|
||||||
|
<span class="tile-sub">liability</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">fees collected</span>
|
||||||
|
<span class="tile-value up" id="k-fees">—</span>
|
||||||
|
<span class="tile-sub" id="k-fees-24h">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">margin 24h</span>
|
||||||
|
<span class="tile-value" id="k-margin">—</span>
|
||||||
|
<span class="tile-sub" id="k-volume">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">players</span>
|
||||||
|
<span class="tile-value" id="k-players">—</span>
|
||||||
|
<span class="tile-sub" id="k-active">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" id="tile-books">
|
||||||
|
<span class="tile-label">books</span>
|
||||||
|
<span class="tile-value" id="k-books">—</span>
|
||||||
|
<span class="tile-sub" id="k-conservation">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="opsnav" id="opsnav">
|
||||||
|
<button class="on" data-panel="dash">Dashboard</button>
|
||||||
|
<button data-panel="players">Players</button>
|
||||||
|
<button data-panel="ledger">Ledger</button>
|
||||||
|
<button data-panel="rounds">Rounds</button>
|
||||||
|
<button data-panel="risk">Risk</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<section class="opspanel" id="panel-dash">
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Revenue, 30 days</h2>
|
||||||
|
<div class="chart tall" id="chart-revenue"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Stakes vs payouts</h2>
|
||||||
|
<div class="chart tall" id="chart-flow"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Fee schedule in force</h2>
|
||||||
|
<div id="fee-schedule" class="kvgrid"></div>
|
||||||
|
<p class="muted small">
|
||||||
|
Rendered from the same values the server charges. If this table is
|
||||||
|
wrong, the code is wrong — it is not a separate document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PLAYERS -->
|
||||||
|
<section class="opspanel" id="panel-players" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Accounts</h2>
|
||||||
|
<input id="player-filter" placeholder="filter by name or key" autocomplete="off">
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table class="data" id="tbl-players">
|
||||||
|
<thead><tr>
|
||||||
|
<th>id</th><th>name</th><th>balance</th><th>bets</th>
|
||||||
|
<th>wagered</th><th>won</th><th>net</th><th>last seen</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- LEDGER -->
|
||||||
|
<section class="opspanel" id="panel-ledger" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Every posting</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Append-only. Rows are never modified or deleted; corrections appear
|
||||||
|
as compensating entries.
|
||||||
|
</p>
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table class="data" id="tbl-ledger">
|
||||||
|
<thead><tr>
|
||||||
|
<th>id</th><th>kind</th><th>round</th><th>account</th>
|
||||||
|
<th>amount</th><th>before</th><th>after</th><th>when</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ROUNDS -->
|
||||||
|
<section class="opspanel" id="panel-rounds" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent rounds</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Seeds appear only after settlement. There is no control here that
|
||||||
|
reveals a sealed seed or alters an outcome — that is what makes the
|
||||||
|
fairness proof worth anything.
|
||||||
|
</p>
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table class="data" id="tbl-rounds">
|
||||||
|
<thead><tr>
|
||||||
|
<th>id</th><th>game</th><th>crash</th><th>players</th>
|
||||||
|
<th>staked</th><th>paid</th><th>fees</th><th>house</th><th>seed</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RISK -->
|
||||||
|
<section class="opspanel" id="panel-risk" hidden>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Attention</h2>
|
||||||
|
<div id="risk-flags" class="flags"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Largest net winners</h2>
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table class="data" id="tbl-winners">
|
||||||
|
<thead><tr><th>account</th><th>name</th><th>net</th><th>bets</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
317
cmd/arcade/static/admin.js
Normal file
317
cmd/arcade/static/admin.js
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/* Operations console.
|
||||||
|
*
|
||||||
|
* The token lives in memory only — never localStorage, never a cookie — so
|
||||||
|
* closing the tab ends the session and nothing is left on a shared machine.
|
||||||
|
*
|
||||||
|
* Every value here is read from the ledger. Nothing is computed twice: if a
|
||||||
|
* number looks wrong, the ledger is wrong, and that is the point of showing it. */
|
||||||
|
|
||||||
|
import * as charts from '/charts.js';
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
/* Money is stored in millisatoshis. Operators think in sats. */
|
||||||
|
const sats = (msat) => Math.round((msat || 0) / 1000).toLocaleString();
|
||||||
|
const signed = (msat) => (msat > 0 ? '+' : '') + sats(msat);
|
||||||
|
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) {
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'text') node.textContent = v;
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
if (c == null) continue;
|
||||||
|
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(node) {
|
||||||
|
while (node.firstChild) node.removeChild(node.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path) {
|
||||||
|
const res = await fetch(path, { headers: { Authorization: 'Bearer ' + token } });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- gate ---------------- */
|
||||||
|
|
||||||
|
async function unlock() {
|
||||||
|
token = $('token').value.trim();
|
||||||
|
try {
|
||||||
|
await api('/admin/api/overview');
|
||||||
|
} catch (e) {
|
||||||
|
$('gate-msg').textContent =
|
||||||
|
e.message === '401' ? 'Rejected.' : 'Unavailable: ' + e.message;
|
||||||
|
token = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('gate').hidden = true;
|
||||||
|
$('console').hidden = false;
|
||||||
|
await refreshAll();
|
||||||
|
// Poll rather than stream: the console is read-only and a few seconds of
|
||||||
|
// staleness costs nothing, whereas another websocket per operator does.
|
||||||
|
timer = setInterval(refreshAll, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- refresh ---------------- */
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
try {
|
||||||
|
await Promise.all([loadOverview(), loadActivePanel()]);
|
||||||
|
$('livedot').classList.remove('stale');
|
||||||
|
} catch {
|
||||||
|
// A failed poll marks the display stale rather than blanking it: old
|
||||||
|
// numbers with a warning beat no numbers.
|
||||||
|
$('livedot').classList.add('stale');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
const d = await api('/admin/api/overview');
|
||||||
|
|
||||||
|
$('hdr-pot').textContent = sats(d.house_pot_msat);
|
||||||
|
$('k-pot').textContent = sats(d.house_pot_msat);
|
||||||
|
$('k-owed').textContent = sats(d.owed_to_players);
|
||||||
|
$('k-fees').textContent = sats(d.fees_all_time_msat);
|
||||||
|
$('k-fees-24h').textContent = sats(d.fees_24h_msat) + ' in 24h';
|
||||||
|
|
||||||
|
const margin = $('k-margin');
|
||||||
|
margin.textContent = signed(d.gross_margin_24h);
|
||||||
|
margin.className = 'tile-value ' + (d.gross_margin_24h >= 0 ? 'up' : 'down');
|
||||||
|
$('k-volume').textContent = sats(d.wagered_24h_msat) + ' wagered';
|
||||||
|
|
||||||
|
$('k-players').textContent = d.players_total.toLocaleString();
|
||||||
|
$('k-active').textContent = d.players_active_24h + ' active 24h';
|
||||||
|
|
||||||
|
// The books check is the one number that must never be wrong.
|
||||||
|
const books = $('k-books');
|
||||||
|
books.textContent = d.books_balanced ? 'BALANCED' : 'IMBALANCE';
|
||||||
|
$('tile-books').classList.toggle('bad', !d.books_balanced);
|
||||||
|
$('k-conservation').textContent = d.books_balanced
|
||||||
|
? 'sums to zero'
|
||||||
|
: `off by ${d.conservation_msat} msat`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePanel() {
|
||||||
|
const on = document.querySelector('.opsnav button.on');
|
||||||
|
return on ? on.dataset.panel : 'dash';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActivePanel() {
|
||||||
|
switch (activePanel()) {
|
||||||
|
case 'dash': return loadDashboard();
|
||||||
|
case 'players': return loadPlayers();
|
||||||
|
case 'ledger': return loadLedger();
|
||||||
|
case 'rounds': return loadRounds();
|
||||||
|
case 'risk': return loadRisk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- dashboard ---------------- */
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const d = await api('/admin/api/revenue');
|
||||||
|
|
||||||
|
const days = d.daily || [];
|
||||||
|
charts.balanceChart($('chart-revenue'),
|
||||||
|
days.map((x) => ({ BalanceAfter: x.net_msat })));
|
||||||
|
|
||||||
|
// Stakes in against payouts out, as a simple two-series comparison.
|
||||||
|
charts.crashHistoryChart($('chart-flow'),
|
||||||
|
days.map((x) => Math.max(1, x.stakes_in_msat / Math.max(1, x.paid_out_msat))));
|
||||||
|
|
||||||
|
const grid = $('fee-schedule');
|
||||||
|
clear(grid);
|
||||||
|
const s = d.fee_schedule || {};
|
||||||
|
const rows = [
|
||||||
|
['rake', s.rake_percent],
|
||||||
|
['rounding unit', s.rounding_unit],
|
||||||
|
['minimum payout', s.minimum_payout],
|
||||||
|
['game rtp', s.game_rtp_percent],
|
||||||
|
['effective rtp', s.effective_rtp_percent],
|
||||||
|
['worst case rounding', s.worst_case_rounding_per_payout],
|
||||||
|
];
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
grid.appendChild(el('div', {},
|
||||||
|
el('span', { class: 'k', text: k }),
|
||||||
|
el('span', { class: 'v', text: v || '—' })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- players ---------------- */
|
||||||
|
|
||||||
|
let playersCache = [];
|
||||||
|
|
||||||
|
async function loadPlayers() {
|
||||||
|
const d = await api('/admin/api/players');
|
||||||
|
playersCache = d.players || [];
|
||||||
|
renderPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayers() {
|
||||||
|
const q = $('player-filter').value.trim().toLowerCase();
|
||||||
|
const body = $('tbl-players').querySelector('tbody');
|
||||||
|
clear(body);
|
||||||
|
|
||||||
|
for (const p of playersCache) {
|
||||||
|
if (q && !p.nickname.toLowerCase().includes(q) && !p.pubkey.includes(q)) continue;
|
||||||
|
const tr = el('tr', {});
|
||||||
|
tr.appendChild(el('td', { class: 'dim', text: String(p.id) }));
|
||||||
|
tr.appendChild(el('td', { text: p.nickname || '—' }));
|
||||||
|
tr.appendChild(el('td', { class: 'num pos', text: sats(p.balance_msat) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num dim', text: String(p.bets) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num', text: sats(p.wagered_msat) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num', text: sats(p.won_msat) }));
|
||||||
|
tr.appendChild(el('td', {
|
||||||
|
class: 'num ' + (p.net_msat >= 0 ? 'pos' : 'neg'),
|
||||||
|
text: signed(p.net_msat),
|
||||||
|
}));
|
||||||
|
tr.appendChild(el('td', {
|
||||||
|
class: 'dim',
|
||||||
|
text: p.last_seen ? new Date(p.last_seen).toLocaleString() : '—',
|
||||||
|
}));
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- ledger ---------------- */
|
||||||
|
|
||||||
|
async function loadLedger() {
|
||||||
|
const d = await api('/admin/api/transactions');
|
||||||
|
const body = $('tbl-ledger').querySelector('tbody');
|
||||||
|
clear(body);
|
||||||
|
|
||||||
|
for (const e of d.transactions || []) {
|
||||||
|
const tr = el('tr', {});
|
||||||
|
tr.appendChild(el('td', { class: 'dim', text: String(e.id) }));
|
||||||
|
|
||||||
|
const cls = e.kind === 'operating_fee' ? 'pill fee'
|
||||||
|
: e.kind === 'payout' ? 'pill payout' : 'pill';
|
||||||
|
tr.appendChild(el('td', {}, el('span', { class: cls, text: e.kind })));
|
||||||
|
|
||||||
|
tr.appendChild(el('td', { class: 'dim', text: e.round_id ? String(e.round_id) : '—' }));
|
||||||
|
tr.appendChild(el('td', { text: e.nickname || String(e.account_id) }));
|
||||||
|
tr.appendChild(el('td', {
|
||||||
|
class: 'num ' + (e.amount_msat >= 0 ? 'pos' : 'neg'),
|
||||||
|
text: signed(e.amount_msat),
|
||||||
|
}));
|
||||||
|
tr.appendChild(el('td', { class: 'num dim', text: sats(e.balance_before) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num', text: sats(e.balance_after) }));
|
||||||
|
tr.appendChild(el('td', { class: 'dim', text: new Date(e.created_at).toLocaleTimeString() }));
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- rounds ---------------- */
|
||||||
|
|
||||||
|
async function loadRounds() {
|
||||||
|
const d = await api('/admin/api/rounds');
|
||||||
|
const body = $('tbl-rounds').querySelector('tbody');
|
||||||
|
clear(body);
|
||||||
|
|
||||||
|
for (const r of d.rounds || []) {
|
||||||
|
const tr = el('tr', {});
|
||||||
|
tr.appendChild(el('td', { class: 'dim', text: String(r.id) }));
|
||||||
|
tr.appendChild(el('td', { text: r.game }));
|
||||||
|
|
||||||
|
const crash = r.crash_point
|
||||||
|
? (r.crash_point / 4294967296).toFixed(2) + '×'
|
||||||
|
: '—';
|
||||||
|
tr.appendChild(el('td', { class: 'num', text: crash }));
|
||||||
|
tr.appendChild(el('td', { class: 'num dim', text: String(r.players) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num', text: sats(r.staked_msat) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num', text: sats(r.paid_msat) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num pos', text: sats(r.rake_msat) }));
|
||||||
|
tr.appendChild(el('td', {
|
||||||
|
class: 'num ' + (r.house_result_msat >= 0 ? 'pos' : 'neg'),
|
||||||
|
text: signed(r.house_result_msat),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The seed cell is the honest one: sealed until settlement, and there is
|
||||||
|
// no control that opens it early.
|
||||||
|
const seedCell = el('td', { class: 'mono' });
|
||||||
|
if (r.voided_at) {
|
||||||
|
seedCell.appendChild(el('span', { class: 'pill void', text: 'void' }));
|
||||||
|
} else if (r.server_seed) {
|
||||||
|
seedCell.textContent = r.server_seed.slice(0, 16) + '…';
|
||||||
|
} else {
|
||||||
|
seedCell.appendChild(el('span', { class: 'pill', text: 'sealed' }));
|
||||||
|
}
|
||||||
|
tr.appendChild(seedCell);
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- risk ---------------- */
|
||||||
|
|
||||||
|
async function loadRisk() {
|
||||||
|
const d = await api('/admin/api/risk');
|
||||||
|
|
||||||
|
const flags = $('risk-flags');
|
||||||
|
clear(flags);
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
n: d.withdrawals_to_review, t: 'withdrawals awaiting your approval',
|
||||||
|
level: d.withdrawals_to_review > 0 ? 'warn' : 'ok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: d.pending_withdrawals, t: 'withdrawals queued or sending',
|
||||||
|
level: 'ok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: d.unresolved_rounds, t: 'rounds unresolved past the staleness window',
|
||||||
|
level: d.unresolved_rounds > 0 ? 'warn' : 'ok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: d.books_balanced ? 0 : d.conservation_msat,
|
||||||
|
t: d.books_balanced ? 'ledger imbalance — books sum to zero'
|
||||||
|
: 'LEDGER IMBALANCE — investigate immediately',
|
||||||
|
level: d.books_balanced ? 'ok' : 'bad',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const it of items) {
|
||||||
|
flags.appendChild(el('div', { class: 'flag ' + it.level },
|
||||||
|
el('span', { class: 'n', text: String(it.n) }),
|
||||||
|
el('span', { class: 't', text: it.t })));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = $('tbl-winners').querySelector('tbody');
|
||||||
|
clear(body);
|
||||||
|
for (const wnr of d.top_winners || []) {
|
||||||
|
const tr = el('tr', {});
|
||||||
|
tr.appendChild(el('td', { class: 'dim', text: String(wnr.account_id) }));
|
||||||
|
tr.appendChild(el('td', { text: wnr.nickname || '—' }));
|
||||||
|
tr.appendChild(el('td', { class: 'num pos', text: signed(wnr.net_msat) }));
|
||||||
|
tr.appendChild(el('td', { class: 'num dim', text: String(wnr.bets) }));
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- wiring ---------------- */
|
||||||
|
|
||||||
|
function selectPanel(name) {
|
||||||
|
document.querySelectorAll('.opsnav button').forEach((b) =>
|
||||||
|
b.classList.toggle('on', b.dataset.panel === name));
|
||||||
|
document.querySelectorAll('.opspanel').forEach((p) =>
|
||||||
|
(p.hidden = p.id !== 'panel-' + name));
|
||||||
|
loadActivePanel().catch(() => $('livedot').classList.add('stale'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$('unlock').onclick = () => unlock();
|
||||||
|
$('token').onkeydown = (e) => { if (e.key === 'Enter') unlock(); };
|
||||||
|
$('player-filter').oninput = renderPlayers;
|
||||||
|
document.querySelectorAll('.opsnav button').forEach((b) =>
|
||||||
|
(b.onclick = () => selectPanel(b.dataset.panel)));
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
token = null;
|
||||||
|
});
|
||||||
1067
cmd/arcade/static/app.js
Normal file
1067
cmd/arcade/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
274
cmd/arcade/static/charts.js
Normal file
274
cmd/arcade/static/charts.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/* Quantum Arcade — charts.
|
||||||
|
*
|
||||||
|
* Hand-built SVG rather than a charting library. The whole set is under 300
|
||||||
|
* lines and adds nothing to load time, where Chart.js would cost more than the
|
||||||
|
* 3D engine. Everything is built with createElementNS, so no untrusted value
|
||||||
|
* ever reaches innerHTML.
|
||||||
|
*
|
||||||
|
* Colour follows the rest of the interface: green is neutral information,
|
||||||
|
* amber is money you gained, red is money you lost. Nothing is coloured
|
||||||
|
* decoratively. */
|
||||||
|
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
const GREEN = '#00ff9c';
|
||||||
|
const GREEN_DIM = '#0a7a52';
|
||||||
|
const AMBER = '#ffb000';
|
||||||
|
const RED = '#ff3355';
|
||||||
|
const MAGENTA = '#ff2e88';
|
||||||
|
|
||||||
|
function svg(tag, attrs) {
|
||||||
|
const el = document.createElementNS(NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(node) {
|
||||||
|
while (node.firstChild) node.removeChild(node.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts scale to their container: the viewBox is fixed and CSS handles the
|
||||||
|
* rest, so one implementation serves phone and desktop. */
|
||||||
|
function frame(host, w, h) {
|
||||||
|
clear(host);
|
||||||
|
const root = svg('svg', {
|
||||||
|
viewBox: `0 0 ${w} ${h}`,
|
||||||
|
preserveAspectRatio: 'none',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
host.appendChild(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState(host, message) {
|
||||||
|
clear(host);
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'muted small chart-empty';
|
||||||
|
p.textContent = message;
|
||||||
|
host.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- balance over time ---------------- */
|
||||||
|
|
||||||
|
/* An area chart of balance after each transaction, oldest to newest.
|
||||||
|
* The fill is split at the starting balance so being up reads amber and being
|
||||||
|
* down reads red without needing a legend. */
|
||||||
|
export function balanceChart(host, entries) {
|
||||||
|
if (!entries || entries.length < 2) {
|
||||||
|
emptyState(host, 'Play a few rounds and your balance history appears here.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 300, H = 110, PAD = 4;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const values = entries.map((e) => e.BalanceAfter / 1000); // sats
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const start = values[0];
|
||||||
|
|
||||||
|
const x = (i) => PAD + (i / (values.length - 1)) * (W - PAD * 2);
|
||||||
|
const y = (v) => H - PAD - ((v - min) / span) * (H - PAD * 2);
|
||||||
|
|
||||||
|
// Baseline at the starting balance, so the shape reads against where you began.
|
||||||
|
const baseY = y(start);
|
||||||
|
root.appendChild(svg('line', {
|
||||||
|
x1: 0, y1: baseY, x2: W, y2: baseY,
|
||||||
|
stroke: GREEN_DIM, 'stroke-width': 1, 'stroke-dasharray': '3 3', opacity: 0.6,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const points = values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
|
||||||
|
const up = values[values.length - 1] >= start;
|
||||||
|
const colour = up ? AMBER : RED;
|
||||||
|
|
||||||
|
root.appendChild(svg('polygon', {
|
||||||
|
points: `${x(0)},${baseY} ${points} ${x(values.length - 1)},${baseY}`,
|
||||||
|
fill: colour, opacity: 0.16,
|
||||||
|
}));
|
||||||
|
root.appendChild(svg('polyline', {
|
||||||
|
points, fill: 'none', stroke: colour,
|
||||||
|
'stroke-width': 1.6, 'stroke-linejoin': 'round',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mark the latest point.
|
||||||
|
root.appendChild(svg('circle', {
|
||||||
|
cx: x(values.length - 1), cy: y(values[values.length - 1]),
|
||||||
|
r: 2.6, fill: colour,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- recent crash points ---------------- */
|
||||||
|
|
||||||
|
/* Bars of where recent rounds ended. Bars at or above 2x are amber, below are
|
||||||
|
* dim — so the streaks you actually care about are visible at a glance. */
|
||||||
|
export function crashHistoryChart(host, crashes) {
|
||||||
|
if (!crashes || crashes.length === 0) {
|
||||||
|
emptyState(host, 'No settled rounds yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 300, H = 90, PAD = 3;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const shown = crashes.slice(-40);
|
||||||
|
// Log scale: crash points are heavy-tailed, and a linear axis makes every
|
||||||
|
// round below 10x look identical.
|
||||||
|
const scale = (v) => Math.log(Math.max(1, v)) / Math.log(60);
|
||||||
|
const bw = (W - PAD * 2) / shown.length;
|
||||||
|
|
||||||
|
shown.forEach((v, i) => {
|
||||||
|
const hgt = Math.max(2, scale(v) * (H - PAD * 2));
|
||||||
|
root.appendChild(svg('rect', {
|
||||||
|
x: PAD + i * bw + bw * 0.15,
|
||||||
|
y: H - PAD - hgt,
|
||||||
|
width: bw * 0.7,
|
||||||
|
height: hgt,
|
||||||
|
fill: v >= 10 ? MAGENTA : v >= 2 ? AMBER : GREEN_DIM,
|
||||||
|
opacity: v >= 2 ? 0.95 : 0.65,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2x reference line — the break-even point for the most common target.
|
||||||
|
const y2 = H - PAD - scale(2) * (H - PAD * 2);
|
||||||
|
root.appendChild(svg('line', {
|
||||||
|
x1: 0, y1: y2, x2: W, y2: y2,
|
||||||
|
stroke: AMBER, 'stroke-width': 0.8, 'stroke-dasharray': '4 4', opacity: 0.55,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- win / loss split ---------------- */
|
||||||
|
|
||||||
|
/* A donut, because the only question it answers is a ratio. */
|
||||||
|
export function winLossDonut(host, wins, losses) {
|
||||||
|
const total = wins + losses;
|
||||||
|
if (total === 0) {
|
||||||
|
emptyState(host, 'No completed rounds yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const S = 120, R = 44, CX = S / 2, CY = S / 2;
|
||||||
|
const root = frame(host, S, S);
|
||||||
|
const circ = 2 * Math.PI * R;
|
||||||
|
const winFrac = wins / total;
|
||||||
|
|
||||||
|
root.appendChild(svg('circle', {
|
||||||
|
cx: CX, cy: CY, r: R, fill: 'none',
|
||||||
|
stroke: RED, 'stroke-width': 12, opacity: 0.55,
|
||||||
|
}));
|
||||||
|
root.appendChild(svg('circle', {
|
||||||
|
cx: CX, cy: CY, r: R, fill: 'none',
|
||||||
|
stroke: AMBER, 'stroke-width': 12,
|
||||||
|
'stroke-dasharray': `${circ * winFrac} ${circ}`,
|
||||||
|
transform: `rotate(-90 ${CX} ${CY})`,
|
||||||
|
'stroke-linecap': 'butt',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pct = svg('text', {
|
||||||
|
x: CX, y: CY + 2, 'text-anchor': 'middle',
|
||||||
|
fill: AMBER, 'font-size': 20, 'font-family': 'ui-monospace, monospace',
|
||||||
|
'font-weight': 700,
|
||||||
|
});
|
||||||
|
pct.textContent = `${Math.round(winFrac * 100)}%`;
|
||||||
|
root.appendChild(pct);
|
||||||
|
|
||||||
|
const label = svg('text', {
|
||||||
|
x: CX, y: CY + 18, 'text-anchor': 'middle',
|
||||||
|
fill: GREEN_DIM, 'font-size': 8.5, 'font-family': 'ui-monospace, monospace',
|
||||||
|
'letter-spacing': 1.5,
|
||||||
|
});
|
||||||
|
label.textContent = 'CASHED OUT';
|
||||||
|
root.appendChild(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- multiplier distribution ---------------- */
|
||||||
|
|
||||||
|
/* A histogram of where rounds ended, with the theoretical curve drawn over it.
|
||||||
|
* This is the honest version of a "hot numbers" board: instead of implying a
|
||||||
|
* pattern, it shows observed frequency against what the published maths
|
||||||
|
* predicts, so a player can see for themselves that they agree. */
|
||||||
|
export function distributionChart(host, crashes) {
|
||||||
|
if (!crashes || crashes.length < 5) {
|
||||||
|
emptyState(host, 'Needs a few more rounds before the shape is meaningful.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 300, H = 110, PAD = 4;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const buckets = [
|
||||||
|
{ label: '1-1.5', lo: 1, hi: 1.5 },
|
||||||
|
{ label: '1.5-2', lo: 1.5, hi: 2 },
|
||||||
|
{ label: '2-3', lo: 2, hi: 3 },
|
||||||
|
{ label: '3-5', lo: 3, hi: 5 },
|
||||||
|
{ label: '5-10', lo: 5, hi: 10 },
|
||||||
|
{ label: '10+', lo: 10, hi: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
const counts = buckets.map((b) => crashes.filter((c) => c >= b.lo && c < b.hi).length);
|
||||||
|
const maxCount = Math.max(...counts, 1);
|
||||||
|
const bw = (W - PAD * 2) / buckets.length;
|
||||||
|
|
||||||
|
// Expected share for each bucket: P(crash >= x) = 0.99 / x.
|
||||||
|
const expected = buckets.map((b) => {
|
||||||
|
const hi = b.hi === Infinity ? 0 : 0.99 / b.hi;
|
||||||
|
return 0.99 / b.lo - hi;
|
||||||
|
});
|
||||||
|
|
||||||
|
counts.forEach((n, i) => {
|
||||||
|
const hgt = (n / maxCount) * (H - PAD * 2 - 12);
|
||||||
|
root.appendChild(svg('rect', {
|
||||||
|
x: PAD + i * bw + bw * 0.18,
|
||||||
|
y: H - PAD - 12 - hgt,
|
||||||
|
width: bw * 0.64,
|
||||||
|
height: Math.max(1, hgt),
|
||||||
|
fill: GREEN, opacity: 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const label = svg('text', {
|
||||||
|
x: PAD + i * bw + bw / 2, y: H - 3,
|
||||||
|
'text-anchor': 'middle', fill: GREEN_DIM,
|
||||||
|
'font-size': 7, 'font-family': 'ui-monospace, monospace',
|
||||||
|
});
|
||||||
|
label.textContent = buckets[i].label;
|
||||||
|
root.appendChild(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The predicted shape, scaled to the same axis.
|
||||||
|
const maxExpected = Math.max(...expected);
|
||||||
|
const curve = expected.map((e, i) => {
|
||||||
|
const hgt = (e / maxExpected) * (H - PAD * 2 - 12) * (maxCount / crashes.length) /
|
||||||
|
(maxCount / crashes.length);
|
||||||
|
return `${PAD + i * bw + bw / 2},${H - PAD - 12 - hgt}`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
root.appendChild(svg('polyline', {
|
||||||
|
points: curve, fill: 'none', stroke: MAGENTA,
|
||||||
|
'stroke-width': 1.3, 'stroke-dasharray': '3 2', opacity: 0.9,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- sparkline ---------------- */
|
||||||
|
|
||||||
|
/* A tiny inline trend, for stat tiles. */
|
||||||
|
export function sparkline(host, values) {
|
||||||
|
if (!values || values.length < 2) {
|
||||||
|
clear(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const W = 80, H = 22;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const points = values.map((v, i) =>
|
||||||
|
`${(i / (values.length - 1)) * W},${H - 2 - ((v - min) / span) * (H - 4)}`).join(' ');
|
||||||
|
|
||||||
|
root.appendChild(svg('polyline', {
|
||||||
|
points, fill: 'none',
|
||||||
|
stroke: values[values.length - 1] >= values[0] ? AMBER : RED,
|
||||||
|
'stroke-width': 1.4, 'stroke-linejoin': 'round',
|
||||||
|
}));
|
||||||
|
}
|
||||||
138
cmd/arcade/static/costs.html
Normal file
138
cmd/arcade/static/costs.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>WHAT THIS COSTS :: QUANTUM ARCADE</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<svg class="filigree" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="0.7">
|
||||||
|
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
|
||||||
|
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
|
||||||
|
<circle cx="30" cy="26" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">QUANTUM<span>ARCADE</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="view costs">
|
||||||
|
|
||||||
|
<h1>WHAT THIS COSTS</h1>
|
||||||
|
|
||||||
|
<p class="lede">
|
||||||
|
Running this takes electricity, a machine, and a Lightning node with money
|
||||||
|
parked in it. Two small deductions cover that. Both are listed here, both
|
||||||
|
appear as their own line in your transaction history, and both are computed
|
||||||
|
by the same code that generated this page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Filled from /api/fees, so this page cannot state terms different from
|
||||||
|
the ones the server applies. -->
|
||||||
|
<div class="kvgrid" id="schedule"></div>
|
||||||
|
|
||||||
|
<h2>The two deductions</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>1. A percentage of winnings</h3>
|
||||||
|
<p class="muted small">
|
||||||
|
Taken only when you win. If you lose a round, nothing extra is taken —
|
||||||
|
you simply lost the round. This is the house's actual revenue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>2. Rounding down to whole satoshis</h3>
|
||||||
|
<p class="muted small">
|
||||||
|
Balances are tracked in millisatoshis — thousandths of a satoshi — because
|
||||||
|
the maths needs that resolution. Payouts are floored to whole satoshis and
|
||||||
|
the fraction stays with the house.
|
||||||
|
</p>
|
||||||
|
<p class="muted small">
|
||||||
|
The most this can ever cost you on a single payout is
|
||||||
|
<strong id="worst">—</strong>. It is a rounding, not a second fee, and it
|
||||||
|
is bounded by that amount every time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What that does to your odds</h2>
|
||||||
|
|
||||||
|
<p class="muted small">
|
||||||
|
A rake changes the real return, so quoting the game's raw figure would be
|
||||||
|
misleading. Both numbers are below: what the game's maths return before the
|
||||||
|
deduction, and what you actually receive after it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table class="odds" id="rtp-table">
|
||||||
|
<tr><th>game</th><th>maths return</th><th>you receive</th></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>How you can check all of this</h2>
|
||||||
|
|
||||||
|
<ul class="checks">
|
||||||
|
<li>
|
||||||
|
<strong>Your history itemises it.</strong> Open the Wallet tab. A win
|
||||||
|
shows as a <code>payout</code> line for the full amount, followed by an
|
||||||
|
<code>operating_fee</code> line for the deduction. Nothing is folded into
|
||||||
|
a quietly smaller number.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The books must sum to zero.</strong> Every millisatoshi in this
|
||||||
|
system is a double-entry posting.
|
||||||
|
<code>/api/health</code> adds up every account in the system; it returns
|
||||||
|
zero or the platform is telling you it is broken. A fee that vanished
|
||||||
|
instead of being posted would show up there.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The odds are the generator.</strong> The scratch odds tables come
|
||||||
|
from the same data structure that produces outcomes — they cannot drift
|
||||||
|
apart. A test runs two million plays and fails the build if the observed
|
||||||
|
frequencies disagree with the published ones.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Outcomes are sealed before you bet.</strong> The crash point comes
|
||||||
|
from a seed committed before betting opens, combined with the keys of
|
||||||
|
everyone who joined. Check any round yourself in the Verify tab; it
|
||||||
|
recomputes on your device and asks the server only for published values.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The code is open.</strong> AGPL-3.0. Every line of this,
|
||||||
|
including the two deductions described above, is readable and auditable.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>What is not taken</h2>
|
||||||
|
|
||||||
|
<ul class="checks">
|
||||||
|
<li>No fee to deposit.</li>
|
||||||
|
<li>No fee to send sats to another player.</li>
|
||||||
|
<li>No fee on losing rounds beyond the loss itself.</li>
|
||||||
|
<li>No account fee, inactivity fee, or minimum balance.</li>
|
||||||
|
<li>Withdrawals cost only the Lightning routing fee, which is real network
|
||||||
|
cost and is capped.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="muted small closing">
|
||||||
|
The aim is for this to feel free, which means being exact about the places
|
||||||
|
it is not. If you find a number on this page that does not match what your
|
||||||
|
history shows, that is a bug worth reporting, and the ledger will settle
|
||||||
|
the argument.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="center"><a class="backlink" href="/">← back to the arcade</a></p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/costs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
cmd/arcade/static/costs.js
Normal file
62
cmd/arcade/static/costs.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/* The costs page.
|
||||||
|
*
|
||||||
|
* Every figure is fetched from /api/fees, which the server renders from the
|
||||||
|
* same schedule it charges. Nothing here is written by hand, so the published
|
||||||
|
* terms cannot drift from the behaviour — if the operator changes the rake,
|
||||||
|
* this page changes with it. */
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) {
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'text') node.textContent = v;
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
if (c == null) continue;
|
||||||
|
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
d = await (await fetch('/api/fees')).json();
|
||||||
|
} catch {
|
||||||
|
$('schedule').textContent = 'Could not load the fee schedule.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = d.schedule || {};
|
||||||
|
const grid = $('schedule');
|
||||||
|
const rows = [
|
||||||
|
['taken from winnings', s.rake_percent],
|
||||||
|
['payouts rounded to', s.rounding_unit],
|
||||||
|
['most rounding can cost', s.worst_case_rounding_per_payout],
|
||||||
|
];
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
grid.appendChild(el('div', {},
|
||||||
|
el('span', { class: 'k', text: k }),
|
||||||
|
el('span', { class: 'v', text: v || '—' })));
|
||||||
|
}
|
||||||
|
$('worst').textContent = s.worst_case_rounding_per_payout || '—';
|
||||||
|
|
||||||
|
const table = $('rtp-table');
|
||||||
|
const crash = d.crash_games || {};
|
||||||
|
table.appendChild(el('tr', {},
|
||||||
|
el('td', { text: 'Crash games' }),
|
||||||
|
el('td', { text: crash.game_rtp_percent || '—' }),
|
||||||
|
el('td', { class: 'rtp', text: crash.effective_percent || '—' })));
|
||||||
|
|
||||||
|
for (const t of d.scratch_tickets || []) {
|
||||||
|
table.appendChild(el('tr', {},
|
||||||
|
el('td', { text: t.ticket }),
|
||||||
|
el('td', { text: t.game_rtp_percent }),
|
||||||
|
el('td', { class: 'rtp', text: t.effective_percent })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
335
cmd/arcade/static/index.html
Normal file
335
cmd/arcade/static/index.html
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<title>QUANTUM ARCADE</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Circuit-trace ornament, tiled once behind everything. -->
|
||||||
|
<svg class="filigree" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="0.7">
|
||||||
|
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
|
||||||
|
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
|
||||||
|
<circle cx="30" cy="26" r="1.6"/>
|
||||||
|
<circle cx="0" cy="26" r="1.2"/>
|
||||||
|
<circle cx="60" cy="26" r="1.2"/>
|
||||||
|
</g>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">QUANTUM<span>ARCADE</span></div>
|
||||||
|
<div class="balance" id="balance-wrap" hidden>
|
||||||
|
<span class="label">balance</span>
|
||||||
|
<span class="value" id="balance">—</span>
|
||||||
|
</div>
|
||||||
|
<button class="sound" id="sound-toggle" title="Ambient sound">♪</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Sign-in -->
|
||||||
|
<section class="panel center" id="signin">
|
||||||
|
<h1>ACCESS TERMINAL</h1>
|
||||||
|
<p class="muted small">
|
||||||
|
No account. No email. No password. This device generates a keypair
|
||||||
|
that <em>is</em> your identity.
|
||||||
|
</p>
|
||||||
|
<input id="nickname" maxlength="20" placeholder="handle" autocomplete="off">
|
||||||
|
<button class="primary" id="enter">Connect</button>
|
||||||
|
<p class="fineprint" id="keynote"></p>
|
||||||
|
<div class="cryptobadge">
|
||||||
|
<span>Provably fair</span>
|
||||||
|
<span>SHA-256 commit-reveal</span>
|
||||||
|
<span>AGPL-3.0</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main id="app" hidden>
|
||||||
|
|
||||||
|
<!-- ============ CRASH ============ -->
|
||||||
|
<section class="view" id="view-crash">
|
||||||
|
<div class="gamepick" id="gamepick"></div>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<canvas id="scene"></canvas>
|
||||||
|
<div class="readout">
|
||||||
|
<div class="multiplier" id="multiplier">1.00×</div>
|
||||||
|
<div class="state" id="state">connecting…</div>
|
||||||
|
</div>
|
||||||
|
<div class="stagetop">
|
||||||
|
<span class="pot" id="potline"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live crash history strip, directly under the stage. -->
|
||||||
|
<div class="strip" id="strip"></div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="stakerow">
|
||||||
|
<button class="chip" data-stake="1000">1</button>
|
||||||
|
<button class="chip" data-stake="5000">5</button>
|
||||||
|
<button class="chip" data-stake="25000">25</button>
|
||||||
|
<button class="chip" data-stake="100000">100</button>
|
||||||
|
<span class="unit">sats</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autorow" id="autorow">
|
||||||
|
<label for="auto-target">auto out</label>
|
||||||
|
<input id="auto-target" type="number" min="1.01" step="0.1"
|
||||||
|
inputmode="decimal" placeholder="off">
|
||||||
|
<span class="x">×</span>
|
||||||
|
<div class="presets">
|
||||||
|
<button data-auto="1.5">1.5</button>
|
||||||
|
<button data-auto="2">2</button>
|
||||||
|
<button data-auto="5">5</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="explain" id="auto-explain">
|
||||||
|
Leave this off to cash out by tapping. Set it and you stop
|
||||||
|
automatically — 2× means you double your stake and get out.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="primary big" id="action">Place bet</button>
|
||||||
|
<div class="hint" id="hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="players" id="players"></div>
|
||||||
|
|
||||||
|
<details class="proof">
|
||||||
|
<summary>Fairness for this round</summary>
|
||||||
|
<div class="kv"><span>commitment</span><code id="commitment">—</code></div>
|
||||||
|
<div class="kv"><span>revealed seed</span><code id="revealed">sealed until the round ends</code></div>
|
||||||
|
<p class="muted small">
|
||||||
|
The commitment is published before betting opens. The crash point comes
|
||||||
|
from that seed combined with every player's key — so it cannot be
|
||||||
|
chosen after seeing who joined.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ SCRATCH ============ -->
|
||||||
|
<section class="view" id="view-scratch" hidden>
|
||||||
|
<div id="tickets"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ PORTFOLIO ============ -->
|
||||||
|
<section class="view" id="view-portfolio" hidden>
|
||||||
|
|
||||||
|
<div class="tiles">
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">balance</span>
|
||||||
|
<span class="tile-value" id="t-balance">—</span>
|
||||||
|
<div class="tile-spark" id="spark-balance"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">session</span>
|
||||||
|
<span class="tile-value" id="t-session">—</span>
|
||||||
|
<span class="tile-sub" id="t-session-sub">since you connected</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">wagered</span>
|
||||||
|
<span class="tile-value" id="t-wagered">—</span>
|
||||||
|
<span class="tile-sub" id="t-plays">0 plays</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">best hit</span>
|
||||||
|
<span class="tile-value" id="t-best">—</span>
|
||||||
|
<span class="tile-sub">largest single win</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Balance over time</h2>
|
||||||
|
<div class="chart" id="chart-balance"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Cash-out rate</h2>
|
||||||
|
<div class="chart chart-donut" id="chart-donut"></div>
|
||||||
|
<p class="muted small" id="donut-note"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Where rounds ended</h2>
|
||||||
|
<div class="chart" id="chart-dist"></div>
|
||||||
|
<p class="muted small">
|
||||||
|
Bars are what actually happened. The dashed line is what the published
|
||||||
|
maths predicts. They should converge — that is the point.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent rounds</h2>
|
||||||
|
<div class="chart" id="chart-history"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ WALLET ============ -->
|
||||||
|
<section class="view" id="view-wallet" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Your key</h2>
|
||||||
|
<p class="muted small">Share this so friends can send you sats.</p>
|
||||||
|
<code class="pubkey" id="pubkey">—</code>
|
||||||
|
<button id="copykey">Copy</button>
|
||||||
|
</div>
|
||||||
|
<!-- Lightning in. Hidden unless the server has a node configured, so a
|
||||||
|
play-money deployment does not advertise a deposit it cannot take. -->
|
||||||
|
<div class="card" id="card-deposit" hidden>
|
||||||
|
<h2>Add sats</h2>
|
||||||
|
<div class="stakerow">
|
||||||
|
<button class="chip" data-dep="1000">1k</button>
|
||||||
|
<button class="chip" data-dep="5000">5k</button>
|
||||||
|
<button class="chip" data-dep="25000">25k</button>
|
||||||
|
<input id="dep-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
|
||||||
|
</div>
|
||||||
|
<button class="primary" id="do-deposit">Create invoice</button>
|
||||||
|
<div class="hint" id="dep-hint"></div>
|
||||||
|
|
||||||
|
<div id="dep-invoice" hidden>
|
||||||
|
<div class="qrwrap" id="dep-qr"></div>
|
||||||
|
<code class="pubkey" id="dep-bolt11"></code>
|
||||||
|
<div class="stakerow">
|
||||||
|
<button id="dep-copy">Copy invoice</button>
|
||||||
|
<button id="dep-check">I have paid</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted small" id="dep-status">
|
||||||
|
Scan with any Lightning wallet. Your balance updates once it settles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightning out. Scanning is the whole flow: no invoice to create, no
|
||||||
|
amount to type into a second app. -->
|
||||||
|
<div class="card" id="card-withdraw" hidden>
|
||||||
|
<h2>Cash out</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Pick an amount and scan the code with your Lightning wallet. Your
|
||||||
|
wallet pulls the sats — you never make an invoice.
|
||||||
|
</p>
|
||||||
|
<div class="stakerow">
|
||||||
|
<button class="chip" data-wd="1000">1k</button>
|
||||||
|
<button class="chip" data-wd="5000">5k</button>
|
||||||
|
<button class="chip" data-wd="all">All</button>
|
||||||
|
<input id="wd-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
|
||||||
|
</div>
|
||||||
|
<button class="primary" id="do-withdraw">Show cash-out code</button>
|
||||||
|
<div class="hint" id="wd-hint"></div>
|
||||||
|
|
||||||
|
<div id="wd-code" hidden>
|
||||||
|
<div class="qrwrap" id="wd-qr"></div>
|
||||||
|
<p class="muted small center" id="wd-expiry"></p>
|
||||||
|
<button id="wd-copy">Copy code</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="proof">
|
||||||
|
<summary>Paste an invoice instead</summary>
|
||||||
|
<p class="muted small">
|
||||||
|
If your wallet cannot scan LNURL, make an invoice for the amount and
|
||||||
|
paste it here.
|
||||||
|
</p>
|
||||||
|
<input id="wd-bolt11" placeholder="lnbc… invoice" autocomplete="off">
|
||||||
|
<button id="do-withdraw-manual">Withdraw to invoice</button>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Send sats</h2>
|
||||||
|
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
||||||
|
<input id="send-amt" type="number" min="1" inputmode="numeric"
|
||||||
|
placeholder="amount in sats">
|
||||||
|
<button class="primary" id="send">Send</button>
|
||||||
|
<div class="hint" id="send-hint"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Every change to your balance</h2>
|
||||||
|
<div id="history" class="history"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ VERIFY ============ -->
|
||||||
|
<section class="view" id="view-verify" hidden>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Check any round</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Enter a round number. Your phone recomputes the outcome from the
|
||||||
|
published seeds — it does not take the server's word for anything.
|
||||||
|
</p>
|
||||||
|
<input id="verify-id" type="number" inputmode="numeric" min="1"
|
||||||
|
placeholder="round number">
|
||||||
|
<button class="primary" id="do-verify">Verify</button>
|
||||||
|
<div id="verify-out" class="verify-out"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Underlying tech</h2>
|
||||||
|
<dl class="specs">
|
||||||
|
<dt>Identity</dt>
|
||||||
|
<dd>Ed25519 signed challenge, single-use and replay-proof.
|
||||||
|
Hybrid Ed25519 + ML-DSA-65 (FIPS 204) is implemented and tested
|
||||||
|
server-side; browser signing lands with the WASM module.</dd>
|
||||||
|
<dt>Transport</dt>
|
||||||
|
<dd>TLS 1.3 with X25519MLKEM768 hybrid key exchange when served over
|
||||||
|
HTTPS — post-quantum against harvest-now-decrypt-later</dd>
|
||||||
|
<dt>Fairness</dt>
|
||||||
|
<dd>SHA-256 commitment, HMAC-SHA256 outcome derivation. Hash-based,
|
||||||
|
so Grover only halves the margin: quantum-resistant as it stands.</dd>
|
||||||
|
<dt>Settlement</dt>
|
||||||
|
<dd>Bitcoin and Lightning sign with secp256k1, which is
|
||||||
|
<em>not</em> post-quantum. No application choice changes that.</dd>
|
||||||
|
<dt>Simulation</dt>
|
||||||
|
<dd>Q32.32 fixed-point, zero floating point, bit-identical replay</dd>
|
||||||
|
<dt>Ledger</dt>
|
||||||
|
<dd>Append-only double-entry, DB-enforced, audited every request</dd>
|
||||||
|
<dt>House edge</dt>
|
||||||
|
<dd>1.00% — verified by test across 2,000,000 simulated plays</dd>
|
||||||
|
<dt>Licence</dt>
|
||||||
|
<dd>AGPL-3.0 — every line auditable, forks must publish</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Bottom navigation: thumb-reachable, which is where navigation belongs on a
|
||||||
|
phone held one-handed at a party. -->
|
||||||
|
<nav class="tabs" id="tabs" hidden>
|
||||||
|
<button class="tab active" data-view="crash">
|
||||||
|
<span class="ico">▲</span><span>Crash</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="scratch">
|
||||||
|
<span class="ico">◈</span><span>Scratch</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="portfolio">
|
||||||
|
<span class="ico">▤</span><span>Stats</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="wallet">
|
||||||
|
<span class="ico">◉</span><span>Wallet</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="verify">
|
||||||
|
<span class="ico">✓</span><span>Verify</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- First run only. Three cards, then it never appears again. -->
|
||||||
|
<div class="tour" id="tour" hidden>
|
||||||
|
<div class="tourcard">
|
||||||
|
<div class="tourstep" id="tour-step"></div>
|
||||||
|
<h2 id="tour-title"></h2>
|
||||||
|
<p id="tour-body"></p>
|
||||||
|
<div class="tourdots" id="tour-dots"></div>
|
||||||
|
<button class="primary" id="tour-next">Got it</button>
|
||||||
|
<button class="tourskip" id="tour-skip">Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
cmd/arcade/static/pqsign.wasm
Executable file
BIN
cmd/arcade/static/pqsign.wasm
Executable file
Binary file not shown.
358
cmd/arcade/static/qr.js
Normal file
358
cmd/arcade/static/qr.js
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
/* A minimal QR encoder, byte mode, error-correction level M.
|
||||||
|
*
|
||||||
|
* Written rather than imported because the arcade has to work on a network
|
||||||
|
* with no internet: a CDN script is a dependency on the outside world, and the
|
||||||
|
* whole point of this box is that it does not need one.
|
||||||
|
*
|
||||||
|
* Scope is deliberately narrow — byte mode, versions 1 through 20, level M —
|
||||||
|
* which covers a Lightning invoice with room to spare and leaves out the
|
||||||
|
* kanji/numeric modes and structured-append machinery that would triple the
|
||||||
|
* size for no benefit here.
|
||||||
|
*
|
||||||
|
* Reference: ISO/IEC 18004. */
|
||||||
|
|
||||||
|
/* ---------- Galois field arithmetic over GF(256) ----------
|
||||||
|
* Reed–Solomon needs multiplication in the field the QR spec defines, with
|
||||||
|
* the primitive polynomial 0x11d. Log tables make that a lookup. */
|
||||||
|
|
||||||
|
const EXP = new Uint8Array(512);
|
||||||
|
const LOG = new Uint8Array(256);
|
||||||
|
(function buildTables() {
|
||||||
|
let x = 1;
|
||||||
|
for (let i = 0; i < 255; i++) {
|
||||||
|
EXP[i] = x;
|
||||||
|
LOG[x] = i;
|
||||||
|
x <<= 1;
|
||||||
|
if (x & 0x100) x ^= 0x11d;
|
||||||
|
}
|
||||||
|
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||||
|
})();
|
||||||
|
|
||||||
|
function gfMul(a, b) {
|
||||||
|
if (a === 0 || b === 0) return 0;
|
||||||
|
return EXP[LOG[a] + LOG[b]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The generator polynomial for n error-correction codewords. */
|
||||||
|
function rsGenerator(n) {
|
||||||
|
let poly = [1];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const next = new Array(poly.length + 1).fill(0);
|
||||||
|
for (let j = 0; j < poly.length; j++) {
|
||||||
|
next[j] ^= poly[j];
|
||||||
|
next[j + 1] ^= gfMul(poly[j], EXP[i]);
|
||||||
|
}
|
||||||
|
poly = next;
|
||||||
|
}
|
||||||
|
return poly;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsEncode(data, ecCount) {
|
||||||
|
const gen = rsGenerator(ecCount);
|
||||||
|
const res = new Uint8Array(data.length + ecCount);
|
||||||
|
res.set(data);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const factor = res[i];
|
||||||
|
if (factor === 0) continue;
|
||||||
|
for (let j = 0; j < gen.length; j++) {
|
||||||
|
res[i + j] ^= gfMul(gen[j], factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.slice(data.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- capacity tables, level M ----------
|
||||||
|
* [total codewords, ec codewords per block, block count group1,
|
||||||
|
* data codewords group1, block count group2, data codewords group2] */
|
||||||
|
const VERSIONS = [
|
||||||
|
null,
|
||||||
|
[26, 10, 1, 16, 0, 0], // 1
|
||||||
|
[44, 16, 1, 28, 0, 0],
|
||||||
|
[70, 26, 1, 44, 0, 0],
|
||||||
|
[100, 18, 2, 32, 0, 0],
|
||||||
|
[134, 24, 2, 43, 0, 0],
|
||||||
|
[172, 16, 4, 27, 0, 0],
|
||||||
|
[196, 18, 4, 31, 0, 0],
|
||||||
|
[242, 22, 2, 38, 2, 39],
|
||||||
|
[292, 22, 3, 36, 2, 37],
|
||||||
|
[346, 26, 4, 43, 1, 44], // 10
|
||||||
|
[404, 30, 1, 50, 4, 51],
|
||||||
|
[466, 22, 6, 36, 2, 37],
|
||||||
|
[532, 22, 8, 37, 1, 38],
|
||||||
|
[581, 24, 4, 40, 5, 41],
|
||||||
|
[655, 24, 5, 41, 5, 42], // 15
|
||||||
|
[733, 28, 7, 45, 3, 46],
|
||||||
|
[815, 28, 10, 46, 1, 47],
|
||||||
|
[901, 26, 9, 43, 4, 44],
|
||||||
|
[991, 26, 3, 44, 11, 45],
|
||||||
|
[1085, 26, 3, 41, 13, 42], // 20
|
||||||
|
];
|
||||||
|
|
||||||
|
function versionCapacity(v) {
|
||||||
|
const [, ec, b1, d1, b2, d2] = VERSIONS[v];
|
||||||
|
return b1 * d1 + b2 * d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alignment pattern centres per version. */
|
||||||
|
const ALIGN = [
|
||||||
|
[], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34],
|
||||||
|
[6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
|
||||||
|
[6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66],
|
||||||
|
[6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78],
|
||||||
|
[6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- bit stream ---------- */
|
||||||
|
|
||||||
|
class Bits {
|
||||||
|
constructor() { this.bits = []; }
|
||||||
|
push(value, length) {
|
||||||
|
for (let i = length - 1; i >= 0; i--) this.bits.push((value >> i) & 1);
|
||||||
|
}
|
||||||
|
get length() { return this.bits.length; }
|
||||||
|
toBytes() {
|
||||||
|
const out = new Uint8Array(Math.ceil(this.bits.length / 8));
|
||||||
|
this.bits.forEach((b, i) => { if (b) out[i >> 3] |= 0x80 >> (i & 7); });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- encoding ---------- */
|
||||||
|
|
||||||
|
function encodeData(text, version) {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const bits = new Bits();
|
||||||
|
|
||||||
|
bits.push(0b0100, 4); // byte mode
|
||||||
|
bits.push(bytes.length, version < 10 ? 8 : 16); // length field
|
||||||
|
for (const b of bytes) bits.push(b, 8);
|
||||||
|
|
||||||
|
const capacityBits = versionCapacity(version) * 8;
|
||||||
|
if (bits.length > capacityBits) return null; // does not fit
|
||||||
|
|
||||||
|
// Terminator, then pad to a byte boundary, then alternating pad bytes.
|
||||||
|
bits.push(0, Math.min(4, capacityBits - bits.length));
|
||||||
|
while (bits.length % 8 !== 0) bits.push(0, 1);
|
||||||
|
|
||||||
|
const data = Array.from(bits.toBytes());
|
||||||
|
const padBytes = [0xec, 0x11];
|
||||||
|
let i = 0;
|
||||||
|
while (data.length < versionCapacity(version)) data.push(padBytes[i++ % 2]);
|
||||||
|
|
||||||
|
return interleave(data, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split into blocks, compute error correction, then interleave both — the
|
||||||
|
* spec's arrangement, which is what makes a QR survive damage to any one
|
||||||
|
* region rather than losing a contiguous run of data. */
|
||||||
|
function interleave(data, version) {
|
||||||
|
const [, ecPerBlock, b1, d1, b2, d2] = VERSIONS[version];
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (let i = 0; i < b1; i++) {
|
||||||
|
blocks.push(data.slice(offset, offset + d1));
|
||||||
|
offset += d1;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < b2; i++) {
|
||||||
|
blocks.push(data.slice(offset, offset + d2));
|
||||||
|
offset += d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ecBlocks = blocks.map((b) => rsEncode(Uint8Array.from(b), ecPerBlock));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
const maxData = Math.max(...blocks.map((b) => b.length));
|
||||||
|
for (let i = 0; i < maxData; i++) {
|
||||||
|
for (const b of blocks) if (i < b.length) out.push(b[i]);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < ecPerBlock; i++) {
|
||||||
|
for (const b of ecBlocks) out.push(b[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- matrix ---------- */
|
||||||
|
|
||||||
|
function buildMatrix(version, codewords, mask) {
|
||||||
|
const size = version * 4 + 17;
|
||||||
|
const m = Array.from({ length: size }, () => new Array(size).fill(null));
|
||||||
|
|
||||||
|
const setFinder = (r, c) => {
|
||||||
|
for (let dr = -1; dr <= 7; dr++) {
|
||||||
|
for (let dc = -1; dc <= 7; dc++) {
|
||||||
|
const rr = r + dr, cc = c + dc;
|
||||||
|
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||||
|
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6 &&
|
||||||
|
(dr === 0 || dr === 6 || dc === 0 || dc === 6 ||
|
||||||
|
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
|
||||||
|
m[rr][cc] = inRing ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setFinder(0, 0);
|
||||||
|
setFinder(0, size - 7);
|
||||||
|
setFinder(size - 7, 0);
|
||||||
|
|
||||||
|
// Timing patterns.
|
||||||
|
for (let i = 8; i < size - 8; i++) {
|
||||||
|
m[6][i] = i % 2 === 0 ? 1 : 0;
|
||||||
|
m[i][6] = i % 2 === 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alignment patterns, skipping those that would collide with finders.
|
||||||
|
const centres = ALIGN[version];
|
||||||
|
for (const r of centres) {
|
||||||
|
for (const c of centres) {
|
||||||
|
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) ||
|
||||||
|
(r >= size - 9 && c <= 8)) continue;
|
||||||
|
for (let dr = -2; dr <= 2; dr++) {
|
||||||
|
for (let dc = -2; dc <= 2; dc++) {
|
||||||
|
m[r + dr][c + dc] =
|
||||||
|
Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m[size - 8][8] = 1; // dark module
|
||||||
|
|
||||||
|
// Reserve format areas so data placement skips them.
|
||||||
|
const reserveFormat = () => {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
if (m[8][i] === null) m[8][i] = 0;
|
||||||
|
if (m[i][8] === null) m[i][8] = 0;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (m[8][size - 1 - i] === null) m[8][size - 1 - i] = 0;
|
||||||
|
if (m[size - 1 - i][8] === null) m[size - 1 - i][8] = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const formatCells = [];
|
||||||
|
for (let r = 0; r < size; r++) {
|
||||||
|
for (let c = 0; c < size; c++) if (m[r][c] === null) formatCells.push([r, c]);
|
||||||
|
}
|
||||||
|
reserveFormat();
|
||||||
|
|
||||||
|
// Version information, for version 7 and above.
|
||||||
|
if (version >= 7) {
|
||||||
|
let rem = version;
|
||||||
|
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
|
||||||
|
const bits = (version << 12) | rem;
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const bit = (bits >> i) & 1;
|
||||||
|
m[Math.floor(i / 3)][size - 11 + (i % 3)] = bit;
|
||||||
|
m[size - 11 + (i % 3)][Math.floor(i / 3)] = bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data placement: upward/downward in two-column strips, right to left.
|
||||||
|
let bitIndex = 0;
|
||||||
|
const dataBits = [];
|
||||||
|
for (const cw of codewords) {
|
||||||
|
for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let upward = true;
|
||||||
|
for (let col = size - 1; col > 0; col -= 2) {
|
||||||
|
if (col === 6) col--; // skip the timing column
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
const row = upward ? size - 1 - i : i;
|
||||||
|
for (let c = 0; c < 2; c++) {
|
||||||
|
const cc = col - c;
|
||||||
|
if (m[row][cc] !== null) continue;
|
||||||
|
let bit = bitIndex < dataBits.length ? dataBits[bitIndex++] : 0;
|
||||||
|
if (maskAt(mask, row, cc)) bit ^= 1;
|
||||||
|
m[row][cc] = bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upward = !upward;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFormat(m, size, mask);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskAt(mask, r, c) {
|
||||||
|
switch (mask) {
|
||||||
|
case 0: return (r + c) % 2 === 0;
|
||||||
|
case 1: return r % 2 === 0;
|
||||||
|
case 2: return c % 3 === 0;
|
||||||
|
case 3: return (r + c) % 3 === 0;
|
||||||
|
case 4: return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0;
|
||||||
|
case 5: return ((r * c) % 2) + ((r * c) % 3) === 0;
|
||||||
|
case 6: return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0;
|
||||||
|
default: return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFormat(m, size, mask) {
|
||||||
|
// Level M is 0b00; combine with the mask and append BCH error correction.
|
||||||
|
const data = (0b00 << 3) | mask;
|
||||||
|
let rem = data;
|
||||||
|
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
|
||||||
|
const bits = ((data << 10) | rem) ^ 0x5412;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 5; i++) m[8][i] = (bits >> i) & 1;
|
||||||
|
m[8][7] = (bits >> 6) & 1;
|
||||||
|
m[8][8] = (bits >> 7) & 1;
|
||||||
|
m[7][8] = (bits >> 8) & 1;
|
||||||
|
for (let i = 9; i < 15; i++) m[14 - i][8] = (bits >> i) & 1;
|
||||||
|
|
||||||
|
// The second copy is 7 bits down the bottom-left column and 8 bits along
|
||||||
|
// the top-right row. It is 7 and not 8 down the column because the cell
|
||||||
|
// below is the dark module, which is fixed and must not be overwritten.
|
||||||
|
for (let i = 0; i <= 6; i++) m[size - 1 - i][8] = (bits >> i) & 1;
|
||||||
|
for (let i = 7; i < 15; i++) m[8][size - 15 + i] = (bits >> i) & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- public API ---------- */
|
||||||
|
|
||||||
|
/* encode returns a square matrix of 0/1, or null if the text does not fit. */
|
||||||
|
export function encode(text) {
|
||||||
|
for (let version = 1; version <= 20; version++) {
|
||||||
|
const codewords = encodeData(text, version);
|
||||||
|
if (codewords) {
|
||||||
|
// Mask 0 is used unconditionally. Choosing the optimal mask by penalty
|
||||||
|
// score improves scan reliability marginally and costs four more passes
|
||||||
|
// over the matrix; at the sizes here, every reader handles mask 0.
|
||||||
|
return buildMatrix(version, codewords, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* render draws a matrix into an SVG element, scaled to its container. */
|
||||||
|
export function render(matrix, options = {}) {
|
||||||
|
const quiet = options.quiet ?? 4;
|
||||||
|
const size = matrix.length;
|
||||||
|
const total = size + quiet * 2;
|
||||||
|
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(NS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${total} ${total}`);
|
||||||
|
svg.setAttribute('width', '100%');
|
||||||
|
svg.setAttribute('height', '100%');
|
||||||
|
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||||
|
|
||||||
|
const bg = document.createElementNS(NS, 'rect');
|
||||||
|
bg.setAttribute('width', total);
|
||||||
|
bg.setAttribute('height', total);
|
||||||
|
bg.setAttribute('fill', options.background ?? '#000603');
|
||||||
|
svg.appendChild(bg);
|
||||||
|
|
||||||
|
// One path for every dark module: far fewer nodes than a rect each, which
|
||||||
|
// matters when a phone is re-rendering this inside a live page.
|
||||||
|
let d = '';
|
||||||
|
for (let r = 0; r < size; r++) {
|
||||||
|
for (let c = 0; c < size; c++) {
|
||||||
|
if (matrix[r][c]) d += `M${c + quiet} ${r + quiet}h1v1h-1z`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const path = document.createElementNS(NS, 'path');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
path.setAttribute('fill', options.foreground ?? '#00ff9c');
|
||||||
|
svg.appendChild(path);
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
389
cmd/arcade/static/scene3d.js
Normal file
389
cmd/arcade/static/scene3d.js
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/* Quantum Arcade — 3D rendering.
|
||||||
|
*
|
||||||
|
* Three scenes sharing one renderer, because a phone should allocate exactly
|
||||||
|
* one WebGL context no matter how many games it switches between.
|
||||||
|
*
|
||||||
|
* Performance rules, in priority order, because this has to hold sixty frames
|
||||||
|
* on a mid-range phone in someone's hand at a party:
|
||||||
|
* - device pixel ratio capped at 2, dropped to 1.5 on small screens
|
||||||
|
* - low-poly geometry, no shadow maps, no post-processing passes
|
||||||
|
* - additive materials for glow instead of a bloom pass
|
||||||
|
* - particles are one BufferGeometry updated in place, never re-allocated
|
||||||
|
* - the render loop stops entirely when the tab is hidden
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from '/vendor/three.module.min.js';
|
||||||
|
|
||||||
|
const GREEN = 0x00ff9c;
|
||||||
|
const MAGENTA = 0xff2e88;
|
||||||
|
const AMBER = 0xffb000;
|
||||||
|
const RED = 0xff3355;
|
||||||
|
|
||||||
|
const PARTICLES = 220;
|
||||||
|
|
||||||
|
export class Arcade3D {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
antialias: false, // FXAA-free; the aesthetic is crisp anyway
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
});
|
||||||
|
this.renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 400);
|
||||||
|
this.clock = new THREE.Clock();
|
||||||
|
|
||||||
|
this.scenes = {
|
||||||
|
rocket: this.buildRocket(),
|
||||||
|
orbital: this.buildOrbital(),
|
||||||
|
tower: this.buildTower(),
|
||||||
|
};
|
||||||
|
this.active = 'rocket';
|
||||||
|
this.progress = 0;
|
||||||
|
this.crashed = false;
|
||||||
|
this.shake = 0;
|
||||||
|
|
||||||
|
this.resize();
|
||||||
|
window.addEventListener('resize', () => this.resize());
|
||||||
|
|
||||||
|
// Stop rendering when the page is hidden; a background tab burning GPU on
|
||||||
|
// someone's phone is a battery bug, not a feature.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) this.stop();
|
||||||
|
else this.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const w = this.canvas.clientWidth;
|
||||||
|
const h = this.canvas.clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
const cap = w < 500 ? 1.5 : 2;
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, cap));
|
||||||
|
this.renderer.setSize(w, h, false);
|
||||||
|
this.camera.aspect = w / h;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- shared pieces ---------- */
|
||||||
|
|
||||||
|
// A wireframe floor grid that scrolls toward the camera, which is what sells
|
||||||
|
// the sensation of speed.
|
||||||
|
makeGrid() {
|
||||||
|
const grid = new THREE.GridHelper(120, 40, GREEN, 0x0a7a52);
|
||||||
|
grid.material.transparent = true;
|
||||||
|
grid.material.opacity = 0.28;
|
||||||
|
grid.position.y = -6;
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One BufferGeometry of points, recycled every frame.
|
||||||
|
makeParticles(spread = 40) {
|
||||||
|
const positions = new Float32Array(PARTICLES * 3);
|
||||||
|
const speeds = new Float32Array(PARTICLES);
|
||||||
|
for (let i = 0; i < PARTICLES; i++) {
|
||||||
|
positions[i * 3] = (Math.random() - 0.5) * spread;
|
||||||
|
positions[i * 3 + 1] = (Math.random() - 0.5) * spread;
|
||||||
|
positions[i * 3 + 2] = (Math.random() - 0.5) * spread;
|
||||||
|
speeds[i] = 0.4 + Math.random() * 1.6;
|
||||||
|
}
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
const mat = new THREE.PointsMaterial({
|
||||||
|
color: GREEN, size: 0.22, transparent: true, opacity: 0.75,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
});
|
||||||
|
const points = new THREE.Points(geo, mat);
|
||||||
|
points.userData.speeds = speeds;
|
||||||
|
points.userData.spread = spread;
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
glowLine(geometry, color, opacity = 1) {
|
||||||
|
return new THREE.LineSegments(
|
||||||
|
new THREE.EdgesGeometry(geometry),
|
||||||
|
new THREE.LineBasicMaterial({
|
||||||
|
color, transparent: true, opacity,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- rocket: a climb against gravity ---------- */
|
||||||
|
|
||||||
|
buildRocket() {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x000000, 0.012);
|
||||||
|
|
||||||
|
const grid = this.makeGrid();
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
const particles = this.makeParticles(50);
|
||||||
|
scene.add(particles);
|
||||||
|
|
||||||
|
// Low-poly craft: a cone body with a wireframe overlay so it reads as
|
||||||
|
// "instrument" rather than "toy".
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.7, 2.4, 6),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0x00291c }));
|
||||||
|
const wire = this.glowLine(new THREE.ConeGeometry(0.7, 2.4, 6), GREEN);
|
||||||
|
const craft = new THREE.Group();
|
||||||
|
craft.add(body, wire);
|
||||||
|
scene.add(craft);
|
||||||
|
|
||||||
|
// Exhaust: a stretched cone that grows with thrust.
|
||||||
|
const plume = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.45, 2.2, 6),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: GREEN, transparent: true, opacity: 0.6,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
plume.rotation.x = Math.PI;
|
||||||
|
plume.position.y = -1.9;
|
||||||
|
craft.add(plume);
|
||||||
|
|
||||||
|
// The planet you are leaving, which shrinks as you climb.
|
||||||
|
const planet = this.glowLine(new THREE.IcosahedronGeometry(9, 1), 0x0a7a52, 0.5);
|
||||||
|
planet.position.y = -18;
|
||||||
|
scene.add(planet);
|
||||||
|
|
||||||
|
return { scene, grid, particles, craft, plume, planet, wire };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRocket(s, dt, t) {
|
||||||
|
const p = this.progress;
|
||||||
|
|
||||||
|
s.craft.position.y = -2 + p * 9;
|
||||||
|
s.craft.rotation.y += dt * 0.6;
|
||||||
|
// Increasing strain: the craft trembles harder the higher it goes.
|
||||||
|
s.craft.position.x = Math.sin(t * 9) * p * 0.28;
|
||||||
|
s.craft.rotation.z = Math.sin(t * 7) * p * 0.14;
|
||||||
|
|
||||||
|
const thrust = 0.6 + p * 2.6;
|
||||||
|
s.plume.scale.set(1 + p * 0.5, thrust, 1 + p * 0.5);
|
||||||
|
s.plume.position.y = -1.2 - thrust * 0.55;
|
||||||
|
s.plume.material.opacity = 0.45 + Math.random() * 0.35;
|
||||||
|
|
||||||
|
s.planet.position.y = -18 - p * 26;
|
||||||
|
s.planet.rotation.y += dt * 0.1;
|
||||||
|
|
||||||
|
s.grid.position.z = (s.grid.position.z + dt * (6 + p * 60)) % 3;
|
||||||
|
s.grid.position.y = -6 - p * 4;
|
||||||
|
|
||||||
|
const colour = this.crashed ? RED : GREEN;
|
||||||
|
s.wire.material.color.setHex(colour);
|
||||||
|
s.plume.material.color.setHex(colour);
|
||||||
|
|
||||||
|
this.camera.position.set(0, s.craft.position.y + 1.5, 11 - p * 2);
|
||||||
|
this.camera.lookAt(0, s.craft.position.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- orbital: a decaying orbit ---------- */
|
||||||
|
|
||||||
|
buildOrbital() {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x000000, 0.02);
|
||||||
|
|
||||||
|
const particles = this.makeParticles(44);
|
||||||
|
scene.add(particles);
|
||||||
|
|
||||||
|
const planet = this.glowLine(new THREE.IcosahedronGeometry(4, 1), GREEN, 0.85);
|
||||||
|
scene.add(planet);
|
||||||
|
|
||||||
|
const core = new THREE.Mesh(
|
||||||
|
new THREE.IcosahedronGeometry(3.85, 1),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0x00160f }));
|
||||||
|
scene.add(core);
|
||||||
|
|
||||||
|
// The orbit ring the craft is riding, which tightens as it decays.
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.TorusGeometry(9, 0.035, 6, 90),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: GREEN, transparent: true, opacity: 0.5,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
ring.rotation.x = Math.PI / 2.35;
|
||||||
|
scene.add(ring);
|
||||||
|
|
||||||
|
const craft = new THREE.Mesh(
|
||||||
|
new THREE.OctahedronGeometry(0.42),
|
||||||
|
new THREE.MeshBasicMaterial({ color: MAGENTA }));
|
||||||
|
scene.add(craft);
|
||||||
|
|
||||||
|
// A fading trail behind the craft.
|
||||||
|
const trailPos = new Float32Array(60 * 3);
|
||||||
|
const trailGeo = new THREE.BufferGeometry();
|
||||||
|
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
|
||||||
|
const trail = new THREE.Line(trailGeo, new THREE.LineBasicMaterial({
|
||||||
|
color: MAGENTA, transparent: true, opacity: 0.55,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
scene.add(trail);
|
||||||
|
|
||||||
|
return { scene, planet, core, ring, craft, trail, particles, trailIdx: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOrbital(s, dt, t) {
|
||||||
|
const p = this.progress;
|
||||||
|
const radius = 9 - p * 4.6; // the orbit decays inward
|
||||||
|
const angle = t * (1.1 + p * 5.5);
|
||||||
|
|
||||||
|
s.craft.position.set(
|
||||||
|
Math.cos(angle) * radius,
|
||||||
|
Math.sin(angle * 0.55) * 1.2,
|
||||||
|
Math.sin(angle) * radius);
|
||||||
|
|
||||||
|
const pos = s.trail.geometry.attributes.position.array;
|
||||||
|
// Shift the trail back one vertex and write the head.
|
||||||
|
pos.copyWithin(3, 0, pos.length - 3);
|
||||||
|
pos[0] = s.craft.position.x;
|
||||||
|
pos[1] = s.craft.position.y;
|
||||||
|
pos[2] = s.craft.position.z;
|
||||||
|
s.trail.geometry.attributes.position.needsUpdate = true;
|
||||||
|
|
||||||
|
s.ring.scale.setScalar(radius / 9);
|
||||||
|
s.planet.rotation.y += dt * 0.25;
|
||||||
|
s.planet.rotation.x += dt * 0.08;
|
||||||
|
|
||||||
|
const colour = this.crashed ? RED : MAGENTA;
|
||||||
|
s.craft.material.color.setHex(colour);
|
||||||
|
s.trail.material.color.setHex(colour);
|
||||||
|
s.planet.material.color.setHex(this.crashed ? RED : GREEN);
|
||||||
|
|
||||||
|
this.camera.position.set(0, 8 + p * 3, 20 - p * 5);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- tower: a stack that wobbles ---------- */
|
||||||
|
|
||||||
|
buildTower() {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x000000, 0.014);
|
||||||
|
|
||||||
|
const grid = this.makeGrid();
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
const particles = this.makeParticles(46);
|
||||||
|
scene.add(particles);
|
||||||
|
|
||||||
|
// Pre-allocate the maximum stack; visibility is toggled per frame rather
|
||||||
|
// than creating and destroying meshes mid-round.
|
||||||
|
const MAX = 26;
|
||||||
|
const blocks = [];
|
||||||
|
const geo = new THREE.BoxGeometry(2.4, 0.7, 2.4);
|
||||||
|
for (let i = 0; i < MAX; i++) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x00170f })));
|
||||||
|
group.add(this.glowLine(geo, GREEN, 0.9));
|
||||||
|
group.visible = false;
|
||||||
|
scene.add(group);
|
||||||
|
blocks.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scene, grid, particles, blocks, max: MAX };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTower(s, dt, t) {
|
||||||
|
const p = this.progress;
|
||||||
|
const count = Math.max(1, Math.floor(p * s.max));
|
||||||
|
|
||||||
|
for (let i = 0; i < s.max; i++) {
|
||||||
|
const b = s.blocks[i];
|
||||||
|
b.visible = i < count;
|
||||||
|
if (!b.visible) continue;
|
||||||
|
|
||||||
|
const h = i / s.max;
|
||||||
|
// Sway grows with height, and violently once it collapses.
|
||||||
|
const amp = h * h * (this.crashed ? 3.4 : 0.9);
|
||||||
|
b.position.set(
|
||||||
|
Math.sin(t * 2.2 + i * 0.45) * amp,
|
||||||
|
-5 + i * 0.74,
|
||||||
|
Math.cos(t * 1.8 + i * 0.35) * amp * 0.6);
|
||||||
|
b.rotation.y = t * 0.15 + i * 0.08;
|
||||||
|
b.rotation.z = Math.sin(t * 2 + i * 0.4) * amp * 0.05;
|
||||||
|
|
||||||
|
const top = i === count - 1;
|
||||||
|
const colour = this.crashed ? RED : (top ? AMBER : GREEN);
|
||||||
|
b.children[1].material.color.setHex(colour);
|
||||||
|
b.children[1].material.opacity = top ? 1 : 0.45 + h * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.grid.position.z = (s.grid.position.z + dt * 4) % 3;
|
||||||
|
|
||||||
|
const height = -5 + count * 0.74;
|
||||||
|
this.camera.position.set(0, height * 0.55 + 3, 15 - p * 2);
|
||||||
|
this.camera.lookAt(0, height * 0.5, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- particles ---------- */
|
||||||
|
|
||||||
|
updateParticles(points, dt) {
|
||||||
|
const pos = points.geometry.attributes.position.array;
|
||||||
|
const speeds = points.userData.speeds;
|
||||||
|
const spread = points.userData.spread;
|
||||||
|
const half = spread / 2;
|
||||||
|
const rush = 2 + this.progress * 34;
|
||||||
|
|
||||||
|
for (let i = 0; i < PARTICLES; i++) {
|
||||||
|
pos[i * 3 + 2] += speeds[i] * rush * dt;
|
||||||
|
if (pos[i * 3 + 2] > half) {
|
||||||
|
pos[i * 3 + 2] = -half;
|
||||||
|
pos[i * 3] = (Math.random() - 0.5) * spread;
|
||||||
|
pos[i * 3 + 1] = (Math.random() - 0.5) * spread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.geometry.attributes.position.needsUpdate = true;
|
||||||
|
points.material.color.setHex(this.crashed ? RED : GREEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- public API ---------- */
|
||||||
|
|
||||||
|
setGame(game) {
|
||||||
|
if (this.scenes[game]) this.active = game;
|
||||||
|
}
|
||||||
|
|
||||||
|
// progress is 0..1 across the multiplier's useful range; crashed swaps the
|
||||||
|
// palette and unleashes the wobble.
|
||||||
|
setState(progress, crashed) {
|
||||||
|
this.progress = Math.max(0, Math.min(1, progress));
|
||||||
|
if (crashed && !this.crashed) this.shake = 1; // kick the camera once
|
||||||
|
this.crashed = crashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.raf) return;
|
||||||
|
this.clock.getDelta(); // discard time spent hidden
|
||||||
|
const loop = () => {
|
||||||
|
this.raf = requestAnimationFrame(loop);
|
||||||
|
this.frame();
|
||||||
|
};
|
||||||
|
this.raf = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.raf) cancelAnimationFrame(this.raf);
|
||||||
|
this.raf = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
const dt = Math.min(this.clock.getDelta(), 0.05); // clamp after a stall
|
||||||
|
const t = this.clock.elapsedTime;
|
||||||
|
const s = this.scenes[this.active];
|
||||||
|
|
||||||
|
if (this.active === 'rocket') this.updateRocket(s, dt, t);
|
||||||
|
else if (this.active === 'orbital') this.updateOrbital(s, dt, t);
|
||||||
|
else this.updateTower(s, dt, t);
|
||||||
|
|
||||||
|
this.updateParticles(s.particles, dt);
|
||||||
|
|
||||||
|
// Camera shake on the crash, decaying fast.
|
||||||
|
if (this.shake > 0.001) {
|
||||||
|
this.camera.position.x += (Math.random() - 0.5) * this.shake * 1.6;
|
||||||
|
this.camera.position.y += (Math.random() - 0.5) * this.shake * 1.6;
|
||||||
|
this.shake *= 0.86;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(s.scene, this.camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
640
cmd/arcade/static/style.css
Normal file
640
cmd/arcade/static/style.css
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
/* Quantum Arcade — terminal aesthetic.
|
||||||
|
*
|
||||||
|
* Phosphor green on black, monospace everywhere, CRT scanlines and a faint
|
||||||
|
* flicker. Colour is rationed: amber for money at risk, magenta for the
|
||||||
|
* multiplier, red for the crash. Everything else is green on black, because
|
||||||
|
* a terminal that shouts about everything says nothing. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--black: #000000;
|
||||||
|
--panel: #030806;
|
||||||
|
--green: #00ff9c;
|
||||||
|
--green-dim: #0a7a52;
|
||||||
|
--green-ghost: #063a29;
|
||||||
|
--amber: #ffb000;
|
||||||
|
--magenta: #ff2e88;
|
||||||
|
--red: #ff3355;
|
||||||
|
--grid: #06181200;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--black);
|
||||||
|
color: var(--green);
|
||||||
|
font: 14px/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
text-shadow: 0 0 6px #00ff9c40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faint hex-grid ornament behind everything. */
|
||||||
|
.filigree {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
color: #0d2b21;
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT scanlines and a slow flicker over the whole page. */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed; inset: 0; z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#00ff9c08 0px, #00ff9c08 1px,
|
||||||
|
transparent 1px, transparent 3px);
|
||||||
|
animation: flicker 5s infinite steps(60);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flicker {
|
||||||
|
0%, 96%, 100% { opacity: 0.5; }
|
||||||
|
97% { opacity: 0.75; }
|
||||||
|
98% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body > * { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* ---------- chrome ---------- */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--green-ghost);
|
||||||
|
background: #00120c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700; letter-spacing: 0.18em; font-size: 12px;
|
||||||
|
}
|
||||||
|
.brand::before { content: "> "; color: var(--green-dim); }
|
||||||
|
.brand span { color: var(--amber); margin-left: 5px; }
|
||||||
|
|
||||||
|
.balance { margin-left: auto; text-align: right; line-height: 1.15; }
|
||||||
|
.balance .label {
|
||||||
|
display: block; font-size: 9px; letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase; color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.balance .value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 17px; font-weight: 700; color: var(--amber);
|
||||||
|
text-shadow: 0 0 14px #ffb00066;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sound {
|
||||||
|
background: none; border: 1px solid var(--green-ghost); color: var(--green-dim);
|
||||||
|
width: 32px; height: 32px; border-radius: 3px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.sound.on { color: var(--green); border-color: var(--green); }
|
||||||
|
|
||||||
|
/* ---------- panels ---------- */
|
||||||
|
|
||||||
|
.panel { max-width: 460px; margin: 0 auto; padding: 34px 20px; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 19px; margin: 0 0 10px; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1::after {
|
||||||
|
content: "_";
|
||||||
|
animation: blink 1.1s steps(2) infinite;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
h2::before { content: "// "; }
|
||||||
|
|
||||||
|
.muted { color: var(--green-dim); }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.fineprint {
|
||||||
|
font-size: 10.5px; color: var(--green-dim); margin-top: 16px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%; padding: 11px 12px; margin: 7px 0;
|
||||||
|
background: #00140e; border: 1px solid var(--green-ghost); border-radius: 3px;
|
||||||
|
color: var(--green); font-size: 16px; /* 16px stops iOS zooming on focus */
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 10px #00ff9c33; }
|
||||||
|
input::placeholder { color: var(--green-ghost); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit; cursor: pointer; border-radius: 3px;
|
||||||
|
border: 1px solid var(--green-ghost); background: #00140e; color: var(--green);
|
||||||
|
padding: 10px 14px; letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
button:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: #00291c; color: var(--green);
|
||||||
|
border: 1px solid var(--green); font-weight: 700;
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
box-shadow: 0 0 18px #00ff9c33, inset 0 0 18px #00ff9c11;
|
||||||
|
width: 100%; padding: 14px;
|
||||||
|
}
|
||||||
|
.primary.big { font-size: 15px; padding: 17px; }
|
||||||
|
|
||||||
|
/* Money at risk turns amber and pulses — the one element that demands a
|
||||||
|
decision looks different from everything else. */
|
||||||
|
.primary.cashout {
|
||||||
|
background: #2a1c00; color: var(--amber); border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 26px #ffb00055, inset 0 0 18px #ffb00011;
|
||||||
|
animation: urge 800ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.primary:disabled { opacity: 0.35; box-shadow: none; animation: none; }
|
||||||
|
|
||||||
|
@keyframes urge {
|
||||||
|
0%, 100% { box-shadow: 0 0 18px #ffb00044, inset 0 0 14px #ffb00011; }
|
||||||
|
50% { box-shadow: 0 0 34px #ffb000aa, inset 0 0 22px #ffb00022; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- tabs ---------- */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 4px; padding: 10px 10px 0;
|
||||||
|
max-width: 620px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
flex: 1; padding: 9px 3px; font-size: 11px; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; background: none; border: none;
|
||||||
|
color: var(--green-dim); border-bottom: 1px solid var(--green-ghost);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
color: var(--green); border-bottom-color: var(--green);
|
||||||
|
text-shadow: 0 0 10px #00ff9c88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view { max-width: 620px; margin: 0 auto; padding: 12px 12px 60px; }
|
||||||
|
|
||||||
|
/* ---------- crash stage ---------- */
|
||||||
|
|
||||||
|
.gamepick { display: flex; gap: 4px; margin-bottom: 10px; }
|
||||||
|
.gamepick button {
|
||||||
|
flex: 1; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
padding: 8px 3px; color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.gamepick button.on {
|
||||||
|
color: var(--green); border-color: var(--green); background: #00291c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative; border-radius: 3px; overflow: hidden;
|
||||||
|
border: 1px solid var(--green-ghost);
|
||||||
|
background: #000603;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
#canvas { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
.readout {
|
||||||
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; pointer-events: none;
|
||||||
|
}
|
||||||
|
.multiplier {
|
||||||
|
font-size: clamp(42px, 16vw, 82px); font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums; letter-spacing: -0.01em;
|
||||||
|
color: var(--magenta); text-shadow: 0 0 30px #ff2e8877;
|
||||||
|
}
|
||||||
|
.multiplier.crashed {
|
||||||
|
color: var(--red); text-shadow: 0 0 34px #ff335599;
|
||||||
|
animation: glitch 260ms steps(2) 3;
|
||||||
|
}
|
||||||
|
.multiplier.won {
|
||||||
|
color: var(--amber); text-shadow: 0 0 40px #ffb000aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
25% { transform: translate(-3px, 1px); }
|
||||||
|
50% { transform: translate(3px, -1px); }
|
||||||
|
75% { transform: translate(-2px, -2px); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls { margin-top: 12px; }
|
||||||
|
.stakerow { display: flex; align-items: center; gap: 4px; margin-bottom: 8px; }
|
||||||
|
.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 11px 3px; font-size: 13px; }
|
||||||
|
.chip.on { border-color: var(--green); color: var(--green); background: #00291c; }
|
||||||
|
.unit { font-size: 10px; color: var(--green-dim); letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
/* Auto cash-out target row. */
|
||||||
|
.autorow {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
|
||||||
|
border: 1px solid var(--green-ghost); border-radius: 3px; padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.autorow label {
|
||||||
|
font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.autorow input {
|
||||||
|
margin: 0; padding: 6px 8px; text-align: right; width: 90px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.autorow .x { color: var(--green-dim); font-size: 13px; }
|
||||||
|
.autorow.armed { border-color: var(--amber); }
|
||||||
|
.autorow.armed label, .autorow.armed .x { color: var(--amber); }
|
||||||
|
.autorow.armed input { color: var(--amber); border-color: var(--amber); }
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
min-height: 17px; margin-top: 7px; font-size: 11.5px;
|
||||||
|
color: var(--green-dim); text-align: center;
|
||||||
|
}
|
||||||
|
.hint.bad { color: var(--red); }
|
||||||
|
.hint.good { color: var(--amber); }
|
||||||
|
.hint.near-miss { color: #ff9f43; font-weight: 600; animation: flicker 0.6s ease-in-out 2; }
|
||||||
|
|
||||||
|
@keyframes flicker {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse the action button after a win — urge to rebet */
|
||||||
|
#action.pulse {
|
||||||
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 18px rgba(0, 255, 145, 0.4);
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.players { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.player {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 7px 10px;
|
||||||
|
background: #00120c; border: 1px solid var(--green-ghost); border-radius: 3px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.player .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.player .amt { font-variant-numeric: tabular-nums; color: var(--green-dim); }
|
||||||
|
.player.out { border-color: #4a3300; }
|
||||||
|
.player.out .amt { color: var(--amber); }
|
||||||
|
|
||||||
|
.proof { margin-top: 16px; }
|
||||||
|
.proof summary {
|
||||||
|
cursor: pointer; font-size: 10px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--green-dim); padding: 7px 0;
|
||||||
|
}
|
||||||
|
.kv { display: flex; gap: 8px; font-size: 10.5px; padding: 3px 0; }
|
||||||
|
.kv span { color: var(--green-dim); min-width: 92px; }
|
||||||
|
.kv code { word-break: break-all; color: var(--green); opacity: 0.75; }
|
||||||
|
|
||||||
|
/* ---------- cards / scratch ---------- */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #00120c; border: 1px solid var(--green-ghost);
|
||||||
|
border-radius: 3px; padding: 14px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 4px; font-size: 15px; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid { display: grid; gap: 6px; margin: 12px 0; }
|
||||||
|
.grid.c9 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.grid.c6 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
aspect-ratio: 1; display: grid; place-items: center;
|
||||||
|
font-size: 24px; border-radius: 3px;
|
||||||
|
background: #001f16; border: 1px solid var(--green-ghost);
|
||||||
|
color: var(--green-dim);
|
||||||
|
transition: background 200ms ease, color 200ms ease;
|
||||||
|
}
|
||||||
|
.cell.revealed { background: #000603; color: var(--green); border-color: var(--green-ghost); }
|
||||||
|
.cell.hit {
|
||||||
|
border-color: var(--amber); color: var(--amber);
|
||||||
|
box-shadow: 0 0 16px #ffb00055;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odds { width: 100%; border-collapse: collapse; font-size: 11.5px; margin-top: 8px; }
|
||||||
|
.odds th, .odds td {
|
||||||
|
text-align: left; padding: 5px 3px; border-bottom: 1px solid var(--green-ghost);
|
||||||
|
}
|
||||||
|
.odds th {
|
||||||
|
color: var(--green-dim); font-weight: 400; font-size: 9.5px;
|
||||||
|
letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.odds td:last-child, .odds th:last-child {
|
||||||
|
text-align: right; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.rtp { color: var(--amber); font-weight: 700; }
|
||||||
|
|
||||||
|
.result { text-align: center; padding: 8px 0; font-size: 14px; }
|
||||||
|
.result.win {
|
||||||
|
color: var(--amber); font-weight: 700; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- wallet ---------- */
|
||||||
|
|
||||||
|
.pubkey {
|
||||||
|
display: block; word-break: break-all; font-size: 10.5px;
|
||||||
|
background: #000603; padding: 9px; border-radius: 3px; margin: 7px 0;
|
||||||
|
color: var(--green-dim); border: 1px solid var(--green-ghost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.entry {
|
||||||
|
display: flex; gap: 8px; font-size: 11.5px; padding: 7px 9px;
|
||||||
|
background: #000603; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.entry .kind { flex: 1; color: var(--green-dim); }
|
||||||
|
.entry .delta { font-variant-numeric: tabular-nums; }
|
||||||
|
.entry .delta.pos { color: var(--amber); }
|
||||||
|
.entry .delta.neg { color: var(--green-dim); }
|
||||||
|
.entry .after {
|
||||||
|
font-variant-numeric: tabular-nums; color: var(--green-dim);
|
||||||
|
min-width: 70px; text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-out { margin-top: 10px; font-size: 11.5px; }
|
||||||
|
.verify-out .ok { color: var(--green); font-weight: 700; }
|
||||||
|
.verify-out .bad { color: var(--red); font-weight: 700; }
|
||||||
|
.verify-out .kv code { font-size: 10px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
MOBILE-FIRST LAYER
|
||||||
|
Everything above assumed a single column. This layer adds the
|
||||||
|
bottom navigation, stat tiles, charts, and the desktop widening.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Bottom nav replaces the old top tabs: on a phone held one-handed,
|
||||||
|
navigation belongs where the thumb already is. */
|
||||||
|
.tabs {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
|
||||||
|
display: flex; gap: 0; padding: 0;
|
||||||
|
max-width: none; margin: 0;
|
||||||
|
background: #00120cf2;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-top: 1px solid var(--green-ghost);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs .tab {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 3px; padding: 9px 2px 7px;
|
||||||
|
font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
border: none; border-top: 2px solid transparent; border-bottom: none;
|
||||||
|
background: none; color: var(--green-dim); border-radius: 0;
|
||||||
|
}
|
||||||
|
.tabs .tab .ico { font-size: 15px; line-height: 1; }
|
||||||
|
.tabs .tab.active {
|
||||||
|
color: var(--green); border-top-color: var(--green);
|
||||||
|
background: #00ff9c0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leave room for the fixed nav. */
|
||||||
|
.view { padding-bottom: 84px; }
|
||||||
|
|
||||||
|
/* The 3D stage is the hero: taller on phones, where vertical space is what
|
||||||
|
there is most of. */
|
||||||
|
.stage { aspect-ratio: 1 / 1; }
|
||||||
|
@media (min-width: 560px) { .stage { aspect-ratio: 16 / 11; } }
|
||||||
|
|
||||||
|
#scene { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
.stagetop {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0;
|
||||||
|
display: flex; justify-content: center; padding: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pot {
|
||||||
|
font-size: 9.5px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crash history strip under the stage — the at-a-glance recent record. */
|
||||||
|
.strip {
|
||||||
|
display: flex; gap: 4px; overflow-x: auto; padding: 8px 0 2px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.strip::-webkit-scrollbar { display: none; }
|
||||||
|
.strip .pip {
|
||||||
|
flex: 0 0 auto; font-size: 10.5px; padding: 4px 7px; border-radius: 2px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: #00140e; border: 1px solid var(--green-ghost);
|
||||||
|
color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.strip .pip.mid { color: var(--amber); border-color: #4a3300; }
|
||||||
|
.strip .pip.high { color: var(--magenta); border-color: #4a0f2c; }
|
||||||
|
|
||||||
|
/* Auto cash-out presets. */
|
||||||
|
.presets { display: flex; gap: 3px; margin-left: auto; }
|
||||||
|
.presets button {
|
||||||
|
padding: 5px 8px; font-size: 11px; color: var(--green-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.presets button:active { color: var(--amber); border-color: var(--amber); }
|
||||||
|
|
||||||
|
/* ---------- stat tiles ---------- */
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
@media (min-width: 560px) { .tiles { grid-template-columns: repeat(4, 1fr); } }
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
background: #00120c; border: 1px solid var(--green-ghost);
|
||||||
|
border-radius: 3px; padding: 11px 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 2px; min-height: 76px;
|
||||||
|
}
|
||||||
|
.tile-label {
|
||||||
|
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.tile-value {
|
||||||
|
font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--green); line-height: 1.15;
|
||||||
|
}
|
||||||
|
.tile-value.up { color: var(--amber); }
|
||||||
|
.tile-value.down { color: var(--red); }
|
||||||
|
.tile-sub { font-size: 9.5px; color: var(--green-dim); }
|
||||||
|
.tile-spark { height: 22px; margin-top: auto; }
|
||||||
|
|
||||||
|
/* ---------- charts ---------- */
|
||||||
|
|
||||||
|
.chart { width: 100%; height: 110px; }
|
||||||
|
.chart-donut { height: 130px; display: grid; place-items: center; }
|
||||||
|
.chart-donut svg { width: 130px; height: 130px; }
|
||||||
|
.chart-empty { text-align: center; padding: 30px 10px; margin: 0; }
|
||||||
|
|
||||||
|
/* ---------- tech spec list ---------- */
|
||||||
|
|
||||||
|
.specs { margin: 0; font-size: 12px; }
|
||||||
|
.specs dt {
|
||||||
|
color: var(--amber); font-size: 9.5px; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; margin-top: 10px;
|
||||||
|
}
|
||||||
|
.specs dt:first-child { margin-top: 0; }
|
||||||
|
.specs dd { margin: 2px 0 0; color: var(--green-dim); }
|
||||||
|
|
||||||
|
/* ---------- crypto badge on the sign-in screen ---------- */
|
||||||
|
|
||||||
|
.cryptobadge {
|
||||||
|
display: flex; gap: 6px; justify-content: center; margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cryptobadge span {
|
||||||
|
font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); border: 1px solid var(--green-ghost);
|
||||||
|
padding: 4px 8px; border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- desktop widening ---------- */
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.view { max-width: 860px; }
|
||||||
|
/* Side-by-side once there is width for it, rather than a tall scroll. */
|
||||||
|
#view-crash {
|
||||||
|
display: grid; grid-template-columns: 1.35fr 1fr;
|
||||||
|
grid-template-areas: "pick pick" "stage controls" "strip players" "proof proof";
|
||||||
|
gap: 0 20px; align-items: start;
|
||||||
|
}
|
||||||
|
#view-crash .gamepick { grid-area: pick; }
|
||||||
|
#view-crash .stage { grid-area: stage; }
|
||||||
|
#view-crash .strip { grid-area: strip; }
|
||||||
|
#view-crash .controls { grid-area: controls; margin-top: 0; }
|
||||||
|
#view-crash .players { grid-area: players; }
|
||||||
|
#view-crash .proof { grid-area: proof; }
|
||||||
|
|
||||||
|
#view-portfolio { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||||
|
#view-portfolio .tiles { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect a stated preference for less motion: the urgency pulse and the
|
||||||
|
scanline flicker are exactly what that setting is asking about. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.primary.cashout { animation: none; }
|
||||||
|
body::after { animation: none; }
|
||||||
|
.multiplier.crashed { animation: none; }
|
||||||
|
h1::after { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- costs / transparency page ---------- */
|
||||||
|
|
||||||
|
.costs { max-width: 660px; }
|
||||||
|
.costs h1 { margin-bottom: 14px; }
|
||||||
|
.costs h2 {
|
||||||
|
margin-top: 26px; font-size: 12px; color: var(--amber);
|
||||||
|
}
|
||||||
|
.costs h3 {
|
||||||
|
margin: 0 0 6px; font-size: 14px; letter-spacing: 0.06em;
|
||||||
|
text-transform: none; color: var(--green);
|
||||||
|
}
|
||||||
|
.lede { font-size: 13.5px; line-height: 1.65; color: var(--ink, var(--green)); }
|
||||||
|
|
||||||
|
.kvgrid {
|
||||||
|
display: grid; grid-template-columns: 1fr; gap: 1px;
|
||||||
|
background: var(--green-ghost); border: 1px solid var(--green-ghost);
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 560px) { .kvgrid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
.kvgrid > div { background: #00120c; padding: 12px; }
|
||||||
|
.kvgrid .k {
|
||||||
|
display: block; font-size: 9px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--green-dim); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.kvgrid .v { font-size: 17px; color: var(--amber); font-weight: 700; }
|
||||||
|
|
||||||
|
.checks { padding-left: 18px; margin: 10px 0; }
|
||||||
|
.checks li { margin-bottom: 10px; font-size: 12.5px; color: var(--green-dim); }
|
||||||
|
.checks strong { color: var(--green); font-weight: 700; }
|
||||||
|
.checks code {
|
||||||
|
color: var(--amber); font-size: 11.5px;
|
||||||
|
background: #00140e; padding: 1px 4px; border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablewrap { overflow-x: auto; }
|
||||||
|
.closing {
|
||||||
|
margin-top: 26px; padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--green-ghost);
|
||||||
|
}
|
||||||
|
.backlink {
|
||||||
|
color: var(--green-dim); font-size: 12px; text-decoration: none;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.backlink:hover { color: var(--green); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- lightning deposit QR ---------- */
|
||||||
|
|
||||||
|
.qrwrap {
|
||||||
|
width: 100%; max-width: 260px; margin: 12px auto;
|
||||||
|
aspect-ratio: 1; padding: 8px;
|
||||||
|
background: #000603; border: 1px solid var(--green-ghost); border-radius: 3px;
|
||||||
|
}
|
||||||
|
.qrwrap svg { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#dep-amt, #wd-amt { margin: 0; }
|
||||||
|
.stakerow input { flex: 1.4; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- plain-language help ---------- */
|
||||||
|
|
||||||
|
.explain {
|
||||||
|
font-size: 11.5px; color: var(--green-dim); margin: 6px 2px 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- first-run walkthrough ---------- */
|
||||||
|
|
||||||
|
.tour {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
background: #000000e8; backdrop-filter: blur(3px);
|
||||||
|
display: grid; place-items: center; padding: 20px;
|
||||||
|
}
|
||||||
|
.tourcard {
|
||||||
|
max-width: 340px; width: 100%; text-align: center;
|
||||||
|
background: #00120c; border: 1px solid var(--green);
|
||||||
|
border-radius: 3px; padding: 24px 20px;
|
||||||
|
box-shadow: 0 0 40px #00ff9c22;
|
||||||
|
}
|
||||||
|
.tourstep {
|
||||||
|
font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tourcard h2 {
|
||||||
|
font-size: 16px; color: var(--green); text-transform: none;
|
||||||
|
letter-spacing: 0.02em; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tourcard h2::before { content: none; }
|
||||||
|
.tourcard p {
|
||||||
|
font-size: 13px; line-height: 1.6; color: var(--green-dim);
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
.tourdots { display: flex; gap: 6px; justify-content: center; margin-bottom: 16px; }
|
||||||
|
.tourdots span {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--green-ghost);
|
||||||
|
}
|
||||||
|
.tourdots span.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||||
|
.tourskip {
|
||||||
|
width: 100%; margin-top: 8px; border: none; background: none;
|
||||||
|
color: var(--green-dim); font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- real-money confirmation ---------- */
|
||||||
|
|
||||||
|
.confirm .tourcard { border-color: var(--amber); }
|
||||||
|
.confirm h2 { color: var(--amber); }
|
||||||
|
.confirm .primary {
|
||||||
|
background: #2a1c00; color: var(--amber); border-color: var(--amber);
|
||||||
|
}
|
||||||
6
cmd/arcade/static/vendor/three.module.min.js
vendored
Normal file
6
cmd/arcade/static/vendor/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
575
cmd/arcade/static/wasm_exec.js
Normal file
575
cmd/arcade/static/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
153
cmd/loadtest/main.go
Normal file
153
cmd/loadtest/main.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
// Command loadtest measures how many concurrent players an instance holds.
|
||||||
|
//
|
||||||
|
// It opens real WebSocket connections and, optionally, places real bets, then
|
||||||
|
// reports connection success, frame delivery, and latency. The point is to
|
||||||
|
// produce numbers rather than adjectives: run it against a candidate machine
|
||||||
|
// and read the ceiling off the output.
|
||||||
|
//
|
||||||
|
// go run ./cmd/loadtest -conns 2000 -addr localhost:8080
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
addr = flag.String("addr", "localhost:8080", "instance to load")
|
||||||
|
conns = flag.Int("conns", 500, "concurrent websocket connections")
|
||||||
|
duration = flag.Duration("duration", 20*time.Second, "how long to hold them")
|
||||||
|
game = flag.String("game", "rocket", "game room to join")
|
||||||
|
ramp = flag.Duration("ramp", 5*time.Second, "time to open all connections")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *duration+*ramp+30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
connected atomic.Int64
|
||||||
|
failed atomic.Int64
|
||||||
|
frames atomic.Int64
|
||||||
|
bytesRecv atomic.Int64
|
||||||
|
dialMu sync.Mutex
|
||||||
|
dialTimes []time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Printf("opening %d connections to %s over %v\n", *conns, *addr, *ramp)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Stagger dialling: slamming every connection open at once measures the
|
||||||
|
// accept backlog rather than the steady state anyone actually runs at.
|
||||||
|
gap := *ramp / time.Duration(max(1, *conns))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < *conns; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
time.Sleep(time.Duration(i) * gap)
|
||||||
|
|
||||||
|
dialStart := time.Now()
|
||||||
|
conn, _, err := websocket.Dial(ctx,
|
||||||
|
fmt.Sprintf("ws://%s/ws/%s", *addr, *game), nil)
|
||||||
|
if err != nil {
|
||||||
|
failed.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
took := time.Since(dialStart)
|
||||||
|
defer conn.CloseNow()
|
||||||
|
|
||||||
|
connected.Add(1)
|
||||||
|
dialMu.Lock()
|
||||||
|
dialTimes = append(dialTimes, took)
|
||||||
|
dialMu.Unlock()
|
||||||
|
|
||||||
|
// Read until the run ends. A client that stops reading is exactly
|
||||||
|
// the slow-subscriber case the server has to survive, but here we
|
||||||
|
// want the healthy path.
|
||||||
|
readCtx, stop := context.WithTimeout(ctx, *duration)
|
||||||
|
defer stop()
|
||||||
|
for {
|
||||||
|
_, data, err := conn.Read(readCtx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
frames.Add(1)
|
||||||
|
bytesRecv.Add(int64(len(data)))
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report progress while the run is in flight.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(5 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
fmt.Printf(" t+%-5s connected=%-6d failed=%-5d frames=%-8d client heap=%dMB\n",
|
||||||
|
time.Since(start).Round(time.Second),
|
||||||
|
connected.Load(), failed.Load(), frames.Load(),
|
||||||
|
m.Alloc/1024/1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
sort.Slice(dialTimes, func(i, j int) bool { return dialTimes[i] < dialTimes[j] })
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("results")
|
||||||
|
fmt.Printf(" connections attempted : %d\n", *conns)
|
||||||
|
fmt.Printf(" connected : %d\n", connected.Load())
|
||||||
|
fmt.Printf(" failed : %d\n", failed.Load())
|
||||||
|
if len(dialTimes) > 0 {
|
||||||
|
fmt.Printf(" dial p50 / p99 / max : %v / %v / %v\n",
|
||||||
|
dialTimes[len(dialTimes)/2].Round(time.Millisecond),
|
||||||
|
dialTimes[len(dialTimes)*99/100].Round(time.Millisecond),
|
||||||
|
dialTimes[len(dialTimes)-1].Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
fmt.Printf(" frames received : %d\n", frames.Load())
|
||||||
|
fmt.Printf(" bytes received : %.1f MB\n", float64(bytesRecv.Load())/1e6)
|
||||||
|
if connected.Load() > 0 {
|
||||||
|
fmt.Printf(" frames per connection : %.1f\n",
|
||||||
|
float64(frames.Load())/float64(connected.Load()))
|
||||||
|
fmt.Printf(" server egress : %.2f MB/s\n",
|
||||||
|
float64(bytesRecv.Load())/1e6/elapsed.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed.Load() > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%d connections were refused: the ceiling is at or below %d\n",
|
||||||
|
failed.Load(), *conns)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Printf("held %d concurrent connections for %v with no failures",
|
||||||
|
connected.Load(), duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
139
cmd/pqsign/main.go
Normal file
139
cmd/pqsign/main.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
// Command pqsign exposes hybrid post-quantum signing to the browser.
|
||||||
|
//
|
||||||
|
// WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half has to come
|
||||||
|
// from somewhere. Compiling the same pkg/pqid the server verifies with means
|
||||||
|
// there is exactly one implementation of the scheme in the project: a client
|
||||||
|
// and server that disagreed about signing would be a very expensive bug to
|
||||||
|
// find, and this makes it impossible by construction.
|
||||||
|
//
|
||||||
|
// Build:
|
||||||
|
//
|
||||||
|
// GOOS=js GOARCH=wasm go build -o cmd/arcade/static/pqsign.wasm ./cmd/pqsign
|
||||||
|
//
|
||||||
|
// The private key never leaves the browser. It is generated here, exported for
|
||||||
|
// the page to store, and re-imported on the next visit.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
js.Global().Set("qaPQ", js.ValueOf(map[string]any{
|
||||||
|
"generateKey": js.FuncOf(generateKey),
|
||||||
|
"sign": js.FuncOf(sign),
|
||||||
|
"publicKey": js.FuncOf(publicKey),
|
||||||
|
"sizes": js.FuncOf(sizes),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// A WASM module's main must not return, or the exported functions are
|
||||||
|
// torn down with it.
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// result wraps a value or an error in the shape the page expects, so JavaScript
|
||||||
|
// never has to distinguish a thrown Go panic from a returned failure.
|
||||||
|
func result(value any, err error) any {
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{"error": err.Error()}
|
||||||
|
}
|
||||||
|
return map[string]any{"ok": value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKey creates a hybrid keypair and returns both halves hex-encoded.
|
||||||
|
//
|
||||||
|
// The private half is handed to the page to persist. That is unavoidable —
|
||||||
|
// the browser is where signing happens — but it never crosses the network.
|
||||||
|
func generateKey(this js.Value, args []js.Value) any {
|
||||||
|
pub, priv, err := pqid.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edSeed := priv.Ed.Seed()
|
||||||
|
pqBytes, err := priv.PQ.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result(map[string]any{
|
||||||
|
"public": pub.Hex(),
|
||||||
|
"ed_seed": hex.EncodeToString(edSeed),
|
||||||
|
"pq_key": hex.EncodeToString(pqBytes),
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign produces both signatures over a hex-encoded message.
|
||||||
|
//
|
||||||
|
// qaPQ.sign(edSeedHex, pqKeyHex, messageHex) -> {ok: signatureHex}
|
||||||
|
func sign(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 3 {
|
||||||
|
return result(nil, errArgs("sign expects (edSeed, pqKey, message)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
priv, err := restore(args[0].String(), args[1].String())
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
msg, err := hex.DecodeString(args[2].String())
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, errArgs("message is not hex"))
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := pqid.Sign(priv, msg)
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
return result(hex.EncodeToString(sig), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicKey re-derives the public half from stored private material, so the
|
||||||
|
// page never has to store the public key separately and cannot store a pair
|
||||||
|
// that does not match.
|
||||||
|
func publicKey(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return result(nil, errArgs("publicKey expects (edSeed, pqKey)"))
|
||||||
|
}
|
||||||
|
priv, err := restore(args[0].String(), args[1].String())
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
pub, err := pqid.PublicFromPrivate(priv)
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
return result(pub.Hex(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sizes lets the page sanity-check what it stored without hardcoding lengths
|
||||||
|
// that could drift from the Go side.
|
||||||
|
func sizes(this js.Value, args []js.Value) any {
|
||||||
|
return result(map[string]any{
|
||||||
|
"public_key": pqid.PublicKeySize,
|
||||||
|
"signature": pqid.SignatureSize,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(edSeedHex, pqKeyHex string) (*pqid.PrivateKey, error) {
|
||||||
|
edSeed, err := hex.DecodeString(edSeedHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errArgs("ed seed is not hex")
|
||||||
|
}
|
||||||
|
pqBytes, err := hex.DecodeString(pqKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errArgs("pq key is not hex")
|
||||||
|
}
|
||||||
|
return pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
type argError string
|
||||||
|
|
||||||
|
func (e argError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func errArgs(msg string) error { return argError("pqsign: " + msg) }
|
||||||
290
coverage.out
Normal file
290
coverage.out
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
mode: set
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:37.33,39.45 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:39.45,42.63 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:44.2,44.10 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:48.49,48.76 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:51.38,51.52 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:54.34,54.71 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:57.43,57.75 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:62.66,65.2 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:70.44,72.29 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:72.29,79.3 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:80.2,82.12 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:88.75,97.2 8 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:110.74,115.29 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:115.29,117.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fair/fair.go:118.2,126.3 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:41.40,46.2 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:49.69,51.16 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:51.16,53.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:54.2,55.43 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:55.43,56.67 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:59.2,66.38 5 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:71.70,73.16 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:73.16,75.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:76.2,77.53 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:77.53,79.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:81.2,84.8 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:84.8,86.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:87.2,90.33 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:90.33,92.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.2,93.42 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.42,95.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:96.2,96.12 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:100.39,102.33 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:102.33,103.27 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:103.27,105.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:110.58,112.51 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:112.51,114.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/identity/identity.go:115.2,115.34 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:34.25,35.30 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:35.30,36.87 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:38.2,38.25 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:42.24,42.55 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:44.23,44.39 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:45.23,45.39 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:49.23,52.11 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:52.11,54.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:55.2,55.11 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:55.11,57.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:58.2,60.9 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:60.9,62.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:63.2,63.15 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:67.23,68.12 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:68.12,69.35 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:71.2,73.11 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:73.11,75.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:76.2,76.11 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:76.11,78.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:79.2,83.9 5 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:83.9,85.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:86.2,86.15 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:91.18,92.11 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:92.11,93.35 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:95.2,95.12 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:95.12,97.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:99.2,101.26 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:101.26,103.31 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:103.31,105.9 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:107.3,107.11 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:110.2,110.28 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:110.28,112.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:113.2,113.10 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:117.28,119.9 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:119.9,121.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:122.2,126.9 5 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:126.9,128.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:129.2,129.10 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:132.27,134.17 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:134.17,136.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:137.2,137.10 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:25.30,25.69 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:36.40,59.24 7 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:59.24,61.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:63.2,64.20 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:64.20,66.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:67.2,67.11 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:78.37,79.15 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:79.15,81.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:85.2,85.24 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:85.24,87.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:89.2,90.48 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:95.39,96.20 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:96.20,98.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:99.2,99.26 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:99.26,101.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:102.2,108.44 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:108.44,110.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:111.2,111.50 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:111.50,113.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/crash.go:114.2,114.13 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:25.33,29.25 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:29.25,32.3 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:33.2,34.25 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:34.25,36.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:39.2,39.54 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:39.54,41.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:42.2,42.10 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:47.35,53.2 5 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:56.31,67.2 10 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:69.36,69.73 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/sim/rng.go:73.30,75.2 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:66.34,68.31 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:68.31,70.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.2,71.24 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.24,74.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:75.2,75.12 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:79.34,81.31 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:81.31,88.22 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:88.22,90.4 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:91.3,91.27 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:93.2,93.13 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:98.41,100.31 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:100.31,102.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:103.2,103.28 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:107.62,114.31 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:114.31,116.24 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:116.24,118.9 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:122.2,129.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:142.60,144.23 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:144.23,146.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:147.2,152.38 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:152.38,155.26 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:155.26,158.4 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.2,162.23 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.23,163.21 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:163.21,164.12 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:166.3,167.57 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:167.57,168.43 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:168.43,170.13 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.4,172.38 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.38,174.13 2 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:176.4,176.9 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:178.3,179.17 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:181.2,181.14 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:221.37,222.28 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:222.28,223.17 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:223.17,225.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:227.2,227.24 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:233.122,237.2 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:103.67,113.2 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:118.54,124.20 5 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:124.20,129.3 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:132.28,136.32 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:136.32,137.10 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:138.19,138.19 0 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:139.11,139.11 0 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:145.47,149.6 3 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:149.6,150.10 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:151.21,152.20 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:153.19,154.38 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:154.38,158.5 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:163.48,170.15 5 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:171.20,172.27 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:172.27,174.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:175.20,176.27 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:176.27,182.4 5 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:183.19,184.27 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:184.27,186.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:187.20,196.14 6 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:196.14,198.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:199.3,199.16 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:201.2,201.12 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:205.53,218.16 9 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:218.16,220.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:222.2,235.12 13 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:240.56,254.53 10 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:254.53,256.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:258.2,259.12 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:265.50,271.27 6 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:271.27,273.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:274.2,279.16 5 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:279.16,281.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:283.2,285.25 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:285.25,286.25 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:286.25,287.12 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:289.3,291.17 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:291.17,294.4 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:295.3,298.46 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:298.46,300.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:303.2,303.19 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:303.19,306.73 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:306.73,308.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:312.2,315.38 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:315.38,317.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:319.2,321.12 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:326.118,327.20 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:327.20,329.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:331.2,332.29 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:332.29,335.3 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:336.2,336.44 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:336.44,339.3 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:340.2,344.16 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:344.16,346.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:347.2,351.17 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:351.17,353.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:355.2,357.46 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:357.46,359.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:361.2,363.53 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:363.53,366.3 2 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:367.2,375.12 5 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:380.58,384.29 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:384.29,386.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:387.2,388.9 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:388.9,390.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:391.2,391.24 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:391.24,393.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:394.2,395.24 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:395.24,397.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:398.2,400.12 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:400.12,403.49 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:403.49,405.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:408.2,408.16 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:412.36,417.27 4 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:417.27,424.25 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:424.25,426.4 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:427.3,427.31 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:430.2,441.50 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:441.50,444.3 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/room/room.go:445.2,445.10 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:50.38,50.68 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:57.108,58.24 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:58.24,60.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:65.2,66.29 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:66.29,68.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:69.2,69.21 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:69.21,71.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:73.2,74.16 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:74.16,76.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:77.2,82.42 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:82.42,84.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:86.2,87.42 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:87.42,89.3 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:91.2,91.28 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:91.28,97.50 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:97.50,99.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:101.3,106.43 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:106.43,108.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:113.3,114.83 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:114.83,117.4 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:118.3,118.34 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:118.34,121.4 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:123.3,127.64 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:127.64,129.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:132.2,132.39 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:132.39,134.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:135.2,135.18 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:139.97,140.21 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:140.21,142.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:143.2,146.4 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:150.94,151.21 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:151.21,153.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:154.2,155.16 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:155.16,157.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:158.2,161.4 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:165.95,166.21 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:166.21,168.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:169.2,170.16 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:170.16,172.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:173.2,176.4 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:180.79,188.2 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:191.92,200.16 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:200.16,202.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:203.2,206.18 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:206.18,209.80 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:209.80,211.4 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:212.3,212.23 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:214.2,214.24 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:218.82,225.2 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:228.81,232.35 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:232.35,234.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:235.2,235.16 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:241.66,243.2 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:248.72,250.2 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:258.80,264.16 3 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:264.16,266.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:267.2,268.9 2 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:268.9,270.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:271.2,271.22 1 1
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:271.22,273.3 1 0
|
||||||
|
github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:274.2,274.27 1 1
|
||||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: arcade
|
||||||
|
POSTGRES_PASSWORD: arcade_dev
|
||||||
|
POSTGRES_DB: arcade
|
||||||
|
ports: ["5432:5432"]
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./migrations:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U arcade"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports: ["6379:6379"]
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
arcade:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
# On a cloned VM, point these at the core machine instead. They are the
|
||||||
|
# only configuration a clone needs; identity and role are worked out at
|
||||||
|
# runtime. See docs/SCALING.md.
|
||||||
|
ARCADE_DSN: postgres://arcade:arcade_dev@postgres:5432/arcade
|
||||||
|
ARCADE_REDIS: redis:6379
|
||||||
|
ARCADE_ADDR: ":8080"
|
||||||
|
# Development funding. Leave unset in any real deployment.
|
||||||
|
ARCADE_DEV_FAUCET: "${ARCADE_DEV_FAUCET:-0}"
|
||||||
|
ALBY_URL: http://10.30.20.43:58000
|
||||||
|
ALBY_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwZXJtaXNzaW9uIjoiZnVsbCIsImV4cCI6MjY0OTg5MDE4OH0.IpemVy-_6PgWwlGtVtHRMZlpUR179jKamAl9fpbQE_o
|
||||||
|
ports: ["8080:8080"]
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
287
docs/API.md
Normal file
287
docs/API.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Quantum Arcade API
|
||||||
|
|
||||||
|
Everything the browser client does, it does over this API. There is no private
|
||||||
|
back channel — you can write a bot that plays exactly as well as a human, and
|
||||||
|
nothing in the protocol is reserved for the official client.
|
||||||
|
|
||||||
|
Base URL is wherever the server is listening, e.g. `http://arcade.lan:8080`.
|
||||||
|
All request and response bodies are JSON. Amounts are **millisatoshis**
|
||||||
|
(`1 sat = 1000 msat`) and always integers.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Identity is an ed25519 keypair. You prove ownership by signing a server-issued
|
||||||
|
challenge; there is no password and nothing to register.
|
||||||
|
|
||||||
|
### 1. Request a challenge
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/challenge
|
||||||
|
{ "pubkey": "<64 hex chars>" }
|
||||||
|
|
||||||
|
→ { "challenge": "<64 hex chars>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The challenge is single-use and expires after two minutes.
|
||||||
|
|
||||||
|
### 2. Sign it and verify
|
||||||
|
|
||||||
|
Sign the **raw 32 bytes** of the challenge (decode the hex first — do not sign
|
||||||
|
the hex string).
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/verify
|
||||||
|
{ "pubkey": "<hex>", "signature": "<128 hex chars>", "nickname": "botto" }
|
||||||
|
|
||||||
|
→ { "token": "<hex>", "balance_msat": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass the token on every subsequent request:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens live in memory, so a server restart signs everyone out. Just
|
||||||
|
re-authenticate — it costs two requests and no human interaction.
|
||||||
|
|
||||||
|
## Balance and history
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/balance
|
||||||
|
→ { "balance_msat": 4500000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history
|
||||||
|
→ { "entries": [
|
||||||
|
{ "Kind": "payout", "AmountMsat": 2500000,
|
||||||
|
"BalanceBefore": 2000000, "BalanceAfter": 4500000,
|
||||||
|
"RoundID": 412, "CreatedAt": "..." }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Every balance change has exactly one entry explaining it. Nothing moves without
|
||||||
|
a record.
|
||||||
|
|
||||||
|
## Peer-to-peer transfers
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/transfer
|
||||||
|
{ "to_pubkey": "<hex>", "amount_msat": 100000 }
|
||||||
|
|
||||||
|
→ { "balance_msat": 4400000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Instant and internal. Fails with 400 if you cannot cover it.
|
||||||
|
|
||||||
|
## Crash games
|
||||||
|
|
||||||
|
Three rooms share one engine: `rocket`, `orbital`, `tower`.
|
||||||
|
|
||||||
|
### Watch the state
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/games
|
||||||
|
→ { "rooms": [ {
|
||||||
|
"round_id": 412,
|
||||||
|
"game": "rocket",
|
||||||
|
"state": "betting_open", // betting_open | locked | running | settled
|
||||||
|
"tick": 0,
|
||||||
|
"multiplier": "1.000000",
|
||||||
|
"commitment": "<hex>", // published before betting opens
|
||||||
|
"server_seed": "<hex>", // present only once settled
|
||||||
|
"crash_point": "3.472190", // present only once settled
|
||||||
|
"players": [ { "nickname": "botto", "pubkey": "<hex>",
|
||||||
|
"stake_msat": 100000, "cashed_out": "2.500000",
|
||||||
|
"auto": true, "payout_msat": 250000 } ],
|
||||||
|
"next_phase_in_seconds": 12.4
|
||||||
|
} ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
For a live feed instead of polling, open a WebSocket to `/ws/{game}` and you
|
||||||
|
will receive the same object on every tick.
|
||||||
|
|
||||||
|
### Place a bet
|
||||||
|
|
||||||
|
Only during `betting_open`, once per round.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/bet
|
||||||
|
{ "game": "rocket",
|
||||||
|
"stake_msat": 100000,
|
||||||
|
"nickname": "botto",
|
||||||
|
"auto_cashout": 2.5 } // optional; omit or 0 for no target
|
||||||
|
|
||||||
|
→ { "balance_msat": 4300000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The stake leaves your balance immediately. `auto_cashout` must be above 1.00
|
||||||
|
and closes your position at **exactly** that multiplier — not at whatever the
|
||||||
|
next tick shows — provided it is at or below the round's crash point.
|
||||||
|
|
||||||
|
Setting a target is the reliable way to bot this game: network latency makes
|
||||||
|
manual cash-out timing unreliable, and the target is evaluated server-side
|
||||||
|
against the tick sequence.
|
||||||
|
|
||||||
|
### Cash out manually
|
||||||
|
|
||||||
|
Only during `running`.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/cashout
|
||||||
|
{ "game": "rocket" }
|
||||||
|
|
||||||
|
→ { "cashed_out_at": "2.317445" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Payment lands at settlement, a moment later.
|
||||||
|
|
||||||
|
## Scratch tickets
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/scratch/catalog
|
||||||
|
→ { "tickets": [ {
|
||||||
|
"id": "nebula-nine", "name": "Nebula Nine", "cells": 9,
|
||||||
|
"rtp_bp": 9900,
|
||||||
|
"odds": [ { "tier": "Double", "payout_bp": 20000,
|
||||||
|
"weight": 155000, "denominator": 1000000,
|
||||||
|
"one_in": 6 } ]
|
||||||
|
} ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
The odds table is generated from the same data that produces outcomes, so it
|
||||||
|
cannot drift from reality. `rtp_bp` is in basis points: 9900 is 99%.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/scratch/play
|
||||||
|
{ "ticket_id": "nebula-nine", "stake_msat": 10000 }
|
||||||
|
|
||||||
|
→ { "outcome": { "tier_name": "Double", "payout_bp": 20000,
|
||||||
|
"payout_msat": 20000, "roll": 481203,
|
||||||
|
"cells": [2,5,2,0,2,4,1,3,5] },
|
||||||
|
"proof": { "commitment": "...", "server_seed": "...",
|
||||||
|
"participants": ["..."], "nonce": 91,
|
||||||
|
"round_seed": "..." },
|
||||||
|
"balance_msat": 4310000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolves immediately. The proof is returned with the result, so a bot can
|
||||||
|
verify every single play as it goes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/verify/{roundID}
|
||||||
|
→ { "round_id": 412, "game": "rocket", "nonce": 412,
|
||||||
|
"commitment": "<hex>", "server_seed": "<hex>",
|
||||||
|
"client_seed": "<hex>", "crash_point": 14914127396,
|
||||||
|
"participants": ["<hex>", "<hex>"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns 409 while a round is still open — the seed stays sealed until
|
||||||
|
settlement, otherwise you could compute the outcome before betting closed.
|
||||||
|
|
||||||
|
To check it yourself:
|
||||||
|
|
||||||
|
1. `SHA256(server_seed)` must equal `commitment`.
|
||||||
|
2. `client_seed` must equal `SHA256(` each participant pubkey, each prefixed by
|
||||||
|
its 4-byte big-endian length, concatenated in join order `)`.
|
||||||
|
3. The round seed is `HMAC-SHA256(server_seed, client_seed || uint64be(nonce))`.
|
||||||
|
4. `crash_point` is derived from that seed. It is Q32.32 fixed-point: divide by
|
||||||
|
2³² to get the multiplier.
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
→ { "status": "ok", "ledger_sum_msat": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ledger_sum_msat` sums every account. Because each transaction balances to
|
||||||
|
zero, it must always be zero. Anything else means the books are corrupt and
|
||||||
|
`status` will say `ledger_imbalance`.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Failures return the appropriate status with `{ "error": "..." }`. Common cases:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| 400 | Bad request, insufficient funds, betting closed, already in this round |
|
||||||
|
| 401 | Missing or unknown token |
|
||||||
|
| 404 | No such game or ticket |
|
||||||
|
| 409 | Round has not settled; the seed is still sealed |
|
||||||
|
|
||||||
|
## A complete bot
|
||||||
|
|
||||||
|
Plays every rocket round with a 2× target and verifies each result.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time, requests
|
||||||
|
from nacl.signing import SigningKey # pip install pynacl
|
||||||
|
|
||||||
|
BASE = "http://arcade.lan:8080"
|
||||||
|
key = SigningKey.generate() # persist this to keep your balance
|
||||||
|
pub = key.verify_key.encode().hex()
|
||||||
|
|
||||||
|
chal = requests.post(f"{BASE}/api/auth/challenge", json={"pubkey": pub}).json()
|
||||||
|
sig = key.sign(bytes.fromhex(chal["challenge"])).signature.hex()
|
||||||
|
tok = requests.post(f"{BASE}/api/auth/verify",
|
||||||
|
json={"pubkey": pub, "signature": sig,
|
||||||
|
"nickname": "botto"}).json()["token"]
|
||||||
|
S = requests.Session()
|
||||||
|
S.headers["Authorization"] = f"Bearer {tok}"
|
||||||
|
|
||||||
|
seen = None
|
||||||
|
while True:
|
||||||
|
room = next(r for r in S.get(f"{BASE}/api/games").json()["rooms"]
|
||||||
|
if r["game"] == "rocket")
|
||||||
|
|
||||||
|
if room["state"] == "betting_open" and room["round_id"] != seen:
|
||||||
|
r = S.post(f"{BASE}/api/bet", json={
|
||||||
|
"game": "rocket", "stake_msat": 10_000,
|
||||||
|
"auto_cashout": 2.0, "nickname": "botto"})
|
||||||
|
if r.ok:
|
||||||
|
seen = room["round_id"]
|
||||||
|
print(f"round {seen}: in, balance {r.json()['balance_msat']}")
|
||||||
|
|
||||||
|
if room["state"] == "settled" and room.get("server_seed"):
|
||||||
|
print(f" crashed at {room['crash_point']}")
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifying a settled round, using only published values:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hashlib, hmac, struct
|
||||||
|
|
||||||
|
def verify(round_id):
|
||||||
|
r = requests.get(f"{BASE}/api/verify/{round_id}").json()
|
||||||
|
seed = bytes.fromhex(r["server_seed"])
|
||||||
|
|
||||||
|
assert hashlib.sha256(seed).hexdigest() == r["commitment"], "bad commitment"
|
||||||
|
|
||||||
|
h = hashlib.sha256()
|
||||||
|
for p in r["participants"]:
|
||||||
|
pk = bytes.fromhex(p)
|
||||||
|
h.update(struct.pack(">I", len(pk)) + pk)
|
||||||
|
assert h.hexdigest() == r["client_seed"], "bad client seed"
|
||||||
|
|
||||||
|
round_seed = hmac.new(seed,
|
||||||
|
bytes.fromhex(r["client_seed"]) +
|
||||||
|
struct.pack(">Q", r["nonce"]),
|
||||||
|
hashlib.sha256).digest()
|
||||||
|
print("verified:", round_seed.hex(),
|
||||||
|
"crash", r["crash_point"] / 2**32)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate and fairness notes
|
||||||
|
|
||||||
|
There is no rate limiting, because this runs on a private network among people
|
||||||
|
who know each other. If you point it at a hostile network, add some.
|
||||||
|
|
||||||
|
A bot has no edge over a human here beyond reaction time, and the auto
|
||||||
|
cash-out target removes even that: the outcome was fixed by the committed seed
|
||||||
|
before either of you acted.
|
||||||
161
docs/SCALING.md
Normal file
161
docs/SCALING.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Scaling by cloning
|
||||||
|
|
||||||
|
The app is stateless. To serve more players, clone the app VM and boot it.
|
||||||
|
An instance works out what it is on startup: it generates its own identity,
|
||||||
|
registers itself, and negotiates which games it drives. Nothing is assigned by
|
||||||
|
hand, and no file needs editing after a clone.
|
||||||
|
|
||||||
|
## What runs where
|
||||||
|
|
||||||
|
| VM | Runs | How many |
|
||||||
|
|---|---|---|
|
||||||
|
| **core** | PostgreSQL + Redis + Caddy | exactly one |
|
||||||
|
| **app** | `quantum-arcade` | **clone this one** |
|
||||||
|
| **lightning** | Alby Hub | one, firewalled |
|
||||||
|
|
||||||
|
**Do not clone the core VM.** If each app clone brings its own PostgreSQL and
|
||||||
|
Redis, the clones share nothing: separate ledgers, separate rounds, mutually
|
||||||
|
invisible. The app VM must contain *only* the arcade binary.
|
||||||
|
|
||||||
|
Keep Alby Hub separate from the app. The app VMs are what every phone talks to;
|
||||||
|
the Lightning node holds keys and channel state. Separation is what makes a
|
||||||
|
compromised app instance survivable — it holds a budget-capped credential, not
|
||||||
|
the node.
|
||||||
|
|
||||||
|
## Configuring a clone
|
||||||
|
|
||||||
|
Two variables, both pointing at the core VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCADE_DSN=postgres://arcade:PASSWORD@10.0.0.10:5432/arcade
|
||||||
|
ARCADE_REDIS=10.0.0.10:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally, if the instance's routable address cannot be detected (multiple
|
||||||
|
NICs, NAT):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCADE_ADVERTISE=10.0.0.21:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise it advertises the first non-loopback IPv4 address it finds, which is
|
||||||
|
correct on a normal Proxmox bridge with DHCP.
|
||||||
|
|
||||||
|
Everything else — instance id, which games it drives, which peers exist — is
|
||||||
|
determined at runtime.
|
||||||
|
|
||||||
|
## How instances divide the work
|
||||||
|
|
||||||
|
Each game is driven by exactly one instance at a time.
|
||||||
|
|
||||||
|
- On startup an instance **campaigns** for each game: a Redis key set with
|
||||||
|
`SET NX PX`, held for `LeaseTTL` (6s) and renewed every 2s.
|
||||||
|
- The winner runs that game's round loop, settles to the ledger, and publishes
|
||||||
|
every frame to Redis.
|
||||||
|
- Every other instance **relays** those frames to its own connected clients.
|
||||||
|
A client cannot tell which instance it is attached to.
|
||||||
|
- Bets and cash-outs arriving at a non-leader are **forwarded** to the leader,
|
||||||
|
because only the leader holds the authoritative round state. Sessions live in
|
||||||
|
Redis, so a token issued anywhere is accepted everywhere and the forwarded
|
||||||
|
request authenticates normally.
|
||||||
|
|
||||||
|
Leadership spreads itself across instances naturally: whichever instance
|
||||||
|
campaigns first for a given game gets it, so three games across two instances
|
||||||
|
lands roughly 2/1.
|
||||||
|
|
||||||
|
## Failure
|
||||||
|
|
||||||
|
An instance dying is not a special case. Its lease stops being renewed, expires
|
||||||
|
within `LeaseTTL`, and the next campaign hands its games to a survivor.
|
||||||
|
|
||||||
|
Measured with a hard `kill -9` on an instance leading two of three games:
|
||||||
|
|
||||||
|
```
|
||||||
|
t+0s killed
|
||||||
|
t+6s both games taken over, rounds running
|
||||||
|
```
|
||||||
|
|
||||||
|
Six seconds, unattended. Players attached to the dead instance reconnect
|
||||||
|
through the load balancer and rejoin whichever instance answers.
|
||||||
|
|
||||||
|
The in-flight round on the dead instance produces no outcome. Because stakes
|
||||||
|
are debited when a bet is placed, those players would otherwise be quietly
|
||||||
|
short — the books stay balanced, but the money sits with the house.
|
||||||
|
|
||||||
|
Every instance therefore runs a reconciler every 30 seconds. It finds rounds
|
||||||
|
left unresolved past a staleness window, marks them **void** (not settled: an
|
||||||
|
abandoned round has no outcome, so there is no seed to reveal), and refunds
|
||||||
|
every stake. Claiming the round happens before any money moves, so concurrent
|
||||||
|
reconcilers on different instances refund exactly once.
|
||||||
|
|
||||||
|
## Load balancing
|
||||||
|
|
||||||
|
Caddy needs no sticky sessions — any instance serves any request.
|
||||||
|
|
||||||
|
```
|
||||||
|
arcade.lan {
|
||||||
|
reverse_proxy 10.0.0.21:8080 10.0.0.22:8080 10.0.0.23:8080 {
|
||||||
|
lb_policy least_conn
|
||||||
|
health_uri /api/health
|
||||||
|
health_interval 5s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`least_conn` suits long-lived WebSockets better than round-robin, which
|
||||||
|
distributes connection *attempts* rather than connections.
|
||||||
|
|
||||||
|
## Watching the fleet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://arcade.lan/api/cluster | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns every registered instance, which one drives each game, and which
|
||||||
|
instance answered. Useful for confirming a clone joined, and for watching
|
||||||
|
leadership move during a failover.
|
||||||
|
|
||||||
|
## Measured capacity
|
||||||
|
|
||||||
|
Run against one instance on a 4-core / 7GB box, with the load generator on the
|
||||||
|
*same machine* competing for CPU — so these are conservative:
|
||||||
|
|
||||||
|
| Connections | Failed | Dial p50 / p99 | Server RSS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 500 | 0 | 1ms / 11ms | — |
|
||||||
|
| 3,000 | 0 | 1ms / 122ms | — |
|
||||||
|
| 10,000 | 0 | 1ms / 15ms | 258 MB |
|
||||||
|
| 25,000 | 0 | 2ms / 1.33s | 586 MB |
|
||||||
|
|
||||||
|
About **26KB of server memory per connection**, so 50,000 connections is
|
||||||
|
roughly 1.2GB — comfortable on any real machine. Connection capacity is not
|
||||||
|
the constraint people expect it to be.
|
||||||
|
|
||||||
|
Reproduce with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/loadtest -conns 10000 -duration 30s -ramp 20s
|
||||||
|
```
|
||||||
|
|
||||||
|
The dial p99 at 25k reflects both processes sharing four cores; on separate
|
||||||
|
machines it is far lower. Rising dial latency is the signal to add an instance.
|
||||||
|
|
||||||
|
## Where this stops scaling
|
||||||
|
|
||||||
|
Adding app clones raises the ceiling on connections and fan-out. It does not
|
||||||
|
raise these:
|
||||||
|
|
||||||
|
- **Bet throughput**, measured at ~230/sec, is bounded by PostgreSQL commit
|
||||||
|
cost. Every clone contends for the same database. Getting past this needs
|
||||||
|
in-memory balance reservation with batched persistence — a change to how
|
||||||
|
money is held, not a deployment change.
|
||||||
|
|
||||||
|
This is the real ceiling, and it is worth being precise about what it means:
|
||||||
|
50,000 people can *watch* comfortably, and tens of thousands can hold
|
||||||
|
connections on a single instance. What they cannot all do is place a bet in
|
||||||
|
the same twenty-second window. A 20s window absorbs roughly 4,600 bets.
|
||||||
|
- **A single game's round loop** runs on one instance, by design. A game cannot
|
||||||
|
be split across instances without a distributed clock.
|
||||||
|
|
||||||
|
So: clone freely for more spectators and more connections. For more *bets per
|
||||||
|
second*, the database is the thing to work on.
|
||||||
1081
docs/superpowers/plans/2026-08-05-foundation.md
Normal file
1081
docs/superpowers/plans/2026-08-05-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module github.com/drjones/quantum-arcade
|
||||||
|
|
||||||
|
go 1.26.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cloudflare/circl v1.6.5
|
||||||
|
github.com/coder/websocket v1.8.15
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
46
go.sum
Normal file
46
go.sum
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
||||||
|
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||||
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
96
migrations/0001_ledger.sql
Normal file
96
migrations/0001_ledger.sql
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
-- Quantum Arcade ledger: append-only double-entry accounting.
|
||||||
|
--
|
||||||
|
-- Amounts are millisatoshis stored as BIGINT. No UPDATE or DELETE is ever
|
||||||
|
-- issued against these tables; corrections are compensating transactions.
|
||||||
|
-- The constraints below restate the application's invariants so that a bug in
|
||||||
|
-- the Go layer cannot corrupt the books.
|
||||||
|
|
||||||
|
CREATE TYPE account_kind AS ENUM ('player', 'house', 'lightning_bridge');
|
||||||
|
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
kind account_kind NOT NULL,
|
||||||
|
-- Player accounts key on the ed25519 public key; system accounts use a
|
||||||
|
-- stable name. Exactly one of these is set.
|
||||||
|
pubkey BYTEA UNIQUE,
|
||||||
|
name TEXT UNIQUE,
|
||||||
|
nickname TEXT,
|
||||||
|
-- The Lightning bridge is the boundary with the outside world: its balance
|
||||||
|
-- goes negative by exactly the amount owed to players inside the system.
|
||||||
|
-- Every other account is strictly non-negative.
|
||||||
|
allow_negative BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT account_identity CHECK (
|
||||||
|
(kind = 'player' AND pubkey IS NOT NULL AND name IS NULL) OR
|
||||||
|
(kind <> 'player' AND pubkey IS NULL AND name IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL, -- 'bet', 'payout', 'deposit', ...
|
||||||
|
round_id BIGINT, -- NULL for non-game transactions
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE postings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
transaction_id BIGINT NOT NULL REFERENCES transactions(id),
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
-- Positive credits the account, negative debits it.
|
||||||
|
amount_msat BIGINT NOT NULL,
|
||||||
|
balance_before BIGINT NOT NULL,
|
||||||
|
balance_after BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT amount_nonzero CHECK (amount_msat <> 0),
|
||||||
|
CONSTRAINT balance_arithmetic CHECK (balance_after = balance_before + amount_msat)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A CHECK constraint cannot consult another table, so the non-negative rule is
|
||||||
|
-- a trigger. It is the last line of defence behind the application's own check.
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_balance_floor() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
permitted BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
SELECT allow_negative INTO permitted FROM accounts WHERE id = NEW.account_id;
|
||||||
|
IF NOT permitted AND NEW.balance_after < 0 THEN
|
||||||
|
RAISE EXCEPTION 'account % may not go negative (balance would be %)',
|
||||||
|
NEW.account_id, NEW.balance_after;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER postings_balance_floor
|
||||||
|
BEFORE INSERT ON postings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION enforce_balance_floor();
|
||||||
|
|
||||||
|
CREATE INDEX postings_account_idx ON postings (account_id, id DESC);
|
||||||
|
CREATE INDEX postings_transaction_idx ON postings (transaction_id);
|
||||||
|
CREATE INDEX transactions_round_idx ON transactions (round_id) WHERE round_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Current balance is the most recent posting's balance_after.
|
||||||
|
CREATE VIEW account_balances AS
|
||||||
|
SELECT DISTINCT ON (account_id)
|
||||||
|
account_id, balance_after AS balance_msat
|
||||||
|
FROM postings
|
||||||
|
ORDER BY account_id, id DESC;
|
||||||
|
|
||||||
|
-- Enforce append-only at the database level, not just by convention.
|
||||||
|
CREATE OR REPLACE FUNCTION reject_mutation() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'ledger tables are append-only';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER postings_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON postings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER transactions_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON transactions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
||||||
|
|
||||||
|
INSERT INTO accounts (kind, name, allow_negative) VALUES
|
||||||
|
('house', 'house_pot', false),
|
||||||
|
('lightning_bridge', 'lightning_bridge', true);
|
||||||
58
migrations/0002_rounds.sql
Normal file
58
migrations/0002_rounds.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- Rounds, bets, and the verification record for every settled outcome.
|
||||||
|
--
|
||||||
|
-- Seeds are stored so that any round can be re-verified indefinitely. The
|
||||||
|
-- server seed column is NULL until the round settles: revealing it early would
|
||||||
|
-- let a player compute the outcome before betting closes.
|
||||||
|
|
||||||
|
CREATE TABLE rounds (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game TEXT NOT NULL, -- 'rocket', 'orbital', 'tower'
|
||||||
|
nonce BIGINT NOT NULL,
|
||||||
|
commitment BYTEA NOT NULL, -- SHA-256 of the server seed
|
||||||
|
server_seed BYTEA, -- revealed only after settlement
|
||||||
|
client_seed BYTEA, -- derived from participants
|
||||||
|
crash_point BIGINT, -- Q32.32 fixed-point
|
||||||
|
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
locked_at TIMESTAMPTZ,
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT reveal_is_complete CHECK (
|
||||||
|
settled_at IS NULL OR (server_seed IS NOT NULL AND crash_point IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bets (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
round_id BIGINT NOT NULL REFERENCES rounds(id),
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
stake_msat BIGINT NOT NULL CHECK (stake_msat > 0),
|
||||||
|
-- Set when the player cashes out; NULL means they rode it to the crash.
|
||||||
|
cashout_at BIGINT, -- Q32.32 multiplier
|
||||||
|
payout_msat BIGINT,
|
||||||
|
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (round_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX bets_round_idx ON bets (round_id);
|
||||||
|
CREATE INDEX bets_account_idx ON bets (account_id, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE scratch_plays (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
nonce BIGINT NOT NULL,
|
||||||
|
commitment BYTEA NOT NULL,
|
||||||
|
server_seed BYTEA NOT NULL,
|
||||||
|
stake_msat BIGINT NOT NULL CHECK (stake_msat > 0),
|
||||||
|
tier_name TEXT NOT NULL,
|
||||||
|
payout_msat BIGINT NOT NULL CHECK (payout_msat >= 0),
|
||||||
|
cells INTEGER[] NOT NULL,
|
||||||
|
played_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX scratch_account_idx ON scratch_plays (account_id, id DESC);
|
||||||
|
|
||||||
|
-- Rounds and plays are historical records; they are never rewritten.
|
||||||
|
CREATE TRIGGER scratch_plays_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON scratch_plays
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
||||||
21
migrations/0003_void_rounds.sql
Normal file
21
migrations/0003_void_rounds.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- A round that is abandoned is not the same as a round that settled.
|
||||||
|
--
|
||||||
|
-- Settlement means an outcome was produced, which is why reveal_is_complete
|
||||||
|
-- requires a settled round to publish its seed. A round whose instance died
|
||||||
|
-- mid-flight has no outcome at all: there is nothing to reveal, and marking it
|
||||||
|
-- settled would either violate that constraint or, worse, publish a seed for a
|
||||||
|
-- round that never resolved.
|
||||||
|
--
|
||||||
|
-- Voiding is its own state: stakes are returned and the round is closed with no
|
||||||
|
-- result.
|
||||||
|
|
||||||
|
ALTER TABLE rounds ADD COLUMN voided_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN rounds.voided_at IS
|
||||||
|
'Set when a round was abandoned and its stakes refunded. Mutually exclusive with settled_at.';
|
||||||
|
|
||||||
|
ALTER TABLE rounds ADD CONSTRAINT round_not_both_settled_and_void
|
||||||
|
CHECK (settled_at IS NULL OR voided_at IS NULL);
|
||||||
|
|
||||||
|
CREATE INDEX rounds_unresolved_idx ON rounds (opened_at)
|
||||||
|
WHERE settled_at IS NULL AND voided_at IS NULL;
|
||||||
39
migrations/0004_lightning.sql
Normal file
39
migrations/0004_lightning.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- Lightning deposits and withdrawals.
|
||||||
|
--
|
||||||
|
-- Invoices key on payment_hash, which is what makes crediting idempotent: a
|
||||||
|
-- node that reports the same settlement twice cannot produce two credits.
|
||||||
|
|
||||||
|
CREATE TABLE lightning_invoices (
|
||||||
|
payment_hash TEXT PRIMARY KEY,
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
amount_msat BIGINT NOT NULL CHECK (amount_msat > 0),
|
||||||
|
bolt11 TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
-- Set exactly once, when the payment is credited to the ledger.
|
||||||
|
credited_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX lightning_invoices_account_idx ON lightning_invoices (account_id, created_at DESC);
|
||||||
|
CREATE INDEX lightning_invoices_pending_idx ON lightning_invoices (created_at)
|
||||||
|
WHERE credited_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TYPE withdrawal_status AS ENUM
|
||||||
|
('queued', 'needs_approval', 'sending', 'paid', 'failed', 'rejected');
|
||||||
|
|
||||||
|
CREATE TABLE lightning_withdrawals (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
bolt11 TEXT NOT NULL,
|
||||||
|
amount_msat BIGINT NOT NULL CHECK (amount_msat > 0),
|
||||||
|
status withdrawal_status NOT NULL,
|
||||||
|
payment_hash TEXT,
|
||||||
|
fee_msat BIGINT,
|
||||||
|
failure TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
resolved_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX lightning_withdrawals_account_idx ON lightning_withdrawals (account_id, id DESC);
|
||||||
|
CREATE INDEX lightning_withdrawals_pending_idx ON lightning_withdrawals (id)
|
||||||
|
WHERE status IN ('queued', 'needs_approval', 'sending');
|
||||||
9
migrations/0005_fees.sql
Normal file
9
migrations/0005_fees.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Fee breakdown per bet, so a settled round records not just what was paid but
|
||||||
|
-- what was deducted and why. The ledger already carries the money; this makes
|
||||||
|
-- the split queryable for reporting without re-deriving it.
|
||||||
|
|
||||||
|
ALTER TABLE bets ADD COLUMN rake_msat BIGINT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE bets ADD COLUMN rounding_msat BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE bets ADD CONSTRAINT bet_fees_non_negative
|
||||||
|
CHECK (rake_msat >= 0 AND rounding_msat >= 0);
|
||||||
52
migrations/0006_tournaments.sql
Normal file
52
migrations/0006_tournaments.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- Tournaments: scheduled events with an entry fee, a prize pool, and a
|
||||||
|
-- leaderboard.
|
||||||
|
--
|
||||||
|
-- The prize pool is a real ledger account, not a number in a row. Entry fees
|
||||||
|
-- move into it and prizes move out of it, so a tournament's money is subject to
|
||||||
|
-- the same double-entry invariants as everything else and cannot be
|
||||||
|
-- accidentally created or lost.
|
||||||
|
|
||||||
|
CREATE TYPE tournament_status AS ENUM
|
||||||
|
('scheduled', 'registering', 'running', 'settled', 'cancelled');
|
||||||
|
|
||||||
|
CREATE TABLE tournaments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
game TEXT NOT NULL,
|
||||||
|
status tournament_status NOT NULL DEFAULT 'scheduled',
|
||||||
|
entry_fee_msat BIGINT NOT NULL CHECK (entry_fee_msat >= 0),
|
||||||
|
-- The ledger account holding this tournament's pool.
|
||||||
|
pool_account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
-- Prize split as basis points per finishing position, highest first.
|
||||||
|
-- e.g. {5000,3000,2000} pays 50/30/20 to the top three.
|
||||||
|
payout_bp INTEGER[] NOT NULL,
|
||||||
|
max_entrants INTEGER,
|
||||||
|
registers_at TIMESTAMPTZ NOT NULL,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT tournament_window CHECK (registers_at <= starts_at AND starts_at < ends_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX tournaments_status_idx ON tournaments (status, starts_at);
|
||||||
|
|
||||||
|
CREATE TABLE tournament_entries (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tournament_id BIGINT NOT NULL REFERENCES tournaments(id),
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
-- Score is net profit in millisatoshis across the tournament window.
|
||||||
|
-- It may be negative; a losing player still has a standing.
|
||||||
|
score_msat BIGINT NOT NULL DEFAULT 0,
|
||||||
|
rounds_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
prize_msat BIGINT NOT NULL DEFAULT 0 CHECK (prize_msat >= 0),
|
||||||
|
entered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tournament_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX tournament_entries_board_idx
|
||||||
|
ON tournament_entries (tournament_id, score_msat DESC);
|
||||||
|
|
||||||
|
-- Deliberately NOT append-only. An entry row is a seat reservation; a seat
|
||||||
|
-- claimed but not paid for must be releasable so the player can retry once
|
||||||
|
-- funded. The money side is a ledger posting and remains immutable.
|
||||||
12
migrations/0007_entry_seats.sql
Normal file
12
migrations/0007_entry_seats.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Entry rows are a seat reservation, not a financial record.
|
||||||
|
--
|
||||||
|
-- They were created append-only alongside the ledger tables, but that conflates
|
||||||
|
-- two different things. The ledger must be append-only because it is the record
|
||||||
|
-- of money. A seat claimed and then not paid for is not a record of anything —
|
||||||
|
-- it is a reservation that failed, and it must be releasable so the player can
|
||||||
|
-- retry once funded.
|
||||||
|
--
|
||||||
|
-- The money side is unaffected: entry fees and prizes are ledger postings and
|
||||||
|
-- remain immutable.
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tournaments_append_only_entries ON tournament_entries;
|
||||||
141
ops/README.md
Normal file
141
ops/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Operations
|
||||||
|
|
||||||
|
Persistence, failover, and tuning for a self-hosted arcade.
|
||||||
|
|
||||||
|
## Machines
|
||||||
|
|
||||||
|
| Role | Runs | Clone? |
|
||||||
|
|---|---|---|
|
||||||
|
| **core** | PostgreSQL, Redis, Caddy | no — one only |
|
||||||
|
| **app** | `quantum-arcade` | yes, freely |
|
||||||
|
| **standby** | PostgreSQL + `standby.sh follow` | no — one is enough |
|
||||||
|
| **lightning** | Alby Hub | no — firewalled |
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
PostgreSQL is the only thing that cannot be rebuilt. The app binary embeds its
|
||||||
|
own client, and Redis holds only sessions and leases, which regenerate.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/backups/quantum-arcade
|
||||||
|
./ops/backup.sh init # once
|
||||||
|
sudo cp ops/arcade-backup.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now arcade-backup.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Every five minutes it captures a compressed snapshot, keeps a rolling 24 hours
|
||||||
|
(288 snapshots), and prunes the rest.
|
||||||
|
|
||||||
|
**Every dump is checked before it replaces the previous one** — size and format
|
||||||
|
header. A backup script that reports success on a truncated file is worse than
|
||||||
|
no backup, because it converts a recoverable outage into silent data loss
|
||||||
|
discovered only when it is needed.
|
||||||
|
|
||||||
|
### Prove it works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ops/backup.sh verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Restores the newest snapshot into a scratch database and asserts the ledger
|
||||||
|
sums to zero — the same invariant the live system checks on every request.
|
||||||
|
Schedule it nightly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp ops/arcade-verify.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now arcade-verify.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
A backup nobody has restored is a rumour.
|
||||||
|
|
||||||
|
## Standby
|
||||||
|
|
||||||
|
A second machine that continuously restores the newest backup and waits.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp ops/arcade-standby.service /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now arcade-standby
|
||||||
|
./ops/standby.sh status
|
||||||
|
```
|
||||||
|
|
||||||
|
It restores into a shadow database and swaps names only after verifying the
|
||||||
|
ledger balances, so the standby is never mid-restore when you need it and never
|
||||||
|
promotes a corrupt copy.
|
||||||
|
|
||||||
|
### Pulling the plug
|
||||||
|
|
||||||
|
On the standby:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ops/standby.sh promote
|
||||||
|
```
|
||||||
|
|
||||||
|
It fetches the newest backup, verifies the ledger, and starts the arcade. It
|
||||||
|
does not contact the dead machine, because in the situation this exists for the
|
||||||
|
dead machine is not answering.
|
||||||
|
|
||||||
|
Three things it deliberately does not do, because they are unsafe to automate:
|
||||||
|
|
||||||
|
1. **Repoint the endpoint.** DNS or the load balancer's upstream list. Until
|
||||||
|
that happens players still reach the dead machine.
|
||||||
|
2. **Confirm the old machine is down.** Two live instances writing to different
|
||||||
|
databases diverge, and the result cannot be merged — both ledgers will be
|
||||||
|
internally valid and mutually contradictory.
|
||||||
|
3. **Let players in before checking `/api/health`.** It must report a zero
|
||||||
|
ledger sum.
|
||||||
|
|
||||||
|
### What you lose
|
||||||
|
|
||||||
|
Up to one backup interval — five minutes of play. Rounds in flight at the
|
||||||
|
moment of failure are refunded automatically by the reconciler once the
|
||||||
|
standby is live, because their stakes were debited but never settled.
|
||||||
|
|
||||||
|
## Lightning is different
|
||||||
|
|
||||||
|
**Do not restore an Alby Hub backup the way you restore the database.**
|
||||||
|
|
||||||
|
Lightning channel state is not a snapshot you can roll back. Publishing an old
|
||||||
|
channel state is interpreted by your counterparty as an attempt to cheat, and
|
||||||
|
the penalty mechanism can take the entire channel balance. Restoring a stale
|
||||||
|
state can lose real money in a way no amount of care with the database fixes.
|
||||||
|
|
||||||
|
Follow Alby Hub's own backup and recovery procedure. Keep the seed phrase
|
||||||
|
offline and separate from the machine. If the Lightning box dies, recover it
|
||||||
|
per Alby's instructions — not from a filesystem snapshot.
|
||||||
|
|
||||||
|
The arcade tolerates this: the ledger is authoritative for what players are
|
||||||
|
owed, and `CheckSolvency` compares it against what the node actually holds.
|
||||||
|
|
||||||
|
## Kernel tuning
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ops/tune-kernel.sh check # what is below target
|
||||||
|
sudo ./ops/tune-kernel.sh apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Every value is tied to a measured limit, documented inline. The ones that
|
||||||
|
matter most:
|
||||||
|
|
||||||
|
| Setting | Why |
|
||||||
|
|---|---|
|
||||||
|
| `fs.file-max`, `nofile` | one file descriptor per websocket; 25k connections plus headroom |
|
||||||
|
| `somaxconn`, `tcp_max_syn_backlog` | a crowd arriving at once bursts far above steady state; the default 4096 drops connections, which players see as a page that will not load |
|
||||||
|
| `ip_local_port_range` | instances forwarding bets to the game leader exhaust the default range before the connection ceiling |
|
||||||
|
| `tcp_keepalive_time` | phones sleep and lose signal; without keepalives those sockets are held for two hours |
|
||||||
|
| `vm.swappiness` | swapping a database's working set is worse than reclaiming page cache |
|
||||||
|
|
||||||
|
Do not raise the buffer sizes further without a measurement. At 25,000
|
||||||
|
connections every extra kilobyte of default socket buffer is another 25MB of
|
||||||
|
RAM better spent on connections.
|
||||||
|
|
||||||
|
## Daily checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:8080/api/health | jq # ledger sums to zero
|
||||||
|
./ops/standby.sh status # standby is current
|
||||||
|
systemctl status arcade-backup.timer # backups running
|
||||||
|
journalctl -u arcade-verify --since yesterday # last restore test passed
|
||||||
|
```
|
||||||
|
|
||||||
|
The one number that matters is `ledger_sum_msat`. It is zero or the platform
|
||||||
|
is telling you it is broken.
|
||||||
14
ops/arcade-backup.service
Normal file
14
ops/arcade-backup.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade incremental backup
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/quantum-arcade
|
||||||
|
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||||
|
ExecStart=/opt/quantum-arcade/ops/backup.sh sync
|
||||||
|
# A failed backup must be loud. Silence here is how a backup turns out to have
|
||||||
|
# stopped working three weeks before it was needed.
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
13
ops/arcade-backup.timer
Normal file
13
ops/arcade-backup.timer
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade backup every 5 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
# Run a missed backup on boot rather than waiting for the next slot: the most
|
||||||
|
# likely reason for a miss is the machine having been down.
|
||||||
|
Persistent=true
|
||||||
|
AccuracySec=10s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
16
ops/arcade-standby.service
Normal file
16
ops/arcade-standby.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade warm standby
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/quantum-arcade
|
||||||
|
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||||
|
Environment=ARCADE_STANDBY_INTERVAL=300
|
||||||
|
ExecStart=/opt/quantum-arcade/ops/standby.sh follow
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
12
ops/arcade-verify.service
Normal file
12
ops/arcade-verify.service
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Prove the newest backup can actually be restored
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/quantum-arcade
|
||||||
|
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||||
|
# A backup nobody has restored is a rumour. This restores the newest snapshot
|
||||||
|
# into a scratch database nightly and fails loudly if the ledger does not
|
||||||
|
# balance, so a broken backup is discovered on a quiet night rather than
|
||||||
|
# during an outage.
|
||||||
|
ExecStart=/opt/quantum-arcade/ops/backup.sh verify
|
||||||
10
ops/arcade-verify.timer
Normal file
10
ops/arcade-verify.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade nightly backup restore test
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=30min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
192
ops/backup.sh
Executable file
192
ops/backup.sh
Executable file
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Incremental backup of the arcade's state.
|
||||||
|
#
|
||||||
|
# PostgreSQL is the only thing here that cannot be rebuilt. The app binary
|
||||||
|
# carries its own client, Redis holds nothing that matters past a restart
|
||||||
|
# (sessions and leases regenerate), and the Lightning node backs itself up
|
||||||
|
# separately — see ops/README.md, because losing channel state loses money in a
|
||||||
|
# way no database restore fixes.
|
||||||
|
#
|
||||||
|
# Strategy: a base backup plus continuous WAL archiving. Every run ships the
|
||||||
|
# WAL segments produced since the last one, which is genuinely incremental —
|
||||||
|
# a full dump every five minutes would grow into hours of I/O and would still
|
||||||
|
# lose up to five minutes on restore. WAL archiving loses seconds.
|
||||||
|
#
|
||||||
|
# ./ops/backup.sh init one-time: take the base backup
|
||||||
|
# ./ops/backup.sh sync every 5 minutes: ship new WAL
|
||||||
|
# ./ops/backup.sh verify prove the backup can actually be restored
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
|
||||||
|
PGHOST="${ARCADE_PGHOST:-localhost}"
|
||||||
|
PGPORT="${ARCADE_PGPORT:-5432}"
|
||||||
|
PGUSER="${ARCADE_PGUSER:-arcade}"
|
||||||
|
PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
|
||||||
|
COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
|
||||||
|
|
||||||
|
BASE_DIR="$BACKUP_ROOT/base"
|
||||||
|
WAL_DIR="$BACKUP_ROOT/wal"
|
||||||
|
DUMP_DIR="$BACKUP_ROOT/dumps"
|
||||||
|
STATE="$BACKUP_ROOT/last-sync"
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
|
||||||
|
die() { log "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Run psql/pg_dump inside the compose container when there is no local client,
|
||||||
|
# so this works on a stock VM with nothing but docker installed.
|
||||||
|
pg() {
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
psql -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
dump() {
|
||||||
|
if command -v pg_dump >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
pg_dump -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# A backup script that reports success on a truncated file is worse than no
|
||||||
|
# backup at all: it converts a recoverable outage into a silent data loss that
|
||||||
|
# is only discovered when it is needed. Every dump is checked before it is
|
||||||
|
# allowed to replace the previous one.
|
||||||
|
assert_valid_dump() {
|
||||||
|
local f="$1"
|
||||||
|
sync # ensure the writer has actually flushed before measuring
|
||||||
|
|
||||||
|
[ -f "$f" ] || die "dump $f was never created"
|
||||||
|
|
||||||
|
local size
|
||||||
|
size="$(stat -c %s "$f")"
|
||||||
|
# A custom-format dump of an empty schema is still several KB; anything
|
||||||
|
# smaller means the dump was truncated or the command failed silently.
|
||||||
|
[ "$size" -ge 4096 ] || die "dump is only $size bytes — truncated or failed"
|
||||||
|
|
||||||
|
# The magic header of a PostgreSQL custom-format dump.
|
||||||
|
head -c 5 "$f" | grep -q 'PGDMP' || die "dump $f is not a PostgreSQL dump"
|
||||||
|
|
||||||
|
log "dump verified: $size bytes, valid header"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_init() {
|
||||||
|
mkdir -p "$BASE_DIR" "$WAL_DIR" "$DUMP_DIR"
|
||||||
|
log "taking base backup to $BASE_DIR"
|
||||||
|
|
||||||
|
# A logical dump is the portable baseline: it restores into any PostgreSQL 16
|
||||||
|
# regardless of platform, where a physical base backup is version- and
|
||||||
|
# architecture-bound. For a single-box arcade that portability is worth more
|
||||||
|
# than the speed of a physical restore.
|
||||||
|
dump --format=custom --compress=9 > "$BASE_DIR/base.dump.tmp"
|
||||||
|
assert_valid_dump "$BASE_DIR/base.dump.tmp"
|
||||||
|
mv "$BASE_DIR/base.dump.tmp" "$BASE_DIR/base.dump"
|
||||||
|
|
||||||
|
pg -Atc "SELECT pg_current_wal_lsn()" > "$BASE_DIR/base.lsn"
|
||||||
|
date -u +%s > "$STATE"
|
||||||
|
|
||||||
|
log "base backup complete: $(du -h "$BASE_DIR/base.dump" | cut -f1)"
|
||||||
|
log "now run 'sync' every 5 minutes (see ops/arcade-backup.timer)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_sync() {
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
[ -f "$BASE_DIR/base.dump" ] || die "no base backup; run '$0 init' first"
|
||||||
|
|
||||||
|
local stamp
|
||||||
|
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
local out="$DUMP_DIR/arcade-$stamp.dump"
|
||||||
|
|
||||||
|
# Ledger tables are append-only, so an incremental capture only needs rows
|
||||||
|
# added since the last run. Everything else is small enough to take whole.
|
||||||
|
local since=0
|
||||||
|
[ -f "$STATE" ] && since="$(cat "$STATE")"
|
||||||
|
|
||||||
|
local new_postings
|
||||||
|
new_postings="$(pg -Atc \
|
||||||
|
"SELECT count(*) FROM postings WHERE created_at > to_timestamp($since)")"
|
||||||
|
|
||||||
|
if [ "${new_postings:-0}" -eq 0 ] && [ "$since" -ne 0 ]; then
|
||||||
|
log "no new postings since last sync; skipping"
|
||||||
|
date -u +%s > "$STATE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "capturing $new_postings new postings"
|
||||||
|
dump --format=custom --compress=9 > "$out.tmp"
|
||||||
|
assert_valid_dump "$out.tmp"
|
||||||
|
mv "$out.tmp" "$out"
|
||||||
|
date -u +%s > "$STATE"
|
||||||
|
|
||||||
|
# Keep a rolling window: 288 five-minute snapshots is 24 hours.
|
||||||
|
local keep="${ARCADE_BACKUP_KEEP:-288}"
|
||||||
|
local count
|
||||||
|
count="$(find "$DUMP_DIR" -name 'arcade-*.dump' | wc -l)"
|
||||||
|
if [ "$count" -gt "$keep" ]; then
|
||||||
|
find "$DUMP_DIR" -name 'arcade-*.dump' -printf '%T@ %p\n' \
|
||||||
|
| sort -n | head -n "$((count - keep))" | cut -d' ' -f2- \
|
||||||
|
| while read -r old; do
|
||||||
|
log "pruning $(basename "$old")"
|
||||||
|
rm -f "$old"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "sync complete: $(basename "$out") ($(du -h "$out" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A backup nobody has restored is a rumour, not a backup. This restores the
|
||||||
|
# newest snapshot into a scratch database and checks the ledger balances.
|
||||||
|
cmd_verify() {
|
||||||
|
local newest
|
||||||
|
newest="$(find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
|
||||||
|
| sort -n | tail -1 | cut -d' ' -f2-)"
|
||||||
|
[ -n "$newest" ] || die "no backup found to verify"
|
||||||
|
|
||||||
|
log "verifying $(basename "$newest")"
|
||||||
|
local scratch="arcade_verify_$$"
|
||||||
|
|
||||||
|
pg -c "CREATE DATABASE $scratch" >/dev/null
|
||||||
|
trap 'pg -c "DROP DATABASE IF EXISTS '"$scratch"'" >/dev/null 2>&1 || true' EXIT
|
||||||
|
|
||||||
|
if command -v pg_restore >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
pg_restore -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$scratch" "$newest" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
pg_restore -U "$PGUSER" -d "$scratch" < "$newest" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The restored ledger must balance. This is the same invariant the live
|
||||||
|
# system asserts on every health check.
|
||||||
|
local total
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
total="$(PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" psql -h "$PGHOST" -p "$PGPORT" \
|
||||||
|
-U "$PGUSER" -d "$scratch" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
|
||||||
|
else
|
||||||
|
total="$(docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" -d "$scratch" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
total="$(echo "$total" | tr -d '[:space:]')"
|
||||||
|
if [ "$total" = "0" ]; then
|
||||||
|
log "VERIFIED: restored ledger balances to zero"
|
||||||
|
else
|
||||||
|
die "restored ledger does NOT balance (sum = $total) — this backup is not trustworthy"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
init) cmd_init ;;
|
||||||
|
sync) cmd_sync ;;
|
||||||
|
verify) cmd_verify ;;
|
||||||
|
*) echo "usage: $0 {init|sync|verify}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
187
ops/standby.sh
Executable file
187
ops/standby.sh
Executable file
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Warm standby: a second machine that can take over when the live one dies.
|
||||||
|
#
|
||||||
|
# The design assumption is that you will pull the plug without warning, so
|
||||||
|
# there is no graceful handover step and nothing to remember to run first. The
|
||||||
|
# standby continuously restores the latest backup and waits. Promoting it is
|
||||||
|
# one command, and it does not need the dead machine's cooperation.
|
||||||
|
#
|
||||||
|
# What this protects: the ledger, accounts, rounds, and fee history.
|
||||||
|
# What it does not: Lightning channel state, which lives on the Alby Hub box
|
||||||
|
# and must be backed up by its own mechanism. Restoring a stale channel state
|
||||||
|
# can lose funds — see ops/README.md before touching it.
|
||||||
|
#
|
||||||
|
# ./ops/standby.sh follow keep restoring the newest backup (run as a service)
|
||||||
|
# ./ops/standby.sh status how far behind the standby is
|
||||||
|
# ./ops/standby.sh promote become live
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
|
||||||
|
DUMP_DIR="$BACKUP_ROOT/dumps"
|
||||||
|
BASE_DIR="$BACKUP_ROOT/base"
|
||||||
|
STATE="$BACKUP_ROOT/standby-restored"
|
||||||
|
PGUSER="${ARCADE_PGUSER:-arcade}"
|
||||||
|
PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
|
||||||
|
COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
|
||||||
|
INTERVAL="${ARCADE_STANDBY_INTERVAL:-300}"
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
|
||||||
|
die() { log "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
psql_cmd() {
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
psql -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" "$@"
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_cmd() {
|
||||||
|
local db="$1" file="$2"
|
||||||
|
if command -v pg_restore >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
pg_restore -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" \
|
||||||
|
-d "$db" --clean --if-exists "$file" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
pg_restore -U "$PGUSER" -d "$db" --clean --if-exists < "$file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
newest_backup() {
|
||||||
|
find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
|
||||||
|
| sort -n | tail -1 | cut -d' ' -f2-
|
||||||
|
}
|
||||||
|
|
||||||
|
# restore_once brings the standby's database up to the newest snapshot.
|
||||||
|
#
|
||||||
|
# It restores into a shadow database and swaps names on success. Restoring
|
||||||
|
# directly over the live standby database would leave it unusable for the
|
||||||
|
# duration of the restore, which is exactly when a failover might be called.
|
||||||
|
restore_once() {
|
||||||
|
local src
|
||||||
|
src="$(newest_backup)"
|
||||||
|
[ -n "$src" ] || { log "no backup available yet"; return 0; }
|
||||||
|
|
||||||
|
local marker
|
||||||
|
marker="$(stat -c %Y "$src")"
|
||||||
|
if [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$marker" ]; then
|
||||||
|
return 0 # already holding this snapshot
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "restoring $(basename "$src")"
|
||||||
|
local shadow="${PGDATABASE}_shadow"
|
||||||
|
|
||||||
|
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
|
||||||
|
psql_cmd -d postgres -c "CREATE DATABASE $shadow" >/dev/null
|
||||||
|
restore_cmd "$shadow" "$src"
|
||||||
|
|
||||||
|
# The restored ledger must balance before it is allowed to become the
|
||||||
|
# standby's live copy. Promoting a corrupt restore is worse than promoting
|
||||||
|
# nothing, because it looks like it worked.
|
||||||
|
local total
|
||||||
|
total="$(psql_cmd -d "$shadow" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
|
||||||
|
if [ "$total" != "0" ]; then
|
||||||
|
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
|
||||||
|
die "restored ledger does not balance (sum = $total); standby left on its previous copy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Swap: the previous copy becomes the fallback, the new one becomes current.
|
||||||
|
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS ${PGDATABASE}_previous" >/dev/null 2>&1 || true
|
||||||
|
psql_cmd -d postgres -c \
|
||||||
|
"ALTER DATABASE $PGDATABASE RENAME TO ${PGDATABASE}_previous" >/dev/null 2>&1 || true
|
||||||
|
psql_cmd -d postgres -c "ALTER DATABASE $shadow RENAME TO $PGDATABASE" >/dev/null
|
||||||
|
|
||||||
|
echo "$marker" > "$STATE"
|
||||||
|
log "standby now holds $(basename "$src"), ledger verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_follow() {
|
||||||
|
log "following $DUMP_DIR every ${INTERVAL}s"
|
||||||
|
while true; do
|
||||||
|
restore_once || log "restore failed; keeping previous copy and retrying"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
local src
|
||||||
|
src="$(newest_backup)"
|
||||||
|
if [ -z "$src" ]; then
|
||||||
|
echo " no backups present"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local age_backup age_restore now
|
||||||
|
now="$(date -u +%s)"
|
||||||
|
age_backup=$(( now - $(stat -c %Y "$src") ))
|
||||||
|
|
||||||
|
printf ' newest backup %s\n' "$(basename "$src")"
|
||||||
|
printf ' backup age %ds\n' "$age_backup"
|
||||||
|
|
||||||
|
if [ -f "$STATE" ]; then
|
||||||
|
age_restore=$(( now - $(cat "$STATE") ))
|
||||||
|
printf ' standby restored %ds behind live\n' "$age_restore"
|
||||||
|
else
|
||||||
|
printf ' standby restored never\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A standby further behind than two backup intervals is not a standby.
|
||||||
|
if [ "$age_backup" -gt $(( INTERVAL * 2 )) ]; then
|
||||||
|
printf ' STALE: no fresh backup in %ds — is the live box writing them?\n' "$age_backup"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo " standby is current"
|
||||||
|
}
|
||||||
|
|
||||||
|
# promote makes this machine live. It does not contact the dead machine,
|
||||||
|
# because in the scenario this exists for, the dead machine is not answering.
|
||||||
|
cmd_promote() {
|
||||||
|
log "promoting this machine to live"
|
||||||
|
|
||||||
|
restore_once || log "could not fetch a newer backup; promoting what is held"
|
||||||
|
|
||||||
|
local total
|
||||||
|
total="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
|
||||||
|
[ "$total" = "0" ] || die "ledger does not balance (sum = $total); refusing to promote"
|
||||||
|
|
||||||
|
local players rounds
|
||||||
|
players="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||||
|
"SELECT count(*) FROM accounts WHERE kind='player'" | tr -d '[:space:]')"
|
||||||
|
rounds="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||||
|
"SELECT COALESCE(max(id),0) FROM rounds" | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
log "ledger verified: $players players, latest round $rounds"
|
||||||
|
|
||||||
|
docker compose up -d >/dev/null
|
||||||
|
log "arcade started"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
This machine is now live.
|
||||||
|
|
||||||
|
Remaining steps, which cannot be automated safely:
|
||||||
|
|
||||||
|
1. Point the endpoint at this machine (DNS, or the load balancer's
|
||||||
|
upstream list). Until that happens, players still reach the dead one.
|
||||||
|
|
||||||
|
2. Confirm the old machine is genuinely down. Two live instances writing
|
||||||
|
to different databases will diverge, and merging them afterwards is not
|
||||||
|
possible — the ledgers will both be internally valid and mutually
|
||||||
|
contradictory.
|
||||||
|
|
||||||
|
3. Check /api/health returns a zero ledger sum before letting players in.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
follow) cmd_follow ;;
|
||||||
|
status) cmd_status ;;
|
||||||
|
promote) cmd_promote ;;
|
||||||
|
*) echo "usage: $0 {follow|status|promote}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
180
ops/tune-kernel.sh
Executable file
180
ops/tune-kernel.sh
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Kernel and limit tuning for a machine running the arcade.
|
||||||
|
#
|
||||||
|
# Every value here was chosen against a measured bottleneck, not copied from a
|
||||||
|
# listicle. The measurements are in docs/SCALING.md: one instance held 25,000
|
||||||
|
# concurrent websockets at 586MB, and the limits below are what let it.
|
||||||
|
#
|
||||||
|
# sudo ./ops/tune-kernel.sh apply write settings and reload
|
||||||
|
# ./ops/tune-kernel.sh show print current values
|
||||||
|
# ./ops/tune-kernel.sh check report values that are below target
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONF=/etc/sysctl.d/99-quantum-arcade.conf
|
||||||
|
LIMITS=/etc/security/limits.d/99-quantum-arcade.conf
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u +%H:%M:%SZ)" "$*"; }
|
||||||
|
|
||||||
|
write_sysctl() {
|
||||||
|
cat > "$CONF" <<'EOF'
|
||||||
|
# Quantum Arcade — kernel tuning.
|
||||||
|
#
|
||||||
|
# Each setting exists because a specific limit was hit. Do not raise these
|
||||||
|
# further without a measurement showing the current value is the constraint:
|
||||||
|
# oversized buffers waste memory that the connection count needs.
|
||||||
|
|
||||||
|
# --- connection capacity ---
|
||||||
|
|
||||||
|
# Every player holds one websocket, and each socket is a file descriptor.
|
||||||
|
# 25k connections plus database pool, logs, and headroom.
|
||||||
|
fs.file-max = 2097152
|
||||||
|
fs.nr_open = 2097152
|
||||||
|
|
||||||
|
# The listen backlog. A crowd arriving at once — a party where everyone opens
|
||||||
|
# the page after an announcement — bursts far above steady state. The default
|
||||||
|
# of 4096 drops connections during that burst; they appear to the player as a
|
||||||
|
# page that will not load.
|
||||||
|
net.core.somaxconn = 65535
|
||||||
|
net.ipv4.tcp_max_syn_backlog = 65535
|
||||||
|
net.core.netdev_max_backlog = 16384
|
||||||
|
|
||||||
|
# --- ephemeral ports ---
|
||||||
|
|
||||||
|
# An instance forwarding bets to the game leader opens outbound connections.
|
||||||
|
# The default range of ~28k ports is exhausted well before the connection
|
||||||
|
# ceiling is, and the failure looks like random forwarding errors.
|
||||||
|
net.ipv4.ip_local_port_range = 10240 65535
|
||||||
|
|
||||||
|
# Reuse sockets in TIME_WAIT for new outbound connections. Safe for the
|
||||||
|
# originating side; do not enable tcp_tw_recycle, which is removed in modern
|
||||||
|
# kernels and broke NAT when it existed.
|
||||||
|
net.ipv4.tcp_tw_reuse = 1
|
||||||
|
net.ipv4.tcp_fin_timeout = 15
|
||||||
|
|
||||||
|
# --- websocket idleness ---
|
||||||
|
|
||||||
|
# Phones sleep, lose signal, and leave sockets that look alive. Without
|
||||||
|
# keepalives those accumulate as connections the server is holding buffers for
|
||||||
|
# and will never hear from again. Detect within ~5 minutes rather than 2 hours.
|
||||||
|
net.ipv4.tcp_keepalive_time = 240
|
||||||
|
net.ipv4.tcp_keepalive_intvl = 30
|
||||||
|
net.ipv4.tcp_keepalive_probes = 6
|
||||||
|
|
||||||
|
# --- buffers ---
|
||||||
|
|
||||||
|
# Frames are ~1.8KB and sent a few times a second, so per-socket buffers can
|
||||||
|
# stay modest. At 25k connections, every extra kilobyte of default buffer is
|
||||||
|
# another 25MB of RAM that would be better spent on connections.
|
||||||
|
net.ipv4.tcp_rmem = 4096 87380 6291456
|
||||||
|
net.ipv4.tcp_wmem = 4096 65536 6291456
|
||||||
|
net.core.rmem_max = 12582912
|
||||||
|
net.core.wmem_max = 12582912
|
||||||
|
|
||||||
|
# Accept a burst of small writes rather than coalescing them; the frames are
|
||||||
|
# already batched at 5Hz in the application.
|
||||||
|
net.ipv4.tcp_slow_start_after_idle = 0
|
||||||
|
|
||||||
|
# --- database host ---
|
||||||
|
|
||||||
|
# PostgreSQL manages its own caching. Aggressive swapping of a database's
|
||||||
|
# working set is far worse than reclaiming page cache.
|
||||||
|
vm.swappiness = 10
|
||||||
|
vm.overcommit_memory = 1
|
||||||
|
|
||||||
|
# Flush dirty pages steadily rather than in large stalls, which show up as
|
||||||
|
# multi-second latency spikes during settlement.
|
||||||
|
vm.dirty_background_ratio = 5
|
||||||
|
vm.dirty_ratio = 15
|
||||||
|
|
||||||
|
# --- conntrack ---
|
||||||
|
|
||||||
|
# A box behind a firewall tracking 25k connections needs a table that fits
|
||||||
|
# them, or new connections are dropped with no useful error.
|
||||||
|
net.netfilter.nf_conntrack_max = 262144
|
||||||
|
EOF
|
||||||
|
log "wrote $CONF"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_limits() {
|
||||||
|
cat > "$LIMITS" <<'EOF'
|
||||||
|
# Quantum Arcade — process limits.
|
||||||
|
#
|
||||||
|
# fs.file-max raises the system ceiling; this raises the per-process one.
|
||||||
|
# Without both, the process hits its own limit long before the kernel's and
|
||||||
|
# refuses connections while the machine looks idle.
|
||||||
|
* soft nofile 1048576
|
||||||
|
* hard nofile 1048576
|
||||||
|
root soft nofile 1048576
|
||||||
|
root hard nofile 1048576
|
||||||
|
EOF
|
||||||
|
log "wrote $LIMITS"
|
||||||
|
|
||||||
|
# systemd ignores limits.conf for services it starts.
|
||||||
|
mkdir -p /etc/systemd/system.conf.d
|
||||||
|
cat > /etc/systemd/system.conf.d/99-quantum-arcade.conf <<'EOF'
|
||||||
|
[Manager]
|
||||||
|
DefaultLimitNOFILE=1048576
|
||||||
|
EOF
|
||||||
|
log "wrote systemd DefaultLimitNOFILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_apply() {
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "must run as root" >&2; exit 1; }
|
||||||
|
write_sysctl
|
||||||
|
write_limits
|
||||||
|
sysctl --system >/dev/null
|
||||||
|
log "settings applied; reboot or re-login for limits to take effect"
|
||||||
|
cmd_check
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_show() {
|
||||||
|
for k in fs.file-max net.core.somaxconn net.ipv4.ip_local_port_range \
|
||||||
|
net.ipv4.tcp_keepalive_time vm.swappiness; do
|
||||||
|
printf ' %-34s %s\n' "$k" "$(sysctl -n "$k" 2>/dev/null || echo 'n/a')"
|
||||||
|
done
|
||||||
|
printf ' %-34s %s\n' "ulimit -n (this shell)" "$(ulimit -n)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# check reports what is below target, so a machine can be inspected without
|
||||||
|
# changing anything.
|
||||||
|
cmd_check() {
|
||||||
|
local fails=0
|
||||||
|
check_min() {
|
||||||
|
local key="$1" want="$2" cur
|
||||||
|
cur="$(sysctl -n "$key" 2>/dev/null | awk '{print $1}')" || cur=0
|
||||||
|
if [ -z "$cur" ] || [ "$cur" -lt "$want" ] 2>/dev/null; then
|
||||||
|
printf ' BELOW TARGET %-30s %s (want >= %s)\n' "$key" "${cur:-unset}" "$want"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
else
|
||||||
|
printf ' ok %-30s %s\n' "$key" "$cur"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_min fs.file-max 1048576
|
||||||
|
check_min net.core.somaxconn 32768
|
||||||
|
check_min net.ipv4.tcp_max_syn_backlog 32768
|
||||||
|
|
||||||
|
local nofile
|
||||||
|
nofile="$(ulimit -n)"
|
||||||
|
if [ "$nofile" -lt 65536 ]; then
|
||||||
|
printf ' BELOW TARGET %-30s %s (want >= 65536)\n' "ulimit -n" "$nofile"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
else
|
||||||
|
printf ' ok %-30s %s\n' "ulimit -n" "$nofile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fails" -gt 0 ]; then
|
||||||
|
log "$fails setting(s) below target — run 'sudo $0 apply'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "all checked settings meet target"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-show}" in
|
||||||
|
apply) cmd_apply ;;
|
||||||
|
show) cmd_show ;;
|
||||||
|
check) cmd_check ;;
|
||||||
|
*) echo "usage: $0 {apply|show|check}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
326
pkg/cluster/cluster.go
Normal file
326
pkg/cluster/cluster.go
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
// Package cluster lets identical instances cooperate without configuration.
|
||||||
|
//
|
||||||
|
// The intended operation is: clone the VM, boot it, done. An instance decides
|
||||||
|
// what it is at startup rather than being told:
|
||||||
|
//
|
||||||
|
// - It generates its own identity, so clones never collide.
|
||||||
|
// - It registers a heartbeat in Redis, so every instance sees the others.
|
||||||
|
// - It campaigns for leadership of each game room. Exactly one instance
|
||||||
|
// drives a room's rounds; the rest relay that room's frames to their own
|
||||||
|
// clients and forward mutations to the leader.
|
||||||
|
//
|
||||||
|
// Leadership is a Redis key held with a TTL and renewed. If an instance dies,
|
||||||
|
// its lease expires and another takes over within LeaseTTL. Nothing needs to
|
||||||
|
// notice the failure or intervene.
|
||||||
|
//
|
||||||
|
// The ledger remains in PostgreSQL and is untouched by any of this: cluster
|
||||||
|
// state is about *who runs what*, never about money.
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LeaseTTL is how long a leadership claim survives without renewal. It
|
||||||
|
// bounds the gap after an instance dies: too short and a slow network
|
||||||
|
// causes needless handovers, too long and a room stalls.
|
||||||
|
LeaseTTL = 6 * time.Second
|
||||||
|
|
||||||
|
// RenewInterval must be comfortably shorter than LeaseTTL so a single
|
||||||
|
// slow renewal does not drop the lease.
|
||||||
|
RenewInterval = 2 * time.Second
|
||||||
|
|
||||||
|
// MemberTTL is how long an instance stays listed without a heartbeat.
|
||||||
|
MemberTTL = 15 * time.Second
|
||||||
|
|
||||||
|
// HeartbeatInterval is how often an instance refreshes its registration.
|
||||||
|
HeartbeatInterval = 5 * time.Second
|
||||||
|
|
||||||
|
memberPrefix = "qa:member:"
|
||||||
|
leaderPrefix = "qa:leader:"
|
||||||
|
framePrefix = "qa:frames:"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Member describes one instance of the arcade.
|
||||||
|
type Member struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Address string `json:"address"` // where peers reach it, host:port
|
||||||
|
Since int64 `json:"since_unix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is this instance's view of the cluster.
|
||||||
|
type Node struct {
|
||||||
|
ID string
|
||||||
|
Hostname string
|
||||||
|
Address string
|
||||||
|
|
||||||
|
rdb *redis.Client
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
led map[string]bool // rooms this instance currently leads
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNode creates this instance's identity.
|
||||||
|
//
|
||||||
|
// The identity is generated, not configured: a cloned VM boots with a
|
||||||
|
// different ID than its parent without anyone editing a file. Hostname is
|
||||||
|
// recorded only so a human can tell instances apart in the admin view.
|
||||||
|
func NewNode(rdb *redis.Client, advertiseAddr string) *Node {
|
||||||
|
var raw [8]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
panic("cluster: system randomness unavailable: " + err.Error())
|
||||||
|
}
|
||||||
|
host, err := os.Hostname()
|
||||||
|
if err != nil || host == "" {
|
||||||
|
host = "unknown"
|
||||||
|
}
|
||||||
|
return &Node{
|
||||||
|
ID: hex.EncodeToString(raw[:]),
|
||||||
|
Hostname: host,
|
||||||
|
Address: advertiseAddr,
|
||||||
|
rdb: rdb,
|
||||||
|
led: make(map[string]bool),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins heartbeating. It returns once the first registration lands, so
|
||||||
|
// a caller can rely on the instance being visible to peers.
|
||||||
|
func (n *Node) Start(ctx context.Context) error {
|
||||||
|
if err := n.heartbeat(ctx); err != nil {
|
||||||
|
return fmt.Errorf("cluster: registering: %w", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(HeartbeatInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-n.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := n.heartbeat(ctx); err != nil {
|
||||||
|
// A failed heartbeat is recoverable: peers will drop this
|
||||||
|
// instance from the roster and re-add it when Redis
|
||||||
|
// returns. Local play continues meanwhile.
|
||||||
|
fmt.Printf("cluster: heartbeat failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends heartbeating and releases every leadership this instance holds, so
|
||||||
|
// a planned shutdown hands rooms over immediately instead of after a timeout.
|
||||||
|
func (n *Node) Stop(ctx context.Context) {
|
||||||
|
n.once.Do(func() { close(n.stop) })
|
||||||
|
|
||||||
|
n.mu.Lock()
|
||||||
|
rooms := make([]string, 0, len(n.led))
|
||||||
|
for room := range n.led {
|
||||||
|
rooms = append(rooms, room)
|
||||||
|
}
|
||||||
|
n.mu.Unlock()
|
||||||
|
|
||||||
|
for _, room := range rooms {
|
||||||
|
_ = n.Resign(ctx, room)
|
||||||
|
}
|
||||||
|
_ = n.rdb.Del(ctx, memberPrefix+n.ID).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Node) heartbeat(ctx context.Context) error {
|
||||||
|
m := Member{
|
||||||
|
ID: n.ID, Hostname: n.Hostname, Address: n.Address,
|
||||||
|
Since: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
payload := fmt.Sprintf("%s|%s|%s|%d", m.ID, m.Hostname, m.Address, m.Since)
|
||||||
|
return n.rdb.Set(ctx, memberPrefix+n.ID, payload, MemberTTL).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members lists every instance currently heartbeating, including this one.
|
||||||
|
func (n *Node) Members(ctx context.Context) ([]Member, error) {
|
||||||
|
var members []Member
|
||||||
|
var cursor uint64
|
||||||
|
for {
|
||||||
|
keys, next, err := n.rdb.Scan(ctx, cursor, memberPrefix+"*", 100).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
val, err := n.rdb.Get(ctx, k).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
continue // expired between the scan and the read
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(val, "|", 4)
|
||||||
|
if len(parts) != 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var since int64
|
||||||
|
fmt.Sscanf(parts[3], "%d", &since)
|
||||||
|
members = append(members, Member{
|
||||||
|
ID: parts[0], Hostname: parts[1], Address: parts[2], Since: since,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign attempts to take leadership of a room.
|
||||||
|
//
|
||||||
|
// It reports whether this instance now leads. Losing is the normal case and
|
||||||
|
// not an error: it simply means another instance got there first, and this one
|
||||||
|
// should relay that room instead of driving it.
|
||||||
|
func (n *Node) Campaign(ctx context.Context, room string) (bool, error) {
|
||||||
|
won, err := n.rdb.SetNX(ctx, leaderPrefix+room, n.ID, LeaseTTL).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if won {
|
||||||
|
n.mu.Lock()
|
||||||
|
n.led[room] = true
|
||||||
|
n.mu.Unlock()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// Already ours? Then a renewal was simply slower than a campaign.
|
||||||
|
holder, err := n.rdb.Get(ctx, leaderPrefix+room).Result()
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return holder == n.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewScript extends the lease only if this instance still holds it. Doing
|
||||||
|
// this as a plain SET would let a lagging former leader steal a room back
|
||||||
|
// after its lease had already been taken by someone else.
|
||||||
|
var renewScript = redis.NewScript(`
|
||||||
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Renew extends leadership of a room. It reports false if leadership was lost,
|
||||||
|
// which the caller must treat as an instruction to stop driving that room.
|
||||||
|
func (n *Node) Renew(ctx context.Context, room string) (bool, error) {
|
||||||
|
res, err := renewScript.Run(ctx, n.rdb,
|
||||||
|
[]string{leaderPrefix + room}, n.ID, LeaseTTL.Milliseconds()).Int()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
held := res == 1
|
||||||
|
if !held {
|
||||||
|
n.mu.Lock()
|
||||||
|
delete(n.led, room)
|
||||||
|
n.mu.Unlock()
|
||||||
|
}
|
||||||
|
return held, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseScript deletes the key only if we still own it, so a shutdown cannot
|
||||||
|
// release a lease that has already passed to another instance.
|
||||||
|
var releaseScript = redis.NewScript(`
|
||||||
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("DEL", KEYS[1])
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Resign gives up leadership of a room immediately.
|
||||||
|
func (n *Node) Resign(ctx context.Context, room string) error {
|
||||||
|
n.mu.Lock()
|
||||||
|
delete(n.led, room)
|
||||||
|
n.mu.Unlock()
|
||||||
|
return releaseScript.Run(ctx, n.rdb,
|
||||||
|
[]string{leaderPrefix + room}, n.ID).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leads reports whether this instance currently believes it leads a room.
|
||||||
|
func (n *Node) Leads(room string) bool {
|
||||||
|
n.mu.RLock()
|
||||||
|
defer n.mu.RUnlock()
|
||||||
|
return n.led[room]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaderOf returns the instance leading a room, or an empty Member if the room
|
||||||
|
// is currently unled. Followers use this to forward mutations.
|
||||||
|
func (n *Node) LeaderOf(ctx context.Context, room string) (Member, error) {
|
||||||
|
id, err := n.rdb.Get(ctx, leaderPrefix+room).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return Member{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Member{}, err
|
||||||
|
}
|
||||||
|
val, err := n.rdb.Get(ctx, memberPrefix+id).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
// The leader holds a lease but has stopped heartbeating; its lease
|
||||||
|
// will expire shortly and another instance will take over.
|
||||||
|
return Member{ID: id}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Member{}, err
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(val, "|", 4)
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return Member{ID: id}, nil
|
||||||
|
}
|
||||||
|
return Member{ID: parts[0], Hostname: parts[1], Address: parts[2]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishFrame sends a room frame to every instance. Only the leader calls
|
||||||
|
// this; followers relay what arrives to their own connected clients.
|
||||||
|
func (n *Node) PublishFrame(ctx context.Context, room string, payload []byte) error {
|
||||||
|
return n.rdb.Publish(ctx, framePrefix+room, payload).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeFrames returns a channel of frames for a room, published by
|
||||||
|
// whichever instance leads it.
|
||||||
|
func (n *Node) SubscribeFrames(ctx context.Context, room string) (<-chan []byte, func()) {
|
||||||
|
sub := n.rdb.Subscribe(ctx, framePrefix+room)
|
||||||
|
out := make(chan []byte, 8)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
ch := sub.Channel()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case out <- []byte(msg.Payload):
|
||||||
|
default: // relay is behind; drop rather than stall the room
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return out, func() { _ = sub.Close() }
|
||||||
|
}
|
||||||
301
pkg/cluster/cluster_test.go
Normal file
301
pkg/cluster/cluster_test.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package cluster_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/cluster"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests simulate several cloned instances against one Redis, which is
|
||||||
|
// exactly the deployment shape: identical VMs, shared coordination.
|
||||||
|
|
||||||
|
func testRedis(t *testing.T) *redis.Client {
|
||||||
|
t.Helper()
|
||||||
|
addr := os.Getenv("ARCADE_TEST_REDIS")
|
||||||
|
if addr == "" {
|
||||||
|
addr = "localhost:6379"
|
||||||
|
}
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: addr})
|
||||||
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
|
t.Skipf("no redis available: %v", err)
|
||||||
|
}
|
||||||
|
return rdb
|
||||||
|
}
|
||||||
|
|
||||||
|
// room returns a name unique to this test run, so parallel packages and repeat
|
||||||
|
// runs do not fight over the same leadership key.
|
||||||
|
func room(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return fmt.Sprintf("test-%s-%d", t.Name(), time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNode(t *testing.T, rdb *redis.Client, addr string) *cluster.Node {
|
||||||
|
t.Helper()
|
||||||
|
n := cluster.NewNode(rdb, addr)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := n.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { n.Stop(context.Background()) })
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two clones of the same image must not end up with the same identity.
|
||||||
|
func TestClonedInstancesGetDistinctIdentities(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
n := cluster.NewNode(rdb, "10.0.0.1:8080")
|
||||||
|
if seen[n.ID] {
|
||||||
|
t.Fatalf("identity collision after %d instances: %s", i, n.ID)
|
||||||
|
}
|
||||||
|
seen[n.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstancesDiscoverEachOther(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
a := newNode(t, rdb, "10.0.0.1:8080")
|
||||||
|
b := newNode(t, rdb, "10.0.0.2:8080")
|
||||||
|
|
||||||
|
members, err := a.Members(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, m := range members {
|
||||||
|
ids[m.ID] = true
|
||||||
|
}
|
||||||
|
if !ids[a.ID] || !ids[b.ID] {
|
||||||
|
t.Fatalf("instances did not see each other: %+v", members)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core guarantee: exactly one instance drives a room, however many
|
||||||
|
// campaign at once.
|
||||||
|
func TestExactlyOneLeaderPerRoom(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
rm := room(t)
|
||||||
|
|
||||||
|
const instances = 12
|
||||||
|
nodes := make([]*cluster.Node, instances)
|
||||||
|
for i := range nodes {
|
||||||
|
nodes[i] = newNode(t, rdb, fmt.Sprintf("10.0.0.%d:8080", i+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]bool, instances)
|
||||||
|
done := make(chan struct{})
|
||||||
|
for i, n := range nodes {
|
||||||
|
go func(i int, n *cluster.Node) {
|
||||||
|
won, err := n.Campaign(ctx, rm)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("campaign: %v", err)
|
||||||
|
}
|
||||||
|
results[i] = won
|
||||||
|
done <- struct{}{}
|
||||||
|
}(i, n)
|
||||||
|
}
|
||||||
|
for range nodes {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
leaders := 0
|
||||||
|
for _, won := range results {
|
||||||
|
if won {
|
||||||
|
leaders++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if leaders != 1 {
|
||||||
|
t.Fatalf("%d instances claimed leadership of one room, want 1", leaders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFollowerFindsTheLeaderAddress(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
rm := room(t)
|
||||||
|
|
||||||
|
leader := newNode(t, rdb, "10.0.0.9:8080")
|
||||||
|
follower := newNode(t, rdb, "10.0.0.10:8080")
|
||||||
|
|
||||||
|
won, err := leader.Campaign(ctx, rm)
|
||||||
|
if err != nil || !won {
|
||||||
|
t.Fatalf("leader failed to take the room: won=%v err=%v", won, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := follower.LeaderOf(ctx, rm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.ID != leader.ID {
|
||||||
|
t.Fatalf("follower found leader %q, want %q", m.ID, leader.ID)
|
||||||
|
}
|
||||||
|
if m.Address != "10.0.0.9:8080" {
|
||||||
|
t.Fatalf("leader address = %q, want 10.0.0.9:8080", m.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Losing the lease must be visible to the instance that lost it, so it stops
|
||||||
|
// driving the room rather than producing a second stream of rounds.
|
||||||
|
func TestRenewFailsAfterLeadershipIsLost(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
rm := room(t)
|
||||||
|
|
||||||
|
a := newNode(t, rdb, "10.0.0.1:8080")
|
||||||
|
b := newNode(t, rdb, "10.0.0.2:8080")
|
||||||
|
|
||||||
|
if won, _ := a.Campaign(ctx, rm); !won {
|
||||||
|
t.Fatal("first instance did not win an uncontested room")
|
||||||
|
}
|
||||||
|
if held, _ := a.Renew(ctx, rm); !held {
|
||||||
|
t.Fatal("leader could not renew its own lease")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the lease expiring and another instance taking over.
|
||||||
|
if err := a.Resign(ctx, rm); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if won, _ := b.Campaign(ctx, rm); !won {
|
||||||
|
t.Fatal("second instance could not take the vacated room")
|
||||||
|
}
|
||||||
|
|
||||||
|
held, err := a.Renew(ctx, rm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if held {
|
||||||
|
t.Fatal("the former leader renewed a lease it no longer holds")
|
||||||
|
}
|
||||||
|
if a.Leads(rm) {
|
||||||
|
t.Fatal("the former leader still believes it leads the room")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead instance must hand its room over on its own, without intervention.
|
||||||
|
func TestLeadershipPassesOnWhenAnInstanceDies(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
rm := room(t)
|
||||||
|
|
||||||
|
dying := newNode(t, rdb, "10.0.0.1:8080")
|
||||||
|
survivor := newNode(t, rdb, "10.0.0.2:8080")
|
||||||
|
|
||||||
|
if won, _ := dying.Campaign(ctx, rm); !won {
|
||||||
|
t.Fatal("first instance did not take the room")
|
||||||
|
}
|
||||||
|
if won, _ := survivor.Campaign(ctx, rm); won {
|
||||||
|
t.Fatal("a second instance took a room that was already led")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instance vanishes without resigning; its lease simply stops being
|
||||||
|
// renewed. Waiting out the TTL is the whole point of the mechanism.
|
||||||
|
dying.Stop(ctx)
|
||||||
|
_ = rdb.Del(ctx, "qa:leader:"+rm) // stand in for the lease expiring
|
||||||
|
|
||||||
|
deadline := time.Now().Add(cluster.LeaseTTL + 3*time.Second)
|
||||||
|
took := false
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if won, _ := survivor.Campaign(ctx, rm); won {
|
||||||
|
took = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !took {
|
||||||
|
t.Fatal("no instance took over the room after the leader died")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resigning instance must not be able to release a lease that has since
|
||||||
|
// passed to someone else.
|
||||||
|
func TestResignDoesNotStealAnotherLease(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
rm := room(t)
|
||||||
|
|
||||||
|
a := newNode(t, rdb, "10.0.0.1:8080")
|
||||||
|
b := newNode(t, rdb, "10.0.0.2:8080")
|
||||||
|
|
||||||
|
if won, _ := a.Campaign(ctx, rm); !won {
|
||||||
|
t.Fatal("a did not take the room")
|
||||||
|
}
|
||||||
|
if err := a.Resign(ctx, rm); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if won, _ := b.Campaign(ctx, rm); !won {
|
||||||
|
t.Fatal("b could not take the vacated room")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale resign from the former leader must be a no-op.
|
||||||
|
if err := a.Resign(ctx, rm); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := b.LeaderOf(ctx, rm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.ID != b.ID {
|
||||||
|
t.Fatalf("stale resign released the current leader's lease (leader now %q)", m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frames published by the leader must reach the instances relaying them.
|
||||||
|
func TestFramesFanOutToFollowers(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
rm := room(t)
|
||||||
|
|
||||||
|
leader := newNode(t, rdb, "10.0.0.1:8080")
|
||||||
|
follower := newNode(t, rdb, "10.0.0.2:8080")
|
||||||
|
|
||||||
|
frames, unsubscribe := follower.SubscribeFrames(ctx, rm)
|
||||||
|
defer unsubscribe()
|
||||||
|
|
||||||
|
// Give the subscription a moment to establish before publishing.
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
want := []byte(`{"state":"running","multiplier":"2.500000"}`)
|
||||||
|
if err := leader.PublishFrame(ctx, rm, want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-frames:
|
||||||
|
if string(got) != string(want) {
|
||||||
|
t.Fatalf("relayed frame = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
case <-time.After(4 * time.Second):
|
||||||
|
t.Fatal("follower never received the leader's frame")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instances that stop heartbeating must drop off the roster.
|
||||||
|
func TestDeadInstancesLeaveTheRoster(t *testing.T) {
|
||||||
|
rdb := testRedis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
alive := newNode(t, rdb, "10.0.0.1:8080")
|
||||||
|
transient := newNode(t, rdb, "10.0.0.2:8080")
|
||||||
|
|
||||||
|
transient.Stop(ctx) // a clean shutdown deregisters immediately
|
||||||
|
|
||||||
|
members, err := alive.Members(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range members {
|
||||||
|
if m.ID == transient.ID {
|
||||||
|
t.Fatal("a stopped instance is still listed as a member")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
pkg/fair/fair.go
Normal file
127
pkg/fair/fair.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Package fair implements the commit-reveal protocol that makes every outcome
|
||||||
|
// independently verifiable.
|
||||||
|
//
|
||||||
|
// The protocol, per round:
|
||||||
|
//
|
||||||
|
// 1. Commit — the server generates a random 32-byte seed and publishes
|
||||||
|
// SHA-256(seed) before betting opens. It is now bound to that seed.
|
||||||
|
// 2. Client seed — derived from the public keys of everyone in the round.
|
||||||
|
// The operator does not control these, so it cannot steer the outcome even
|
||||||
|
// with full knowledge of its own seed.
|
||||||
|
// 3. Outcome — HMAC-SHA256(serverSeed, clientSeed || nonce) seeds the
|
||||||
|
// simulation. The result is a pure function of that seed.
|
||||||
|
// 4. Reveal — after settlement the server publishes the seed. Anyone can
|
||||||
|
// recompute the commitment, re-derive the outcome, and confirm it.
|
||||||
|
//
|
||||||
|
// The security property is that the operator must choose its seed before it
|
||||||
|
// knows the participant set, and cannot change it afterwards without breaking
|
||||||
|
// a published SHA-256 commitment.
|
||||||
|
package fair
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerSeed is the operator's secret contribution to a round, revealed after
|
||||||
|
// settlement.
|
||||||
|
type ServerSeed struct {
|
||||||
|
b [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerSeed generates a cryptographically random server seed.
|
||||||
|
func NewServerSeed() ServerSeed {
|
||||||
|
var s ServerSeed
|
||||||
|
if _, err := rand.Read(s.b[:]); err != nil {
|
||||||
|
// A failure of the system CSPRNG is not something to paper over: any
|
||||||
|
// fallback would silently weaken every outcome derived from it.
|
||||||
|
panic("fair: system randomness unavailable: " + err.Error())
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerSeedFromBytes reconstructs a seed, for verification of a past round.
|
||||||
|
func ServerSeedFromBytes(b [32]byte) ServerSeed { return ServerSeed{b: b} }
|
||||||
|
|
||||||
|
// Bytes returns the raw seed. Callers must not publish this before settlement.
|
||||||
|
func (s ServerSeed) Bytes() [32]byte { return s.b }
|
||||||
|
|
||||||
|
// Hex renders the seed for the reveal step.
|
||||||
|
func (s ServerSeed) Hex() string { return hex.EncodeToString(s.b[:]) }
|
||||||
|
|
||||||
|
// Commitment is SHA-256 of the seed, published before the round opens.
|
||||||
|
func (s ServerSeed) Commitment() [32]byte { return sha256.Sum256(s.b[:]) }
|
||||||
|
|
||||||
|
// VerifyCommitment reports whether a revealed seed matches a published
|
||||||
|
// commitment. The comparison is constant-time out of habit; nothing secret
|
||||||
|
// depends on it by this point, but the cost is zero.
|
||||||
|
func VerifyCommitment(commitment [32]byte, seed ServerSeed) bool {
|
||||||
|
actual := seed.Commitment()
|
||||||
|
return subtle.ConstantTimeCompare(commitment[:], actual[:]) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientSeed derives the players' collective contribution from the public keys
|
||||||
|
// of everyone in the round, in join order. Because the operator cannot control
|
||||||
|
// who joins, it cannot predict this value when it commits to its own seed.
|
||||||
|
func ClientSeed(pubkeys [][]byte) [32]byte {
|
||||||
|
h := sha256.New()
|
||||||
|
for _, pk := range pubkeys {
|
||||||
|
// Length-prefix each key so that concatenation is unambiguous and two
|
||||||
|
// different participant lists cannot hash to the same value.
|
||||||
|
var n [4]byte
|
||||||
|
binary.BigEndian.PutUint32(n[:], uint32(len(pk)))
|
||||||
|
h.Write(n[:])
|
||||||
|
h.Write(pk)
|
||||||
|
}
|
||||||
|
var out [32]byte
|
||||||
|
copy(out[:], h.Sum(nil))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundSeed combines both seeds and a nonce into the value that seeds the
|
||||||
|
// simulation. The nonce separates rounds, or individual plays, that share a
|
||||||
|
// server seed.
|
||||||
|
func RoundSeed(server ServerSeed, client [32]byte, nonce uint64) [32]byte {
|
||||||
|
mac := hmac.New(sha256.New, server.b[:])
|
||||||
|
mac.Write(client[:])
|
||||||
|
var n [8]byte
|
||||||
|
binary.BigEndian.PutUint64(n[:], nonce)
|
||||||
|
mac.Write(n[:])
|
||||||
|
var out [32]byte
|
||||||
|
copy(out[:], mac.Sum(nil))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proof is everything a player needs to verify one outcome without trusting
|
||||||
|
// any server response. It is what the verification endpoint returns.
|
||||||
|
type Proof struct {
|
||||||
|
Commitment string `json:"commitment"` // published before the round
|
||||||
|
ServerSeed string `json:"server_seed"` // revealed after settlement
|
||||||
|
Participants []string `json:"participants"` // hex public keys, join order
|
||||||
|
Nonce uint64 `json:"nonce"`
|
||||||
|
RoundSeed string `json:"round_seed"` // derived, shown for convenience
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildProof assembles the verification record for a settled round.
|
||||||
|
func BuildProof(server ServerSeed, pubkeys [][]byte, nonce uint64) Proof {
|
||||||
|
client := ClientSeed(pubkeys)
|
||||||
|
seed := RoundSeed(server, client, nonce)
|
||||||
|
|
||||||
|
participants := make([]string, len(pubkeys))
|
||||||
|
for i, pk := range pubkeys {
|
||||||
|
participants[i] = hex.EncodeToString(pk)
|
||||||
|
}
|
||||||
|
commitment := server.Commitment()
|
||||||
|
|
||||||
|
return Proof{
|
||||||
|
Commitment: hex.EncodeToString(commitment[:]),
|
||||||
|
ServerSeed: server.Hex(),
|
||||||
|
Participants: participants,
|
||||||
|
Nonce: nonce,
|
||||||
|
RoundSeed: hex.EncodeToString(seed[:]),
|
||||||
|
}
|
||||||
|
}
|
||||||
215
pkg/fair/fair_test.go
Normal file
215
pkg/fair/fair_test.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package fair_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommitmentHidesSeed(t *testing.T) {
|
||||||
|
s := fair.NewServerSeed()
|
||||||
|
c := s.Commitment()
|
||||||
|
raw := s.Bytes()
|
||||||
|
if bytes.Contains(c[:], raw[:8]) {
|
||||||
|
t.Fatal("commitment leaks seed bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitmentVerifies(t *testing.T) {
|
||||||
|
s := fair.NewServerSeed()
|
||||||
|
c := s.Commitment()
|
||||||
|
if !fair.VerifyCommitment(c, s) {
|
||||||
|
t.Fatal("valid seed failed its own commitment")
|
||||||
|
}
|
||||||
|
other := fair.NewServerSeed()
|
||||||
|
if fair.VerifyCommitment(c, other) {
|
||||||
|
t.Fatal("a different seed satisfied the commitment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientSeedDependsOnEveryParticipant(t *testing.T) {
|
||||||
|
a := []byte("player-a-pubkey")
|
||||||
|
b := []byte("player-b-pubkey")
|
||||||
|
c := []byte("player-c-pubkey")
|
||||||
|
|
||||||
|
withAll := fair.ClientSeed([][]byte{a, b, c})
|
||||||
|
withoutC := fair.ClientSeed([][]byte{a, b})
|
||||||
|
if withAll == withoutC {
|
||||||
|
t.Fatal("removing a participant did not change the client seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join order must matter in a defined way, but the same set in the same order
|
||||||
|
// must always produce the same seed.
|
||||||
|
func TestClientSeedIsStable(t *testing.T) {
|
||||||
|
keys := [][]byte{[]byte("k1"), []byte("k2")}
|
||||||
|
if fair.ClientSeed(keys) != fair.ClientSeed(keys) {
|
||||||
|
t.Fatal("client seed is not stable for identical input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundSeedIsDeterministic(t *testing.T) {
|
||||||
|
s := fair.NewServerSeed()
|
||||||
|
cs := fair.ClientSeed([][]byte{[]byte("p1")})
|
||||||
|
first := fair.RoundSeed(s, cs, 7)
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if fair.RoundSeed(s, cs, 7) != first {
|
||||||
|
t.Fatal("round seed is not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonceSeparatesOutcomes(t *testing.T) {
|
||||||
|
s := fair.NewServerSeed()
|
||||||
|
cs := fair.ClientSeed([][]byte{[]byte("p1")})
|
||||||
|
seen := map[[32]byte]bool{}
|
||||||
|
for n := uint64(0); n < 1000; n++ {
|
||||||
|
seed := fair.RoundSeed(s, cs, n)
|
||||||
|
if seen[seed] {
|
||||||
|
t.Fatalf("nonce %d collided with an earlier round seed", n)
|
||||||
|
}
|
||||||
|
seen[seed] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full protocol as a player would check it: the commitment published before
|
||||||
|
// the round must match the seed revealed after, and the seed must reproduce the
|
||||||
|
// outcome.
|
||||||
|
func TestEndToEndVerification(t *testing.T) {
|
||||||
|
server := fair.NewServerSeed()
|
||||||
|
published := server.Commitment()
|
||||||
|
|
||||||
|
participants := [][]byte{[]byte("alice"), []byte("bob")}
|
||||||
|
cs := fair.ClientSeed(participants)
|
||||||
|
const nonce = 42
|
||||||
|
|
||||||
|
seed := fair.RoundSeed(server, cs, nonce)
|
||||||
|
|
||||||
|
// After the round the server reveals the seed. A player recomputes:
|
||||||
|
if !fair.VerifyCommitment(published, server) {
|
||||||
|
t.Fatal("revealed seed does not match published commitment")
|
||||||
|
}
|
||||||
|
recomputed := fair.RoundSeed(server, fair.ClientSeed(participants), nonce)
|
||||||
|
if recomputed != seed {
|
||||||
|
t.Fatal("independent recomputation produced a different seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerSeedsAreUnique(t *testing.T) {
|
||||||
|
seen := map[[32]byte]bool{}
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
s := fair.NewServerSeed()
|
||||||
|
if seen[s.Bytes()] {
|
||||||
|
t.Fatal("NewServerSeed returned a duplicate")
|
||||||
|
}
|
||||||
|
seen[s.Bytes()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerSeedRoundTripsThroughBytes(t *testing.T) {
|
||||||
|
original := fair.NewServerSeed()
|
||||||
|
restored := fair.ServerSeedFromBytes(original.Bytes())
|
||||||
|
|
||||||
|
if restored.Bytes() != original.Bytes() {
|
||||||
|
t.Fatal("seed did not survive a byte round trip")
|
||||||
|
}
|
||||||
|
if restored.Commitment() != original.Commitment() {
|
||||||
|
t.Fatal("restored seed produces a different commitment")
|
||||||
|
}
|
||||||
|
if restored.Hex() != original.Hex() {
|
||||||
|
t.Fatal("restored seed renders differently")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHexIsFullLength(t *testing.T) {
|
||||||
|
s := fair.NewServerSeed()
|
||||||
|
if len(s.Hex()) != 64 {
|
||||||
|
t.Fatalf("hex seed is %d characters, want 64", len(s.Hex()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProofIsSelfConsistent(t *testing.T) {
|
||||||
|
server := fair.NewServerSeed()
|
||||||
|
keys := [][]byte{[]byte("alice"), []byte("bob"), []byte("carol")}
|
||||||
|
const nonce = 17
|
||||||
|
|
||||||
|
proof := fair.BuildProof(server, keys, nonce)
|
||||||
|
|
||||||
|
if proof.Nonce != nonce {
|
||||||
|
t.Fatalf("proof nonce = %d, want %d", proof.Nonce, nonce)
|
||||||
|
}
|
||||||
|
if len(proof.Participants) != len(keys) {
|
||||||
|
t.Fatalf("proof lists %d participants, want %d", len(proof.Participants), len(keys))
|
||||||
|
}
|
||||||
|
// Every value in the proof must be reproducible from the others.
|
||||||
|
seedBytes, err := hex.DecodeString(proof.ServerSeed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(seedBytes)
|
||||||
|
if hex.EncodeToString(sum[:]) != proof.Commitment {
|
||||||
|
t.Fatal("proof commitment does not match its own seed")
|
||||||
|
}
|
||||||
|
var restored [32]byte
|
||||||
|
copy(restored[:], seedBytes)
|
||||||
|
want := fair.RoundSeed(fair.ServerSeedFromBytes(restored), fair.ClientSeed(keys), nonce)
|
||||||
|
if hex.EncodeToString(want[:]) != proof.RoundSeed {
|
||||||
|
t.Fatal("proof round seed does not follow from its inputs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProofParticipantsPreserveOrder(t *testing.T) {
|
||||||
|
server := fair.NewServerSeed()
|
||||||
|
keys := [][]byte{[]byte("first"), []byte("second")}
|
||||||
|
proof := fair.BuildProof(server, keys, 1)
|
||||||
|
|
||||||
|
if proof.Participants[0] != hex.EncodeToString(keys[0]) {
|
||||||
|
t.Fatal("participant order was not preserved")
|
||||||
|
}
|
||||||
|
if proof.Participants[1] != hex.EncodeToString(keys[1]) {
|
||||||
|
t.Fatal("participant order was not preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reordering the same players must change the seed, since order is part of the
|
||||||
|
// commitment. Otherwise a player could be swapped in without detection.
|
||||||
|
func TestParticipantOrderAffectsTheSeed(t *testing.T) {
|
||||||
|
a, b := []byte("alice"), []byte("bob")
|
||||||
|
if fair.ClientSeed([][]byte{a, b}) == fair.ClientSeed([][]byte{b, a}) {
|
||||||
|
t.Fatal("reordering participants did not change the client seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length-prefixing must prevent two different participant lists from colliding
|
||||||
|
// through simple concatenation.
|
||||||
|
func TestClientSeedResistsConcatenationCollisions(t *testing.T) {
|
||||||
|
// Without length prefixes, {"ab","c"} and {"a","bc"} would hash the same.
|
||||||
|
one := fair.ClientSeed([][]byte{[]byte("ab"), []byte("c")})
|
||||||
|
two := fair.ClientSeed([][]byte{[]byte("a"), []byte("bc")})
|
||||||
|
if one == two {
|
||||||
|
t.Fatal("different participant lists collided; length prefixing is broken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyParticipantListIsStable(t *testing.T) {
|
||||||
|
if fair.ClientSeed(nil) != fair.ClientSeed([][]byte{}) {
|
||||||
|
t.Fatal("nil and empty participant lists disagree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A round with no players must still produce a valid, verifiable outcome.
|
||||||
|
func TestRoundWithNoPlayersStillVerifies(t *testing.T) {
|
||||||
|
server := fair.NewServerSeed()
|
||||||
|
commitment := server.Commitment()
|
||||||
|
proof := fair.BuildProof(server, nil, 5)
|
||||||
|
|
||||||
|
if !fair.VerifyCommitment(commitment, server) {
|
||||||
|
t.Fatal("empty round does not verify")
|
||||||
|
}
|
||||||
|
if proof.RoundSeed == "" {
|
||||||
|
t.Fatal("empty round produced no seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
178
pkg/fees/fees.go
Normal file
178
pkg/fees/fees.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// Package fees computes the operator's cut and the rounding residue.
|
||||||
|
//
|
||||||
|
// Two deductions apply to money leaving the house:
|
||||||
|
//
|
||||||
|
// - A rake: a percentage of each payout, disclosed as a rate.
|
||||||
|
// - Rounding: payouts are floored to a whole unit, and the fractional
|
||||||
|
// remainder stays with the house.
|
||||||
|
//
|
||||||
|
// Neither is hidden, and neither can be hidden. Every millisatoshi in this
|
||||||
|
// system is a double-entry posting, and the ledger's conservation check fails
|
||||||
|
// if any amount is unaccounted for. So a rake and a rounding residue must each
|
||||||
|
// appear as their own posting against the house — which means they also appear
|
||||||
|
// in the player's own transaction history, itemised. The architecture makes
|
||||||
|
// silent skimming impossible rather than merely discouraged.
|
||||||
|
//
|
||||||
|
// The consequence worth stating plainly: a rake reduces the real return to
|
||||||
|
// player. A game advertising 99% that then takes 1% of wins does not return
|
||||||
|
// 99%. EffectiveRTPBasisPoints computes what players actually get, and the
|
||||||
|
// disclosure page is generated from it, so the published figure cannot drift
|
||||||
|
// from the code.
|
||||||
|
package fees
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Schedule is the operator's fee configuration.
|
||||||
|
type Schedule struct {
|
||||||
|
// RakeBP is taken from each payout, in basis points. 100 = 1%.
|
||||||
|
RakeBP int64
|
||||||
|
|
||||||
|
// RoundToMsat floors payouts to a multiple of this. 1000 rounds to whole
|
||||||
|
// satoshis. Set to 1 (or 0) to disable rounding entirely.
|
||||||
|
RoundToMsat int64
|
||||||
|
|
||||||
|
// MinPayoutMsat is the smallest payout worth making. Below it, rounding
|
||||||
|
// would consume the whole amount, so the payout is suppressed and the
|
||||||
|
// player told why rather than silently paid nothing.
|
||||||
|
MinPayoutMsat int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSchedule is deliberately small. The rake is the operator's revenue;
|
||||||
|
// the rounding is a rounding, not a second rake.
|
||||||
|
func DefaultSchedule() Schedule {
|
||||||
|
return Schedule{
|
||||||
|
RakeBP: 100, // 1% of winnings
|
||||||
|
RoundToMsat: 1_000, // whole satoshis
|
||||||
|
MinPayoutMsat: 1_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoFees disables both deductions, for testing and for a house that wants to
|
||||||
|
// run the arcade at cost.
|
||||||
|
func NoFees() Schedule {
|
||||||
|
return Schedule{RakeBP: 0, RoundToMsat: 1, MinPayoutMsat: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split is the breakdown of a single payout.
|
||||||
|
type Split struct {
|
||||||
|
// GrossMsat is what the player won before deductions.
|
||||||
|
GrossMsat int64
|
||||||
|
// RakeMsat is the operator's percentage.
|
||||||
|
RakeMsat int64
|
||||||
|
// RoundingMsat is the fraction left behind by flooring to RoundToMsat.
|
||||||
|
RoundingMsat int64
|
||||||
|
// NetMsat is what the player actually receives.
|
||||||
|
NetMsat int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// HouseMsat is everything the operator keeps from this payout.
|
||||||
|
func (s Split) HouseMsat() int64 { return s.RakeMsat + s.RoundingMsat }
|
||||||
|
|
||||||
|
// Valid reports whether the split accounts for every millisatoshi. A split
|
||||||
|
// that does not balance would corrupt the ledger, so this is asserted before
|
||||||
|
// any posting is written.
|
||||||
|
func (s Split) Valid() bool {
|
||||||
|
return s.NetMsat+s.RakeMsat+s.RoundingMsat == s.GrossMsat &&
|
||||||
|
s.NetMsat >= 0 && s.RakeMsat >= 0 && s.RoundingMsat >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply splits a gross payout into the player's share and the house's.
|
||||||
|
func (sch Schedule) Apply(grossMsat int64) Split {
|
||||||
|
if grossMsat <= 0 {
|
||||||
|
return Split{}
|
||||||
|
}
|
||||||
|
|
||||||
|
rake := mulDivFloor(grossMsat, sch.RakeBP, 10000)
|
||||||
|
afterRake := grossMsat - rake
|
||||||
|
|
||||||
|
unit := sch.RoundToMsat
|
||||||
|
if unit < 1 {
|
||||||
|
unit = 1
|
||||||
|
}
|
||||||
|
net := afterRake / unit * unit
|
||||||
|
rounding := afterRake - net
|
||||||
|
|
||||||
|
// Below the minimum, paying out costs more in dust than it delivers.
|
||||||
|
// Suppressing it must still be accounted: the amount goes to the house
|
||||||
|
// and is visible as such, not quietly dropped.
|
||||||
|
if net < sch.MinPayoutMsat {
|
||||||
|
rounding += net
|
||||||
|
net = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
s := Split{
|
||||||
|
GrossMsat: grossMsat,
|
||||||
|
RakeMsat: rake,
|
||||||
|
RoundingMsat: rounding,
|
||||||
|
NetMsat: net,
|
||||||
|
}
|
||||||
|
if !s.Valid() {
|
||||||
|
// Unreachable by construction; a panic here beats a silent imbalance
|
||||||
|
// that would be discovered later as missing money.
|
||||||
|
panic(fmt.Sprintf("fees: split does not balance: %+v", s))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// mulDivFloor computes v * num / den without overflowing int64.
|
||||||
|
//
|
||||||
|
// The direct form overflows for large payouts: a gross of 2.3e18 times a rake
|
||||||
|
// of 10000 basis points is 2.3e22, far past int64. Splitting the value into
|
||||||
|
// whole and remainder parts keeps every intermediate inside the range while
|
||||||
|
// producing the identical floored result.
|
||||||
|
func mulDivFloor(v, num, den int64) int64 {
|
||||||
|
if den == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (v/den)*num + (v%den)*num/den
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveRTPBasisPoints is the real return to player, given a game's own RTP
|
||||||
|
// before fees.
|
||||||
|
//
|
||||||
|
// This is the number that belongs on the disclosure page. A game whose maths
|
||||||
|
// return 9900 and whose operator takes a 1% rake does not return 99%: winners
|
||||||
|
// hand back a percentage of what they win, so the realised return is lower.
|
||||||
|
//
|
||||||
|
// The rake applies only to payouts, so it scales the returned portion:
|
||||||
|
//
|
||||||
|
// effective = gameRTP * (1 - rake)
|
||||||
|
//
|
||||||
|
// Rounding is excluded here because its size depends on the payout amounts a
|
||||||
|
// player actually hits, and overstating it would be its own dishonesty. It is
|
||||||
|
// disclosed separately, in units, which is a claim that can be checked.
|
||||||
|
func (sch Schedule) EffectiveRTPBasisPoints(gameRTPBasisPoints int64) int64 {
|
||||||
|
return mulDivFloor(gameRTPBasisPoints, 10000-sch.RakeBP, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disclosure is the machine-readable statement of what the operator takes.
|
||||||
|
// The public page is rendered from this, so the published terms are generated
|
||||||
|
// from the same values the code charges.
|
||||||
|
type Disclosure struct {
|
||||||
|
RakePercent string `json:"rake_percent"`
|
||||||
|
RoundingUnit string `json:"rounding_unit"`
|
||||||
|
MinPayout string `json:"minimum_payout"`
|
||||||
|
GameRTPPercent string `json:"game_rtp_percent"`
|
||||||
|
EffectiveRTP string `json:"effective_rtp_percent"`
|
||||||
|
WorstCaseRounding string `json:"worst_case_rounding_per_payout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe renders the schedule for publication.
|
||||||
|
func (sch Schedule) Describe(gameRTPBasisPoints int64) Disclosure {
|
||||||
|
unit := sch.RoundToMsat
|
||||||
|
if unit < 1 {
|
||||||
|
unit = 1
|
||||||
|
}
|
||||||
|
return Disclosure{
|
||||||
|
RakePercent: fmt.Sprintf("%.2f%%", float64(sch.RakeBP)/100),
|
||||||
|
RoundingUnit: fmt.Sprintf("%d msat (%.3f sats)", unit, float64(unit)/1000),
|
||||||
|
MinPayout: fmt.Sprintf("%d msat", sch.MinPayoutMsat),
|
||||||
|
GameRTPPercent: fmt.Sprintf("%.2f%%",
|
||||||
|
float64(gameRTPBasisPoints)/100),
|
||||||
|
EffectiveRTP: fmt.Sprintf("%.2f%%",
|
||||||
|
float64(sch.EffectiveRTPBasisPoints(gameRTPBasisPoints))/100),
|
||||||
|
// The most a single payout can lose to rounding is one unit less one.
|
||||||
|
WorstCaseRounding: fmt.Sprintf("%d msat (%.3f sats)",
|
||||||
|
unit-1, float64(unit-1)/1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
195
pkg/fees/fees_test.go
Normal file
195
pkg/fees/fees_test.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package fees_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fees"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The property that matters most: every millisatoshi is accounted for. If a
|
||||||
|
// split ever failed to balance, the ledger's conservation check would fail and
|
||||||
|
// the arcade would be reporting corrupt books.
|
||||||
|
func TestEveryMillisatoshiIsAccountedFor(t *testing.T) {
|
||||||
|
schedules := []fees.Schedule{
|
||||||
|
fees.DefaultSchedule(),
|
||||||
|
fees.NoFees(),
|
||||||
|
{RakeBP: 250, RoundToMsat: 1_000, MinPayoutMsat: 1_000},
|
||||||
|
{RakeBP: 1, RoundToMsat: 1, MinPayoutMsat: 0},
|
||||||
|
{RakeBP: 10000, RoundToMsat: 1_000, MinPayoutMsat: 0}, // 100% rake
|
||||||
|
}
|
||||||
|
amounts := []int64{0, 1, 999, 1_000, 1_001, 12_345, 1_000_000,
|
||||||
|
999_999_999, math.MaxInt64 / 4}
|
||||||
|
|
||||||
|
for _, sch := range schedules {
|
||||||
|
for _, gross := range amounts {
|
||||||
|
s := sch.Apply(gross)
|
||||||
|
if !s.Valid() {
|
||||||
|
t.Fatalf("schedule %+v on %d produced an unbalanced split: %+v",
|
||||||
|
sch, gross, s)
|
||||||
|
}
|
||||||
|
if s.NetMsat+s.HouseMsat() != gross {
|
||||||
|
t.Fatalf("schedule %+v on %d: net %d + house %d != %d",
|
||||||
|
sch, gross, s.NetMsat, s.HouseMsat(), gross)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRakeIsExactPercentage(t *testing.T) {
|
||||||
|
sch := fees.Schedule{RakeBP: 100, RoundToMsat: 1, MinPayoutMsat: 0}
|
||||||
|
s := sch.Apply(1_000_000)
|
||||||
|
if s.RakeMsat != 10_000 {
|
||||||
|
t.Fatalf("1%% of 1000000 = %d, want 10000", s.RakeMsat)
|
||||||
|
}
|
||||||
|
if s.NetMsat != 990_000 {
|
||||||
|
t.Fatalf("net = %d, want 990000", s.NetMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundingFloorsToTheUnit(t *testing.T) {
|
||||||
|
sch := fees.Schedule{RakeBP: 0, RoundToMsat: 1_000, MinPayoutMsat: 0}
|
||||||
|
cases := []struct {
|
||||||
|
gross, net, rounding int64
|
||||||
|
}{
|
||||||
|
{1_000, 1_000, 0},
|
||||||
|
{1_999, 1_000, 999},
|
||||||
|
{2_000, 2_000, 0},
|
||||||
|
{999, 0, 999},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s := sch.Apply(c.gross)
|
||||||
|
if s.NetMsat != c.net || s.RoundingMsat != c.rounding {
|
||||||
|
t.Errorf("gross %d: net %d rounding %d, want net %d rounding %d",
|
||||||
|
c.gross, s.NetMsat, s.RoundingMsat, c.net, c.rounding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounding must never take more than one unit less one from a payout. That
|
||||||
|
// bound is what makes the disclosure checkable.
|
||||||
|
func TestRoundingIsBoundedByOneUnit(t *testing.T) {
|
||||||
|
sch := fees.DefaultSchedule()
|
||||||
|
for gross := int64(1_000); gross < 200_000; gross += 37 {
|
||||||
|
s := sch.Apply(gross)
|
||||||
|
if s.NetMsat > 0 && s.RoundingMsat >= sch.RoundToMsat {
|
||||||
|
t.Fatalf("gross %d lost %d msat to rounding, more than one unit (%d)",
|
||||||
|
gross, s.RoundingMsat, sch.RoundToMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoFeesTakesNothing(t *testing.T) {
|
||||||
|
sch := fees.NoFees()
|
||||||
|
for _, gross := range []int64{1, 999, 1_000, 123_456} {
|
||||||
|
s := sch.Apply(gross)
|
||||||
|
if s.NetMsat != gross {
|
||||||
|
t.Fatalf("gross %d returned %d with fees disabled", gross, s.NetMsat)
|
||||||
|
}
|
||||||
|
if s.HouseMsat() != 0 {
|
||||||
|
t.Fatalf("house took %d with fees disabled", s.HouseMsat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroAndNegativeGrossAreNoOps(t *testing.T) {
|
||||||
|
sch := fees.DefaultSchedule()
|
||||||
|
for _, gross := range []int64{0, -1, -100_000} {
|
||||||
|
s := sch.Apply(gross)
|
||||||
|
if s.NetMsat != 0 || s.HouseMsat() != 0 {
|
||||||
|
t.Fatalf("gross %d produced %+v", gross, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The published effective RTP must match what players actually receive over a
|
||||||
|
// long run. This is the claim the disclosure page makes, so it gets checked
|
||||||
|
// against simulated play rather than trusted.
|
||||||
|
func TestPublishedEffectiveRTPMatchesReality(t *testing.T) {
|
||||||
|
sch := fees.DefaultSchedule()
|
||||||
|
const gameRTP = 9900 // the games' own 99%
|
||||||
|
|
||||||
|
published := sch.EffectiveRTPBasisPoints(gameRTP)
|
||||||
|
|
||||||
|
// Simulate: players stake, the games return 99% of stakes as gross
|
||||||
|
// winnings, and the rake applies to those winnings.
|
||||||
|
const rounds = 200_000
|
||||||
|
const stake = int64(100_000) // 100 sats, large enough that rounding is noise
|
||||||
|
var staked, received int64
|
||||||
|
for i := 0; i < rounds; i++ {
|
||||||
|
staked += stake
|
||||||
|
gross := stake * gameRTP / 10000
|
||||||
|
received += sch.Apply(gross).NetMsat
|
||||||
|
}
|
||||||
|
observed := received * 10000 / staked
|
||||||
|
|
||||||
|
if observed < published-20 || observed > published+20 {
|
||||||
|
t.Fatalf("published effective RTP %d bp, players actually received %d bp",
|
||||||
|
published, observed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rake must reduce the advertised return. Publishing the game's own RTP
|
||||||
|
// while taking a cut would be a false claim.
|
||||||
|
func TestRakeReducesTheAdvertisedReturn(t *testing.T) {
|
||||||
|
const gameRTP = 9900
|
||||||
|
withFees := fees.DefaultSchedule().EffectiveRTPBasisPoints(gameRTP)
|
||||||
|
withoutFees := fees.NoFees().EffectiveRTPBasisPoints(gameRTP)
|
||||||
|
|
||||||
|
if withoutFees != gameRTP {
|
||||||
|
t.Fatalf("with no fees the effective RTP should equal the game RTP, got %d", withoutFees)
|
||||||
|
}
|
||||||
|
if withFees >= gameRTP {
|
||||||
|
t.Fatalf("effective RTP %d is not below the game's %d despite a rake",
|
||||||
|
withFees, gameRTP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small payouts must not be silently swallowed: whatever is suppressed has to
|
||||||
|
// show up on the house side of the split.
|
||||||
|
func TestSuppressedPayoutIsStillAccounted(t *testing.T) {
|
||||||
|
sch := fees.Schedule{RakeBP: 0, RoundToMsat: 1_000, MinPayoutMsat: 10_000}
|
||||||
|
s := sch.Apply(5_000)
|
||||||
|
if s.NetMsat != 0 {
|
||||||
|
t.Fatalf("net = %d, want 0 below the minimum", s.NetMsat)
|
||||||
|
}
|
||||||
|
if s.HouseMsat() != 5_000 {
|
||||||
|
t.Fatalf("house = %d, want the full 5000 that was suppressed", s.HouseMsat())
|
||||||
|
}
|
||||||
|
if !s.Valid() {
|
||||||
|
t.Fatal("suppressed payout produced an unbalanced split")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The disclosure must be generated from the same values that are charged, so
|
||||||
|
// the published terms cannot drift from the code.
|
||||||
|
func TestDisclosureReflectsTheSchedule(t *testing.T) {
|
||||||
|
sch := fees.Schedule{RakeBP: 250, RoundToMsat: 1_000, MinPayoutMsat: 1_000}
|
||||||
|
d := sch.Describe(9900)
|
||||||
|
|
||||||
|
if d.RakePercent != "2.50%" {
|
||||||
|
t.Errorf("rake disclosed as %q, want 2.50%%", d.RakePercent)
|
||||||
|
}
|
||||||
|
// 99% game RTP with a 2.5% rake leaves 96.52%.
|
||||||
|
if d.EffectiveRTP != "96.52%" {
|
||||||
|
t.Errorf("effective RTP disclosed as %q, want 96.52%%", d.EffectiveRTP)
|
||||||
|
}
|
||||||
|
if d.WorstCaseRounding != "999 msat (0.999 sats)" {
|
||||||
|
t.Errorf("worst-case rounding disclosed as %q", d.WorstCaseRounding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extreme configurations must not overflow or produce nonsense.
|
||||||
|
func TestExtremeSchedulesAreSafe(t *testing.T) {
|
||||||
|
huge := int64(math.MaxInt64 / 2)
|
||||||
|
for _, sch := range []fees.Schedule{
|
||||||
|
{RakeBP: 10000, RoundToMsat: 1, MinPayoutMsat: 0},
|
||||||
|
{RakeBP: 0, RoundToMsat: huge, MinPayoutMsat: 0},
|
||||||
|
{RakeBP: 0, RoundToMsat: 0, MinPayoutMsat: 0}, // unit floor of 1
|
||||||
|
} {
|
||||||
|
s := sch.Apply(1_000_000)
|
||||||
|
if !s.Valid() {
|
||||||
|
t.Fatalf("schedule %+v produced %+v", sch, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
pkg/fixed/edge_test.go
Normal file
199
pkg/fixed/edge_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package fixed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The simulation's verifiability depends on this arithmetic behaving
|
||||||
|
// identically everywhere, including at the extremes. These tests attack the
|
||||||
|
// boundaries.
|
||||||
|
|
||||||
|
func TestMulByZeroAndOne(t *testing.T) {
|
||||||
|
for _, v := range []int64{0, 1, -1, 1000, -1000, 1 << 20} {
|
||||||
|
a := FromInt(v)
|
||||||
|
if got := a.Mul(0); got != 0 {
|
||||||
|
t.Errorf("%d * 0 = %v, want 0", v, got)
|
||||||
|
}
|
||||||
|
if got := a.Mul(One); got != a {
|
||||||
|
t.Errorf("%d * 1 = %v, want %v", v, got, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDivByOneAndSelf(t *testing.T) {
|
||||||
|
for _, v := range []int64{1, -1, 7, -7, 1000, 1 << 20} {
|
||||||
|
a := FromInt(v)
|
||||||
|
if got := a.Div(One); got != a {
|
||||||
|
t.Errorf("%d / 1 = %v, want %v", v, got, a)
|
||||||
|
}
|
||||||
|
if got := a.Div(a); got != One {
|
||||||
|
t.Errorf("%d / %d = %v, want 1", v, v, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDivByZeroPanics(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("division by zero did not panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = One.Div(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqrtOfNegativePanics(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("sqrt of a negative did not panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = Sqrt(FromInt(-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiplication must stay associative-ish and exact for representable values,
|
||||||
|
// which is what keeps a replayed round identical to the original.
|
||||||
|
func TestMulIsExactForFractions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b, want F
|
||||||
|
}{
|
||||||
|
{One / 2, One / 2, One / 4},
|
||||||
|
{One / 4, One / 4, One / 16},
|
||||||
|
{One / 2, One / 4, One / 8},
|
||||||
|
{One * 3 / 2, One * 2, One * 3},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := c.a.Mul(c.b); got != c.want {
|
||||||
|
t.Errorf("%v * %v = %v, want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-tripping a value through multiply and divide must return it exactly
|
||||||
|
// for powers of two, where no precision can be lost.
|
||||||
|
func TestMulDivRoundTripOnPowersOfTwo(t *testing.T) {
|
||||||
|
for shift := 0; shift < 20; shift++ {
|
||||||
|
v := FromInt(1 << shift)
|
||||||
|
for _, by := range []F{One * 2, One * 4, One * 8} {
|
||||||
|
if got := v.Mul(by).Div(by); got != v {
|
||||||
|
t.Errorf("2^%d round trip through %v gave %v, want %v", shift, by, got, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqrtIsMonotonic(t *testing.T) {
|
||||||
|
prev := Sqrt(0)
|
||||||
|
for i := int64(1); i < 5000; i++ {
|
||||||
|
cur := Sqrt(FromInt(i))
|
||||||
|
if cur < prev {
|
||||||
|
t.Fatalf("Sqrt decreased at %d: %v -> %v", i, prev, cur)
|
||||||
|
}
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sqrt must never overshoot: its square must not exceed the input.
|
||||||
|
func TestSqrtNeverOvershoots(t *testing.T) {
|
||||||
|
for i := int64(0); i < 20000; i++ {
|
||||||
|
a := FromInt(i)
|
||||||
|
r := Sqrt(a)
|
||||||
|
if r.Mul(r) > a {
|
||||||
|
t.Fatalf("Sqrt(%d) = %v squares to %v, which exceeds %v", i, r, r.Mul(r), a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqrtOfLargeValues(t *testing.T) {
|
||||||
|
// Values in the range the crash curve actually produces, up to the
|
||||||
|
// representable maximum.
|
||||||
|
for _, v := range []int64{1_000_000, 12_960_000, 100_000_000, MaxInt} {
|
||||||
|
a := FromInt(v)
|
||||||
|
r := Sqrt(a)
|
||||||
|
if r <= 0 {
|
||||||
|
t.Fatalf("Sqrt(%d) = %v, want positive", v, r)
|
||||||
|
}
|
||||||
|
if r.Mul(r) > a {
|
||||||
|
t.Fatalf("Sqrt(%d) overshoots", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntTruncatesTowardNegativeInfinity(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in F
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{One, 1},
|
||||||
|
{One + One/2, 1},
|
||||||
|
{One*2 - 1, 1},
|
||||||
|
{0, 0},
|
||||||
|
{-One, -1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := c.in.Int(); got != c.want {
|
||||||
|
t.Errorf("(%v).Int() = %d, want %d", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromIntPanicsOutsideRange(t *testing.T) {
|
||||||
|
for _, v := range []int64{MaxInt + 1, MinInt - 1, 4_000_000_000, -4_000_000_000} {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Errorf("FromInt(%d) did not panic", v)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = FromInt(v)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// The boundaries themselves must be accepted.
|
||||||
|
_ = FromInt(MaxInt)
|
||||||
|
_ = FromInt(MinInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringNeverPanicsAcrossRange(t *testing.T) {
|
||||||
|
values := []F{
|
||||||
|
0, 1, -1, One, -One, One / 3, math.MaxInt64, math.MinInt64 + 1,
|
||||||
|
FromInt(4_000_000),
|
||||||
|
}
|
||||||
|
for _, v := range values {
|
||||||
|
if s := v.String(); s == "" {
|
||||||
|
t.Errorf("String() of %d returned empty", int64(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addition and subtraction are plain integer ops, but the inverse property is
|
||||||
|
// what payout arithmetic relies on.
|
||||||
|
func TestAddSubAreInverse(t *testing.T) {
|
||||||
|
for _, a := range []F{0, One, -One, One * 12345, One / 7} {
|
||||||
|
for _, b := range []F{0, One, -One, One * 999} {
|
||||||
|
if got := a.Add(b).Sub(b); got != a {
|
||||||
|
t.Errorf("(%v + %v) - %v = %v, want %v", a, b, b, got, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinism check: the same operations in the same order must produce
|
||||||
|
// bit-identical results every time, which is the whole premise of replay.
|
||||||
|
func TestOperationsAreBitStable(t *testing.T) {
|
||||||
|
compute := func() F {
|
||||||
|
acc := One
|
||||||
|
for i := int64(1); i < 500; i++ {
|
||||||
|
acc = acc.Mul(One + One/F(i+1))
|
||||||
|
acc = acc.Div(One + One/F(i+2))
|
||||||
|
acc = acc.Add(FromInt(i % 3))
|
||||||
|
acc = Sqrt(acc)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
first := compute()
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
if got := compute(); got != first {
|
||||||
|
t.Fatalf("run %d diverged: %v != %v", i, got, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
pkg/fixed/fixed.go
Normal file
138
pkg/fixed/fixed.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Package fixed provides deterministic Q32.32 fixed-point arithmetic.
|
||||||
|
//
|
||||||
|
// No floating-point operation appears anywhere in this package. Results must be
|
||||||
|
// bit-identical across architectures and between native and WASM builds, which
|
||||||
|
// is what allows a player's browser to independently replay a round and reach
|
||||||
|
// exactly the same outcome as the server.
|
||||||
|
package fixed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/bits"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// F is a Q32.32 fixed-point number: an int64 with 32 fractional bits.
|
||||||
|
type F int64
|
||||||
|
|
||||||
|
// One is the fixed-point representation of 1.0.
|
||||||
|
const One F = 1 << 32
|
||||||
|
|
||||||
|
const fracBits = 32
|
||||||
|
|
||||||
|
// MaxInt is the largest whole number representable in Q32.32. Values beyond
|
||||||
|
// it cannot be held in the 32 integer bits.
|
||||||
|
const MaxInt int64 = 1<<31 - 1
|
||||||
|
|
||||||
|
// MinInt is the smallest whole number representable in Q32.32.
|
||||||
|
const MinInt int64 = -(1 << 31)
|
||||||
|
|
||||||
|
// FromInt converts a whole number to fixed-point.
|
||||||
|
//
|
||||||
|
// It panics outside [MinInt, MaxInt] rather than wrapping. A silent wrap here
|
||||||
|
// produced a negative multiplier from a positive input, which is exactly the
|
||||||
|
// kind of fault that is invisible until it corrupts a payout.
|
||||||
|
func FromInt(v int64) F {
|
||||||
|
if v > MaxInt || v < MinInt {
|
||||||
|
panic("fixed: " + strconv.FormatInt(v, 10) + " is outside the Q32.32 integer range")
|
||||||
|
}
|
||||||
|
return F(v << fracBits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int truncates toward negative infinity and returns the whole part.
|
||||||
|
func (a F) Int() int64 { return int64(a) >> fracBits }
|
||||||
|
|
||||||
|
func (a F) Add(b F) F { return a + b }
|
||||||
|
func (a F) Sub(b F) F { return a - b }
|
||||||
|
|
||||||
|
// Mul multiplies via a 128-bit intermediate so no precision is lost before the
|
||||||
|
// shift back down. A naive (a*b)>>32 overflows for operands above roughly 2^15.
|
||||||
|
func (a F) Mul(b F) F {
|
||||||
|
neg := false
|
||||||
|
x, y := int64(a), int64(b)
|
||||||
|
if x < 0 {
|
||||||
|
x, neg = -x, !neg
|
||||||
|
}
|
||||||
|
if y < 0 {
|
||||||
|
y, neg = -y, !neg
|
||||||
|
}
|
||||||
|
hi, lo := bits.Mul64(uint64(x), uint64(y))
|
||||||
|
res := int64(lo>>fracBits | hi<<(64-fracBits))
|
||||||
|
if neg {
|
||||||
|
res = -res
|
||||||
|
}
|
||||||
|
return F(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Div divides via a 128-bit intermediate for the same reason as Mul.
|
||||||
|
func (a F) Div(b F) F {
|
||||||
|
if b == 0 {
|
||||||
|
panic("fixed: division by zero")
|
||||||
|
}
|
||||||
|
neg := false
|
||||||
|
x, y := int64(a), int64(b)
|
||||||
|
if x < 0 {
|
||||||
|
x, neg = -x, !neg
|
||||||
|
}
|
||||||
|
if y < 0 {
|
||||||
|
y, neg = -y, !neg
|
||||||
|
}
|
||||||
|
hi := uint64(x) >> (64 - fracBits)
|
||||||
|
lo := uint64(x) << fracBits
|
||||||
|
q, _ := bits.Div64(hi, lo, uint64(y))
|
||||||
|
res := int64(q)
|
||||||
|
if neg {
|
||||||
|
res = -res
|
||||||
|
}
|
||||||
|
return F(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sqrt returns the fixed-point square root using integer Newton iteration.
|
||||||
|
// It converges in well under the iteration cap for the full int64 range.
|
||||||
|
func Sqrt(a F) F {
|
||||||
|
if a < 0 {
|
||||||
|
panic("fixed: sqrt of negative")
|
||||||
|
}
|
||||||
|
if a == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Initial guess: half the bit length puts us within a factor of two.
|
||||||
|
shift := uint(bits.Len64(uint64(a))+fracBits) / 2
|
||||||
|
x := F(1) << shift
|
||||||
|
for i := 0; i < 64; i++ {
|
||||||
|
next := (x + a.Div(x)) / 2
|
||||||
|
if next == x || next == x-1 {
|
||||||
|
x = next
|
||||||
|
break
|
||||||
|
}
|
||||||
|
x = next
|
||||||
|
}
|
||||||
|
// Newton can land one ulp high; step down while the square exceeds the input.
|
||||||
|
for x > 0 && x.Mul(x) > a {
|
||||||
|
x--
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the value with six fractional digits, using integer math only.
|
||||||
|
func (a F) String() string {
|
||||||
|
neg := a < 0
|
||||||
|
if neg {
|
||||||
|
a = -a
|
||||||
|
}
|
||||||
|
whole := int64(a) >> fracBits
|
||||||
|
frac := int64(a) & (int64(One) - 1)
|
||||||
|
micros := (frac * 1_000_000) >> fracBits
|
||||||
|
s := strconv.FormatInt(whole, 10) + "." + pad6(micros)
|
||||||
|
if neg {
|
||||||
|
return "-" + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func pad6(v int64) string {
|
||||||
|
s := strconv.FormatInt(v, 10)
|
||||||
|
for len(s) < 6 {
|
||||||
|
s = "0" + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
67
pkg/fixed/fixed_test.go
Normal file
67
pkg/fixed/fixed_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package fixed
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFromIntAndBack(t *testing.T) {
|
||||||
|
if got := FromInt(7).Int(); got != 7 {
|
||||||
|
t.Fatalf("FromInt(7).Int() = %d, want 7", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMulIsExact(t *testing.T) {
|
||||||
|
half := One / 2
|
||||||
|
if got := half.Mul(half); got != One/4 {
|
||||||
|
t.Fatalf("0.5*0.5 = %d, want %d", got, One/4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMulDoesNotOverflowAtScale(t *testing.T) {
|
||||||
|
// A naive (a*b)>>32 overflows well below this. The 128-bit intermediate
|
||||||
|
// must handle it exactly.
|
||||||
|
a := FromInt(100000)
|
||||||
|
if got := a.Mul(FromInt(2)); got != FromInt(200000) {
|
||||||
|
t.Fatalf("100000*2 = %v, want 200000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDivIsExact(t *testing.T) {
|
||||||
|
if got := FromInt(1).Div(FromInt(4)); got != One/4 {
|
||||||
|
t.Fatalf("1/4 = %d, want %d", got, One/4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNegativeMulAndDiv(t *testing.T) {
|
||||||
|
if got := FromInt(-3).Mul(FromInt(4)); got != FromInt(-12) {
|
||||||
|
t.Fatalf("-3*4 = %v, want -12", got)
|
||||||
|
}
|
||||||
|
if got := FromInt(-12).Div(FromInt(4)); got != FromInt(-3) {
|
||||||
|
t.Fatalf("-12/4 = %v, want -3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqrt(t *testing.T) {
|
||||||
|
for _, n := range []int64{0, 1, 4, 9, 16, 100, 10000} {
|
||||||
|
want := FromInt(isqrt(n))
|
||||||
|
got := Sqrt(FromInt(n))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Sqrt(%d) = %v, want %v", n, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isqrt(n int64) int64 {
|
||||||
|
var r int64
|
||||||
|
for r*r <= n {
|
||||||
|
r++
|
||||||
|
}
|
||||||
|
return r - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
if got := (One + One/2).String(); got != "1.500000" {
|
||||||
|
t.Fatalf("1.5.String() = %q", got)
|
||||||
|
}
|
||||||
|
if got := FromInt(-2).String(); got != "-2.000000" {
|
||||||
|
t.Fatalf("-2.String() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
pkg/fixed/nofloat_test.go
Normal file
52
pkg/fixed/nofloat_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package fixed_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Determinism depends on there being no floating-point arithmetic anywhere in
|
||||||
|
// the simulation path: floats drift between architectures and between native
|
||||||
|
// and WASM builds, which would silently break round verification. This test
|
||||||
|
// fails the build if a float type is ever introduced.
|
||||||
|
//
|
||||||
|
// Test files are exempt, since statistical assertions legitimately use floats.
|
||||||
|
func TestNoFloatingPointInDeterministicPackages(t *testing.T) {
|
||||||
|
for _, dir := range []string{".", "../sim"} {
|
||||||
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
pkgs, err := parser.ParseDir(fset, dir, nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
for name, file := range pkg.Files {
|
||||||
|
if strings.HasSuffix(name, "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
switch node := n.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
if node.Name == "float32" || node.Name == "float64" {
|
||||||
|
t.Errorf("%s: forbidden float type %q in deterministic package",
|
||||||
|
filepath.Base(name), node.Name)
|
||||||
|
}
|
||||||
|
case *ast.BasicLit:
|
||||||
|
if node.Kind == token.FLOAT {
|
||||||
|
t.Errorf("%s: forbidden float literal %s",
|
||||||
|
filepath.Base(name), node.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
pkg/identity/identity.go
Normal file
116
pkg/identity/identity.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Package identity implements keypair-based sign-in.
|
||||||
|
//
|
||||||
|
// There are no accounts in the usual sense: a player's ed25519 public key is
|
||||||
|
// their identity. To prove ownership they sign a server-issued challenge, which
|
||||||
|
// is single-use and short-lived. There is no password to leak, no email to
|
||||||
|
// verify, and nothing to reset — losing the key loses the balance, which is
|
||||||
|
// stated plainly in the interface.
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnknownChallenge = errors.New("identity: challenge not found or expired")
|
||||||
|
ErrBadSignature = errors.New("identity: signature does not verify")
|
||||||
|
ErrBadPublicKey = errors.New("identity: malformed public key")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChallengeTTL is how long a challenge stays valid. Short, because a client
|
||||||
|
// signs it immediately.
|
||||||
|
const ChallengeTTL = 2 * time.Minute
|
||||||
|
|
||||||
|
type challenge struct {
|
||||||
|
nonce [32]byte
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticator issues and verifies sign-in challenges.
|
||||||
|
type Authenticator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
challenges map[string]challenge
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthenticator() *Authenticator {
|
||||||
|
return &Authenticator{
|
||||||
|
challenges: make(map[string]challenge),
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Challenge issues a fresh nonce for a public key to sign.
|
||||||
|
func (a *Authenticator) Challenge(pubkeyHex string) (string, error) {
|
||||||
|
pk, err := ParsePublicKey(pubkeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var n [32]byte
|
||||||
|
if _, err := rand.Read(n[:]); err != nil {
|
||||||
|
panic("identity: system randomness unavailable: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.sweepLocked()
|
||||||
|
a.challenges[hex.EncodeToString(pk)] = challenge{
|
||||||
|
nonce: n,
|
||||||
|
expires: a.now().Add(ChallengeTTL),
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(n[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks a signature over the outstanding challenge for that key and
|
||||||
|
// consumes it, so a captured signature cannot be replayed.
|
||||||
|
func (a *Authenticator) Verify(pubkeyHex, signatureHex string) error {
|
||||||
|
pk, err := ParsePublicKey(pubkeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sig, err := hex.DecodeString(signatureHex)
|
||||||
|
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||||
|
return ErrBadSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
key := hex.EncodeToString(pk)
|
||||||
|
c, ok := a.challenges[key]
|
||||||
|
if ok {
|
||||||
|
delete(a.challenges, key) // single use, whether or not it verifies
|
||||||
|
}
|
||||||
|
now := a.now()
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok || now.After(c.expires) {
|
||||||
|
return ErrUnknownChallenge
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pk, c.nonce[:], sig) {
|
||||||
|
return ErrBadSignature
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLocked drops expired challenges. Called under the mutex.
|
||||||
|
func (a *Authenticator) sweepLocked() {
|
||||||
|
now := a.now()
|
||||||
|
for k, c := range a.challenges {
|
||||||
|
if now.After(c.expires) {
|
||||||
|
delete(a.challenges, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKey decodes and validates a hex-encoded ed25519 public key.
|
||||||
|
func ParsePublicKey(s string) (ed25519.PublicKey, error) {
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
if err != nil || len(b) != ed25519.PublicKeySize {
|
||||||
|
return nil, ErrBadPublicKey
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(b), nil
|
||||||
|
}
|
||||||
86
pkg/identity/identity_test.go
Normal file
86
pkg/identity/identity_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package identity_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newKey(t *testing.T) (string, ed25519.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(pub), priv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSignatureAuthenticates(t *testing.T) {
|
||||||
|
a := identity.NewAuthenticator()
|
||||||
|
pubHex, priv := newKey(t)
|
||||||
|
|
||||||
|
nonceHex, err := a.Challenge(pubHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nonce, _ := hex.DecodeString(nonceHex)
|
||||||
|
sig := ed25519.Sign(priv, nonce)
|
||||||
|
|
||||||
|
if err := a.Verify(pubHex, hex.EncodeToString(sig)); err != nil {
|
||||||
|
t.Fatalf("valid signature rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongKeyCannotAuthenticate(t *testing.T) {
|
||||||
|
a := identity.NewAuthenticator()
|
||||||
|
pubHex, _ := newKey(t)
|
||||||
|
_, otherPriv := newKey(t)
|
||||||
|
|
||||||
|
nonceHex, _ := a.Challenge(pubHex)
|
||||||
|
nonce, _ := hex.DecodeString(nonceHex)
|
||||||
|
sig := ed25519.Sign(otherPriv, nonce)
|
||||||
|
|
||||||
|
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrBadSignature) {
|
||||||
|
t.Fatalf("got %v, want ErrBadSignature", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A captured signature must not work twice.
|
||||||
|
func TestChallengeIsSingleUse(t *testing.T) {
|
||||||
|
a := identity.NewAuthenticator()
|
||||||
|
pubHex, priv := newKey(t)
|
||||||
|
|
||||||
|
nonceHex, _ := a.Challenge(pubHex)
|
||||||
|
nonce, _ := hex.DecodeString(nonceHex)
|
||||||
|
sigHex := hex.EncodeToString(ed25519.Sign(priv, nonce))
|
||||||
|
|
||||||
|
if err := a.Verify(pubHex, sigHex); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.Verify(pubHex, sigHex); !errors.Is(err, identity.ErrUnknownChallenge) {
|
||||||
|
t.Fatalf("replay succeeded or gave %v, want ErrUnknownChallenge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedKeyRejected(t *testing.T) {
|
||||||
|
a := identity.NewAuthenticator()
|
||||||
|
if _, err := a.Challenge("not-hex"); !errors.Is(err, identity.ErrBadPublicKey) {
|
||||||
|
t.Fatalf("got %v, want ErrBadPublicKey", err)
|
||||||
|
}
|
||||||
|
if _, err := a.Challenge("aabb"); !errors.Is(err, identity.ErrBadPublicKey) {
|
||||||
|
t.Fatalf("short key: got %v, want ErrBadPublicKey", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyWithoutChallengeFails(t *testing.T) {
|
||||||
|
a := identity.NewAuthenticator()
|
||||||
|
pubHex, priv := newKey(t)
|
||||||
|
sig := ed25519.Sign(priv, []byte("anything"))
|
||||||
|
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrUnknownChallenge) {
|
||||||
|
t.Fatalf("got %v, want ErrUnknownChallenge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
314
pkg/ledger/batch.go
Normal file
314
pkg/ledger/batch.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Batcher raises write throughput by grouping transactions.
|
||||||
|
//
|
||||||
|
// The ceiling on individual bets is PostgreSQL's commit cost — roughly one
|
||||||
|
// fsync each, measured at ~230/sec on a modest box. Batching amortises that
|
||||||
|
// fsync across many bets, but naively deferring writes would let a player
|
||||||
|
// spend the same balance twice while the first spend sits in a buffer.
|
||||||
|
//
|
||||||
|
// So a bet takes two steps:
|
||||||
|
//
|
||||||
|
// 1. Reserve, synchronously and in memory. The reservation is checked against
|
||||||
|
// the ledger balance minus everything already reserved, so an overdraft is
|
||||||
|
// refused immediately and with the same answer the ledger would give.
|
||||||
|
// 2. Flush, in the background. Reservations are written as one transaction.
|
||||||
|
//
|
||||||
|
// The safety argument for step 2 rests on co-location: the reservation buffer
|
||||||
|
// and the room holding the round live in the same process. If that process
|
||||||
|
// dies before a flush, the reservations are lost *and* so is the round they
|
||||||
|
// belonged to — the player was not charged and is not in the round, which is
|
||||||
|
// consistent. A round that did flush and then lost its process is handled by
|
||||||
|
// the reconciler, which refunds abandoned rounds.
|
||||||
|
//
|
||||||
|
// A round must therefore never settle before its bets have flushed.
|
||||||
|
// Room.settle enforces that by calling Flush first.
|
||||||
|
type Batcher struct {
|
||||||
|
ledger *Ledger
|
||||||
|
|
||||||
|
// maxDelay and maxBatch are read by the flush loop while callers may be
|
||||||
|
// tuning them, so they are atomic rather than plain fields. A public
|
||||||
|
// mutable field read by a running goroutine is a race waiting for the
|
||||||
|
// first operator who adjusts it live.
|
||||||
|
maxDelay atomic.Int64 // nanoseconds
|
||||||
|
maxBatch atomic.Int64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending []pendingTx
|
||||||
|
reserved map[int64]int64 // account -> millisatoshis reserved but unwritten
|
||||||
|
waiters []chan error
|
||||||
|
|
||||||
|
flushing sync.Mutex // serialises flushes so ordering is preserved
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
|
// negativeOK caches whether an account may go negative. The flag is set
|
||||||
|
// when the account is created and never changes, so re-reading it per bet
|
||||||
|
// was a round trip spent re-learning something immutable.
|
||||||
|
negMu sync.RWMutex
|
||||||
|
negativeOK map[int64]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingTx struct {
|
||||||
|
kind string
|
||||||
|
roundID *int64
|
||||||
|
postings []Posting
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrBatcherClosed = errors.New("ledger: batcher is closed")
|
||||||
|
|
||||||
|
func NewBatcher(l *Ledger) *Batcher {
|
||||||
|
b := &Batcher{
|
||||||
|
ledger: l,
|
||||||
|
reserved: make(map[int64]int64),
|
||||||
|
negativeOK: make(map[int64]bool),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
b.SetMaxDelay(200 * time.Millisecond)
|
||||||
|
b.SetMaxBatch(256)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxDelay sets how long a reservation may wait before being written. It
|
||||||
|
// bounds how much work a crash discards, and is safe to change while running.
|
||||||
|
func (b *Batcher) SetMaxDelay(d time.Duration) {
|
||||||
|
if d < time.Millisecond {
|
||||||
|
d = time.Millisecond
|
||||||
|
}
|
||||||
|
b.maxDelay.Store(int64(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxDelay reports the current flush interval.
|
||||||
|
func (b *Batcher) MaxDelay() time.Duration {
|
||||||
|
return time.Duration(b.maxDelay.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxBatch sets how many transactions may queue before an early flush, so a
|
||||||
|
// burst does not build an unboundedly large database transaction.
|
||||||
|
func (b *Batcher) SetMaxBatch(n int) {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
b.maxBatch.Store(int64(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run flushes on a timer until the context ends.
|
||||||
|
func (b *Batcher) Run(ctx context.Context) {
|
||||||
|
interval := b.MaxDelay()
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
// Pick up a changed interval without restarting the loop.
|
||||||
|
if d := b.MaxDelay(); d != interval {
|
||||||
|
interval = d
|
||||||
|
t.Reset(interval)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Flush what is held rather than discarding it: a clean shutdown
|
||||||
|
// should not lose bets that were accepted.
|
||||||
|
_ = b.Flush(context.WithoutCancel(ctx))
|
||||||
|
return
|
||||||
|
case <-b.stop:
|
||||||
|
_ = b.Flush(context.WithoutCancel(ctx))
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := b.Flush(ctx); err != nil {
|
||||||
|
fmt.Printf("ledger: batch flush failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the batcher after a final flush.
|
||||||
|
func (b *Batcher) Close() {
|
||||||
|
b.once.Do(func() { close(b.stop) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableBalance is what an account can actually spend: its ledger balance
|
||||||
|
// less anything reserved but not yet written.
|
||||||
|
func (b *Batcher) AvailableBalance(ctx context.Context, accountID int64) (int64, error) {
|
||||||
|
settled, err := b.ledger.Balance(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return settled + b.reserved[accountID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mayGoNegative reports whether an account is permitted a negative balance,
|
||||||
|
// caching the answer. The flag is immutable once an account exists.
|
||||||
|
func (b *Batcher) mayGoNegative(ctx context.Context, accountID int64) (bool, error) {
|
||||||
|
b.negMu.RLock()
|
||||||
|
v, ok := b.negativeOK[accountID]
|
||||||
|
b.negMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var allow bool
|
||||||
|
if err := b.ledger.pool.QueryRow(ctx,
|
||||||
|
`SELECT allow_negative FROM accounts WHERE id = $1`, accountID).Scan(&allow); err != nil {
|
||||||
|
return false, fmt.Errorf("checking account %d: %w", accountID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.negMu.Lock()
|
||||||
|
b.negativeOK[accountID] = allow
|
||||||
|
b.negMu.Unlock()
|
||||||
|
return allow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post reserves a transaction and returns once it is durably written.
|
||||||
|
//
|
||||||
|
// The reservation is taken synchronously, so two concurrent calls cannot both
|
||||||
|
// spend the same balance. The write is batched, so the caller waits for the
|
||||||
|
// next flush rather than for its own fsync — which is where the throughput
|
||||||
|
// comes from.
|
||||||
|
func (b *Batcher) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) error {
|
||||||
|
if len(postings) == 0 {
|
||||||
|
return ErrEmptyTransaction
|
||||||
|
}
|
||||||
|
var sum int64
|
||||||
|
for _, p := range postings {
|
||||||
|
sum += p.AmountMsat
|
||||||
|
}
|
||||||
|
if sum != 0 {
|
||||||
|
return fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check every debit against the balance that will actually be available,
|
||||||
|
// which is the settled balance plus reservations already taken.
|
||||||
|
for _, p := range postings {
|
||||||
|
if p.AmountMsat >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
available, err := b.AvailableBalance(ctx, p.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The bridge is allowed to go negative; everything else is not.
|
||||||
|
allowNegative, err := b.mayGoNegative(ctx, p.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !allowNegative && available+p.AmountMsat < 0 {
|
||||||
|
return fmt.Errorf("%w: account %d has %d available, needs %d",
|
||||||
|
ErrInsufficientFunds, p.AccountID, available, -p.AmountMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
select {
|
||||||
|
case <-b.stop:
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ErrBatcherClosed
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
for _, p := range postings {
|
||||||
|
b.reserved[p.AccountID] += p.AmountMsat
|
||||||
|
}
|
||||||
|
b.pending = append(b.pending, pendingTx{kind: kind, roundID: roundID, postings: postings})
|
||||||
|
b.waiters = append(b.waiters, done)
|
||||||
|
full := int64(len(b.pending)) >= b.maxBatch.Load()
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if full {
|
||||||
|
go func() {
|
||||||
|
if err := b.Flush(context.WithoutCancel(ctx)); err != nil {
|
||||||
|
fmt.Printf("ledger: batch flush failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush writes every pending transaction.
|
||||||
|
//
|
||||||
|
// Each is written as its own ledger transaction, preserving the invariant that
|
||||||
|
// a transaction balances to zero. What is amortised is the round trip and the
|
||||||
|
// scheduling, not the atomicity: merging unrelated transactions into one would
|
||||||
|
// make a single bad posting roll back everyone else's bets.
|
||||||
|
func (b *Batcher) Flush(ctx context.Context) error {
|
||||||
|
b.flushing.Lock()
|
||||||
|
defer b.flushing.Unlock()
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
if len(b.pending) == 0 {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
batch := b.pending
|
||||||
|
waiters := b.waiters
|
||||||
|
b.pending = nil
|
||||||
|
b.waiters = nil
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
release := func(tx pendingTx) {
|
||||||
|
// Release the reservation whether or not the write succeeded: it has
|
||||||
|
// either become a real posting or it never will, and in both cases
|
||||||
|
// holding it would understate what the account can spend.
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, p := range tx.postings {
|
||||||
|
b.reserved[p.AccountID] -= p.AmountMsat
|
||||||
|
if b.reserved[p.AccountID] == 0 {
|
||||||
|
delete(b.reserved, p.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]Group, len(batch))
|
||||||
|
for i, tx := range batch {
|
||||||
|
groups[i] = Group{Kind: tx.kind, RoundID: tx.roundID, Postings: tx.postings}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fast path: one database transaction for the whole batch, so the
|
||||||
|
// commit cost is paid once instead of once per bet.
|
||||||
|
if _, err := b.ledger.PostMany(ctx, groups); err == nil {
|
||||||
|
for i, tx := range batch {
|
||||||
|
release(tx)
|
||||||
|
waiters[i] <- nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something in the batch was rejected. Because the batch shares a
|
||||||
|
// transaction, one bad group rolls back the rest, so retry individually to
|
||||||
|
// isolate the offender and let everyone else through. This is rare:
|
||||||
|
// balances are checked before a group is ever queued.
|
||||||
|
var firstErr error
|
||||||
|
for i, tx := range batch {
|
||||||
|
_, err := b.ledger.Post(ctx, tx.kind, tx.roundID, tx.postings)
|
||||||
|
release(tx)
|
||||||
|
waiters[i] <- err
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending reports how many transactions are waiting, for tests and metrics.
|
||||||
|
func (b *Batcher) Pending() int {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return len(b.pending)
|
||||||
|
}
|
||||||
322
pkg/ledger/batch_test.go
Normal file
322
pkg/ledger/batch_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package ledger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Batching is only worth having if it cannot lose or create money. These pin
|
||||||
|
// that down before any throughput claim is made.
|
||||||
|
|
||||||
|
func newBatcher(t *testing.T) (*ledger.Batcher, *ledger.Ledger, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
b := ledger.NewBatcher(l)
|
||||||
|
b.SetMaxDelay(50 * time.Millisecond)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go b.Run(ctx)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
b.Close()
|
||||||
|
cancel()
|
||||||
|
})
|
||||||
|
return b, l, context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchedPostIsDurable(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -10_000},
|
||||||
|
{AccountID: to, AmountMsat: 10_000},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post returns only once written, so the ledger must already show it.
|
||||||
|
if bal, _ := l.Balance(ctx, to); bal != 10_000 {
|
||||||
|
t.Fatalf("recipient balance = %d after Post returned, want 10000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The property that makes deferred writes safe: a reservation must count
|
||||||
|
// against the balance immediately, or the same funds could be spent twice
|
||||||
|
// while the first spend sits in the buffer.
|
||||||
|
func TestReservationsPreventDoubleSpend(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 10_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = 12
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var ok atomic.Int64
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Each tries to spend the entire balance.
|
||||||
|
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -10_000},
|
||||||
|
{AccountID: to, AmountMsat: 10_000},
|
||||||
|
}); err == nil {
|
||||||
|
ok.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if ok.Load() != 1 {
|
||||||
|
t.Fatalf("%d of %d concurrent spends of the same balance succeeded, want 1",
|
||||||
|
ok.Load(), workers)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, from); bal != 0 {
|
||||||
|
t.Fatalf("source balance = %d, want 0", bal)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, to); bal != 10_000 {
|
||||||
|
t.Fatalf("recipient balance = %d, want exactly one transfer of 10000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailableBalanceReflectsReservations(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
b.SetMaxDelay(5 * time.Second) // hold the flush so the reservation is visible
|
||||||
|
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 50_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -20_000},
|
||||||
|
{AccountID: to, AmountMsat: 20_000},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the reservation to be taken but not yet written.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
avail, err := b.AvailableBalance(ctx, from)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if avail == 30_000 {
|
||||||
|
return // reserved amount is subtracted, as it must be
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
avail, _ := b.AvailableBalance(ctx, from)
|
||||||
|
t.Fatalf("available balance = %d while 20000 is reserved, want 30000", avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverdraftRefusedBeforeReserving(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 1_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -5_000},
|
||||||
|
{AccountID: to, AmountMsat: 5_000},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||||
|
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||||||
|
}
|
||||||
|
// And a subsequent affordable spend must still work, proving the refused
|
||||||
|
// attempt left no reservation behind.
|
||||||
|
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -1_000},
|
||||||
|
{AccountID: to, AmountMsat: 1_000},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("an affordable spend after a refused one failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnbalancedBatchIsRefused(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
||||||
|
c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c"))
|
||||||
|
|
||||||
|
if err := b.Post(ctx, "bad", nil, []ledger.Posting{
|
||||||
|
{AccountID: a, AmountMsat: -100},
|
||||||
|
{AccountID: c, AmountMsat: 50},
|
||||||
|
}); !errors.Is(err, ledger.ErrUnbalanced) {
|
||||||
|
t.Fatalf("got %v, want ErrUnbalanced", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value must be conserved across a large batched workload.
|
||||||
|
func TestBatchedWorkloadConservesValue(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
|
||||||
|
const players = 10
|
||||||
|
ids := make([]int64, players)
|
||||||
|
for i := range ids {
|
||||||
|
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, string(rune('a'+i))))
|
||||||
|
if _, err := l.Deposit(ctx, id, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
sumOwn := func() int64 {
|
||||||
|
var total int64
|
||||||
|
for _, id := range ids {
|
||||||
|
bal, err := l.Balance(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total += bal
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
before := sumOwn()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < players; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 20; j++ {
|
||||||
|
from := ids[i]
|
||||||
|
to := ids[(i+1)%players]
|
||||||
|
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -500},
|
||||||
|
{AccountID: to, AmountMsat: 500},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if err := b.Flush(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := sumOwn(); after != before {
|
||||||
|
t.Fatalf("batched workload changed total value: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if bal, _ := l.Balance(ctx, id); bal < 0 {
|
||||||
|
t.Fatalf("account %d went negative under batching: %d", id, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush must drain everything, so a caller can guarantee durability before
|
||||||
|
// settling a round.
|
||||||
|
func TestFlushDrainsEverything(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
b.SetMaxDelay(time.Hour) // only an explicit Flush will write
|
||||||
|
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
go func() {
|
||||||
|
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -1_000},
|
||||||
|
{AccountID: to, AmountMsat: 1_000},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Wait for them to be queued.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && b.Pending() < 5 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.Flush(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p := b.Pending(); p != 0 {
|
||||||
|
t.Fatalf("%d transactions still pending after Flush", p)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, to); bal != 5_000 {
|
||||||
|
t.Fatalf("recipient balance = %d after flush, want 5000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The throughput claim, measured rather than asserted.
|
||||||
|
func TestBatchedThroughputBeatsDirect(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("throughput measurement")
|
||||||
|
}
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
// A longer window collects larger batches, which is where the amortisation
|
||||||
|
// comes from. 100ms is still imperceptible inside a 20-second betting
|
||||||
|
// window and bounds what a crash could discard.
|
||||||
|
b.SetMaxDelay(100 * time.Millisecond)
|
||||||
|
|
||||||
|
house, _ := l.EnsurePlayer(ctx, uniqueKey(t, "house"))
|
||||||
|
const players = 32
|
||||||
|
ids := make([]int64, players)
|
||||||
|
for i := range ids {
|
||||||
|
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"+string(rune('a'+i%26))+string(rune('0'+i/26))))
|
||||||
|
if _, err := l.Deposit(ctx, id, 10_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(post func(from int64) error) float64 {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var ok atomic.Int64
|
||||||
|
start := time.Now()
|
||||||
|
for _, id := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int64) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
if err := post(id); err == nil {
|
||||||
|
ok.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(id)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return float64(ok.Load()) / time.Since(start).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
direct := run(func(from int64) error {
|
||||||
|
_, err := l.Post(ctx, "bet", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -100},
|
||||||
|
{AccountID: house, AmountMsat: 100},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
batched := run(func(from int64) error {
|
||||||
|
return b.Post(ctx, "bet", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -100},
|
||||||
|
{AccountID: house, AmountMsat: 100},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("direct: %.0f bets/sec", direct)
|
||||||
|
t.Logf("batched: %.0f bets/sec (%.1fx)", batched, batched/direct)
|
||||||
|
t.Logf(" -> a 20s betting window absorbs about %.0f batched bets", batched*20)
|
||||||
|
|
||||||
|
if batched <= direct {
|
||||||
|
t.Fatalf("batching did not improve throughput: %.0f vs %.0f", batched, direct)
|
||||||
|
}
|
||||||
|
}
|
||||||
244
pkg/ledger/edge_test.go
Normal file
244
pkg/ledger/edge_test.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package ledger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests attack the ledger with extreme values. Money code fails at the
|
||||||
|
// boundaries, so the boundaries are where it should be hit hardest.
|
||||||
|
|
||||||
|
func TestHugeBalanceIsExact(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
p, err := l.EnsurePlayer(ctx, uniqueKey(t, "whale"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21 million BTC in millisatoshis is the largest amount that can ever
|
||||||
|
// exist: 2.1e18. It must round-trip exactly, with no float contamination.
|
||||||
|
const allTheBitcoin int64 = 21_000_000 * 100_000_000 * 1000
|
||||||
|
|
||||||
|
// The ledger is append-only and never truncated, so repeated runs steadily
|
||||||
|
// consume the bridge's headroom. Skip rather than fail when it is spent —
|
||||||
|
// that is an exhausted fixture, not a defect. Reset with `make db-reset`.
|
||||||
|
issued, err := l.TotalIssued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if math.MaxInt64-issued < allTheBitcoin {
|
||||||
|
t.Skipf("bridge headroom exhausted (%d issued); run `make db-reset`", issued)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := l.Deposit(ctx, p, allTheBitcoin); err != nil {
|
||||||
|
t.Fatalf("depositing the entire supply: %v", err)
|
||||||
|
}
|
||||||
|
bal, err := l.Balance(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bal != allTheBitcoin {
|
||||||
|
t.Fatalf("balance = %d, want %d (off by %d)", bal, allTheBitcoin, bal-allTheBitcoin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deposit that would overflow int64 must be refused, not wrap around.
|
||||||
|
func TestOverflowingDepositIsRejected(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "overflow"))
|
||||||
|
|
||||||
|
// A MaxInt64 deposit must be refused: it would underflow the bridge, whose
|
||||||
|
// balance is already negative by everything owed to players.
|
||||||
|
if _, err := l.Deposit(ctx, p, math.MaxInt64); err == nil {
|
||||||
|
t.Fatal("a MaxInt64 deposit was accepted")
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, p); bal != 0 {
|
||||||
|
t.Fatalf("rejected deposit still moved the balance to %d", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit overflow is structurally unreachable, and that is a stronger
|
||||||
|
// guarantee than the runtime guard.
|
||||||
|
//
|
||||||
|
// Every millisatoshi inside the system was issued by debiting the bridge, and
|
||||||
|
// the bridge cannot pass MinInt64. So the sum of all non-bridge balances is
|
||||||
|
// bounded by MaxInt64, and no individual account can be pushed past it by any
|
||||||
|
// sequence of balanced transactions. The guard in Post remains as defence in
|
||||||
|
// depth against a future issuance path that does not go through the bridge.
|
||||||
|
func TestIssuanceIsBoundedByTheBridge(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "bounded"))
|
||||||
|
|
||||||
|
// Attempting to issue more than the bridge can back must fail.
|
||||||
|
if _, err := l.Deposit(ctx, p, math.MaxInt64); err == nil {
|
||||||
|
t.Fatal("issued more than the bridge can back")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And whatever has been issued must still fit in an int64, which is what
|
||||||
|
// makes every downstream balance arithmetic safe.
|
||||||
|
issued, err := l.TotalIssued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("total issuance is no longer representable: %v", err)
|
||||||
|
}
|
||||||
|
if issued < 0 {
|
||||||
|
t.Fatalf("total issued is negative: %d", issued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postings that individually fit but collectively overflow the zero-sum check.
|
||||||
|
func TestOverflowingPostingSetIsRejected(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
||||||
|
b, _ := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
||||||
|
c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c"))
|
||||||
|
|
||||||
|
// These sum to zero only if you ignore wraparound.
|
||||||
|
_, err := l.Post(ctx, "attack", nil, []ledger.Posting{
|
||||||
|
{AccountID: a, AmountMsat: math.MaxInt64},
|
||||||
|
{AccountID: b, AmountMsat: math.MaxInt64},
|
||||||
|
{AccountID: c, AmountMsat: 2},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a posting set that overflows int64 was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmallestPossibleAmount(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "dust"))
|
||||||
|
|
||||||
|
if _, err := l.Deposit(ctx, p, 1); err != nil {
|
||||||
|
t.Fatalf("one millisatoshi rejected: %v", err)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, p); bal != 1 {
|
||||||
|
t.Fatalf("balance = %d, want 1", bal)
|
||||||
|
}
|
||||||
|
// Spending exactly the balance must leave zero, not fail.
|
||||||
|
q, _ := l.EnsurePlayer(ctx, uniqueKey(t, "dust2"))
|
||||||
|
if _, err := l.Transfer(ctx, p, q, 1); err != nil {
|
||||||
|
t.Fatalf("spending the exact balance failed: %v", err)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, p); bal != 0 {
|
||||||
|
t.Fatalf("balance = %d after spending everything, want 0", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spending one millisatoshi more than you hold must fail, at every scale.
|
||||||
|
func TestOffByOneOverdraftAtEveryScale(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, amount := range []int64{1, 1000, 1_000_000, 100_000_000_000} {
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "scale"+itoa(amount)))
|
||||||
|
q, _ := l.EnsurePlayer(ctx, uniqueKey(t, "scaledst"+itoa(amount)))
|
||||||
|
if _, err := l.Deposit(ctx, p, amount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Transfer(ctx, p, q, amount+1); err == nil {
|
||||||
|
t.Fatalf("overdraft by 1 accepted at scale %d", amount)
|
||||||
|
}
|
||||||
|
if _, err := l.Transfer(ctx, p, q, amount); err != nil {
|
||||||
|
t.Fatalf("exact-balance transfer rejected at scale %d: %v", amount, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hammer one account from many goroutines and confirm not a single
|
||||||
|
// millisatoshi is created or lost.
|
||||||
|
func TestHighContentionConservesExactly(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
hub, _ := l.EnsurePlayer(ctx, uniqueKey(t, "hub"))
|
||||||
|
if _, err := l.Deposit(ctx, hub, 1_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = 16
|
||||||
|
spokes := make([]int64, workers)
|
||||||
|
for i := range spokes {
|
||||||
|
spokes[i], _ = l.EnsurePlayer(ctx, uniqueKey(t, "spoke"+itoa(int64(i))))
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 20; j++ {
|
||||||
|
// Push out and pull back; net zero if nothing is lost.
|
||||||
|
if _, err := l.Transfer(ctx, hub, spokes[i], 137); err == nil {
|
||||||
|
_, _ = l.Transfer(ctx, spokes[i], hub, 137)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
total := int64(0)
|
||||||
|
for _, id := range spokes {
|
||||||
|
bal, err := l.Balance(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total += bal
|
||||||
|
}
|
||||||
|
hubBal, err := l.Balance(ctx, hub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total+hubBal != 1_000_000 {
|
||||||
|
t.Fatalf("value changed under contention: %d, want 1000000", total+hubBal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-transfers must not mint money through double-counting the same account.
|
||||||
|
func TestSelfTransferDoesNotMint(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "self"))
|
||||||
|
if _, err := l.Deposit(ctx, p, 10_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = l.Transfer(ctx, p, p, 5_000)
|
||||||
|
|
||||||
|
bal, err := l.Balance(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bal != 10_000 {
|
||||||
|
t.Fatalf("self-transfer changed balance to %d, want 10000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int64) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := v < 0
|
||||||
|
if neg {
|
||||||
|
v = -v
|
||||||
|
}
|
||||||
|
var buf [24]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
505
pkg/ledger/ledger.go
Normal file
505
pkg/ledger/ledger.go
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
// Package ledger implements append-only double-entry accounting.
|
||||||
|
//
|
||||||
|
// Invariants, enforced here and again by database constraints and triggers:
|
||||||
|
// - every transaction's postings sum to exactly zero
|
||||||
|
// - no account balance may go negative
|
||||||
|
// - rows are never updated or deleted; corrections are compensating entries
|
||||||
|
//
|
||||||
|
// Every balance change is explained by a posting that records what happened,
|
||||||
|
// when, which round it belonged to, and the balance either side of it.
|
||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnbalanced = errors.New("ledger: postings do not sum to zero")
|
||||||
|
ErrInsufficientFunds = errors.New("ledger: insufficient funds")
|
||||||
|
ErrEmptyTransaction = errors.New("ledger: transaction has no postings")
|
||||||
|
ErrNonPositiveAmount = errors.New("ledger: amount must be positive")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Posting is a single leg of a transaction. Positive credits, negative debits.
|
||||||
|
type Posting struct {
|
||||||
|
AccountID int64
|
||||||
|
AmountMsat int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry is a posting as seen from one account's history.
|
||||||
|
type Entry struct {
|
||||||
|
TransactionID int64
|
||||||
|
Kind string
|
||||||
|
RoundID *int64
|
||||||
|
AmountMsat int64
|
||||||
|
BalanceBefore int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ledger struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Ledger { return &Ledger{pool: pool} }
|
||||||
|
|
||||||
|
// Post writes one balanced transaction atomically.
|
||||||
|
//
|
||||||
|
// Accounts are locked in ascending id order so that concurrent transactions
|
||||||
|
// touching the same accounts cannot deadlock, and so a balance read cannot be
|
||||||
|
// stale by the time the posting is written.
|
||||||
|
func (l *Ledger) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) (int64, error) {
|
||||||
|
if len(postings) == 0 {
|
||||||
|
return 0, ErrEmptyTransaction
|
||||||
|
}
|
||||||
|
// The zero-sum check must be overflow-safe. Accumulating into an int64
|
||||||
|
// lets a crafted posting set wrap to zero — two legs of MaxInt64 and one
|
||||||
|
// of 2 sum to 0 in wrapping arithmetic — which would mint money out of
|
||||||
|
// nothing. big.Int has no such boundary.
|
||||||
|
sum := new(big.Int)
|
||||||
|
for _, p := range postings {
|
||||||
|
sum.Add(sum, big.NewInt(p.AmountMsat))
|
||||||
|
}
|
||||||
|
if sum.Sign() != 0 {
|
||||||
|
return 0, fmt.Errorf("%w: sum is %s", ErrUnbalanced, sum.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := l.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var txID int64
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`,
|
||||||
|
kind, roundID).Scan(&txID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge duplicate accounts before locking. A transaction that touched the
|
||||||
|
// same account twice would otherwise read a stale balance for the second
|
||||||
|
// leg and write a posting that contradicts the first.
|
||||||
|
merged := make(map[int64]int64, len(postings))
|
||||||
|
order := make([]int64, 0, len(postings))
|
||||||
|
for _, p := range postings {
|
||||||
|
if _, seen := merged[p.AccountID]; !seen {
|
||||||
|
order = append(order, p.AccountID)
|
||||||
|
}
|
||||||
|
merged[p.AccountID] += p.AmountMsat
|
||||||
|
}
|
||||||
|
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(order))
|
||||||
|
amounts := make([]int64, 0, len(order))
|
||||||
|
for _, id := range order {
|
||||||
|
if merged[id] == 0 {
|
||||||
|
continue // legs cancelled out; nothing to record
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
amounts = append(amounts, merged[id])
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
// Every leg cancelled. The transaction row stands as a record that
|
||||||
|
// something was attempted, but there is no balance change to write.
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return txID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock every account first, in ascending id order so concurrent
|
||||||
|
// transactions cannot deadlock against each other.
|
||||||
|
//
|
||||||
|
// This must be its own statement. A single statement — even one whose CTE
|
||||||
|
// does FOR UPDATE — evaluates against one snapshot taken before the locks
|
||||||
|
// are held, so the balance read would see pre-lock data and concurrent
|
||||||
|
// transactions would silently overwrite each other. Splitting it means the
|
||||||
|
// second statement takes a fresh snapshot, by which point we hold the
|
||||||
|
// locks and no other writer can commit against these accounts.
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE`,
|
||||||
|
ids); err != nil {
|
||||||
|
return 0, fmt.Errorf("locking accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now write every posting in one statement, however many legs there are.
|
||||||
|
// Doing this per-posting cost three round trips each, which made
|
||||||
|
// settlement scale in network latency rather than in real work.
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
WITH input AS (
|
||||||
|
SELECT unnest($2::bigint[]) AS account_id,
|
||||||
|
unnest($3::bigint[]) AS amount
|
||||||
|
),
|
||||||
|
current AS (
|
||||||
|
SELECT i.account_id,
|
||||||
|
i.amount,
|
||||||
|
COALESCE((SELECT p.balance_after
|
||||||
|
FROM postings p
|
||||||
|
WHERE p.account_id = i.account_id
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
LIMIT 1), 0) AS balance_before
|
||||||
|
FROM input i
|
||||||
|
)
|
||||||
|
INSERT INTO postings
|
||||||
|
(transaction_id, account_id, amount_msat, balance_before, balance_after)
|
||||||
|
SELECT $1, account_id, amount, balance_before, balance_before + amount
|
||||||
|
FROM current
|
||||||
|
RETURNING account_id, balance_after`,
|
||||||
|
txID, ids, amounts)
|
||||||
|
if err != nil {
|
||||||
|
// The balance floor is enforced by a database trigger, so an overdraft
|
||||||
|
// surfaces here. Translate it into the domain error callers expect.
|
||||||
|
if isBalanceFloorViolation(err) {
|
||||||
|
return 0, fmt.Errorf("%w: %v", ErrInsufficientFunds, err)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
written := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var acct, after int64
|
||||||
|
if err := rows.Scan(&acct, &after); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
if isBalanceFloorViolation(err) {
|
||||||
|
return 0, fmt.Errorf("%w: %v", ErrInsufficientFunds, err)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if written != len(ids) {
|
||||||
|
return 0, fmt.Errorf("ledger: wrote %d postings for %d accounts; "+
|
||||||
|
"an account id does not exist", written, len(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
if isBalanceFloorViolation(err) {
|
||||||
|
return 0, fmt.Errorf("%w: %v", ErrInsufficientFunds, err)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return txID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBalanceFloorViolation reports whether an error is the database refusing to
|
||||||
|
// let an account go negative.
|
||||||
|
func isBalanceFloorViolation(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "may not go negative") ||
|
||||||
|
strings.Contains(msg, "balance_nonnegative")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group is one logical transaction inside a batch.
|
||||||
|
type Group struct {
|
||||||
|
Kind string
|
||||||
|
RoundID *int64
|
||||||
|
Postings []Posting
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostMany writes several transactions in a single database transaction.
|
||||||
|
//
|
||||||
|
// This is what makes batching worth anything. Writing them one at a time costs
|
||||||
|
// one commit — and one fsync — each, which is the ceiling on how fast bets can
|
||||||
|
// be taken. Sharing a commit amortises that across the whole batch.
|
||||||
|
//
|
||||||
|
// Each group remains its own ledger transaction with its own postings, so the
|
||||||
|
// zero-sum invariant is unchanged; what is shared is durability, not identity.
|
||||||
|
//
|
||||||
|
// The trade-off is atomicity across unrelated bets: if one group fails, the
|
||||||
|
// whole batch rolls back. Callers handle that by retrying the batch one group
|
||||||
|
// at a time to isolate the offender, which is rare because balances are
|
||||||
|
// checked before a group ever enters a batch.
|
||||||
|
func (l *Ledger) PostMany(ctx context.Context, groups []Group) ([]int64, error) {
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate every group before opening a transaction, so a malformed one
|
||||||
|
// cannot abort work that was otherwise fine.
|
||||||
|
for i, g := range groups {
|
||||||
|
if len(g.Postings) == 0 {
|
||||||
|
return nil, fmt.Errorf("group %d: %w", i, ErrEmptyTransaction)
|
||||||
|
}
|
||||||
|
sum := new(big.Int)
|
||||||
|
for _, p := range g.Postings {
|
||||||
|
sum.Add(sum, big.NewInt(p.AmountMsat))
|
||||||
|
}
|
||||||
|
if sum.Sign() != 0 {
|
||||||
|
return nil, fmt.Errorf("group %d: %w: sum is %s", i, ErrUnbalanced, sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := l.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Lock every account the batch touches, once, in ascending order. Doing
|
||||||
|
// this per group would take and retake the same locks and reintroduce the
|
||||||
|
// deadlock risk that ordering exists to prevent.
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
var allIDs []int64
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, p := range g.Postings {
|
||||||
|
if _, ok := seen[p.AccountID]; !ok {
|
||||||
|
seen[p.AccountID] = struct{}{}
|
||||||
|
allIDs = append(allIDs, p.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(allIDs, func(i, j int) bool { return allIDs[i] < allIDs[j] })
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE`,
|
||||||
|
allIDs); err != nil {
|
||||||
|
return nil, fmt.Errorf("locking accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read every balance once, then track them in memory as the batch is
|
||||||
|
// applied. Re-reading per group would cost a round trip each and defeat
|
||||||
|
// the point of sharing the transaction.
|
||||||
|
balances := make(map[int64]int64, len(allIDs))
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT a.id,
|
||||||
|
COALESCE((SELECT p.balance_after FROM postings p
|
||||||
|
WHERE p.account_id = a.id
|
||||||
|
ORDER BY p.id DESC LIMIT 1), 0)
|
||||||
|
FROM accounts a WHERE a.id = ANY($1)`, allIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, bal int64
|
||||||
|
if err := rows.Scan(&id, &bal); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
balances[id] = bal
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
negativeOK := make(map[int64]bool, len(allIDs))
|
||||||
|
nrows, err := tx.Query(ctx,
|
||||||
|
`SELECT id, allow_negative FROM accounts WHERE id = ANY($1)`, allIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for nrows.Next() {
|
||||||
|
var id int64
|
||||||
|
var ok bool
|
||||||
|
if err := nrows.Scan(&id, &ok); err != nil {
|
||||||
|
nrows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
negativeOK[id] = ok
|
||||||
|
}
|
||||||
|
nrows.Close()
|
||||||
|
|
||||||
|
txIDs := make([]int64, 0, len(groups))
|
||||||
|
for gi, g := range groups {
|
||||||
|
var txID int64
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`,
|
||||||
|
g.Kind, g.RoundID).Scan(&txID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
txIDs = append(txIDs, txID)
|
||||||
|
|
||||||
|
merged := make(map[int64]int64, len(g.Postings))
|
||||||
|
var order []int64
|
||||||
|
for _, p := range g.Postings {
|
||||||
|
if _, ok := merged[p.AccountID]; !ok {
|
||||||
|
order = append(order, p.AccountID)
|
||||||
|
}
|
||||||
|
merged[p.AccountID] += p.AmountMsat
|
||||||
|
}
|
||||||
|
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
|
||||||
|
|
||||||
|
for _, id := range order {
|
||||||
|
amount := merged[id]
|
||||||
|
if amount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := balances[id]
|
||||||
|
after := before + amount
|
||||||
|
if (amount > 0 && after < before) || (amount < 0 && after > before) {
|
||||||
|
return nil, fmt.Errorf("group %d: amount %d overflows account %d",
|
||||||
|
gi, amount, id)
|
||||||
|
}
|
||||||
|
if after < 0 && !negativeOK[id] {
|
||||||
|
return nil, fmt.Errorf("group %d: %w: account %d holds %d, needs %d",
|
||||||
|
gi, ErrInsufficientFunds, id, before, -amount)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO postings
|
||||||
|
(transaction_id, account_id, amount_msat, balance_before, balance_after)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
txID, id, amount, before, after); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
balances[id] = after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return txIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer moves funds between two accounts. This is the peer-to-peer path.
|
||||||
|
func (l *Ledger) Transfer(ctx context.Context, from, to int64, amountMsat int64) (int64, error) {
|
||||||
|
if amountMsat <= 0 {
|
||||||
|
return 0, ErrNonPositiveAmount
|
||||||
|
}
|
||||||
|
return l.Post(ctx, "transfer", nil, []Posting{
|
||||||
|
{AccountID: from, AmountMsat: -amountMsat},
|
||||||
|
{AccountID: to, AmountMsat: amountMsat},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposit credits a player from the Lightning bridge account.
|
||||||
|
func (l *Ledger) Deposit(ctx context.Context, player int64, amountMsat int64) (int64, error) {
|
||||||
|
if amountMsat <= 0 {
|
||||||
|
return 0, ErrNonPositiveAmount
|
||||||
|
}
|
||||||
|
bridge, err := l.AccountByName(ctx, "lightning_bridge")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return l.Post(ctx, "deposit", nil, []Posting{
|
||||||
|
{AccountID: bridge, AmountMsat: -amountMsat},
|
||||||
|
{AccountID: player, AmountMsat: amountMsat},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withdraw debits a player back to the Lightning bridge account.
|
||||||
|
func (l *Ledger) Withdraw(ctx context.Context, player int64, amountMsat int64) (int64, error) {
|
||||||
|
if amountMsat <= 0 {
|
||||||
|
return 0, ErrNonPositiveAmount
|
||||||
|
}
|
||||||
|
bridge, err := l.AccountByName(ctx, "lightning_bridge")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return l.Post(ctx, "withdraw", nil, []Posting{
|
||||||
|
{AccountID: player, AmountMsat: -amountMsat},
|
||||||
|
{AccountID: bridge, AmountMsat: amountMsat},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance returns the account's current balance in millisatoshis.
|
||||||
|
func (l *Ledger) Balance(ctx context.Context, accountID int64) (int64, error) {
|
||||||
|
var bal int64
|
||||||
|
err := l.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(
|
||||||
|
(SELECT balance_after FROM postings
|
||||||
|
WHERE account_id = $1 ORDER BY id DESC LIMIT 1), 0)`,
|
||||||
|
accountID).Scan(&bal)
|
||||||
|
return bal, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// History returns an account's postings, newest first.
|
||||||
|
func (l *Ledger) History(ctx context.Context, accountID int64, limit int) ([]Entry, error) {
|
||||||
|
rows, err := l.pool.Query(ctx,
|
||||||
|
`SELECT p.transaction_id, t.kind, t.round_id,
|
||||||
|
p.amount_msat, p.balance_before, p.balance_after, p.created_at
|
||||||
|
FROM postings p
|
||||||
|
JOIN transactions t ON t.id = p.transaction_id
|
||||||
|
WHERE p.account_id = $1
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
LIMIT $2`, accountID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
if err := rows.Scan(&e.TransactionID, &e.Kind, &e.RoundID,
|
||||||
|
&e.AmountMsat, &e.BalanceBefore, &e.BalanceAfter, &e.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsurePlayer returns the account id for a public key, creating it if needed.
|
||||||
|
func (l *Ledger) EnsurePlayer(ctx context.Context, pubkey []byte) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := l.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO accounts (kind, pubkey) VALUES ('player', $1)
|
||||||
|
ON CONFLICT (pubkey) DO UPDATE SET pubkey = EXCLUDED.pubkey
|
||||||
|
RETURNING id`, pubkey).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountByName resolves a system account such as "house_pot".
|
||||||
|
func (l *Ledger) AccountByName(ctx context.Context, name string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := l.pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM accounts WHERE name = $1`, name).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, fmt.Errorf("ledger: no account named %q", name)
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalIssued is the value held inside the system by players and the house —
|
||||||
|
// every account except the external Lightning bridge. It changes only when
|
||||||
|
// funds genuinely enter or leave, never through internal play.
|
||||||
|
func (l *Ledger) TotalIssued(ctx context.Context) (int64, error) {
|
||||||
|
return l.sumBalances(ctx, `WHERE NOT a.allow_negative`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConservationCheck sums every account including the bridge. Because each
|
||||||
|
// transaction sums to zero, this must always be exactly zero. A non-zero
|
||||||
|
// result means the books are corrupt, and is the top-level audit alarm.
|
||||||
|
func (l *Ledger) ConservationCheck(ctx context.Context) (int64, error) {
|
||||||
|
return l.sumBalances(ctx, ``)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumBalances totals account balances.
|
||||||
|
//
|
||||||
|
// Postgres SUM() over bigint returns numeric, which can exceed int64 even
|
||||||
|
// though no single balance can. Scanning it as text and parsing through
|
||||||
|
// big.Int means a corrupt ledger reports a clear error instead of a scan
|
||||||
|
// failure — the alarm must survive the very condition it exists to detect.
|
||||||
|
func (l *Ledger) sumBalances(ctx context.Context, where string) (int64, error) {
|
||||||
|
var text string
|
||||||
|
err := l.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(SUM(b.balance_msat), 0)::text
|
||||||
|
FROM account_balances b
|
||||||
|
JOIN accounts a ON a.id = b.account_id `+where).Scan(&text)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
total, ok := new(big.Int).SetString(text, 10)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("ledger: unparseable balance total %q", text)
|
||||||
|
}
|
||||||
|
if !total.IsInt64() {
|
||||||
|
return 0, fmt.Errorf("ledger: balance total %s exceeds int64; the books are corrupt", text)
|
||||||
|
}
|
||||||
|
return total.Int64(), nil
|
||||||
|
}
|
||||||
233
pkg/ledger/ledger_test.go
Normal file
233
pkg/ledger/ledger_test.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package ledger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("ARCADE_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(context.Background()); err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// runID is fresh for each execution of the test binary. The ledger is
|
||||||
|
// append-only and never truncated, so accounts must not be shared between runs
|
||||||
|
// or balances would accumulate across them.
|
||||||
|
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
||||||
|
|
||||||
|
// uniqueKey produces an account key unique to this test and this run.
|
||||||
|
func uniqueKey(t *testing.T, label string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
return []byte(fmt.Sprintf("%s-%s-%s", runID, t.Name(), label))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostRejectsUnbalanced(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
a, err := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = l.Post(ctx, "test", nil, []ledger.Posting{
|
||||||
|
{AccountID: a, AmountMsat: -100},
|
||||||
|
{AccountID: b, AmountMsat: 50},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ledger.ErrUnbalanced) {
|
||||||
|
t.Fatalf("got %v, want ErrUnbalanced", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostRejectsOverdraft(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
||||||
|
b, _ := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
||||||
|
_, err := l.Post(ctx, "test", nil, []ledger.Posting{
|
||||||
|
{AccountID: a, AmountMsat: -1_000_000},
|
||||||
|
{AccountID: b, AmountMsat: 1_000_000},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||||
|
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedTransactionLeavesNoTrace(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
||||||
|
b, _ := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
||||||
|
|
||||||
|
before, err := l.Balance(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = l.Post(ctx, "test", nil, []ledger.Posting{
|
||||||
|
{AccountID: a, AmountMsat: -500},
|
||||||
|
{AccountID: b, AmountMsat: 500},
|
||||||
|
})
|
||||||
|
after, err := l.Balance(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if before != after {
|
||||||
|
t.Fatalf("failed transaction changed balance: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConservationOfValue(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
bridge, err := l.AccountByName(ctx, "lightning_bridge")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "player"))
|
||||||
|
|
||||||
|
// Measure this account, not the system total. Other packages run in
|
||||||
|
// parallel against the same database, so a global figure moves for reasons
|
||||||
|
// unrelated to what this test asserts.
|
||||||
|
before, err := l.Balance(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Deposit(ctx, p, 5000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Withdraw(ctx, p, 5000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := l.Balance(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if before != after {
|
||||||
|
t.Fatalf("a deposit and matching withdrawal changed the balance: %d -> %d",
|
||||||
|
before, after)
|
||||||
|
}
|
||||||
|
_ = bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBalanceTracksPostings(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"))
|
||||||
|
|
||||||
|
if _, err := l.Deposit(ctx, p, 12_345); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bal, err := l.Balance(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bal != 12_345 {
|
||||||
|
t.Fatalf("balance = %d, want 12345", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two concurrent spends of the same funds must not both succeed. The account
|
||||||
|
// row lock is what prevents a double-spend under load.
|
||||||
|
func TestConcurrentSpendsCannotOverdraw(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
|
||||||
|
if _, err := l.Deposit(ctx, from, 1000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
succeeded := make([]bool, workers)
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := l.Transfer(ctx, from, to, 1000)
|
||||||
|
succeeded[i] = err == nil
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
wins := 0
|
||||||
|
for _, ok := range succeeded {
|
||||||
|
if ok {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wins != 1 {
|
||||||
|
t.Fatalf("%d concurrent spends of the same 1000 msat succeeded, want 1", wins)
|
||||||
|
}
|
||||||
|
bal, _ := l.Balance(ctx, from)
|
||||||
|
if bal != 0 {
|
||||||
|
t.Fatalf("source balance = %d, want 0", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendOnlyEnforcedByDatabase(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
l := ledger.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"))
|
||||||
|
if _, err := l.Deposit(ctx, p, 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := pool.Exec(ctx, `UPDATE postings SET amount_msat = 999 WHERE account_id = $1`, p)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("UPDATE on postings succeeded; append-only trigger is not working")
|
||||||
|
}
|
||||||
|
_, err = pool.Exec(ctx, `DELETE FROM postings WHERE account_id = $1`, p)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("DELETE on postings succeeded; append-only trigger is not working")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryExplainsEveryChange(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"))
|
||||||
|
if _, err := l.Deposit(ctx, p, 800); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Withdraw(ctx, p, 300); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := l.History(ctx, p, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("got %d history entries, want 2", len(entries))
|
||||||
|
}
|
||||||
|
// History is newest-first.
|
||||||
|
if entries[0].Kind != "withdraw" || entries[0].AmountMsat != -300 {
|
||||||
|
t.Fatalf("unexpected newest entry: %+v", entries[0])
|
||||||
|
}
|
||||||
|
if entries[0].BalanceAfter != 500 {
|
||||||
|
t.Fatalf("balance after withdraw = %d, want 500", entries[0].BalanceAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
227
pkg/ledger/load_test.go
Normal file
227
pkg/ledger/load_test.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package ledger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These measure throughput on the paths that decide whether the platform
|
||||||
|
// survives a crowd. They print numbers rather than asserting thresholds,
|
||||||
|
// because the numbers depend on the host — but the shape of the result is what
|
||||||
|
// matters, and a regression shows up immediately.
|
||||||
|
|
||||||
|
// Every bet debits the player and credits the house. All of them contend on
|
||||||
|
// the same house account row, which is the first thing to check: if that lock
|
||||||
|
// serialises the workload, no amount of hardware helps.
|
||||||
|
func TestThroughputContendedHouseAccount(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("load test")
|
||||||
|
}
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
house, err := l.EnsurePlayer(ctx, uniqueKey(t, "house"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = 64
|
||||||
|
const perPlayer = 20
|
||||||
|
|
||||||
|
ids := make([]int64, players)
|
||||||
|
for i := range ids {
|
||||||
|
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("p%d", i)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Deposit(ctx, id, 10_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok, failed atomic.Int64
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, id := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int64) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < perPlayer; i++ {
|
||||||
|
_, err := l.Post(ctx, "bet", nil, []ledger.Posting{
|
||||||
|
{AccountID: id, AmountMsat: -1000},
|
||||||
|
{AccountID: house, AmountMsat: 1000},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
failed.Add(1)
|
||||||
|
} else {
|
||||||
|
ok.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(id)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
rate := float64(ok.Load()) / elapsed.Seconds()
|
||||||
|
t.Logf("contended (shared house row): %d bets in %v = %.0f bets/sec (%d failed)",
|
||||||
|
ok.Load(), elapsed.Round(time.Millisecond), rate, failed.Load())
|
||||||
|
t.Logf(" -> a 20s betting window absorbs about %.0f bets", rate*20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same workload with the house side spread over several accounts, to
|
||||||
|
// isolate how much of the cost is lock contention rather than raw database
|
||||||
|
// throughput.
|
||||||
|
func TestThroughputShardedHouseAccount(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("load test")
|
||||||
|
}
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const shards = 16
|
||||||
|
shardIDs := make([]int64, shards)
|
||||||
|
for i := range shardIDs {
|
||||||
|
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("houseshard%d", i)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shardIDs[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = 64
|
||||||
|
const perPlayer = 20
|
||||||
|
|
||||||
|
ids := make([]int64, players)
|
||||||
|
for i := range ids {
|
||||||
|
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("sp%d", i)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Deposit(ctx, id, 10_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok, failed atomic.Int64
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for n, id := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int, id int64) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < perPlayer; i++ {
|
||||||
|
// Each player uses a fixed shard, the way a real sharded
|
||||||
|
// house account would be selected.
|
||||||
|
shard := shardIDs[n%shards]
|
||||||
|
_, err := l.Post(ctx, "bet", nil, []ledger.Posting{
|
||||||
|
{AccountID: id, AmountMsat: -1000},
|
||||||
|
{AccountID: shard, AmountMsat: 1000},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
failed.Add(1)
|
||||||
|
} else {
|
||||||
|
ok.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(n, id)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
rate := float64(ok.Load()) / elapsed.Seconds()
|
||||||
|
t.Logf("sharded (%d house rows): %d bets in %v = %.0f bets/sec (%d failed)",
|
||||||
|
shards, ok.Load(), elapsed.Round(time.Millisecond), rate, failed.Load())
|
||||||
|
t.Logf(" -> a 20s betting window absorbs about %.0f bets", rate*20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settlement writes every payout for a round. At scale this is one large
|
||||||
|
// transaction, so its cost per posting is what decides how long a crowd waits
|
||||||
|
// between rounds.
|
||||||
|
func TestThroughputBatchSettlement(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("load test")
|
||||||
|
}
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
house, _ := l.EnsurePlayer(ctx, uniqueKey(t, "settlehouse"))
|
||||||
|
if _, err := l.Deposit(ctx, house, 1_000_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, size := range []int{10, 100, 500, 1000} {
|
||||||
|
winners := make([]int64, size)
|
||||||
|
for i := range winners {
|
||||||
|
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("w%d-%d", size, i)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
winners[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
postings := make([]ledger.Posting, 0, size+1)
|
||||||
|
for _, w := range winners {
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: w, AmountMsat: 1000})
|
||||||
|
}
|
||||||
|
postings = append(postings,
|
||||||
|
ledger.Posting{AccountID: house, AmountMsat: -int64(size) * 1000})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := l.Post(ctx, "payout", nil, postings); err != nil {
|
||||||
|
t.Fatalf("settling %d winners: %v", size, err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
t.Logf("settle %4d winners in one transaction: %8v (%.2fms per winner)",
|
||||||
|
size, elapsed.Round(time.Millisecond),
|
||||||
|
float64(elapsed.Microseconds())/1000/float64(size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance reads are the most frequent query in the system: every client polls
|
||||||
|
// after every round.
|
||||||
|
func TestThroughputBalanceReads(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("load test")
|
||||||
|
}
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, "reader"))
|
||||||
|
if _, err := l.Deposit(ctx, id, 1_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readers = 32
|
||||||
|
const each = 100
|
||||||
|
var ok atomic.Int64
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < readers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < each; j++ {
|
||||||
|
if _, err := l.Balance(ctx, id); err == nil {
|
||||||
|
ok.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
t.Logf("balance reads: %d in %v = %.0f reads/sec",
|
||||||
|
ok.Load(), elapsed.Round(time.Millisecond),
|
||||||
|
float64(ok.Load())/elapsed.Seconds())
|
||||||
|
}
|
||||||
131
pkg/ledger/property_test.go
Normal file
131
pkg/ledger/property_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package ledger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Across a long run of random transfers, bets, and payouts, total value must
|
||||||
|
// never change and no player balance may go negative. This is the property that
|
||||||
|
// makes end-of-night settlement trustworthy.
|
||||||
|
func TestRandomActivityConservesValue(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// The test uses its own counterparty rather than the shared house account,
|
||||||
|
// and sums only its own accounts. Asserting on a global total would fail
|
||||||
|
// whenever another package's tests run against the same database in
|
||||||
|
// parallel — a broken test, not a broken ledger.
|
||||||
|
house, err := l.EnsurePlayer(ctx, uniqueKey(t, "counterparty"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Deposit(ctx, house, 5_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = 8
|
||||||
|
ids := make([]int64, players)
|
||||||
|
for i := range ids {
|
||||||
|
id, err := l.EnsurePlayer(ctx, uniqueKey(t, string(rune('a'+i))))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[i] = id
|
||||||
|
if _, err := l.Deposit(ctx, id, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumOwn totals only the accounts this test created.
|
||||||
|
sumOwn := func() int64 {
|
||||||
|
t.Helper()
|
||||||
|
var total int64
|
||||||
|
for _, id := range append(append([]int64{}, ids...), house) {
|
||||||
|
bal, err := l.Balance(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total += bal
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
before := sumOwn()
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewSource(1))
|
||||||
|
roundID := int64(0)
|
||||||
|
for i := 0; i < 400; i++ {
|
||||||
|
amt := int64(rng.Intn(5000) + 1)
|
||||||
|
player := ids[rng.Intn(players)]
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch rng.Intn(3) {
|
||||||
|
case 0: // peer transfer
|
||||||
|
other := ids[rng.Intn(players)]
|
||||||
|
if other == player {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err = l.Transfer(ctx, player, other, amt)
|
||||||
|
case 1: // bet: player pays the house
|
||||||
|
roundID++
|
||||||
|
r := roundID
|
||||||
|
_, err = l.Post(ctx, "bet", &r, []ledger.Posting{
|
||||||
|
{AccountID: player, AmountMsat: -amt},
|
||||||
|
{AccountID: house, AmountMsat: amt},
|
||||||
|
})
|
||||||
|
case 2: // payout: house pays the player
|
||||||
|
roundID++
|
||||||
|
r := roundID
|
||||||
|
_, err = l.Post(ctx, "payout", &r, []ledger.Posting{
|
||||||
|
{AccountID: house, AmountMsat: -amt},
|
||||||
|
{AccountID: player, AmountMsat: amt},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running out of funds is a legitimate outcome; nothing else is.
|
||||||
|
if err != nil && !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||||
|
t.Fatalf("iteration %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := sumOwn(); before != after {
|
||||||
|
t.Fatalf("value not conserved: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
bal, err := l.Balance(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bal < 0 {
|
||||||
|
t.Fatalf("account %d went negative: %d", id, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every transaction sums to zero, so the sum across all accounts including the
|
||||||
|
// external bridge must be exactly zero at all times.
|
||||||
|
func TestBooksAlwaysBalanceToZero(t *testing.T) {
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
p, err := l.EnsurePlayer(ctx, uniqueKey(t, "p"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Deposit(ctx, p, 7_777); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total, err := l.ConservationCheck(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("books do not balance: total across all accounts = %d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
228
pkg/lightning/alby.go
Normal file
228
pkg/lightning/alby.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// Package lightning — Alby Hub node implementation.
|
||||||
|
//
|
||||||
|
// Wires the Quantum Arcade double-entry ledger to a self-custodial
|
||||||
|
// Alby Hub Lightning node via its REST API.
|
||||||
|
//
|
||||||
|
// Alby Hub uses satoshis; the internal ledger uses millisatoshis.
|
||||||
|
// All conversions happen at this boundary.
|
||||||
|
package lightning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlbyNode implements Node against an Alby Hub instance.
|
||||||
|
type AlbyNode struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlbyNode returns a Node backed by the Alby Hub at baseURL.
|
||||||
|
// token is the full-access JWT.
|
||||||
|
func NewAlbyNode(baseURL, token string) *AlbyNode {
|
||||||
|
return &AlbyNode{
|
||||||
|
baseURL: baseURL,
|
||||||
|
token: token,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── request helpers ─────────
|
||||||
|
|
||||||
|
func (a *AlbyNode) do(ctx context.Context, method, path string, body any) ([]byte, error) {
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+"/api/"+path, r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("alby request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("alby %d: %s", resp.StatusCode, string(data[:min(len(data), 200)]))
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AlbyNode) get(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
return a.do(ctx, "GET", path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AlbyNode) post(ctx context.Context, path string, body any) ([]byte, error) {
|
||||||
|
return a.do(ctx, "POST", path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── Node interface ─────────
|
||||||
|
|
||||||
|
type albyInvoice struct {
|
||||||
|
PaymentHash string `json:"paymentHash"`
|
||||||
|
Invoice string `json:"invoice"`
|
||||||
|
Amount int64 `json:"amount"` // sats
|
||||||
|
State string `json:"state"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoice creates a Lightning invoice via Alby Hub.
|
||||||
|
//
|
||||||
|
// Alby Hub speaks satoshis. Amounts that do not divide into whole satoshis are
|
||||||
|
// refused rather than truncated: the ledger works in millisatoshis, and
|
||||||
|
// silently rounding here would mean the ledger and the node disagree about how
|
||||||
|
// much moved, with the difference disappearing.
|
||||||
|
func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
||||||
|
sats, err := exactSats(amountMsat)
|
||||||
|
if err != nil {
|
||||||
|
return Invoice{}, err
|
||||||
|
}
|
||||||
|
raw, err := a.post(ctx, "invoices", map[string]any{
|
||||||
|
"amount": sats,
|
||||||
|
"description": memo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Invoice{}, err
|
||||||
|
}
|
||||||
|
var inv albyInvoice
|
||||||
|
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||||
|
return Invoice{}, fmt.Errorf("parsing alby invoice: %w", err)
|
||||||
|
}
|
||||||
|
expires, _ := time.Parse(time.RFC3339, inv.ExpiresAt)
|
||||||
|
return Invoice{
|
||||||
|
PaymentHash: inv.PaymentHash,
|
||||||
|
Bolt11: inv.Invoice,
|
||||||
|
AmountMsat: satToMsat(inv.Amount),
|
||||||
|
ExpiresAt: expires,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupInvoice checks whether an invoice has been paid.
|
||||||
|
func (a *AlbyNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
||||||
|
raw, err := a.get(ctx, "invoices/"+paymentHash)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
var inv albyInvoice
|
||||||
|
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||||
|
return false, 0, fmt.Errorf("parsing alby invoice: %w", err)
|
||||||
|
}
|
||||||
|
return inv.State == "settled", satToMsat(inv.Amount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type albyPayment struct {
|
||||||
|
PaymentHash string `json:"paymentHash"`
|
||||||
|
Preimage string `json:"preimage"`
|
||||||
|
Amount int64 `json:"amountSat"`
|
||||||
|
Fee int64 `json:"feesPaidSat"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayInvoice sends an outbound Lightning payment.
|
||||||
|
//
|
||||||
|
// The fee cap is passed to the node as a limit and checked again on the
|
||||||
|
// result. Alby's REST API does not guarantee it will honour a requested cap,
|
||||||
|
// and the caller sizes the cap against what the house is willing to lose on
|
||||||
|
// routing — so an over-priced payment is reported as a failure, which makes
|
||||||
|
// the Service refund the player rather than absorb an unbounded cost.
|
||||||
|
func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
||||||
|
maxFeeSats := maxFeeMsat / 1000
|
||||||
|
|
||||||
|
raw, err := a.post(ctx, "payments", map[string]any{
|
||||||
|
"invoice": bolt11,
|
||||||
|
// Requested limit. Not all versions enforce it, hence the check below.
|
||||||
|
"maxFeeSat": maxFeeSats,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Payment{}, err
|
||||||
|
}
|
||||||
|
var p albyPayment
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
return Payment{}, fmt.Errorf("parsing alby payment: %w", err)
|
||||||
|
}
|
||||||
|
if p.State != "settled" {
|
||||||
|
return Payment{}, fmt.Errorf("%w: payment %s state=%s",
|
||||||
|
ErrPaymentFailed, p.PaymentHash, p.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
feeMsat := satToMsat(p.Fee)
|
||||||
|
if maxFeeMsat > 0 && feeMsat > maxFeeMsat {
|
||||||
|
// The payment has already gone out — Lightning cannot be recalled. Report
|
||||||
|
// it so the operator sees the overrun rather than discovering it in the
|
||||||
|
// books, and so the Service does not record it as a clean success.
|
||||||
|
return Payment{}, fmt.Errorf(
|
||||||
|
"%w: routing cost %d msat, above the %d msat cap (payment %s already sent)",
|
||||||
|
ErrPaymentFailed, feeMsat, maxFeeMsat, p.PaymentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Payment{
|
||||||
|
PaymentHash: p.PaymentHash,
|
||||||
|
Preimage: p.Preimage,
|
||||||
|
AmountMsat: satToMsat(p.Amount),
|
||||||
|
FeeMsat: satToMsat(p.Fee),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type albyBalances struct {
|
||||||
|
Lightning struct {
|
||||||
|
TotalSpendable int64 `json:"totalSpendableSat"`
|
||||||
|
} `json:"lightning"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance returns the spendable Lightning balance in millisatoshis.
|
||||||
|
func (a *AlbyNode) Balance(ctx context.Context) (int64, error) {
|
||||||
|
raw, err := a.get(ctx, "balances")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var b albyBalances
|
||||||
|
if err := json.Unmarshal(raw, &b); err != nil {
|
||||||
|
return 0, fmt.Errorf("parsing alby balances: %w", err)
|
||||||
|
}
|
||||||
|
return satToMsat(b.Lightning.TotalSpendable), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── sat ↔ msat conversion ─────────
|
||||||
|
|
||||||
|
func satToMsat(sats int64) int64 { return sats * 1000 }
|
||||||
|
|
||||||
|
// exactSats converts millisatoshis to satoshis, refusing any amount that would
|
||||||
|
// lose precision.
|
||||||
|
//
|
||||||
|
// Lightning cannot carry sub-satoshi amounts. Truncating would mean the ledger
|
||||||
|
// debits 1500 msat while 1000 msat actually leaves, and the missing 500 would
|
||||||
|
// be unaccounted — the kind of drift that only shows up as a books-do-not-
|
||||||
|
// balance alarm weeks later.
|
||||||
|
func exactSats(msat int64) (int64, error) {
|
||||||
|
if msat <= 0 {
|
||||||
|
return 0, fmt.Errorf("%w: amount %d is not positive", ErrAmountOutOfRange, msat)
|
||||||
|
}
|
||||||
|
if msat%1000 != 0 {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: %d msat is not a whole number of satoshis; Lightning cannot send sub-satoshi amounts",
|
||||||
|
ErrAmountOutOfRange, msat)
|
||||||
|
}
|
||||||
|
return msat / 1000, nil
|
||||||
|
}
|
||||||
243
pkg/lightning/alby_test.go
Normal file
243
pkg/lightning/alby_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package lightning_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lightning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These exercise the Alby Hub client against a mock of its REST API.
|
||||||
|
//
|
||||||
|
// A real node cannot be part of the test suite — it would need channels,
|
||||||
|
// liquidity, and real sats — but everything the client is responsible for can
|
||||||
|
// be: request shape, response parsing, unit conversion, error handling, and
|
||||||
|
// the fee cap. Those are where a client loses money, not the routing.
|
||||||
|
|
||||||
|
// albyMock stands in for Alby Hub. Handlers can be overridden per test.
|
||||||
|
type albyMock struct {
|
||||||
|
*httptest.Server
|
||||||
|
lastPath string
|
||||||
|
lastBody map[string]any
|
||||||
|
invoiceResp string
|
||||||
|
paymentResp string
|
||||||
|
balanceResp string
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAlbyMock(t *testing.T) *albyMock {
|
||||||
|
t.Helper()
|
||||||
|
m := &albyMock{
|
||||||
|
invoiceResp: `{"paymentHash":"abc123","invoice":"lnbc500n1...",
|
||||||
|
"amount":500,"state":"unpaid",
|
||||||
|
"expiresAt":"2030-01-01T00:00:00Z"}`,
|
||||||
|
paymentResp: `{"paymentHash":"pay123","preimage":"deadbeef",
|
||||||
|
"amountSat":500,"feesPaidSat":2,"state":"settled"}`,
|
||||||
|
balanceResp: `{"lightning":{"totalSpendableSat":15000}}`,
|
||||||
|
status: 200,
|
||||||
|
}
|
||||||
|
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m.lastPath = r.URL.Path
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&m.lastBody)
|
||||||
|
}
|
||||||
|
if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"missing token"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(m.status)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/invoices"):
|
||||||
|
_, _ = w.Write([]byte(m.invoiceResp))
|
||||||
|
case strings.Contains(r.URL.Path, "/payments"):
|
||||||
|
_, _ = w.Write([]byte(m.paymentResp))
|
||||||
|
case strings.Contains(r.URL.Path, "/balances"):
|
||||||
|
_, _ = w.Write([]byte(m.balanceResp))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(m.Close)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbySendsBearerToken(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "test-token")
|
||||||
|
|
||||||
|
if _, err := node.Balance(context.Background()); err != nil {
|
||||||
|
t.Fatalf("authenticated request failed: %v", err)
|
||||||
|
}
|
||||||
|
// And without a token the mock rejects, proving the header is what carries it.
|
||||||
|
bare := lightning.NewAlbyNode(m.URL, "")
|
||||||
|
if _, err := bare.Balance(context.Background()); err == nil {
|
||||||
|
t.Fatal("a request with no token succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alby speaks satoshis; the ledger speaks millisatoshis. A conversion error
|
||||||
|
// here is a factor-of-1000 money bug.
|
||||||
|
func TestAlbyConvertsSatsToMillisats(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
bal, err := node.Balance(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bal != 15_000_000 {
|
||||||
|
t.Fatalf("balance = %d msat, want 15000000 (15000 sats)", bal)
|
||||||
|
}
|
||||||
|
|
||||||
|
inv, err := node.CreateInvoice(context.Background(), 500_000, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inv.AmountMsat != 500_000 {
|
||||||
|
t.Fatalf("invoice amount = %d msat, want 500000", inv.AmountMsat)
|
||||||
|
}
|
||||||
|
// The request must have asked for sats, not millisats.
|
||||||
|
if got := m.lastBody["amount"]; got != float64(500) {
|
||||||
|
t.Fatalf("asked Alby for amount %v, want 500 sats", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fee cap must actually be enforced. The Service computes a cap and relies
|
||||||
|
// on the node refusing anything above it; a client that ignores the cap turns
|
||||||
|
// a bounded cost into an unbounded one.
|
||||||
|
func TestAlbyRefusesPaymentAboveTheFeeCap(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
// Alby reports a 50-sat fee on this route.
|
||||||
|
m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
|
||||||
|
"feesPaidSat":50,"state":"settled"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
// Cap of 10 sats. 50 > 10, so this must fail.
|
||||||
|
_, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a payment costing 50 sats was accepted under a 10 sat cap")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, lightning.ErrPaymentFailed) {
|
||||||
|
t.Fatalf("got %v, want ErrPaymentFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbyAcceptsPaymentWithinTheFeeCap(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
|
||||||
|
"feesPaidSat":2,"state":"settled"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
p, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a payment within the cap was refused: %v", err)
|
||||||
|
}
|
||||||
|
if p.FeeMsat != 2_000 {
|
||||||
|
t.Fatalf("fee = %d msat, want 2000", p.FeeMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A payment that has not settled must not be reported as success. Treating
|
||||||
|
// "pending" as paid would credit a withdrawal that may still fail.
|
||||||
|
func TestAlbyUnsettledPaymentIsAnError(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.paymentResp = `{"paymentHash":"p","preimage":"","amountSat":1000,
|
||||||
|
"feesPaidSat":1,"state":"pending"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
if _, err := node.PayInvoice(context.Background(), "lnbc...", 100_000); err == nil {
|
||||||
|
t.Fatal("a pending payment was reported as settled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbyInvoiceSettlementState(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
|
||||||
|
"state":"unpaid","expiresAt":"2030-01-01T00:00:00Z"}`
|
||||||
|
settled, amt, err := node.LookupInvoice(context.Background(), "h")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settled {
|
||||||
|
t.Fatal("an unpaid invoice reported as settled")
|
||||||
|
}
|
||||||
|
if amt != 300_000 {
|
||||||
|
t.Fatalf("amount = %d msat, want 300000", amt)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
|
||||||
|
"state":"settled","expiresAt":"2030-01-01T00:00:00Z"}`
|
||||||
|
settled, _, err = node.LookupInvoice(context.Background(), "h")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !settled {
|
||||||
|
t.Fatal("a settled invoice reported as unpaid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An error from the node must surface, not be swallowed into a zero value that
|
||||||
|
// downstream code reads as "no balance" or "not settled".
|
||||||
|
func TestAlbyErrorsSurface(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.status = 500
|
||||||
|
m.balanceResp = `{"error":"node offline"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
if _, err := node.Balance(context.Background()); err == nil {
|
||||||
|
t.Fatal("a 500 from the node was not reported as an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbyMalformedResponseIsAnError(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.balanceResp = `not json at all`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
if _, err := node.Balance(context.Background()); err == nil {
|
||||||
|
t.Fatal("a malformed response was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A withdrawal amount that does not divide into whole satoshis must not
|
||||||
|
// silently short the player. Lightning cannot send sub-satoshi amounts, so the
|
||||||
|
// client must refuse rather than truncate.
|
||||||
|
func TestAlbyRefusesSubSatoshiPrecisionLoss(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
// 1500 msat is 1.5 sats. Truncating sends 1 sat while the ledger debited
|
||||||
|
// 1500 msat, quietly costing the player 500 msat.
|
||||||
|
_, err := node.CreateInvoice(context.Background(), 1_500, "dust")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an amount with sub-satoshi precision was accepted; " +
|
||||||
|
"it would be truncated and the difference lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client must respect a cancelled context rather than hanging.
|
||||||
|
func TestAlbyHonoursContextCancellation(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if _, err := node.Balance(ctx); err == nil {
|
||||||
|
t.Fatal("a cancelled context did not stop the request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client must satisfy the Node interface the Service depends on.
|
||||||
|
func TestAlbyImplementsNode(t *testing.T) {
|
||||||
|
var _ lightning.Node = (*lightning.AlbyNode)(nil)
|
||||||
|
}
|
||||||
173
pkg/lightning/fake.go
Normal file
173
pkg/lightning/fake.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package lightning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FakeNode is a Lightning node that can be told to misbehave.
|
||||||
|
//
|
||||||
|
// Real nodes fail in specific, awkward ways: they go unreachable mid-call, they
|
||||||
|
// report a payment as failed after it actually went through, they settle an
|
||||||
|
// invoice twice. The money-handling code has to be correct against all of it,
|
||||||
|
// so the fake makes each of those reproducible instead of waiting for it to
|
||||||
|
// happen at a party.
|
||||||
|
type FakeNode struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
balanceMsat int64
|
||||||
|
invoices map[string]*fakeInvoice
|
||||||
|
payments []Payment
|
||||||
|
|
||||||
|
// Failure switches.
|
||||||
|
FailCreate bool
|
||||||
|
FailLookup bool
|
||||||
|
FailPay bool
|
||||||
|
FailBalance bool
|
||||||
|
// PayLatency delays payments, for testing concurrent processing.
|
||||||
|
PayLatency time.Duration
|
||||||
|
// FeeRateBP is the routing fee charged, in basis points of the amount.
|
||||||
|
// Real Lightning fees are proportional, and a flat fake fee makes small
|
||||||
|
// payments look impossible while large ones look free.
|
||||||
|
FeeRateBP int64
|
||||||
|
// FeeMsat, when non-zero, overrides the rate with a flat fee. Used to
|
||||||
|
// test the fee cap.
|
||||||
|
FeeMsat int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRateBP mirrors Limits.MaxFeeRateBP so the fake can recover the amount
|
||||||
|
// from the cap it was handed.
|
||||||
|
const maxRateBP = 100
|
||||||
|
|
||||||
|
type fakeInvoice struct {
|
||||||
|
hash string
|
||||||
|
amount int64
|
||||||
|
settled bool
|
||||||
|
paidAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFakeNode(balanceMsat int64) *FakeNode {
|
||||||
|
return &FakeNode{
|
||||||
|
balanceMsat: balanceMsat,
|
||||||
|
invoices: make(map[string]*fakeInvoice),
|
||||||
|
FeeRateBP: 10, // 0.1%, a realistic routing fee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.FailCreate {
|
||||||
|
return Invoice{}, fmt.Errorf("fake: node refused to create an invoice")
|
||||||
|
}
|
||||||
|
var raw [16]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
return Invoice{}, err
|
||||||
|
}
|
||||||
|
hash := hex.EncodeToString(raw[:])
|
||||||
|
f.invoices[hash] = &fakeInvoice{hash: hash, amount: amountMsat}
|
||||||
|
return Invoice{
|
||||||
|
PaymentHash: hash,
|
||||||
|
Bolt11: "lnbcrt" + hash,
|
||||||
|
AmountMsat: amountMsat,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.FailLookup {
|
||||||
|
return false, 0, fmt.Errorf("fake: node unreachable")
|
||||||
|
}
|
||||||
|
inv, ok := f.invoices[paymentHash]
|
||||||
|
if !ok {
|
||||||
|
return false, 0, fmt.Errorf("fake: unknown invoice")
|
||||||
|
}
|
||||||
|
return inv.settled, inv.amount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// feeFor computes what this node would charge to route an amount.
|
||||||
|
func (f *FakeNode) feeFor(amountMsat int64) int64 {
|
||||||
|
if f.FeeMsat > 0 {
|
||||||
|
return f.FeeMsat
|
||||||
|
}
|
||||||
|
return amountMsat * f.FeeRateBP / 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
||||||
|
if f.PayLatency > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(f.PayLatency):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Payment{}, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.FailPay {
|
||||||
|
return Payment{}, fmt.Errorf("%w: no route", ErrPaymentFailed)
|
||||||
|
}
|
||||||
|
// The caller passes the cap it computed; the fee here is what routing
|
||||||
|
// would actually cost. A real node refuses when the cap is too tight.
|
||||||
|
fee := f.feeFor(maxFeeMsat * 10000 / maxRateBP)
|
||||||
|
if f.FeeMsat > 0 {
|
||||||
|
fee = f.FeeMsat
|
||||||
|
}
|
||||||
|
if fee > maxFeeMsat {
|
||||||
|
return Payment{}, fmt.Errorf("%w: fee %d exceeds cap %d",
|
||||||
|
ErrPaymentFailed, fee, maxFeeMsat)
|
||||||
|
}
|
||||||
|
var raw [16]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
return Payment{}, err
|
||||||
|
}
|
||||||
|
p := Payment{
|
||||||
|
PaymentHash: hex.EncodeToString(raw[:]),
|
||||||
|
Preimage: hex.EncodeToString(raw[:]),
|
||||||
|
FeeMsat: fee,
|
||||||
|
}
|
||||||
|
f.payments = append(f.payments, p)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeNode) Balance(ctx context.Context) (int64, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.FailBalance {
|
||||||
|
return 0, fmt.Errorf("fake: node unreachable")
|
||||||
|
}
|
||||||
|
return f.balanceMsat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- test controls ---
|
||||||
|
|
||||||
|
// MarkPaid simulates someone paying an invoice.
|
||||||
|
func (f *FakeNode) MarkPaid(paymentHash string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if inv, ok := f.invoices[paymentHash]; ok {
|
||||||
|
inv.settled = true
|
||||||
|
inv.paidAt = time.Now()
|
||||||
|
f.balanceMsat += inv.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBalance overrides the node's reported balance, for solvency tests.
|
||||||
|
func (f *FakeNode) SetBalance(msat int64) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.balanceMsat = msat
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentCount reports how many outbound payments were actually sent, which is
|
||||||
|
// how a test detects a double-spend that the ledger alone would not reveal.
|
||||||
|
func (f *FakeNode) PaymentCount() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.payments)
|
||||||
|
}
|
||||||
396
pkg/lightning/lightning.go
Normal file
396
pkg/lightning/lightning.go
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
// Package lightning moves real money in and out of the internal ledger.
|
||||||
|
//
|
||||||
|
// The node is an interface, so the deposit and withdrawal logic — which is
|
||||||
|
// where money is actually at risk — is exercised by tests against a fake node
|
||||||
|
// that can be made to fail, stall, pay twice, or lie. Swapping in a real node
|
||||||
|
// is configuration, not new code.
|
||||||
|
//
|
||||||
|
// Two rules shape everything here:
|
||||||
|
//
|
||||||
|
// - Crediting a deposit must be idempotent. A node may report the same
|
||||||
|
// settled invoice more than once, and a duplicate credit is indistinguishable
|
||||||
|
// from minting money.
|
||||||
|
// - A withdrawal must debit before it pays. If the ledger write succeeds and
|
||||||
|
// the payment then fails, the money is recoverable. If the payment succeeds
|
||||||
|
// and the ledger write fails, it is not.
|
||||||
|
package lightning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNodeUnavailable = errors.New("lightning: node unavailable")
|
||||||
|
ErrPaymentFailed = errors.New("lightning: payment failed")
|
||||||
|
ErrAmountOutOfRange = errors.New("lightning: amount outside permitted range")
|
||||||
|
ErrAlreadyCredited = errors.New("lightning: invoice already credited")
|
||||||
|
ErrNeedsApproval = errors.New("lightning: withdrawal requires operator approval")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invoice is a request for an inbound payment.
|
||||||
|
type Invoice struct {
|
||||||
|
// PaymentHash uniquely identifies the invoice and is the idempotency key
|
||||||
|
// for crediting it.
|
||||||
|
PaymentHash string
|
||||||
|
Bolt11 string
|
||||||
|
AmountMsat int64
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment is the result of an outbound send.
|
||||||
|
type Payment struct {
|
||||||
|
PaymentHash string
|
||||||
|
Preimage string
|
||||||
|
AmountMsat int64
|
||||||
|
FeeMsat int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is the Lightning wallet. Implemented by the Alby Hub client in
|
||||||
|
// production and by a fake in tests.
|
||||||
|
type Node interface {
|
||||||
|
// CreateInvoice requests an inbound payment.
|
||||||
|
CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error)
|
||||||
|
|
||||||
|
// LookupInvoice reports whether an invoice has been paid.
|
||||||
|
LookupInvoice(ctx context.Context, paymentHash string) (settled bool, amountMsat int64, err error)
|
||||||
|
|
||||||
|
// PayInvoice sends an outbound payment. maxFeeMsat bounds the routing fee.
|
||||||
|
PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error)
|
||||||
|
|
||||||
|
// Balance reports spendable millisatoshis held by the node.
|
||||||
|
Balance(ctx context.Context) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits bound what the service will do without a human.
|
||||||
|
type Limits struct {
|
||||||
|
MinDepositMsat int64
|
||||||
|
MaxDepositMsat int64
|
||||||
|
MinWithdrawMsat int64
|
||||||
|
// MaxAutoWithdrawMsat is the largest withdrawal paid without operator
|
||||||
|
// approval. Above it the request is queued. This is the blast radius of a
|
||||||
|
// stolen session token.
|
||||||
|
MaxAutoWithdrawMsat int64
|
||||||
|
// MaxFeeRateBP caps routing fees as basis points of the amount.
|
||||||
|
MaxFeeRateBP int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLimits are deliberately conservative.
|
||||||
|
func DefaultLimits() Limits {
|
||||||
|
return Limits{
|
||||||
|
MinDepositMsat: 1_000, // 1 sat
|
||||||
|
MaxDepositMsat: 100_000_000, // 100k sats
|
||||||
|
MinWithdrawMsat: 1_000,
|
||||||
|
MaxAutoWithdrawMsat: 50_000_000, // 50k sats
|
||||||
|
MaxFeeRateBP: 100, // 1%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service ties the node to the ledger.
|
||||||
|
type Service struct {
|
||||||
|
node Node
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
limits Limits
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(node Node, l *ledger.Ledger, pool *pgxpool.Pool, limits Limits) *Service {
|
||||||
|
return &Service{node: node, ledger: l, pool: pool, limits: limits}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDeposit creates an invoice for a player and records it as pending.
|
||||||
|
func (s *Service) RequestDeposit(ctx context.Context, accountID int64, amountMsat int64) (Invoice, error) {
|
||||||
|
if amountMsat < s.limits.MinDepositMsat || amountMsat > s.limits.MaxDepositMsat {
|
||||||
|
return Invoice{}, fmt.Errorf("%w: deposits are %d to %d msat",
|
||||||
|
ErrAmountOutOfRange, s.limits.MinDepositMsat, s.limits.MaxDepositMsat)
|
||||||
|
}
|
||||||
|
|
||||||
|
inv, err := s.node.CreateInvoice(ctx, amountMsat,
|
||||||
|
fmt.Sprintf("Quantum Arcade deposit for account %d", accountID))
|
||||||
|
if err != nil {
|
||||||
|
return Invoice{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO lightning_invoices
|
||||||
|
(payment_hash, account_id, amount_msat, bolt11, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
inv.PaymentHash, accountID, inv.AmountMsat, inv.Bolt11, inv.ExpiresAt); err != nil {
|
||||||
|
return Invoice{}, fmt.Errorf("recording invoice: %w", err)
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettleDeposit credits a paid invoice to its player.
|
||||||
|
//
|
||||||
|
// It is idempotent by payment hash. A node that reports the same settlement
|
||||||
|
// twice — through a webhook retry, a reconnect, or a polling overlap — must not
|
||||||
|
// produce two credits, because a duplicate credit is indistinguishable from
|
||||||
|
// minting money out of nothing.
|
||||||
|
func (s *Service) SettleDeposit(ctx context.Context, paymentHash string) (credited int64, err error) {
|
||||||
|
// Claim the invoice first. The UPDATE only matches a row that has not been
|
||||||
|
// credited, so exactly one caller can proceed.
|
||||||
|
var accountID, amountMsat int64
|
||||||
|
err = s.pool.QueryRow(ctx,
|
||||||
|
`UPDATE lightning_invoices
|
||||||
|
SET credited_at = now()
|
||||||
|
WHERE payment_hash = $1 AND credited_at IS NULL
|
||||||
|
RETURNING account_id, amount_msat`,
|
||||||
|
paymentHash).Scan(&accountID, &amountMsat)
|
||||||
|
if err != nil {
|
||||||
|
// No row claimed: either unknown, or already credited.
|
||||||
|
var exists bool
|
||||||
|
if e := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT true FROM lightning_invoices WHERE payment_hash = $1`,
|
||||||
|
paymentHash).Scan(&exists); e == nil && exists {
|
||||||
|
return 0, ErrAlreadyCredited
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("unknown invoice %s", paymentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm with the node before crediting. Trusting a caller's word about a
|
||||||
|
// settled invoice would let anyone who can reach this endpoint mint funds.
|
||||||
|
settled, paidMsat, err := s.node.LookupInvoice(ctx, paymentHash)
|
||||||
|
if err != nil {
|
||||||
|
s.releaseClaim(ctx, paymentHash)
|
||||||
|
return 0, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||||
|
}
|
||||||
|
if !settled {
|
||||||
|
s.releaseClaim(ctx, paymentHash)
|
||||||
|
return 0, fmt.Errorf("invoice %s is not settled", paymentHash)
|
||||||
|
}
|
||||||
|
// Credit what actually arrived, not what was asked for.
|
||||||
|
if paidMsat > 0 && paidMsat != amountMsat {
|
||||||
|
amountMsat = paidMsat
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
|
||||||
|
s.releaseClaim(ctx, paymentHash)
|
||||||
|
return 0, fmt.Errorf("crediting ledger: %w", err)
|
||||||
|
}
|
||||||
|
return amountMsat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseClaim undoes a claim when the credit could not be completed, so a
|
||||||
|
// transient failure does not strand a real payment forever.
|
||||||
|
func (s *Service) releaseClaim(ctx context.Context, paymentHash string) {
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE lightning_invoices SET credited_at = NULL WHERE payment_hash = $1`,
|
||||||
|
paymentHash); err != nil {
|
||||||
|
fmt.Printf("lightning: could not release claim on %s: %v\n", paymentHash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestWithdrawal debits a player and queues an outbound payment.
|
||||||
|
//
|
||||||
|
// The debit happens first and in the same call. If the payment later fails the
|
||||||
|
// funds are refunded; the alternative ordering — pay, then debit — loses money
|
||||||
|
// permanently whenever the second step fails.
|
||||||
|
func (s *Service) RequestWithdrawal(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (int64, error) {
|
||||||
|
if amountMsat < s.limits.MinWithdrawMsat {
|
||||||
|
return 0, fmt.Errorf("%w: minimum withdrawal is %d msat",
|
||||||
|
ErrAmountOutOfRange, s.limits.MinWithdrawMsat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the funds now, so the same balance cannot be withdrawn twice by
|
||||||
|
// two concurrent requests.
|
||||||
|
if _, err := s.ledger.Withdraw(ctx, accountID, amountMsat); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "queued"
|
||||||
|
if amountMsat > s.limits.MaxAutoWithdrawMsat {
|
||||||
|
// Large withdrawals wait for a human. This bounds what a stolen
|
||||||
|
// session token can remove.
|
||||||
|
status = "needs_approval"
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO lightning_withdrawals
|
||||||
|
(account_id, bolt11, amount_msat, status)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
accountID, bolt11, amountMsat, status).Scan(&id); err != nil {
|
||||||
|
// The debit already happened; put it back rather than losing it.
|
||||||
|
if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
|
||||||
|
fmt.Printf("lightning: CRITICAL: debited %d msat from account %d but "+
|
||||||
|
"could not queue or refund: %v / %v\n", amountMsat, accountID, err, rerr)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("queueing withdrawal: %w", err)
|
||||||
|
}
|
||||||
|
if status == "needs_approval" {
|
||||||
|
return id, ErrNeedsApproval
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayHeld sends a payment for funds the caller has already debited.
|
||||||
|
//
|
||||||
|
// The LNURL flow debits when the code is issued, because a code is an
|
||||||
|
// authorisation to pull an exact amount and leaving the balance spendable
|
||||||
|
// meanwhile would let a player cash out and bet the same sats before the
|
||||||
|
// wallet claims them. By the time the wallet calls back, the money is already
|
||||||
|
// out of the player's balance and sitting with the bridge, so this must not
|
||||||
|
// debit again.
|
||||||
|
//
|
||||||
|
// On failure the funds are returned to the player, matching what the queued
|
||||||
|
// path does.
|
||||||
|
func (s *Service) PayHeld(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (Payment, error) {
|
||||||
|
maxFee := amountMsat * s.limits.MaxFeeRateBP / 10000
|
||||||
|
|
||||||
|
payment, err := s.node.PayInvoice(ctx, bolt11, maxFee)
|
||||||
|
if err != nil {
|
||||||
|
if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
|
||||||
|
return Payment{}, fmt.Errorf(
|
||||||
|
"payment failed (%v) and the refund also failed: %w", err, rerr)
|
||||||
|
}
|
||||||
|
return Payment{}, fmt.Errorf("%w: %v", ErrPaymentFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record it alongside the queued withdrawals so the operator sees one
|
||||||
|
// history rather than two.
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO lightning_withdrawals
|
||||||
|
(account_id, bolt11, amount_msat, status, payment_hash, fee_msat, resolved_at)
|
||||||
|
VALUES ($1, $2, $3, 'paid', $4, $5, now())`,
|
||||||
|
accountID, bolt11, amountMsat, payment.PaymentHash, payment.FeeMsat); err != nil {
|
||||||
|
// The payment is already gone; failing to record it is a reporting
|
||||||
|
// problem, not a money problem, so surface it and continue.
|
||||||
|
fmt.Printf("lightning: paid %s but could not record it: %v\n",
|
||||||
|
payment.PaymentHash, err)
|
||||||
|
}
|
||||||
|
return payment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid.
|
||||||
|
func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, account_id, bolt11, amount_msat
|
||||||
|
FROM lightning_withdrawals
|
||||||
|
WHERE status = 'queued'
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
type job struct {
|
||||||
|
id, account, amount int64
|
||||||
|
bolt11 string
|
||||||
|
}
|
||||||
|
var jobs []job
|
||||||
|
for rows.Next() {
|
||||||
|
var j job
|
||||||
|
if err := rows.Scan(&j.id, &j.account, &j.bolt11, &j.amount); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
paid := 0
|
||||||
|
for _, j := range jobs {
|
||||||
|
// Claim before paying, so two instances cannot send the same payment.
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE lightning_withdrawals SET status = 'sending'
|
||||||
|
WHERE id = $1 AND status = 'queued'`, j.id)
|
||||||
|
if err != nil || tag.RowsAffected() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
maxFee := j.amount * s.limits.MaxFeeRateBP / 10000
|
||||||
|
payment, err := s.node.PayInvoice(ctx, j.bolt11, maxFee)
|
||||||
|
if err != nil {
|
||||||
|
// Payment failed: refund the player and record why.
|
||||||
|
if _, rerr := s.ledger.Deposit(ctx, j.account, j.amount); rerr != nil {
|
||||||
|
fmt.Printf("lightning: CRITICAL: payment %d failed and refund failed: %v\n",
|
||||||
|
j.id, rerr)
|
||||||
|
}
|
||||||
|
if _, uerr := s.pool.Exec(ctx,
|
||||||
|
`UPDATE lightning_withdrawals
|
||||||
|
SET status = 'failed', failure = $2, resolved_at = now()
|
||||||
|
WHERE id = $1`, j.id, err.Error()); uerr != nil {
|
||||||
|
fmt.Printf("lightning: recording failure for %d: %v\n", j.id, uerr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE lightning_withdrawals
|
||||||
|
SET status = 'paid', payment_hash = $2, fee_msat = $3, resolved_at = now()
|
||||||
|
WHERE id = $1`, j.id, payment.PaymentHash, payment.FeeMsat); err != nil {
|
||||||
|
fmt.Printf("lightning: payment %d sent but not recorded: %v\n", j.id, err)
|
||||||
|
}
|
||||||
|
paid++
|
||||||
|
}
|
||||||
|
return paid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve releases a withdrawal that was held for review.
|
||||||
|
func (s *Service) Approve(ctx context.Context, withdrawalID int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE lightning_withdrawals SET status = 'queued'
|
||||||
|
WHERE id = $1 AND status = 'needs_approval'`, withdrawalID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject cancels a held withdrawal and refunds the player.
|
||||||
|
func (s *Service) Reject(ctx context.Context, withdrawalID int64, reason string) error {
|
||||||
|
var accountID, amountMsat int64
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`UPDATE lightning_withdrawals
|
||||||
|
SET status = 'rejected', failure = $2, resolved_at = now()
|
||||||
|
WHERE id = $1 AND status = 'needs_approval'
|
||||||
|
RETURNING account_id, amount_msat`,
|
||||||
|
withdrawalID, reason).Scan(&accountID, &amountMsat)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
|
||||||
|
}
|
||||||
|
if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
|
||||||
|
return fmt.Errorf("refunding rejected withdrawal: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solvency compares what the node holds against what the ledger says is owed.
|
||||||
|
//
|
||||||
|
// These must agree. A node holding less than players are owed means the
|
||||||
|
// platform cannot honour its balances, and that is worth knowing before a
|
||||||
|
// player discovers it at withdrawal time.
|
||||||
|
type Solvency struct {
|
||||||
|
NodeBalanceMsat int64
|
||||||
|
OwedToPlayers int64
|
||||||
|
SurplusMsat int64
|
||||||
|
Solvent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CheckSolvency(ctx context.Context) (Solvency, error) {
|
||||||
|
nodeBal, err := s.node.Balance(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Solvency{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||||
|
}
|
||||||
|
owed, err := s.ledger.TotalIssued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Solvency{}, err
|
||||||
|
}
|
||||||
|
return Solvency{
|
||||||
|
NodeBalanceMsat: nodeBal,
|
||||||
|
OwedToPlayers: owed,
|
||||||
|
SurplusMsat: nodeBal - owed,
|
||||||
|
Solvent: nodeBal >= owed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
486
pkg/lightning/lightning_test.go
Normal file
486
pkg/lightning/lightning_test.go
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
package lightning_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lightning"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
||||||
|
|
||||||
|
func testPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("ARCADE_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(context.Background()); err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
t *testing.T
|
||||||
|
svc *lightning.Service
|
||||||
|
node *lightning.FakeNode
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
pool := testPool(t)
|
||||||
|
l := ledger.New(pool)
|
||||||
|
node := lightning.NewFakeNode(1_000_000_000)
|
||||||
|
|
||||||
|
// Withdrawals are processed queue-wide, so leftovers from an earlier run
|
||||||
|
// would be picked up here and counted against this test. Park them.
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE lightning_withdrawals SET status = 'rejected',
|
||||||
|
failure = 'cleared by test fixture', resolved_at = now()
|
||||||
|
WHERE status IN ('queued', 'sending')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &fixture{
|
||||||
|
t: t, node: node, ledger: l, ctx: context.Background(),
|
||||||
|
svc: lightning.New(node, l, pool, lightning.DefaultLimits()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) player(label string, fundMsat int64) int64 {
|
||||||
|
f.t.Helper()
|
||||||
|
pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label))
|
||||||
|
id, err := f.ledger.EnsurePlayer(f.ctx, pk)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fundMsat > 0 {
|
||||||
|
if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- deposits ---------------- */
|
||||||
|
|
||||||
|
func TestDepositCreditsOnlyAfterPayment(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 0)
|
||||||
|
|
||||||
|
inv, err := f.svc.RequestDeposit(f.ctx, id, 50_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody has paid yet: settling must refuse.
|
||||||
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err == nil {
|
||||||
|
t.Fatal("an unpaid invoice was credited")
|
||||||
|
}
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||||
|
t.Fatalf("balance = %d before payment, want 0", bal)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.node.MarkPaid(inv.PaymentHash)
|
||||||
|
credited, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if credited != 50_000 {
|
||||||
|
t.Fatalf("credited %d, want 50000", credited)
|
||||||
|
}
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 50_000 {
|
||||||
|
t.Fatalf("balance = %d after payment, want 50000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A node reporting the same settlement twice must not mint money.
|
||||||
|
func TestDepositIsIdempotent(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 0)
|
||||||
|
|
||||||
|
inv, _ := f.svc.RequestDeposit(f.ctx, id, 25_000)
|
||||||
|
f.node.MarkPaid(inv.PaymentHash)
|
||||||
|
|
||||||
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrAlreadyCredited) {
|
||||||
|
t.Fatalf("repeat settle %d gave %v, want ErrAlreadyCredited", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 25_000 {
|
||||||
|
t.Fatalf("balance = %d after repeated settlement, want 25000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent settlements of one invoice — a webhook and a poll racing — must
|
||||||
|
// credit exactly once.
|
||||||
|
func TestConcurrentSettlementCreditsOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 0)
|
||||||
|
|
||||||
|
inv, _ := f.svc.RequestDeposit(f.ctx, id, 30_000)
|
||||||
|
f.node.MarkPaid(inv.PaymentHash)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
succeeded := make([]bool, 8)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
||||||
|
succeeded[i] = err == nil
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
wins := 0
|
||||||
|
for _, ok := range succeeded {
|
||||||
|
if ok {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wins != 1 {
|
||||||
|
t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
|
||||||
|
}
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 30_000 {
|
||||||
|
t.Fatalf("balance = %d, want 30000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A caller cannot conjure a credit by naming an invoice the node knows nothing
|
||||||
|
// about.
|
||||||
|
func TestUnknownInvoiceCannotBeCredited(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
if _, err := f.svc.SettleDeposit(f.ctx, "deadbeef"); err == nil {
|
||||||
|
t.Fatal("an unknown invoice was credited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the node is unreachable at settle time, the claim must be released so the
|
||||||
|
// real payment is not stranded forever.
|
||||||
|
func TestFailedLookupReleasesTheClaim(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 0)
|
||||||
|
|
||||||
|
inv, _ := f.svc.RequestDeposit(f.ctx, id, 10_000)
|
||||||
|
f.node.MarkPaid(inv.PaymentHash)
|
||||||
|
|
||||||
|
f.node.FailLookup = true
|
||||||
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrNodeUnavailable) {
|
||||||
|
t.Fatalf("got %v, want ErrNodeUnavailable", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the node returns, the deposit must still be creditable.
|
||||||
|
f.node.FailLookup = false
|
||||||
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||||
|
t.Fatalf("deposit stranded after a transient failure: %v", err)
|
||||||
|
}
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
||||||
|
t.Fatalf("balance = %d, want 10000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDepositLimitsEnforced(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 0)
|
||||||
|
limits := lightning.DefaultLimits()
|
||||||
|
|
||||||
|
for _, amt := range []int64{0, limits.MinDepositMsat - 1, limits.MaxDepositMsat + 1} {
|
||||||
|
if _, err := f.svc.RequestDeposit(f.ctx, id, amt); !errors.Is(err, lightning.ErrAmountOutOfRange) {
|
||||||
|
t.Errorf("amount %d gave %v, want ErrAmountOutOfRange", amt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- withdrawals ---------------- */
|
||||||
|
|
||||||
|
func TestWithdrawalDebitsImmediately(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
|
||||||
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc-invoice", 40_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Debited at request time, not at send time: otherwise the same balance
|
||||||
|
// could be withdrawn twice while the first payment is in flight.
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 60_000 {
|
||||||
|
t.Fatalf("balance = %d after requesting withdrawal, want 60000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotWithdrawMoreThanBalance(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 10_000)
|
||||||
|
|
||||||
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000); !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||||
|
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||||||
|
}
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
||||||
|
t.Fatalf("balance = %d after a refused withdrawal, want 10000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two concurrent withdrawals of the same funds: exactly one may proceed.
|
||||||
|
func TestConcurrentWithdrawalsCannotDoubleSpend(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 50_000)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]error, 6)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, results[i] = f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
ok := 0
|
||||||
|
for _, err := range results {
|
||||||
|
if err == nil {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok != 1 {
|
||||||
|
t.Fatalf("%d concurrent withdrawals of the same balance succeeded, want 1", ok)
|
||||||
|
}
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||||
|
t.Fatalf("balance = %d, want 0", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessfulWithdrawalIsPaidOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
|
||||||
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := f.node.PaymentCount()
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := f.node.PaymentCount() - before; got != 1 {
|
||||||
|
t.Fatalf("node sent %d payments, want 1", got)
|
||||||
|
}
|
||||||
|
// Processing again must not re-send.
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := f.node.PaymentCount() - before; got != 1 {
|
||||||
|
t.Fatalf("reprocessing sent the payment again: %d total", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed payment must return the money.
|
||||||
|
func TestFailedPaymentRefundsThePlayer(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
|
||||||
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 30_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterRequest, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
f.node.FailPay = true
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after != afterRequest+30_000 {
|
||||||
|
t.Fatalf("balance = %d after a failed payment, want %d (refunded)",
|
||||||
|
after, afterRequest+30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent processors, as two instances would be, must not double-send.
|
||||||
|
func TestConcurrentProcessorsSendOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 500_000)
|
||||||
|
|
||||||
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.node.PayLatency = 150 * time.Millisecond
|
||||||
|
before := f.node.PaymentCount()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = f.svc.ProcessWithdrawals(f.ctx, 10)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := f.node.PaymentCount() - before; got != 1 {
|
||||||
|
t.Fatalf("concurrent processors sent %d payments, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large withdrawals wait for a human. This bounds what a stolen token removes.
|
||||||
|
func TestLargeWithdrawalNeedsApproval(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
limits := lightning.DefaultLimits()
|
||||||
|
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
||||||
|
|
||||||
|
amount := limits.MaxAutoWithdrawMsat + 1
|
||||||
|
wid, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
||||||
|
if !errors.Is(err, lightning.ErrNeedsApproval) {
|
||||||
|
t.Fatalf("got %v, want ErrNeedsApproval", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It must not be paid while it waits.
|
||||||
|
before := f.node.PaymentCount()
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.node.PaymentCount() != before {
|
||||||
|
t.Fatal("a withdrawal awaiting approval was paid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.svc.Approve(f.ctx, wid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.node.PaymentCount() != before+1 {
|
||||||
|
t.Fatal("an approved withdrawal was not paid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedWithdrawalIsRefunded(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
limits := lightning.DefaultLimits()
|
||||||
|
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
||||||
|
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
amount := limits.MaxAutoWithdrawMsat + 1
|
||||||
|
wid, _ := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
||||||
|
|
||||||
|
if err := f.svc.Reject(f.ctx, wid, "suspicious"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after != before {
|
||||||
|
t.Fatalf("balance = %d after rejection, want %d (fully refunded)", after, before)
|
||||||
|
}
|
||||||
|
// A rejected withdrawal must never be paid afterwards.
|
||||||
|
count := f.node.PaymentCount()
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.node.PaymentCount() != count {
|
||||||
|
t.Fatal("a rejected withdrawal was paid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A routing fee above the cap must fail rather than quietly cost the house.
|
||||||
|
func TestExcessiveFeeIsRefused(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 1_000_000)
|
||||||
|
|
||||||
|
f.node.FeeMsat = 500_000 // far above 1% of the amount
|
||||||
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after != before+100_000 {
|
||||||
|
t.Fatalf("balance = %d, want %d — an over-priced payment should refund",
|
||||||
|
after, before+100_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- solvency ---------------- */
|
||||||
|
|
||||||
|
func TestSolvencyDetectsShortfall(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
|
||||||
|
owed, err := f.ledger.TotalIssued(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.node.SetBalance(owed + 1_000_000)
|
||||||
|
s, err := f.svc.CheckSolvency(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.Solvent {
|
||||||
|
t.Fatalf("reported insolvent while holding a surplus: %+v", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the node holds less than players are owed.
|
||||||
|
f.node.SetBalance(owed - 1)
|
||||||
|
s, err = f.svc.CheckSolvency(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Solvent {
|
||||||
|
t.Fatalf("reported solvent while short: %+v", s)
|
||||||
|
}
|
||||||
|
if s.SurplusMsat >= 0 {
|
||||||
|
t.Fatalf("surplus = %d, want negative", s.SurplusMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- round trip ---------------- */
|
||||||
|
|
||||||
|
// Money in, play, money out — and the books balance at the end.
|
||||||
|
func TestFullDepositWithdrawRoundTrip(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := f.player("a", 0)
|
||||||
|
|
||||||
|
inv, err := f.svc.RequestDeposit(f.ctx, id, 80_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.node.MarkPaid(inv.PaymentHash)
|
||||||
|
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 80_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||||
|
t.Fatalf("balance = %d after a full round trip, want 0", bal)
|
||||||
|
}
|
||||||
|
total, err := f.ledger.ConservationCheck(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("books do not balance after a round trip: %d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
pkg/lnurl/bech32.go
Normal file
169
pkg/lnurl/bech32.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// Package lnurl implements LNURL-withdraw, so cashing out is a scan rather
|
||||||
|
// than an errand.
|
||||||
|
//
|
||||||
|
// Without it, withdrawing means: open your wallet, create an invoice for
|
||||||
|
// exactly the right amount, copy it, come back, paste it. That is the least
|
||||||
|
// approachable thing in the arcade and the step most likely to end with
|
||||||
|
// someone giving up and leaving sats behind.
|
||||||
|
//
|
||||||
|
// With LNURL-withdraw the arcade shows a code, the player's wallet scans it,
|
||||||
|
// and the wallet pulls the funds. The player never types an amount or handles
|
||||||
|
// an invoice.
|
||||||
|
package lnurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
|
||||||
|
// bech32Polymod is the checksum function from BIP-173.
|
||||||
|
func bech32Polymod(values []byte) uint32 {
|
||||||
|
gen := []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
|
||||||
|
chk := uint32(1)
|
||||||
|
for _, v := range values {
|
||||||
|
top := chk >> 25
|
||||||
|
chk = (chk&0x1ffffff)<<5 ^ uint32(v)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if (top>>uint(i))&1 == 1 {
|
||||||
|
chk ^= gen[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
func hrpExpand(hrp string) []byte {
|
||||||
|
out := make([]byte, 0, len(hrp)*2+1)
|
||||||
|
for _, c := range hrp {
|
||||||
|
out = append(out, byte(c)>>5)
|
||||||
|
}
|
||||||
|
out = append(out, 0)
|
||||||
|
for _, c := range hrp {
|
||||||
|
out = append(out, byte(c)&31)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func createChecksum(hrp string, data []byte) []byte {
|
||||||
|
values := append(hrpExpand(hrp), data...)
|
||||||
|
values = append(values, 0, 0, 0, 0, 0, 0)
|
||||||
|
polymod := bech32Polymod(values) ^ 1
|
||||||
|
out := make([]byte, 6)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
out[i] = byte(polymod>>uint(5*(5-i))) & 31
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyChecksum(hrp string, data []byte) bool {
|
||||||
|
return bech32Polymod(append(hrpExpand(hrp), data...)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertBits regroups a byte stream between bit widths, which is how bech32
|
||||||
|
// packs 8-bit data into 5-bit symbols.
|
||||||
|
func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) {
|
||||||
|
var acc uint32
|
||||||
|
var bits uint
|
||||||
|
maxv := uint32(1)<<to - 1
|
||||||
|
var out []byte
|
||||||
|
|
||||||
|
for _, b := range data {
|
||||||
|
if from == 8 && b>>from != 0 {
|
||||||
|
return nil, fmt.Errorf("lnurl: byte %d exceeds %d bits", b, from)
|
||||||
|
}
|
||||||
|
acc = acc<<from | uint32(b)
|
||||||
|
bits += from
|
||||||
|
for bits >= to {
|
||||||
|
bits -= to
|
||||||
|
out = append(out, byte(acc>>bits)&byte(maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pad {
|
||||||
|
if bits > 0 {
|
||||||
|
out = append(out, byte(acc<<(to-bits))&byte(maxv))
|
||||||
|
}
|
||||||
|
} else if bits >= from || byte(acc<<(to-bits))&byte(maxv) != 0 {
|
||||||
|
return nil, fmt.Errorf("lnurl: invalid padding")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode renders data as a bech32 string under the given human-readable part.
|
||||||
|
func Encode(hrp string, data []byte) (string, error) {
|
||||||
|
converted, err := convertBits(data, 8, 5, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
combined := append(converted, createChecksum(hrp, converted)...)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(hrp)
|
||||||
|
sb.WriteByte('1')
|
||||||
|
for _, c := range combined {
|
||||||
|
if int(c) >= len(charset) {
|
||||||
|
return "", fmt.Errorf("lnurl: symbol %d out of range", c)
|
||||||
|
}
|
||||||
|
sb.WriteByte(charset[c])
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode parses a bech32 string back into its human-readable part and data.
|
||||||
|
func Decode(s string) (string, []byte, error) {
|
||||||
|
// Mixed case is explicitly invalid: it makes the checksum ambiguous.
|
||||||
|
lower, upper := strings.ToLower(s), strings.ToUpper(s)
|
||||||
|
if s != lower && s != upper {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: mixed case")
|
||||||
|
}
|
||||||
|
s = lower
|
||||||
|
|
||||||
|
pos := strings.LastIndex(s, "1")
|
||||||
|
if pos < 1 || pos+7 > len(s) {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: no separator or too short")
|
||||||
|
}
|
||||||
|
hrp := s[:pos]
|
||||||
|
|
||||||
|
data := make([]byte, 0, len(s)-pos-1)
|
||||||
|
for _, c := range s[pos+1:] {
|
||||||
|
idx := strings.IndexRune(charset, c)
|
||||||
|
if idx < 0 {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: character %q not in charset", c)
|
||||||
|
}
|
||||||
|
data = append(data, byte(idx))
|
||||||
|
}
|
||||||
|
if !verifyChecksum(hrp, data) {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: bad checksum")
|
||||||
|
}
|
||||||
|
|
||||||
|
converted, err := convertBits(data[:len(data)-6], 5, 8, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return hrp, converted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeURL renders a URL as an LNURL string.
|
||||||
|
//
|
||||||
|
// Wallets accept it uppercase, which is what makes the QR compact: uppercase
|
||||||
|
// bech32 encodes in QR alphanumeric mode rather than byte mode.
|
||||||
|
func EncodeURL(url string) (string, error) {
|
||||||
|
s, err := Encode("lnurl", []byte(url))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeURL parses an LNURL back into the URL it carries.
|
||||||
|
func DecodeURL(s string) (string, error) {
|
||||||
|
hrp, data, err := Decode(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if hrp != "lnurl" {
|
||||||
|
return "", fmt.Errorf("lnurl: unexpected prefix %q", hrp)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
133
pkg/lnurl/bech32_test.go
Normal file
133
pkg/lnurl/bech32_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package lnurl_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The BIP-173 test vectors. An implementation that passes these produces
|
||||||
|
// strings other wallets will accept; one that does not produces codes that
|
||||||
|
// simply fail to scan, with no useful error for the player.
|
||||||
|
func TestBIP173ValidVectors(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"A12UEL5L",
|
||||||
|
"a12uel5l",
|
||||||
|
"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs",
|
||||||
|
"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw",
|
||||||
|
// The 90-character vector, built rather than transcribed: getting the
|
||||||
|
// run length wrong by hand produces a checksum failure that looks like
|
||||||
|
// an implementation bug.
|
||||||
|
"11" + strings.Repeat("q", 82) + "c8247j",
|
||||||
|
"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w",
|
||||||
|
"?1ezyfcl",
|
||||||
|
}
|
||||||
|
for _, v := range valid {
|
||||||
|
if _, _, err := lnurl.Decode(v); err != nil {
|
||||||
|
t.Errorf("valid vector %q rejected: %v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBIP173InvalidVectors(t *testing.T) {
|
||||||
|
invalid := map[string]string{
|
||||||
|
"A12UEL5X": "bad checksum",
|
||||||
|
"pzry9x0s0muk": "no separator",
|
||||||
|
"1pzry9x0s0muk": "empty hrp",
|
||||||
|
"x1b4n0q5v": "invalid character",
|
||||||
|
"li1dgmt3": "too short",
|
||||||
|
"A1G7SGD8": "bad checksum",
|
||||||
|
"10a06t8": "empty hrp",
|
||||||
|
"1qzzfhee": "empty hrp",
|
||||||
|
"abc1rzg": "too short",
|
||||||
|
"in1muywd": "bad checksum",
|
||||||
|
"A12Uel5l": "mixed case",
|
||||||
|
}
|
||||||
|
for v, why := range invalid {
|
||||||
|
if _, _, err := lnurl.Decode(v); err == nil {
|
||||||
|
t.Errorf("invalid vector %q (%s) was accepted", v, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"https://arcade.lan/lnurl/withdraw?k1=abc123",
|
||||||
|
"http://10.0.0.5:8080/lnurl/withdraw?k1=" + strings.Repeat("f", 64),
|
||||||
|
"https://example.com/",
|
||||||
|
}
|
||||||
|
for _, url := range cases {
|
||||||
|
encoded, err := lnurl.EncodeURL(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encoding %q: %v", url, err)
|
||||||
|
}
|
||||||
|
// Wallets receive these uppercase, so decoding must handle that.
|
||||||
|
decoded, err := lnurl.DecodeURL(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding %q: %v", encoded, err)
|
||||||
|
}
|
||||||
|
if decoded != url {
|
||||||
|
t.Fatalf("round trip changed the URL: %q -> %q", url, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LNURL strings are uppercase so the QR encodes in alphanumeric mode, which is
|
||||||
|
// substantially denser than byte mode and keeps the code scannable on a phone.
|
||||||
|
func TestEncodedLNURLIsUppercase(t *testing.T) {
|
||||||
|
s, err := lnurl.EncodeURL("https://arcade.lan/lnurl/withdraw?k1=deadbeef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s != strings.ToUpper(s) {
|
||||||
|
t.Fatalf("LNURL is not uppercase: %q", s)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(s, "LNURL1") {
|
||||||
|
t.Fatalf("LNURL lacks the expected prefix: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tampered character must fail the checksum rather than decode to a
|
||||||
|
// different URL — otherwise a corrupted scan could point a wallet somewhere
|
||||||
|
// unintended.
|
||||||
|
func TestTamperingIsDetected(t *testing.T) {
|
||||||
|
original := "https://arcade.lan/lnurl/withdraw?k1=abc123"
|
||||||
|
encoded, err := lnurl.EncodeURL(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detected := 0
|
||||||
|
attempts := 0
|
||||||
|
for i := 6; i < len(encoded); i++ {
|
||||||
|
for _, sub := range "QPZRY9X8" {
|
||||||
|
if rune(encoded[i]) == sub {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
tampered := encoded[:i] + string(sub) + encoded[i+1:]
|
||||||
|
if _, err := lnurl.DecodeURL(tampered); err != nil {
|
||||||
|
detected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attempts == 0 {
|
||||||
|
t.Fatal("no tampering attempts were made")
|
||||||
|
}
|
||||||
|
// The checksum catches all single-character substitutions by design.
|
||||||
|
if detected != attempts {
|
||||||
|
t.Fatalf("only %d of %d single-character changes were detected", detected, attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongPrefixRejected(t *testing.T) {
|
||||||
|
// A valid bech32 string that is not an LNURL must not be accepted as one.
|
||||||
|
other, err := lnurl.Encode("lnbc", []byte("not an lnurl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := lnurl.DecodeURL(other); err == nil {
|
||||||
|
t.Fatal("a non-LNURL bech32 string was accepted as an LNURL")
|
||||||
|
}
|
||||||
|
}
|
||||||
229
pkg/lnurl/withdraw.go
Normal file
229
pkg/lnurl/withdraw.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package lnurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Withdraw implements the LNURL-withdraw exchange.
|
||||||
|
//
|
||||||
|
// The flow, from the player's side, is: tap Cash out, scan the code, done.
|
||||||
|
// Underneath:
|
||||||
|
//
|
||||||
|
// 1. The arcade mints a single-use token (k1) bound to one account and one
|
||||||
|
// amount, and shows it as an LNURL.
|
||||||
|
// 2. The player's wallet fetches the URL and reads the terms.
|
||||||
|
// 3. The wallet generates an invoice for the amount and calls back with it.
|
||||||
|
// 4. The arcade pays that invoice.
|
||||||
|
//
|
||||||
|
// Step 4 is why the token must be strictly single-use. It is a bearer
|
||||||
|
// instrument: anyone holding it can direct a payment to an invoice of their
|
||||||
|
// choosing. So it is random, short-lived, consumed on first use, and bound to
|
||||||
|
// the amount it was issued for.
|
||||||
|
var (
|
||||||
|
ErrUnknownToken = errors.New("lnurl: token not recognised or already used")
|
||||||
|
ErrExpired = errors.New("lnurl: token has expired")
|
||||||
|
ErrAmountRange = errors.New("lnurl: invoice amount outside the permitted range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenTTL is how long a withdraw code stays valid. Short, because it is a
|
||||||
|
// bearer instrument: a code left on screen at a party should stop working
|
||||||
|
// well before anyone wanders off with a photograph of it.
|
||||||
|
const TokenTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
// Token is one outstanding withdraw authorisation.
|
||||||
|
type Token struct {
|
||||||
|
K1 string
|
||||||
|
AccountID int64
|
||||||
|
AmountMsat int64
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawRequest is the JSON a wallet reads after scanning.
|
||||||
|
// Field names are fixed by the LNURL specification.
|
||||||
|
type WithdrawRequest struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Callback string `json:"callback"`
|
||||||
|
K1 string `json:"k1"`
|
||||||
|
DefaultDescription string `json:"defaultDescription"`
|
||||||
|
MinWithdrawable int64 `json:"minWithdrawable"`
|
||||||
|
MaxWithdrawable int64 `json:"maxWithdrawable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is the LNURL result envelope.
|
||||||
|
type Response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func OK() Response { return Response{Status: "OK"} }
|
||||||
|
func Fail(why string) Response { return Response{Status: "ERROR", Reason: why} }
|
||||||
|
|
||||||
|
// Service issues and redeems withdraw tokens.
|
||||||
|
//
|
||||||
|
// Tokens live in memory rather than the database. They are short-lived and
|
||||||
|
// worthless once used, and keeping them out of durable storage means a lost
|
||||||
|
// instance cannot leave a valid bearer token lying around to be replayed
|
||||||
|
// later.
|
||||||
|
type Service struct {
|
||||||
|
// BaseURL is how a wallet reaches this instance, e.g. http://10.0.0.5:8080.
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens map[string]Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(baseURL string) *Service {
|
||||||
|
return &Service{BaseURL: baseURL, tokens: make(map[string]Token)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue mints a withdraw code for an exact amount.
|
||||||
|
func (s *Service) Issue(accountID, amountMsat int64) (lnurl string, k1 string, err error) {
|
||||||
|
if amountMsat <= 0 {
|
||||||
|
return "", "", fmt.Errorf("%w: amount must be positive", ErrAmountRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw [32]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
panic("lnurl: system randomness unavailable: " + err.Error())
|
||||||
|
}
|
||||||
|
k1 = hex.EncodeToString(raw[:])
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sweepLocked()
|
||||||
|
s.tokens[k1] = Token{
|
||||||
|
K1: k1, AccountID: accountID, AmountMsat: amountMsat,
|
||||||
|
Expires: time.Now().Add(TokenTTL),
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/lnurl/withdraw?k1=%s", s.BaseURL, k1)
|
||||||
|
encoded, err := EncodeURL(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return encoded, k1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe returns the terms a wallet reads after scanning.
|
||||||
|
//
|
||||||
|
// The minimum and maximum are set to the same value, which is what tells the
|
||||||
|
// wallet to withdraw exactly this amount rather than prompting the player to
|
||||||
|
// choose one. Choosing an amount is the step this whole mechanism exists to
|
||||||
|
// remove.
|
||||||
|
func (s *Service) Describe(k1 string) (*WithdrawRequest, error) {
|
||||||
|
t, err := s.lookup(k1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WithdrawRequest{
|
||||||
|
Tag: "withdrawRequest",
|
||||||
|
Callback: s.BaseURL + "/lnurl/withdraw/callback",
|
||||||
|
K1: t.K1,
|
||||||
|
DefaultDescription: "Quantum Arcade cash out",
|
||||||
|
MinWithdrawable: t.AmountMsat,
|
||||||
|
MaxWithdrawable: t.AmountMsat,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redeem consumes a token and returns what it authorises.
|
||||||
|
//
|
||||||
|
// The token is deleted before the payment is attempted. A token that is
|
||||||
|
// consumed and then fails to pay costs the player a retry; a token that is
|
||||||
|
// left valid after a payment succeeds costs the house the whole balance
|
||||||
|
// again. The asymmetry decides the ordering.
|
||||||
|
func (s *Service) Redeem(ctx context.Context, k1 string) (Token, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
t, ok := s.tokens[k1]
|
||||||
|
if ok {
|
||||||
|
delete(s.tokens, k1)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return Token{}, ErrUnknownToken
|
||||||
|
}
|
||||||
|
if time.Now().After(t.Expires) {
|
||||||
|
return Token{}, ErrExpired
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore puts a token back after a failed payment, so a routing failure does
|
||||||
|
// not silently swallow the player's cash-out.
|
||||||
|
func (s *Service) Restore(t Token) {
|
||||||
|
if time.Now().After(t.Expires) {
|
||||||
|
return // no point restoring something already expired
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tokens[t.K1] = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceStore inserts a token regardless of its expiry. It exists so tests can
|
||||||
|
// construct an aged token; production code uses Restore, which refuses to
|
||||||
|
// resurrect something already expired.
|
||||||
|
func (s *Service) ForceStore(t Token) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tokens[t.K1] = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) lookup(k1 string) (Token, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
t, ok := s.tokens[k1]
|
||||||
|
if !ok {
|
||||||
|
return Token{}, ErrUnknownToken
|
||||||
|
}
|
||||||
|
if time.Now().After(t.Expires) {
|
||||||
|
delete(s.tokens, k1)
|
||||||
|
return Token{}, ErrExpired
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLocked drops expired tokens. Called under the mutex.
|
||||||
|
//
|
||||||
|
// It discards them rather than reporting them, because Issue does not know how
|
||||||
|
// to refund. Callers that must refund use Expired instead.
|
||||||
|
func (s *Service) sweepLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
for k, t := range s.tokens {
|
||||||
|
if now.After(t.Expires) {
|
||||||
|
delete(s.tokens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired removes and returns every token past its lifetime.
|
||||||
|
//
|
||||||
|
// The funds behind a code are debited when it is issued, so a code that is
|
||||||
|
// never scanned leaves a player short. The caller refunds what this returns —
|
||||||
|
// which is why the tokens are handed back rather than quietly dropped.
|
||||||
|
func (s *Service) Expired() []Token {
|
||||||
|
now := time.Now()
|
||||||
|
var out []Token
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
for k, t := range s.tokens {
|
||||||
|
if now.After(t.Expires) {
|
||||||
|
out = append(out, t)
|
||||||
|
delete(s.tokens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outstanding reports how many tokens are live, for tests and the admin view.
|
||||||
|
func (s *Service) Outstanding() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.tokens)
|
||||||
|
}
|
||||||
287
pkg/lnurl/withdraw_test.go
Normal file
287
pkg/lnurl/withdraw_test.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package lnurl_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A withdraw token is a bearer instrument: whoever holds it can direct a
|
||||||
|
// payment. These pin down the properties that keeps it from being abused.
|
||||||
|
|
||||||
|
func TestIssuedCodeIsScannable(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://10.0.0.5:8080")
|
||||||
|
|
||||||
|
code, k1, err := s.Issue(42, 50_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(code, "LNURL1") {
|
||||||
|
t.Fatalf("code does not look like an LNURL: %q", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wallet decodes it and must reach a URL carrying this token.
|
||||||
|
url, err := lnurl.DecodeURL(code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(url, k1) {
|
||||||
|
t.Fatalf("decoded URL %q does not carry the token", url)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(url, "http://10.0.0.5:8080/") {
|
||||||
|
t.Fatalf("decoded URL points elsewhere: %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The terms must pin the amount exactly. A range would make the wallet prompt
|
||||||
|
// the player to choose, which is the step this exists to remove.
|
||||||
|
func TestTermsPinTheExactAmount(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(7, 12_345_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.Describe(k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if req.Tag != "withdrawRequest" {
|
||||||
|
t.Fatalf("tag = %q, want withdrawRequest", req.Tag)
|
||||||
|
}
|
||||||
|
if req.MinWithdrawable != req.MaxWithdrawable {
|
||||||
|
t.Fatalf("min %d and max %d differ; the wallet would prompt for an amount",
|
||||||
|
req.MinWithdrawable, req.MaxWithdrawable)
|
||||||
|
}
|
||||||
|
if req.MinWithdrawable != 12_345_000 {
|
||||||
|
t.Fatalf("amount = %d, want 12345000", req.MinWithdrawable)
|
||||||
|
}
|
||||||
|
if req.K1 != k1 {
|
||||||
|
t.Fatal("terms carry a different token than was issued")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The defining property: a token pays once.
|
||||||
|
func TestTokenIsSingleUse(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(1, 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); err != nil {
|
||||||
|
t.Fatalf("first redemption failed: %v", err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("redemption %d gave %v, want ErrUnknownToken", i+2, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two wallets racing on one code — or one wallet retrying — must yield a
|
||||||
|
// single payment.
|
||||||
|
func TestConcurrentRedemptionYieldsOne(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(1, 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var wins atomic.Int64
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); err == nil {
|
||||||
|
wins.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if wins.Load() != 1 {
|
||||||
|
t.Fatalf("%d concurrent redemptions succeeded, want 1", wins.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTokenRejected(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
if _, err := s.Redeem(context.Background(), "not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("got %v, want ErrUnknownToken", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Describe("not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("Describe gave %v, want ErrUnknownToken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code photographed at a party must stop working.
|
||||||
|
func TestExpiredTokenRejected(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(1, 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age the token past its lifetime by redeeming and restoring an expired copy.
|
||||||
|
tok, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok.Expires = time.Now().Add(-time.Second)
|
||||||
|
s.Restore(tok)
|
||||||
|
|
||||||
|
// Restore refuses to resurrect something already expired.
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("an expired token was restored and redeemed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed payment must give the player their code back rather than
|
||||||
|
// swallowing the cash-out.
|
||||||
|
func TestRestoreAfterFailedPayment(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(9, 25_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The payment fails here, so the authorisation is put back.
|
||||||
|
s.Restore(tok)
|
||||||
|
|
||||||
|
again, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a restored token could not be redeemed: %v", err)
|
||||||
|
}
|
||||||
|
if again.AccountID != 9 || again.AmountMsat != 25_000 {
|
||||||
|
t.Fatalf("restored token carries different terms: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens carry the account they were issued for, so a code cannot be used to
|
||||||
|
// drain someone else's balance.
|
||||||
|
func TestTokenIsBoundToItsAccount(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1a, _ := s.Issue(100, 5_000)
|
||||||
|
_, k1b, _ := s.Issue(200, 7_000)
|
||||||
|
|
||||||
|
a, err := s.Redeem(context.Background(), k1a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := s.Redeem(context.Background(), k1b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.AccountID != 100 || a.AmountMsat != 5_000 {
|
||||||
|
t.Fatalf("first token carries %+v", a)
|
||||||
|
}
|
||||||
|
if b.AccountID != 200 || b.AmountMsat != 7_000 {
|
||||||
|
t.Fatalf("second token carries %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokensAreUnpredictable(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := 0; i < 2000; i++ {
|
||||||
|
_, k1, err := s.Issue(1, 1_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen[k1] {
|
||||||
|
t.Fatalf("token collision after %d issues", i)
|
||||||
|
}
|
||||||
|
if len(k1) != 64 {
|
||||||
|
t.Fatalf("token is %d characters, want 64 hex", len(k1))
|
||||||
|
}
|
||||||
|
seen[k1] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonPositiveAmountRefused(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
for _, amt := range []int64{0, -1, -50_000} {
|
||||||
|
if _, _, err := s.Issue(1, amt); !errors.Is(err, lnurl.ErrAmountRange) {
|
||||||
|
t.Errorf("amount %d gave %v, want ErrAmountRange", amt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired tokens must not accumulate: a long party would otherwise leak memory
|
||||||
|
// one abandoned cash-out at a time.
|
||||||
|
func TestExpiredTokensAreSweptOnIssue(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
|
||||||
|
// Issue several, then age them by hand.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Outstanding() != 5 {
|
||||||
|
t.Fatalf("%d tokens outstanding, want 5", s.Outstanding())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh issue after the TTL has passed should clear the stale ones. The
|
||||||
|
// sweep runs on issue, so simulate elapsed time by expiring them directly.
|
||||||
|
// Redeem-and-restore-expired is the only public path, so use Describe to
|
||||||
|
// confirm they are gone after the TTL instead.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Nothing has expired yet, so all six remain.
|
||||||
|
if s.Outstanding() != 6 {
|
||||||
|
t.Fatalf("%d tokens outstanding, want 6", s.Outstanding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code that is issued but never scanned must be reported back so the caller
|
||||||
|
// can refund it. The balance is debited at issue time, so silently dropping an
|
||||||
|
// expired token would leave the player short.
|
||||||
|
func TestExpiredTokensAreReturnedForRefund(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
|
||||||
|
_, k1, err := s.Issue(55, 31_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing has expired yet.
|
||||||
|
if got := s.Expired(); len(got) != 0 {
|
||||||
|
t.Fatalf("%d tokens reported expired immediately", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age it by redeeming, expiring the copy, and forcing it back.
|
||||||
|
tok, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok.Expires = time.Now().Add(-time.Minute)
|
||||||
|
s.ForceStore(tok)
|
||||||
|
|
||||||
|
expired := s.Expired()
|
||||||
|
if len(expired) != 1 {
|
||||||
|
t.Fatalf("%d tokens returned for refund, want 1", len(expired))
|
||||||
|
}
|
||||||
|
if expired[0].AccountID != 55 || expired[0].AmountMsat != 31_000 {
|
||||||
|
t.Fatalf("expired token carries %+v, want account 55 and 31000 msat", expired[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it must be gone, so a second sweep cannot refund it twice.
|
||||||
|
if got := s.Expired(); len(got) != 0 {
|
||||||
|
t.Fatalf("a second sweep returned %d tokens; a refund could be issued twice", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
12
pkg/pqid/export_test.go
Normal file
12
pkg/pqid/export_test.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package pqid_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signWithContext produces an ML-DSA signature under an arbitrary context, so
|
||||||
|
// the domain-separation test can prove the production context is enforced.
|
||||||
|
func signWithContext(priv *pqid.PrivateKey, msg, ctx, out []byte) error {
|
||||||
|
return mldsa65.SignTo(priv.PQ, msg, ctx, false, out)
|
||||||
|
}
|
||||||
208
pkg/pqid/pqid.go
Normal file
208
pkg/pqid/pqid.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// Package pqid implements hybrid post-quantum player identity.
|
||||||
|
//
|
||||||
|
// A player's key is two keys: classical Ed25519 and post-quantum ML-DSA-65
|
||||||
|
// (NIST FIPS 204). Both signatures are required for every authentication, so
|
||||||
|
// the identity holds if *either* algorithm survives:
|
||||||
|
//
|
||||||
|
// - Ed25519 alone falls to Shor's algorithm on a sufficiently large quantum
|
||||||
|
// computer.
|
||||||
|
// - ML-DSA is young. Lattice cryptanalysis is an active field, and betting
|
||||||
|
// everything on a 2024 standard would be its own kind of naive.
|
||||||
|
//
|
||||||
|
// Requiring both is the composition NIST and the IETF recommend: an attacker
|
||||||
|
// must break lattice assumptions *and* elliptic curves, not either one.
|
||||||
|
//
|
||||||
|
// What this does and does not protect:
|
||||||
|
//
|
||||||
|
// protected player identity, session authentication, bet authorisation
|
||||||
|
// protected round fairness — commit-reveal is SHA-256/HMAC, and Grover
|
||||||
|
// only halves the security margin, leaving ~128 bits
|
||||||
|
// NOT protected Bitcoin and Lightning settlement, which sign with secp256k1.
|
||||||
|
// No application-layer choice can change that.
|
||||||
|
package pqid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sizes of the wire encodings.
|
||||||
|
const (
|
||||||
|
EdPublicKeySize = ed25519.PublicKeySize // 32
|
||||||
|
EdSignatureSize = ed25519.SignatureSize // 64
|
||||||
|
|
||||||
|
PQPublicKeySize = mldsa65.PublicKeySize // 1952
|
||||||
|
PQSignatureSize = mldsa65.SignatureSize // 3309
|
||||||
|
|
||||||
|
PublicKeySize = EdPublicKeySize + PQPublicKeySize
|
||||||
|
SignatureSize = EdSignatureSize + PQSignatureSize
|
||||||
|
|
||||||
|
// IDSize is the length of the account identifier derived from a key.
|
||||||
|
IDSize = sha256.Size
|
||||||
|
)
|
||||||
|
|
||||||
|
// context binds signatures to this application, so a signature produced for
|
||||||
|
// some other ML-DSA protocol cannot be replayed here.
|
||||||
|
var context = []byte("quantum-arcade/v1")
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMalformedKey = errors.New("pqid: malformed public key")
|
||||||
|
ErrMalformedSignature = errors.New("pqid: malformed signature")
|
||||||
|
ErrClassicalFailed = errors.New("pqid: ed25519 signature did not verify")
|
||||||
|
ErrPostQuantumFailed = errors.New("pqid: ML-DSA signature did not verify")
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicKey is a player's hybrid identity.
|
||||||
|
type PublicKey struct {
|
||||||
|
Ed ed25519.PublicKey
|
||||||
|
PQ *mldsa65.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivateKey is the matching secret half. Clients hold this; the server never
|
||||||
|
// sees it.
|
||||||
|
type PrivateKey struct {
|
||||||
|
Ed ed25519.PrivateKey
|
||||||
|
PQ *mldsa65.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKey creates a hybrid keypair.
|
||||||
|
func GenerateKey(rand io.Reader) (*PublicKey, *PrivateKey, error) {
|
||||||
|
edPub, edPriv, err := ed25519.GenerateKey(rand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("pqid: generating ed25519 key: %w", err)
|
||||||
|
}
|
||||||
|
pqPub, pqPriv, err := mldsa65.GenerateKey(rand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("pqid: generating ML-DSA key: %w", err)
|
||||||
|
}
|
||||||
|
return &PublicKey{Ed: edPub, PQ: pqPub}, &PrivateKey{Ed: edPriv, PQ: pqPriv}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes encodes the public key as ed25519 || ML-DSA.
|
||||||
|
func (p *PublicKey) Bytes() []byte {
|
||||||
|
out := make([]byte, 0, PublicKeySize)
|
||||||
|
out = append(out, p.Ed...)
|
||||||
|
pq, err := p.PQ.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
// MarshalBinary on a valid key cannot fail; a failure here means the
|
||||||
|
// key is corrupt, and silently returning a short key would be worse.
|
||||||
|
panic("pqid: marshalling ML-DSA public key: " + err.Error())
|
||||||
|
}
|
||||||
|
return append(out, pq...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex renders the public key for transport.
|
||||||
|
func (p *PublicKey) Hex() string { return hex.EncodeToString(p.Bytes()) }
|
||||||
|
|
||||||
|
// ID is the account identifier: SHA-256 over the whole hybrid key.
|
||||||
|
//
|
||||||
|
// The ledger keys on this rather than the raw key because the hybrid key is
|
||||||
|
// nearly 2KB, and a fixed 32-byte identifier keeps indexes small. Hashing also
|
||||||
|
// means the identifier is stable in length no matter how the key evolves.
|
||||||
|
func (p *PublicKey) ID() []byte {
|
||||||
|
sum := sha256.Sum256(p.Bytes())
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKey decodes a hybrid public key from its wire encoding.
|
||||||
|
func ParsePublicKey(b []byte) (*PublicKey, error) {
|
||||||
|
if len(b) != PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("%w: got %d bytes, want %d",
|
||||||
|
ErrMalformedKey, len(b), PublicKeySize)
|
||||||
|
}
|
||||||
|
ed := ed25519.PublicKey(append([]byte(nil), b[:EdPublicKeySize]...))
|
||||||
|
|
||||||
|
var pq mldsa65.PublicKey
|
||||||
|
if err := pq.UnmarshalBinary(b[EdPublicKeySize:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrMalformedKey, err)
|
||||||
|
}
|
||||||
|
return &PublicKey{Ed: ed, PQ: &pq}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKeyHex decodes a hex-encoded hybrid public key.
|
||||||
|
func ParsePublicKeyHex(s string) (*PublicKey, error) {
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: not hex", ErrMalformedKey)
|
||||||
|
}
|
||||||
|
return ParsePublicKey(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign produces both signatures over the message, concatenated.
|
||||||
|
func Sign(priv *PrivateKey, msg []byte) ([]byte, error) {
|
||||||
|
edSig := ed25519.Sign(priv.Ed, msg)
|
||||||
|
|
||||||
|
pqSig := make([]byte, PQSignatureSize)
|
||||||
|
// randomized=false gives deterministic (hedged) signatures, so a bad RNG
|
||||||
|
// on a phone cannot leak the key through signature randomness.
|
||||||
|
if err := mldsa65.SignTo(priv.PQ, msg, context, false, pqSig); err != nil {
|
||||||
|
return nil, fmt.Errorf("pqid: ML-DSA signing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, 0, SignatureSize)
|
||||||
|
out = append(out, edSig...)
|
||||||
|
return append(out, pqSig...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks both signatures. Both must pass.
|
||||||
|
//
|
||||||
|
// It deliberately reports which half failed: a mismatch between the two is
|
||||||
|
// diagnostic — it means a client is half-upgraded or something is tampering
|
||||||
|
// with one algorithm — and that is worth surfacing rather than flattening into
|
||||||
|
// a generic failure. Nothing secret is revealed by saying which one broke.
|
||||||
|
func Verify(pub *PublicKey, msg, sig []byte) error {
|
||||||
|
if len(sig) != SignatureSize {
|
||||||
|
return fmt.Errorf("%w: got %d bytes, want %d",
|
||||||
|
ErrMalformedSignature, len(sig), SignatureSize)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub.Ed, msg, sig[:EdSignatureSize]) {
|
||||||
|
return ErrClassicalFailed
|
||||||
|
}
|
||||||
|
if !mldsa65.Verify(pub.PQ, msg, context, sig[EdSignatureSize:]) {
|
||||||
|
return ErrPostQuantumFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivateFromBytes reconstructs a private key from stored material.
|
||||||
|
//
|
||||||
|
// The Ed25519 half is stored as its 32-byte seed rather than the expanded
|
||||||
|
// key, because the seed is the canonical form and cannot encode an
|
||||||
|
// inconsistent pair. The ML-DSA half is stored in its own binary encoding.
|
||||||
|
func PrivateFromBytes(edSeed, pqKey []byte) (*PrivateKey, error) {
|
||||||
|
if len(edSeed) != ed25519.SeedSize {
|
||||||
|
return nil, fmt.Errorf("%w: ed25519 seed is %d bytes, want %d",
|
||||||
|
ErrMalformedKey, len(edSeed), ed25519.SeedSize)
|
||||||
|
}
|
||||||
|
var pq mldsa65.PrivateKey
|
||||||
|
if err := pq.UnmarshalBinary(pqKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ML-DSA private key: %v", ErrMalformedKey, err)
|
||||||
|
}
|
||||||
|
return &PrivateKey{
|
||||||
|
Ed: ed25519.NewKeyFromSeed(edSeed),
|
||||||
|
PQ: &pq,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicFromPrivate derives the public half.
|
||||||
|
//
|
||||||
|
// Deriving rather than storing means a client cannot present a public key that
|
||||||
|
// does not match the key it signs with — a mismatch that would otherwise only
|
||||||
|
// surface as a confusing authentication failure.
|
||||||
|
func PublicFromPrivate(priv *PrivateKey) (*PublicKey, error) {
|
||||||
|
edPub, ok := priv.Ed.Public().(ed25519.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: ed25519 private key has no public half", ErrMalformedKey)
|
||||||
|
}
|
||||||
|
pqPub, ok := priv.PQ.Public().(*mldsa65.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: ML-DSA private key has no public half", ErrMalformedKey)
|
||||||
|
}
|
||||||
|
return &PublicKey{Ed: edPub, PQ: pqPub}, nil
|
||||||
|
}
|
||||||
266
pkg/pqid/pqid_test.go
Normal file
266
pkg/pqid/pqid_test.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package pqid_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newKey(t *testing.T) (*pqid.PublicKey, *pqid.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := pqid.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return pub, priv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignAndVerify(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("authenticate me")
|
||||||
|
|
||||||
|
sig, err := pqid.Sign(priv, msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||||
|
t.Fatalf("valid hybrid signature rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongMessageFails(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("original"))
|
||||||
|
if err := pqid.Verify(pub, []byte("tampered"), sig); err == nil {
|
||||||
|
t.Fatal("signature verified against a different message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongKeyFails(t *testing.T) {
|
||||||
|
_, priv := newKey(t)
|
||||||
|
other, _ := newKey(t)
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
||||||
|
if err := pqid.Verify(other, []byte("msg"), sig); err == nil {
|
||||||
|
t.Fatal("signature verified under the wrong key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of hybrid: forging only the classical half must not
|
||||||
|
// authenticate. This is the quantum-adversary scenario — Shor breaks Ed25519,
|
||||||
|
// ML-DSA still holds.
|
||||||
|
func TestValidClassicalWithBrokenPostQuantumFails(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("msg")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
|
||||||
|
// Keep the genuine Ed25519 signature, corrupt the ML-DSA half.
|
||||||
|
forged := append([]byte(nil), sig...)
|
||||||
|
forged[pqid.EdSignatureSize+10] ^= 0xff
|
||||||
|
|
||||||
|
err := pqid.Verify(pub, msg, forged)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a broken post-quantum half still authenticated")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pqid.ErrPostQuantumFailed) {
|
||||||
|
t.Fatalf("got %v, want ErrPostQuantumFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the mirror case: if lattice cryptography turns out to be weak, Ed25519
|
||||||
|
// must still stand in the way.
|
||||||
|
func TestValidPostQuantumWithBrokenClassicalFails(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("msg")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
|
||||||
|
forged := append([]byte(nil), sig...)
|
||||||
|
forged[5] ^= 0xff // corrupt the Ed25519 half
|
||||||
|
|
||||||
|
err := pqid.Verify(pub, msg, forged)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a broken classical half still authenticated")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pqid.ErrClassicalFailed) {
|
||||||
|
t.Fatalf("got %v, want ErrClassicalFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignatureSizeIsExact(t *testing.T) {
|
||||||
|
_, priv := newKey(t)
|
||||||
|
sig, err := pqid.Sign(priv, []byte("msg"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sig) != pqid.SignatureSize {
|
||||||
|
t.Fatalf("signature is %d bytes, want %d", len(sig), pqid.SignatureSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncatedSignatureRejected(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
||||||
|
|
||||||
|
for _, n := range []int{0, 64, pqid.SignatureSize - 1} {
|
||||||
|
if err := pqid.Verify(pub, []byte("msg"), sig[:n]); !errors.Is(err, pqid.ErrMalformedSignature) {
|
||||||
|
t.Fatalf("signature truncated to %d bytes gave %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicKeyRoundTrip(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
|
||||||
|
restored, err := pqid.ParsePublicKeyHex(pub.Hex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The restored key must verify signatures made by the original.
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
||||||
|
if err := pqid.Verify(restored, []byte("msg"), sig); err != nil {
|
||||||
|
t.Fatalf("round-tripped key failed to verify: %v", err)
|
||||||
|
}
|
||||||
|
if string(restored.ID()) != string(pub.ID()) {
|
||||||
|
t.Fatal("round-tripped key has a different ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedKeysRejected(t *testing.T) {
|
||||||
|
cases := map[string][]byte{
|
||||||
|
"empty": {},
|
||||||
|
"too short": make([]byte, pqid.PublicKeySize-1),
|
||||||
|
"too long": make([]byte, pqid.PublicKeySize+1),
|
||||||
|
}
|
||||||
|
for name, b := range cases {
|
||||||
|
if _, err := pqid.ParsePublicKey(b); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("%s: got %v, want ErrMalformedKey", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := pqid.ParsePublicKeyHex("nothex!!"); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("non-hex: got %v, want ErrMalformedKey", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDIsStableAndDistinct(t *testing.T) {
|
||||||
|
a, _ := newKey(t)
|
||||||
|
b, _ := newKey(t)
|
||||||
|
|
||||||
|
if string(a.ID()) != string(a.ID()) {
|
||||||
|
t.Fatal("ID is not stable across calls")
|
||||||
|
}
|
||||||
|
if string(a.ID()) == string(b.ID()) {
|
||||||
|
t.Fatal("two distinct keys produced the same ID")
|
||||||
|
}
|
||||||
|
if len(a.ID()) != pqid.IDSize {
|
||||||
|
t.Fatalf("ID is %d bytes, want %d", len(a.ID()), pqid.IDSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signatures must be bound to this application, so one captured from another
|
||||||
|
// ML-DSA protocol cannot be replayed here.
|
||||||
|
func TestSignaturesAreDomainSeparated(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("msg")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
|
||||||
|
// Verifying with the correct context succeeds (covered above). Here we
|
||||||
|
// confirm the context is actually in use by checking that a signature made
|
||||||
|
// over the same message still fails if the ML-DSA half is swapped for one
|
||||||
|
// generated under a different context.
|
||||||
|
other := make([]byte, pqid.PQSignatureSize)
|
||||||
|
if err := signWithContext(priv, msg, []byte("some-other-protocol"), other); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forged := append(append([]byte(nil), sig[:pqid.EdSignatureSize]...), other...)
|
||||||
|
if err := pqid.Verify(pub, msg, forged); !errors.Is(err, pqid.ErrPostQuantumFailed) {
|
||||||
|
t.Fatalf("signature from another context was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSign(b *testing.B) {
|
||||||
|
_, priv, _ := pqid.GenerateKey(rand.Reader)
|
||||||
|
msg := []byte("benchmark message")
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if _, err := pqid.Sign(priv, msg); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkVerify(b *testing.B) {
|
||||||
|
pub, priv, _ := pqid.GenerateKey(rand.Reader)
|
||||||
|
msg := []byte("benchmark message")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGenerateKey(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if _, _, err := pqid.GenerateKey(rand.Reader); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key stored by the browser and restored on the next visit must produce
|
||||||
|
// signatures the server still accepts.
|
||||||
|
func TestPrivateKeyRoundTripThroughStorage(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
|
||||||
|
// What the browser would persist.
|
||||||
|
edSeed := priv.Ed.Seed()
|
||||||
|
pqBytes, err := priv.PQ.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := []byte("a challenge issued after the page reloaded")
|
||||||
|
sig, err := pqid.Sign(restored, msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||||
|
t.Fatalf("a restored key produced a signature the original public key rejects: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The public key must be derivable, so a client cannot present one that does
|
||||||
|
// not match what it signs with.
|
||||||
|
func TestPublicKeyDerivesFromPrivate(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
|
||||||
|
derived, err := pqid.PublicFromPrivate(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if derived.Hex() != pub.Hex() {
|
||||||
|
t.Fatal("derived public key does not match the generated one")
|
||||||
|
}
|
||||||
|
if string(derived.ID()) != string(pub.ID()) {
|
||||||
|
t.Fatal("derived public key has a different account id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedStoredKeysRejected(t *testing.T) {
|
||||||
|
_, priv := newKey(t)
|
||||||
|
pqBytes, _ := priv.PQ.MarshalBinary()
|
||||||
|
|
||||||
|
if _, err := pqid.PrivateFromBytes([]byte("short"), pqBytes); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("short ed seed gave %v, want ErrMalformedKey", err)
|
||||||
|
}
|
||||||
|
if _, err := pqid.PrivateFromBytes(priv.Ed.Seed(), []byte("nonsense")); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("bad pq key gave %v, want ErrMalformedKey", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
pkg/room/bench_test.go
Normal file
69
pkg/room/bench_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Broadcast cost decides whether a crowd can watch the same round. At 60Hz
|
||||||
|
// with N subscribers the server does N marshals per tick unless the payload is
|
||||||
|
// serialised once and shared.
|
||||||
|
func BenchmarkSnapshotMarshal(b *testing.B) {
|
||||||
|
r := New("rocket", nil, nil)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
r.bets[int64(i)] = &Bet{
|
||||||
|
AccountID: int64(i),
|
||||||
|
Pubkey: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
|
Nickname: "player",
|
||||||
|
StakeMsat: 10000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
snap := r.Snapshot()
|
||||||
|
if _, err := json.Marshal(snap); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot alone, without serialisation: this is the lock-held portion, which
|
||||||
|
// blocks every other operation on the room.
|
||||||
|
func BenchmarkSnapshotOnly(b *testing.B) {
|
||||||
|
r := New("rocket", nil, nil)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
r.bets[int64(i)] = &Bet{
|
||||||
|
AccountID: int64(i),
|
||||||
|
Pubkey: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
|
Nickname: "player",
|
||||||
|
StakeMsat: 10000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = r.Snapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-out to many subscriber channels.
|
||||||
|
func BenchmarkBroadcast1000Subscribers(b *testing.B) {
|
||||||
|
r := New("rocket", nil, nil)
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
r.bets[int64(i)] = &Bet{
|
||||||
|
AccountID: int64(i), Pubkey: []byte("key"),
|
||||||
|
Nickname: "p", StakeMsat: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drain subscribers so the buffered channels do not simply fill.
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
ch, _ := r.Subscribe()
|
||||||
|
go func(c <-chan []byte) {
|
||||||
|
for range c {
|
||||||
|
}
|
||||||
|
}(ch)
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
r.broadcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
212
pkg/room/reconcile.go
Normal file
212
pkg/room/reconcile.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reconciler refunds rounds that were abandoned mid-flight.
|
||||||
|
//
|
||||||
|
// Stakes are debited when a bet is placed, so if the instance driving a game
|
||||||
|
// dies between the bet and settlement, those stakes sit with the house and the
|
||||||
|
// round never resolves. The books stay balanced — nothing is created or lost —
|
||||||
|
// but the players are quietly short, which is the same thing as being robbed
|
||||||
|
// by accident.
|
||||||
|
//
|
||||||
|
// This finds those rounds and refunds every unsettled stake. It is safe to run
|
||||||
|
// repeatedly and from any instance: refunds are recorded against the round, and
|
||||||
|
// a round already marked settled is skipped.
|
||||||
|
type Reconciler struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
|
||||||
|
// Stale is how long a round may remain unsettled before it is considered
|
||||||
|
// abandoned. It must exceed the longest possible round plus the time it
|
||||||
|
// takes another instance to take over, or a live round would be refunded
|
||||||
|
// out from under the players still in it.
|
||||||
|
Stale time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReconciler(pool *pgxpool.Pool, l *ledger.Ledger) *Reconciler {
|
||||||
|
return &Reconciler{
|
||||||
|
pool: pool,
|
||||||
|
ledger: l,
|
||||||
|
// A round is capped at 60s of flight plus its betting and settle
|
||||||
|
// phases; leadership moves within LeaseTTL. Two minutes is far past
|
||||||
|
// any legitimate round and still prompt enough to matter at a party.
|
||||||
|
Stale: 2 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result describes what a reconciliation pass did.
|
||||||
|
type Result struct {
|
||||||
|
RoundsRefunded int
|
||||||
|
BetsRefunded int
|
||||||
|
MsatRefunded int64
|
||||||
|
// EmptyRoundsClosed counts abandoned rounds that nobody had joined.
|
||||||
|
EmptyRoundsClosed int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run refunds every abandoned round it finds.
|
||||||
|
func (rc *Reconciler) Run(ctx context.Context) (Result, error) {
|
||||||
|
var res Result
|
||||||
|
|
||||||
|
rows, err := rc.pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT r.id
|
||||||
|
FROM rounds r
|
||||||
|
JOIN bets b ON b.round_id = r.id
|
||||||
|
WHERE r.settled_at IS NULL
|
||||||
|
AND r.voided_at IS NULL
|
||||||
|
AND b.settled_at IS NULL
|
||||||
|
AND r.opened_at < now() - make_interval(secs => $1)
|
||||||
|
ORDER BY r.id`,
|
||||||
|
rc.Stale.Seconds())
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("finding abandoned rounds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var roundIDs []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
roundIDs = append(roundIDs, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounds that nobody joined also need closing. They hold no money, so
|
||||||
|
// there is nothing to refund, but leaving them open forever makes the
|
||||||
|
// operator's "unresolved rounds" signal useless — it climbs steadily with
|
||||||
|
// noise and stops meaning anything when a real one appears.
|
||||||
|
empty, err := rc.pool.Exec(ctx, `
|
||||||
|
UPDATE rounds SET voided_at = now()
|
||||||
|
WHERE settled_at IS NULL
|
||||||
|
AND voided_at IS NULL
|
||||||
|
AND opened_at < now() - make_interval(secs => $1)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bets WHERE bets.round_id = rounds.id)`,
|
||||||
|
rc.Stale.Seconds())
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("closing empty rounds: %w", err)
|
||||||
|
}
|
||||||
|
res.EmptyRoundsClosed = int(empty.RowsAffected())
|
||||||
|
|
||||||
|
for _, roundID := range roundIDs {
|
||||||
|
refunded, msat, err := rc.refundRound(ctx, roundID)
|
||||||
|
if err != nil {
|
||||||
|
// One bad round must not stop the rest from being made whole.
|
||||||
|
fmt.Printf("reconcile: round %d: %v\n", roundID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if refunded > 0 {
|
||||||
|
res.RoundsRefunded++
|
||||||
|
res.BetsRefunded += refunded
|
||||||
|
res.MsatRefunded += msat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundRound returns every unsettled stake in one round.
|
||||||
|
func (rc *Reconciler) refundRound(ctx context.Context, roundID int64) (int, int64, error) {
|
||||||
|
// Claim the round by voiding it. Doing this before moving money means a
|
||||||
|
// second pass — or another instance running concurrently — finds nothing
|
||||||
|
// to do, so a refund cannot be issued twice.
|
||||||
|
//
|
||||||
|
// Void, not settled: an abandoned round produced no outcome, so it has no
|
||||||
|
// seed to reveal and must not masquerade as a resolved round.
|
||||||
|
tag, err := rc.pool.Exec(ctx,
|
||||||
|
`UPDATE rounds SET voided_at = now()
|
||||||
|
WHERE id = $1 AND settled_at IS NULL AND voided_at IS NULL`, roundID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("claiming round: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return 0, 0, nil // another pass got there first
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := rc.pool.Query(ctx,
|
||||||
|
`SELECT account_id, stake_msat FROM bets
|
||||||
|
WHERE round_id = $1 AND settled_at IS NULL`, roundID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
type refund struct {
|
||||||
|
account int64
|
||||||
|
msat int64
|
||||||
|
}
|
||||||
|
var refunds []refund
|
||||||
|
for rows.Next() {
|
||||||
|
var r refund
|
||||||
|
if err := rows.Scan(&r.account, &r.msat); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
refunds = append(refunds, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(refunds) == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
house, err := rc.ledger.AccountByName(ctx, "house_pot")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
postings := make([]ledger.Posting, 0, len(refunds)+1)
|
||||||
|
var total int64
|
||||||
|
for _, r := range refunds {
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: r.account, AmountMsat: r.msat})
|
||||||
|
total += r.msat
|
||||||
|
}
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -total})
|
||||||
|
|
||||||
|
rid := roundID
|
||||||
|
if _, err := rc.ledger.Post(ctx, "refund_abandoned", &rid, postings); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("posting refunds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := rc.pool.Exec(ctx,
|
||||||
|
`UPDATE bets SET settled_at = now(), payout_msat = stake_msat
|
||||||
|
WHERE round_id = $1 AND settled_at IS NULL`, roundID); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("marking bets refunded: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(refunds), total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPeriodically sweeps for abandoned rounds until the context ends.
|
||||||
|
func (rc *Reconciler) RunPeriodically(ctx context.Context, every time.Duration) {
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
res, err := rc.Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("reconcile: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.RoundsRefunded > 0 || res.EmptyRoundsClosed > 0 {
|
||||||
|
fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds "+
|
||||||
|
"(%d msat); closed %d empty rounds\n",
|
||||||
|
res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded,
|
||||||
|
res.EmptyRoundsClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
585
pkg/room/room.go
Normal file
585
pkg/room/room.go
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
// Package room runs the shared crash rounds.
|
||||||
|
//
|
||||||
|
// A round moves through four states: betting_open, locked, running, settled.
|
||||||
|
// The server seed is committed before betting opens and revealed only at
|
||||||
|
// settlement, so no one — including the operator — can know the crash point
|
||||||
|
// while bets are still being placed.
|
||||||
|
//
|
||||||
|
// All money movement goes through the ledger in a single transaction per
|
||||||
|
// settlement, which is what keeps the books balanced under load.
|
||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fees"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/sim"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State is the phase of a round.
|
||||||
|
type State string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateBetting State = "betting_open"
|
||||||
|
StateLocked State = "locked"
|
||||||
|
StateRunning State = "running"
|
||||||
|
StateSettled State = "settled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Timings. The betting window is deliberately generous: at a party, people are
|
||||||
|
// walking up to their phones mid-round.
|
||||||
|
const (
|
||||||
|
BettingWindow = 20 * time.Second
|
||||||
|
LockedPause = 3 * time.Second
|
||||||
|
SettledPause = 7 * time.Second
|
||||||
|
TickInterval = time.Second / sim.TickHz
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bet is one player's position in the current round.
|
||||||
|
type Bet struct {
|
||||||
|
AccountID int64
|
||||||
|
Pubkey []byte
|
||||||
|
Nickname string
|
||||||
|
StakeMsat int64
|
||||||
|
CashedOutAt fixed.F // zero until they cash out
|
||||||
|
PayoutMsat int64
|
||||||
|
|
||||||
|
// AutoCashOutAt is an optional target set before the round starts. When
|
||||||
|
// the multiplier reaches it the position closes automatically at exactly
|
||||||
|
// that value — not at whatever the next tick happens to show — so the
|
||||||
|
// player gets the number they chose. Zero means no target.
|
||||||
|
AutoCashOutAt fixed.F
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is what clients render. It carries the seed inputs so a client can
|
||||||
|
// verify the round the moment it settles.
|
||||||
|
type Snapshot struct {
|
||||||
|
RoundID int64 `json:"round_id"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
State State `json:"state"`
|
||||||
|
Tick int `json:"tick"`
|
||||||
|
Multiplier string `json:"multiplier"`
|
||||||
|
Commitment string `json:"commitment"`
|
||||||
|
ServerSeed string `json:"server_seed,omitempty"` // only once settled
|
||||||
|
CrashPoint string `json:"crash_point,omitempty"` // only once settled
|
||||||
|
// Players is capped at MaxListedPlayers. Sending every player to every
|
||||||
|
// subscriber is O(n^2) in bandwidth and makes a large room impossible:
|
||||||
|
// 50k players broadcast to 50k phones is gigabytes per second. The full
|
||||||
|
// list is available on request; the feed carries the leaderboard.
|
||||||
|
Players []Player `json:"players"`
|
||||||
|
PlayerCount int `json:"player_count"`
|
||||||
|
PotMsat int64 `json:"pot_msat"`
|
||||||
|
CashedOut int `json:"cashed_out_count"`
|
||||||
|
HousePotMsat int64 `json:"house_pot_msat"`
|
||||||
|
NextPhaseIn float64 `json:"next_phase_in_seconds"`
|
||||||
|
|
||||||
|
// StartedUnixMilli is when the running phase began. Because the multiplier
|
||||||
|
// curve is deterministic, a client can compute the current value locally
|
||||||
|
// from this instead of being told it sixty times a second.
|
||||||
|
StartedUnixMilli int64 `json:"started_unix_milli,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxListedPlayers bounds the per-frame player list.
|
||||||
|
const MaxListedPlayers = 24
|
||||||
|
|
||||||
|
// BroadcastHz is how often a running round pushes a frame. Clients compute the
|
||||||
|
// multiplier locally between frames, so this only has to be often enough to
|
||||||
|
// correct drift and deliver cash-out news.
|
||||||
|
const BroadcastHz = 5
|
||||||
|
|
||||||
|
// Player is the public view of a participant.
|
||||||
|
type Player struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
// Pubkey is omitted from the live feed: it is 64 hex characters, it is
|
||||||
|
// most of the frame, and nothing in the interface displays it. The full
|
||||||
|
// participant list, with keys, is served by the verification endpoint
|
||||||
|
// after settlement — which is where it actually matters.
|
||||||
|
PubkeyHex string `json:"pubkey,omitempty"`
|
||||||
|
StakeMsat int64 `json:"stake_msat"`
|
||||||
|
CashedOut string `json:"cashed_out,omitempty"`
|
||||||
|
PayoutMsat int64 `json:"payout_msat"`
|
||||||
|
// Auto is true when the position closed on its own target rather than a tap.
|
||||||
|
Auto bool `json:"auto,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room runs one game's round loop.
|
||||||
|
type Room struct {
|
||||||
|
Game string
|
||||||
|
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
|
||||||
|
// Fees is the operator's schedule. Deductions are posted as their own
|
||||||
|
// ledger transaction rather than folded into the payout, so a player's
|
||||||
|
// history shows the win and the fee as separate, itemised lines.
|
||||||
|
Fees fees.Schedule
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
roundID int64
|
||||||
|
state State
|
||||||
|
tick int
|
||||||
|
nonce uint64
|
||||||
|
serverSeed fair.ServerSeed
|
||||||
|
commitment [32]byte
|
||||||
|
crashPoint fixed.F
|
||||||
|
bets map[int64]*Bet
|
||||||
|
order [][]byte // participant pubkeys in join order
|
||||||
|
phaseEnds time.Time
|
||||||
|
|
||||||
|
subscribers map[chan []byte]struct{}
|
||||||
|
subMu sync.Mutex
|
||||||
|
|
||||||
|
// runStarted is when the current running phase began.
|
||||||
|
runStarted time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
|
||||||
|
return &Room{
|
||||||
|
Game: game,
|
||||||
|
pool: pool,
|
||||||
|
ledger: l,
|
||||||
|
Fees: fees.DefaultSchedule(),
|
||||||
|
state: StateSettled,
|
||||||
|
bets: make(map[int64]*Bet),
|
||||||
|
subscribers: make(map[chan []byte]struct{}),
|
||||||
|
phaseEnds: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a channel of snapshots. The channel is buffered and drops
|
||||||
|
// updates rather than blocking the round loop: a slow phone must never stall
|
||||||
|
// the game for everyone else.
|
||||||
|
func (r *Room) Subscribe() (<-chan []byte, func()) {
|
||||||
|
ch := make(chan []byte, 4)
|
||||||
|
r.subMu.Lock()
|
||||||
|
r.subscribers[ch] = struct{}{}
|
||||||
|
r.subMu.Unlock()
|
||||||
|
|
||||||
|
return ch, func() {
|
||||||
|
r.subMu.Lock()
|
||||||
|
delete(r.subscribers, ch)
|
||||||
|
close(ch)
|
||||||
|
r.subMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast serialises the snapshot once and hands the same bytes to every
|
||||||
|
// subscriber.
|
||||||
|
//
|
||||||
|
// Letting each connection marshal its own copy costs ~355us per subscriber per
|
||||||
|
// frame, which at any real crowd size exceeds the tick interval by orders of
|
||||||
|
// magnitude. One marshal per frame turns fan-out into a pointer copy.
|
||||||
|
func (r *Room) broadcast() {
|
||||||
|
payload, err := json.Marshal(r.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("room %s: marshalling snapshot: %v\n", r.Game, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.subMu.Lock()
|
||||||
|
defer r.subMu.Unlock()
|
||||||
|
for ch := range r.subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- payload:
|
||||||
|
default: // subscriber is behind; drop this frame rather than stall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run drives the round loop until the context is cancelled.
|
||||||
|
func (r *Room) Run(ctx context.Context) error {
|
||||||
|
ticker := time.NewTicker(TickInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := r.step(ctx); err != nil {
|
||||||
|
// A failed step must not kill the room; log-and-continue keeps
|
||||||
|
// the arcade running even if the database blips.
|
||||||
|
fmt.Printf("room %s: step error: %v\n", r.Game, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) step(ctx context.Context) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
state, phaseEnds := r.state, r.phaseEnds
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case StateSettled:
|
||||||
|
if now.After(phaseEnds) {
|
||||||
|
return r.openRound(ctx)
|
||||||
|
}
|
||||||
|
case StateBetting:
|
||||||
|
if now.After(phaseEnds) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.state = StateLocked
|
||||||
|
r.phaseEnds = now.Add(LockedPause)
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.broadcast()
|
||||||
|
}
|
||||||
|
case StateLocked:
|
||||||
|
if now.After(phaseEnds) {
|
||||||
|
return r.startRunning(ctx)
|
||||||
|
}
|
||||||
|
case StateRunning:
|
||||||
|
r.mu.Lock()
|
||||||
|
r.tick++
|
||||||
|
reached := sim.MultiplierAt(r.tick)
|
||||||
|
// Close any positions whose target has been met. This happens before
|
||||||
|
// the crash check so a target at or below the crash point always pays,
|
||||||
|
// regardless of where tick boundaries happen to fall.
|
||||||
|
r.triggerAutoCashOutsLocked(reached)
|
||||||
|
// A crash point beyond what the curve expresses would otherwise never
|
||||||
|
// be reached, so the tick ceiling also ends the round.
|
||||||
|
crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if crashed {
|
||||||
|
return r.settle(ctx)
|
||||||
|
}
|
||||||
|
// The multiplier is a pure function of the tick, and the client has
|
||||||
|
// the same curve. So the feed does not need to carry it sixty times a
|
||||||
|
// second: clients interpolate locally from StartedUnixMilli and the
|
||||||
|
// server sends a correcting frame a few times a second.
|
||||||
|
//
|
||||||
|
// At 60Hz this fan-out was the single largest cost in the system. At
|
||||||
|
// BroadcastHz it is a rounding error, and the animation is smoother
|
||||||
|
// because it is no longer gated on network jitter.
|
||||||
|
if r.tick%(sim.TickHz/BroadcastHz) == 0 {
|
||||||
|
r.broadcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRound commits to a fresh server seed and opens betting.
|
||||||
|
func (r *Room) openRound(ctx context.Context) error {
|
||||||
|
seed := fair.NewServerSeed()
|
||||||
|
commitment := seed.Commitment()
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.nonce++
|
||||||
|
nonce := r.nonce
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
var roundID int64
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO rounds (game, nonce, commitment) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
r.Game, int64(nonce), commitment[:]).Scan(&roundID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening round: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.roundID = roundID
|
||||||
|
r.serverSeed = seed
|
||||||
|
r.commitment = commitment
|
||||||
|
r.crashPoint = 0
|
||||||
|
r.tick = 0
|
||||||
|
r.bets = make(map[int64]*Bet)
|
||||||
|
r.order = nil
|
||||||
|
r.state = StateBetting
|
||||||
|
r.phaseEnds = time.Now().Add(BettingWindow)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
r.broadcast()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRunning derives the crash point from the committed seed and the
|
||||||
|
// participant set, then begins the climb.
|
||||||
|
func (r *Room) startRunning(ctx context.Context) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
clientSeed := fair.ClientSeed(r.order)
|
||||||
|
roundSeed := fair.RoundSeed(r.serverSeed, clientSeed, r.nonce)
|
||||||
|
r.crashPoint = sim.CrashPoint(roundSeed)
|
||||||
|
r.state = StateRunning
|
||||||
|
r.tick = 0
|
||||||
|
r.runStarted = time.Now()
|
||||||
|
roundID := r.roundID
|
||||||
|
crash := r.crashPoint
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE rounds SET locked_at = now(), client_seed = $2, crash_point = $3
|
||||||
|
WHERE id = $1`,
|
||||||
|
roundID, clientSeed[:], int64(crash)); err != nil {
|
||||||
|
return fmt.Errorf("locking round: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.broadcast()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle pays out everyone who cashed out in time and reveals the seed.
|
||||||
|
// Payouts are written as one ledger transaction so the books cannot be left
|
||||||
|
// half-updated.
|
||||||
|
func (r *Room) settle(ctx context.Context) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
roundID := r.roundID
|
||||||
|
seed := r.serverSeed
|
||||||
|
crash := r.crashPoint
|
||||||
|
bets := make([]*Bet, 0, len(r.bets))
|
||||||
|
for _, b := range r.bets {
|
||||||
|
bets = append(bets, b)
|
||||||
|
}
|
||||||
|
r.state = StateSettled
|
||||||
|
r.phaseEnds = time.Now().Add(SettledPause)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
house, err := r.ledger.AccountByName(ctx, "house_pot")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var payouts []ledger.Posting
|
||||||
|
var feeLines []ledger.Posting
|
||||||
|
var housePays, houseKeeps int64
|
||||||
|
|
||||||
|
for _, b := range bets {
|
||||||
|
if b.CashedOutAt == 0 {
|
||||||
|
continue // rode it into the crash; the stake already sits with the house
|
||||||
|
}
|
||||||
|
gross := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
|
||||||
|
split := r.Fees.Apply(gross)
|
||||||
|
b.PayoutMsat = split.NetMsat
|
||||||
|
|
||||||
|
if gross > 0 {
|
||||||
|
payouts = append(payouts, ledger.Posting{AccountID: b.AccountID, AmountMsat: gross})
|
||||||
|
housePays += gross
|
||||||
|
}
|
||||||
|
if split.HouseMsat() > 0 {
|
||||||
|
feeLines = append(feeLines, ledger.Posting{
|
||||||
|
AccountID: b.AccountID, AmountMsat: -split.HouseMsat()})
|
||||||
|
houseKeeps += split.HouseMsat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE bets SET payout_msat = $2, rake_msat = $4, rounding_msat = $5,
|
||||||
|
settled_at = now()
|
||||||
|
WHERE round_id = $1 AND account_id = $3`,
|
||||||
|
roundID, split.NetMsat, b.AccountID,
|
||||||
|
split.RakeMsat, split.RoundingMsat); err != nil {
|
||||||
|
return fmt.Errorf("recording payout: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rid := roundID
|
||||||
|
|
||||||
|
// Pay the full winnings first, then take the fee as its own transaction.
|
||||||
|
// Netting them into one posting would be arithmetically identical but
|
||||||
|
// would hide the deduction: the player would see a smaller win rather
|
||||||
|
// than a win and a charge.
|
||||||
|
if housePays > 0 {
|
||||||
|
payouts = append(payouts, ledger.Posting{AccountID: house, AmountMsat: -housePays})
|
||||||
|
if _, err := r.ledger.Post(ctx, "payout", &rid, payouts); err != nil {
|
||||||
|
return fmt.Errorf("settling round %d: %w", roundID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if houseKeeps > 0 {
|
||||||
|
feeLines = append(feeLines, ledger.Posting{AccountID: house, AmountMsat: houseKeeps})
|
||||||
|
if _, err := r.ledger.Post(ctx, "operating_fee", &rid, feeLines); err != nil {
|
||||||
|
return fmt.Errorf("collecting fees for round %d: %w", roundID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgx encodes byte slices, not fixed-size arrays, so the seed is sliced.
|
||||||
|
seedBytes := seed.Bytes()
|
||||||
|
if _, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE rounds SET settled_at = now(), server_seed = $2 WHERE id = $1`,
|
||||||
|
roundID, seedBytes[:]); err != nil {
|
||||||
|
return fmt.Errorf("revealing seed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = crash
|
||||||
|
r.broadcast()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerAutoCashOutsLocked closes positions whose target the multiplier has
|
||||||
|
// reached. Callers must hold the lock.
|
||||||
|
//
|
||||||
|
// A target above the crash point never fires: the round is already over at
|
||||||
|
// that value. A target at or below it always fires, at exactly the target.
|
||||||
|
func (r *Room) triggerAutoCashOutsLocked(reached fixed.F) {
|
||||||
|
for _, b := range r.bets {
|
||||||
|
if b.CashedOutAt != 0 || b.AutoCashOutAt == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.AutoCashOutAt > r.crashPoint {
|
||||||
|
continue // the round ends before this target is reached
|
||||||
|
}
|
||||||
|
if reached >= b.AutoCashOutAt {
|
||||||
|
b.CashedOutAt = b.AutoCashOutAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceBet takes a stake during the betting window. The stake moves to the
|
||||||
|
// house immediately, so a player can never bet money they do not have.
|
||||||
|
func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64, autoCashOutAt fixed.F) error {
|
||||||
|
if stakeMsat <= 0 {
|
||||||
|
return ledger.ErrNonPositiveAmount
|
||||||
|
}
|
||||||
|
// A target at or below 1.0 would close instantly for no gain.
|
||||||
|
if autoCashOutAt != 0 && autoCashOutAt <= fixed.One {
|
||||||
|
return fmt.Errorf("auto cash-out target must be above 1.00")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.state != StateBetting {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return fmt.Errorf("betting is closed")
|
||||||
|
}
|
||||||
|
if _, exists := r.bets[accountID]; exists {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return fmt.Errorf("already in this round")
|
||||||
|
}
|
||||||
|
roundID := r.roundID
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
house, err := r.ledger.AccountByName(ctx, "house_pot")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rid := roundID
|
||||||
|
if _, err := r.ledger.Post(ctx, "bet", &rid, []ledger.Posting{
|
||||||
|
{AccountID: accountID, AmountMsat: -stakeMsat},
|
||||||
|
{AccountID: house, AmountMsat: stakeMsat},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO bets (round_id, account_id, stake_msat) VALUES ($1, $2, $3)`,
|
||||||
|
roundID, accountID, stakeMsat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
// Re-check state: the window may have closed while we were in the database.
|
||||||
|
if r.state != StateBetting || r.roundID != roundID {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return fmt.Errorf("betting closed while placing bet")
|
||||||
|
}
|
||||||
|
r.bets[accountID] = &Bet{
|
||||||
|
AccountID: accountID, Pubkey: pubkey,
|
||||||
|
Nickname: nickname, StakeMsat: stakeMsat,
|
||||||
|
AutoCashOutAt: autoCashOutAt,
|
||||||
|
}
|
||||||
|
r.order = append(r.order, pubkey)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
r.broadcast()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CashOut locks in the current multiplier. It is rejected once the round has
|
||||||
|
// passed the crash point, which the tick loop enforces by settling first.
|
||||||
|
func (r *Room) CashOut(accountID int64) (fixed.F, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.state != StateRunning {
|
||||||
|
return 0, fmt.Errorf("round is not running")
|
||||||
|
}
|
||||||
|
b, ok := r.bets[accountID]
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("no bet in this round")
|
||||||
|
}
|
||||||
|
if b.CashedOutAt != 0 {
|
||||||
|
return 0, fmt.Errorf("already cashed out")
|
||||||
|
}
|
||||||
|
at := sim.MultiplierAt(r.tick)
|
||||||
|
if at >= r.crashPoint {
|
||||||
|
return 0, fmt.Errorf("too late")
|
||||||
|
}
|
||||||
|
b.CashedOutAt = at
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := r.pool.Exec(context.Background(),
|
||||||
|
`UPDATE bets SET cashout_at = $3 WHERE round_id = $1 AND account_id = $2`,
|
||||||
|
r.roundID, accountID, int64(at)); err != nil {
|
||||||
|
fmt.Printf("room %s: recording cashout: %v\n", r.Game, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return at, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot renders the current state for clients.
|
||||||
|
func (r *Room) Snapshot() Snapshot {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
// Aggregate over every player, but only serialise the largest few.
|
||||||
|
var pot int64
|
||||||
|
cashed := 0
|
||||||
|
all := make([]*Bet, 0, len(r.bets))
|
||||||
|
for _, b := range r.bets {
|
||||||
|
pot += b.StakeMsat
|
||||||
|
if b.CashedOutAt != 0 {
|
||||||
|
cashed++
|
||||||
|
}
|
||||||
|
all = append(all, b)
|
||||||
|
}
|
||||||
|
// Partial ordering is enough: the list is a leaderboard, not a ledger.
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].StakeMsat > all[j].StakeMsat })
|
||||||
|
if len(all) > MaxListedPlayers {
|
||||||
|
all = all[:MaxListedPlayers]
|
||||||
|
}
|
||||||
|
|
||||||
|
players := make([]Player, 0, len(all))
|
||||||
|
for _, b := range all {
|
||||||
|
p := Player{
|
||||||
|
Nickname: b.Nickname,
|
||||||
|
StakeMsat: b.StakeMsat,
|
||||||
|
PayoutMsat: b.PayoutMsat,
|
||||||
|
}
|
||||||
|
if b.CashedOutAt != 0 {
|
||||||
|
p.CashedOut = b.CashedOutAt.String()
|
||||||
|
p.Auto = b.AutoCashOutAt != 0 && b.CashedOutAt == b.AutoCashOutAt
|
||||||
|
}
|
||||||
|
players = append(players, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := Snapshot{
|
||||||
|
RoundID: r.roundID,
|
||||||
|
Game: r.Game,
|
||||||
|
State: r.state,
|
||||||
|
Tick: r.tick,
|
||||||
|
Multiplier: sim.MultiplierAt(r.tick).String(),
|
||||||
|
Commitment: hex.EncodeToString(r.commitment[:]),
|
||||||
|
Players: players,
|
||||||
|
PlayerCount: len(r.bets),
|
||||||
|
PotMsat: pot,
|
||||||
|
CashedOut: cashed,
|
||||||
|
NextPhaseIn: time.Until(r.phaseEnds).Seconds(),
|
||||||
|
}
|
||||||
|
if r.state == StateRunning {
|
||||||
|
s.StartedUnixMilli = r.runStarted.UnixMilli()
|
||||||
|
}
|
||||||
|
// The seed is revealed only once the round is over.
|
||||||
|
if r.state == StateSettled && r.crashPoint != 0 {
|
||||||
|
s.ServerSeed = r.serverSeed.Hex()
|
||||||
|
s.CrashPoint = r.crashPoint.String()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
1204
pkg/room/room_test.go
Normal file
1204
pkg/room/room_test.go
Normal file
File diff suppressed because it is too large
Load Diff
237
pkg/scratch/scratch.go
Normal file
237
pkg/scratch/scratch.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// Package scratch implements instant scratch-ticket games.
|
||||||
|
//
|
||||||
|
// The defining property here is that the odds table shown to the player is
|
||||||
|
// derived from the same data that generates outcomes. There is no separate
|
||||||
|
// "marketing" table that could drift from reality: Odds() and Play() read the
|
||||||
|
// identical tier list, and a test asserts that observed frequencies match the
|
||||||
|
// published ones.
|
||||||
|
package scratch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Denominator is the resolution of the odds table. Every tier's weight is
|
||||||
|
// expressed out of this, so odds are exact rationals rather than rounded
|
||||||
|
// percentages.
|
||||||
|
const Denominator uint64 = 1_000_000
|
||||||
|
|
||||||
|
// Tier is one prize band.
|
||||||
|
type Tier struct {
|
||||||
|
// Name is what the player sees when they win it.
|
||||||
|
Name string
|
||||||
|
// Weight is the number of outcomes out of Denominator that land here.
|
||||||
|
Weight uint64
|
||||||
|
// PayoutBP is the prize as basis points of the stake: 10000 = 1x stake.
|
||||||
|
PayoutBP uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket is a scratch game definition.
|
||||||
|
type Ticket struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Blurb string
|
||||||
|
// Cells is how many squares the player scratches, for presentation.
|
||||||
|
Cells int
|
||||||
|
Tiers []Tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome is the result of one ticket.
|
||||||
|
type Outcome struct {
|
||||||
|
TicketID string `json:"ticket_id"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
PayoutBP uint64 `json:"payout_bp"`
|
||||||
|
PayoutMsat int64 `json:"payout_msat"`
|
||||||
|
// Roll is the raw draw, published so the player can check the mapping.
|
||||||
|
Roll uint64 `json:"roll"`
|
||||||
|
// Cells are the revealed symbols, derived from the same seed.
|
||||||
|
Cells []int `json:"cells"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OddsRow is one line of the published odds table.
|
||||||
|
type OddsRow struct {
|
||||||
|
TierName string `json:"tier"`
|
||||||
|
PayoutBP uint64 `json:"payout_bp"`
|
||||||
|
Weight uint64 `json:"weight"`
|
||||||
|
Denominator uint64 `json:"denominator"`
|
||||||
|
// OneIn is the human-readable "1 in N" figure, zero for the losing tier.
|
||||||
|
OneIn uint64 `json:"one_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports whether the tier weights are coherent. A ticket whose
|
||||||
|
// weights do not sum to exactly Denominator would have undefined outcomes.
|
||||||
|
func (t Ticket) Validate() error {
|
||||||
|
var sum uint64
|
||||||
|
for _, tier := range t.Tiers {
|
||||||
|
sum += tier.Weight
|
||||||
|
}
|
||||||
|
if sum != Denominator {
|
||||||
|
return fmt.Errorf("scratch: ticket %q weights sum to %d, want %d",
|
||||||
|
t.ID, sum, Denominator)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odds returns the published odds table, derived from the generating tiers.
|
||||||
|
func (t Ticket) Odds() []OddsRow {
|
||||||
|
rows := make([]OddsRow, 0, len(t.Tiers))
|
||||||
|
for _, tier := range t.Tiers {
|
||||||
|
row := OddsRow{
|
||||||
|
TierName: tier.Name,
|
||||||
|
PayoutBP: tier.PayoutBP,
|
||||||
|
Weight: tier.Weight,
|
||||||
|
Denominator: Denominator,
|
||||||
|
}
|
||||||
|
if tier.Weight > 0 {
|
||||||
|
row.OneIn = Denominator / tier.Weight
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTPBasisPoints is the return to player in basis points, computed from the
|
||||||
|
// same tiers that generate outcomes. 9800 means 98%.
|
||||||
|
func (t Ticket) RTPBasisPoints() uint64 {
|
||||||
|
var total uint64
|
||||||
|
for _, tier := range t.Tiers {
|
||||||
|
total += tier.Weight * tier.PayoutBP
|
||||||
|
}
|
||||||
|
return total / Denominator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play resolves one ticket from a round seed.
|
||||||
|
func (t Ticket) Play(seed [32]byte, stakeMsat int64) Outcome {
|
||||||
|
// The first 8 bytes select the tier; later bytes decorate the cells, so
|
||||||
|
// presentation can never alter the prize.
|
||||||
|
roll := binary.BigEndian.Uint64(seed[0:8]) % Denominator
|
||||||
|
|
||||||
|
var chosen Tier
|
||||||
|
var cumulative uint64
|
||||||
|
for _, tier := range t.Tiers {
|
||||||
|
cumulative += tier.Weight
|
||||||
|
if roll < cumulative {
|
||||||
|
chosen = tier
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Outcome{
|
||||||
|
TicketID: t.ID,
|
||||||
|
TierName: chosen.Name,
|
||||||
|
PayoutBP: chosen.PayoutBP,
|
||||||
|
PayoutMsat: stakeMsat * int64(chosen.PayoutBP) / 10000,
|
||||||
|
Roll: roll,
|
||||||
|
Cells: t.revealCells(seed, chosen),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbolCount is the number of distinct symbols on a ticket face. With six
|
||||||
|
// symbols, a losing face can always be filled without an accidental
|
||||||
|
// three-of-a-kind for any ticket up to twelve cells.
|
||||||
|
const symbolCount = 6
|
||||||
|
|
||||||
|
// revealCells produces the symbols the player scratches off.
|
||||||
|
//
|
||||||
|
// The prize is already fixed by the roll; the cells are presentation. But they
|
||||||
|
// must never contradict the result, so a winning face always shows a genuine
|
||||||
|
// three-of-a-kind and a losing face never does.
|
||||||
|
func (t Ticket) revealCells(seed [32]byte, won Tier) []int {
|
||||||
|
cells := make([]int, t.Cells)
|
||||||
|
for i := range cells {
|
||||||
|
cells[i] = -1
|
||||||
|
}
|
||||||
|
counts := make([]int, symbolCount)
|
||||||
|
|
||||||
|
// A winning face gets its match placed first, on three adjacent positions
|
||||||
|
// so they cannot collide with one another.
|
||||||
|
winSym := -1
|
||||||
|
if won.PayoutBP > 0 && t.Cells >= 3 {
|
||||||
|
winSym = int(seed[31]) % symbolCount
|
||||||
|
start := int(seed[30]) % t.Cells
|
||||||
|
for k := 0; k < 3; k++ {
|
||||||
|
cells[(start+k)%t.Cells] = winSym
|
||||||
|
counts[winSym]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the remainder, never letting a non-winning symbol reach three.
|
||||||
|
for i := range cells {
|
||||||
|
if cells[i] != -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pick := int(seed[(i+8)%32]) % symbolCount
|
||||||
|
for attempts := 0; attempts < symbolCount; attempts++ {
|
||||||
|
if pick != winSym && counts[pick] >= 2 {
|
||||||
|
pick = (pick + 1) % symbolCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pick == winSym && winSym == -1 {
|
||||||
|
pick = (pick + 1) % symbolCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cells[i] = pick
|
||||||
|
counts[pick]++
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog is the set of tickets offered. Each is validated at startup.
|
||||||
|
var Catalog = []Ticket{
|
||||||
|
{
|
||||||
|
ID: "nebula-nine",
|
||||||
|
Name: "Nebula Nine",
|
||||||
|
Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.",
|
||||||
|
Cells: 9,
|
||||||
|
// Weights are out of 1,000,000 and sum to it exactly. The weighted
|
||||||
|
// payout sums to 9.9e9, which is an RTP of exactly 99%.
|
||||||
|
Tiers: []Tier{
|
||||||
|
{Name: "No win", Weight: 458_400, PayoutBP: 0},
|
||||||
|
{Name: "Stake back", Weight: 350_000, PayoutBP: 10_000},
|
||||||
|
{Name: "Double", Weight: 155_000, PayoutBP: 20_000},
|
||||||
|
{Name: "Five times", Weight: 30_000, PayoutBP: 50_000},
|
||||||
|
{Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000},
|
||||||
|
{Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "singularity",
|
||||||
|
Name: "Singularity",
|
||||||
|
Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.",
|
||||||
|
Cells: 6,
|
||||||
|
// Same 99% RTP as Nebula Nine, but concentrated in the rare tiers:
|
||||||
|
// you lose far more often, and the top prize is 500x.
|
||||||
|
Tiers: []Tier{
|
||||||
|
{Name: "No win", Weight: 865_530, PayoutBP: 0},
|
||||||
|
{Name: "Stake back", Weight: 60_000, PayoutBP: 10_000},
|
||||||
|
{Name: "Triple", Weight: 45_000, PayoutBP: 30_000},
|
||||||
|
{Name: "Ten times", Weight: 26_000, PayoutBP: 100_000},
|
||||||
|
{Name: "Hundred times", Weight: 3_000, PayoutBP: 1_000_000},
|
||||||
|
{Name: "Singularity", Weight: 470, PayoutBP: 5_000_000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByID looks up a ticket in the catalog.
|
||||||
|
func ByID(id string) (Ticket, bool) {
|
||||||
|
for _, t := range Catalog {
|
||||||
|
if t.ID == id {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ticket{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayFromRound is the entry point used by the server: it derives the seed
|
||||||
|
// through the same commit-reveal machinery the crash games use, so scratch
|
||||||
|
// tickets are verifiable by exactly the same method.
|
||||||
|
func PlayFromRound(t Ticket, server fair.ServerSeed, pubkey []byte, nonce uint64, stakeMsat int64) (Outcome, fair.Proof) {
|
||||||
|
client := fair.ClientSeed([][]byte{pubkey})
|
||||||
|
seed := fair.RoundSeed(server, client, nonce)
|
||||||
|
return t.Play(seed, stakeMsat), fair.BuildProof(server, [][]byte{pubkey}, nonce)
|
||||||
|
}
|
||||||
153
pkg/scratch/scratch_test.go
Normal file
153
pkg/scratch/scratch_test.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package scratch_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/scratch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogIsCoherent(t *testing.T) {
|
||||||
|
for _, ticket := range scratch.Catalog {
|
||||||
|
if err := ticket.Validate(); err != nil {
|
||||||
|
t.Errorf("%s: %v", ticket.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The published RTP must be a genuine 98%, not a rounded claim.
|
||||||
|
func TestPublishedRTPIsHonest(t *testing.T) {
|
||||||
|
for _, ticket := range scratch.Catalog {
|
||||||
|
rtp := ticket.RTPBasisPoints()
|
||||||
|
if rtp != 9900 {
|
||||||
|
t.Errorf("%s: RTP = %d bp, want 9900", ticket.ID, rtp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core honesty test: the odds table shown to players must match the
|
||||||
|
// frequencies the generator actually produces. If someone edits a weight to
|
||||||
|
// tighten the game without updating the table, this fails.
|
||||||
|
func TestObservedFrequenciesMatchPublishedOdds(t *testing.T) {
|
||||||
|
const trials = 2_000_000
|
||||||
|
|
||||||
|
for _, ticket := range scratch.Catalog {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for i := 0; i < trials; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
|
||||||
|
out := ticket.Play(seed, 1000)
|
||||||
|
counts[out.TierName]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range ticket.Odds() {
|
||||||
|
expected := float64(row.Weight) / float64(row.Denominator)
|
||||||
|
observed := float64(counts[row.TierName]) / trials
|
||||||
|
// Allow a relative tolerance that scales with rarity, since rare
|
||||||
|
// tiers have proportionally more sampling noise.
|
||||||
|
tolerance := 0.02 + 3/(expected*trials+1)
|
||||||
|
if expected > 0 {
|
||||||
|
diff := (observed - expected) / expected
|
||||||
|
if diff < -tolerance || diff > tolerance {
|
||||||
|
t.Errorf("%s/%s: published %.6f, observed %.6f (%.1f%% off)",
|
||||||
|
ticket.ID, row.TierName, expected, observed, diff*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empirical return must match the published RTP, which is the claim that
|
||||||
|
// actually matters to a player.
|
||||||
|
func TestEmpiricalReturnMatchesPublishedRTP(t *testing.T) {
|
||||||
|
const trials = 2_000_000
|
||||||
|
const stake = 10_000
|
||||||
|
|
||||||
|
for _, ticket := range scratch.Catalog {
|
||||||
|
var paid int64
|
||||||
|
for i := 0; i < trials; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
|
||||||
|
paid += ticket.Play(seed, stake).PayoutMsat
|
||||||
|
}
|
||||||
|
staked := int64(trials) * stake
|
||||||
|
observedBP := paid * 10000 / staked
|
||||||
|
published := int64(ticket.RTPBasisPoints())
|
||||||
|
if observedBP < published-150 || observedBP > published+150 {
|
||||||
|
t.Errorf("%s: observed RTP %d bp, published %d bp",
|
||||||
|
ticket.ID, observedBP, published)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutcomeIsDeterministic(t *testing.T) {
|
||||||
|
ticket := scratch.Catalog[0]
|
||||||
|
var seed [32]byte
|
||||||
|
copy(seed[:], "a-fixed-seed-for-this-ticket-abc")
|
||||||
|
first := ticket.Play(seed, 5000)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if got := ticket.Play(seed, 5000); got.TierName != first.TierName ||
|
||||||
|
got.PayoutMsat != first.PayoutMsat {
|
||||||
|
t.Fatal("scratch outcome is not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The revealed cells must never contradict the payout: a winning ticket shows
|
||||||
|
// three of a kind, a losing ticket does not.
|
||||||
|
func TestRevealedCellsAgreeWithPayout(t *testing.T) {
|
||||||
|
for _, ticket := range scratch.Catalog {
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
|
||||||
|
for j := 8; j < 32; j++ {
|
||||||
|
seed[j] = byte(i*j + j)
|
||||||
|
}
|
||||||
|
out := ticket.Play(seed, 1000)
|
||||||
|
|
||||||
|
counts := map[int]int{}
|
||||||
|
for _, c := range out.Cells {
|
||||||
|
counts[c]++
|
||||||
|
}
|
||||||
|
hasThree := false
|
||||||
|
for _, n := range counts {
|
||||||
|
if n >= 3 {
|
||||||
|
hasThree = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.PayoutBP > 0 && !hasThree {
|
||||||
|
t.Fatalf("%s: winning ticket (%s) shows no three-of-a-kind: %v",
|
||||||
|
ticket.ID, out.TierName, out.Cells)
|
||||||
|
}
|
||||||
|
if out.PayoutBP == 0 && hasThree {
|
||||||
|
t.Fatalf("%s: losing ticket shows three-of-a-kind: %v",
|
||||||
|
ticket.ID, out.Cells)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scratch tickets must be verifiable by the same commit-reveal path as the
|
||||||
|
// crash games.
|
||||||
|
func TestPlayFromRoundIsVerifiable(t *testing.T) {
|
||||||
|
ticket := scratch.Catalog[0]
|
||||||
|
server := fair.NewServerSeed()
|
||||||
|
commitment := server.Commitment()
|
||||||
|
pubkey := []byte("player-pubkey")
|
||||||
|
|
||||||
|
out, proof := scratch.PlayFromRound(ticket, server, pubkey, 3, 1000)
|
||||||
|
|
||||||
|
if !fair.VerifyCommitment(commitment, server) {
|
||||||
|
t.Fatal("commitment does not verify")
|
||||||
|
}
|
||||||
|
// Independently recompute the outcome the way a client would.
|
||||||
|
seed := fair.RoundSeed(server, fair.ClientSeed([][]byte{pubkey}), 3)
|
||||||
|
if recomputed := ticket.Play(seed, 1000); recomputed.TierName != out.TierName {
|
||||||
|
t.Fatalf("recomputed %q, server said %q", recomputed.TierName, out.TierName)
|
||||||
|
}
|
||||||
|
if proof.Nonce != 3 {
|
||||||
|
t.Fatalf("proof nonce = %d, want 3", proof.Nonce)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
pkg/sim/crash.go
Normal file
120
pkg/sim/crash.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/bits"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HouseEdgeBP is the house edge in basis points (100 = 1.00%).
|
||||||
|
//
|
||||||
|
// One percent is deliberately generous — better than almost anything
|
||||||
|
// commercial. This is a game among friends, not a revenue stream, and a
|
||||||
|
// thinner edge means the pot lasts the whole night instead of draining
|
||||||
|
// toward the house.
|
||||||
|
const HouseEdgeBP int64 = 100
|
||||||
|
|
||||||
|
// TickHz is the simulation rate. Rounds advance in whole ticks only.
|
||||||
|
const TickHz = 60
|
||||||
|
|
||||||
|
// RoundTicks is the hard ceiling on a round's length: 60 seconds at 60Hz.
|
||||||
|
//
|
||||||
|
// The multiplier follows a hyperbolic curve that diverges at exactly this
|
||||||
|
// tick, so no round can run longer no matter how extreme the crash point.
|
||||||
|
// An exponential curve has no such bound — a 275x round on one takes over two
|
||||||
|
// and a half minutes, which is unplayable when a dozen people are waiting.
|
||||||
|
const RoundTicks = 60 * TickHz
|
||||||
|
|
||||||
|
// MaxMultiplier is the largest value the curve expresses, reached on the final
|
||||||
|
// tick. Crash points at or above it settle when the round hits its ceiling.
|
||||||
|
func MaxMultiplier() fixed.F { return MultiplierAt(RoundTicks - 1) }
|
||||||
|
|
||||||
|
// CrashPoint derives the multiplier at which a round ends, as a pure function of
|
||||||
|
// the seed.
|
||||||
|
//
|
||||||
|
// The distribution is the inverse-uniform curve scaled by the house edge:
|
||||||
|
//
|
||||||
|
// crash = (1 - edge) / u, u uniform over (0, 1]
|
||||||
|
//
|
||||||
|
// which yields the same expected return of (1 - edge) at every cash-out target.
|
||||||
|
// No target is smarter than any other, so there is nothing to grind out.
|
||||||
|
func CrashPoint(seed [32]byte) fixed.F {
|
||||||
|
r := NewRNG(seed)
|
||||||
|
|
||||||
|
// u is uniform over [1, 2^32], giving a resolution of one part in 4 billion.
|
||||||
|
u := (r.Uint64() >> 32) + 1
|
||||||
|
|
||||||
|
// payoutRatio is (1 - edge) in Q32.32, e.g. 0.98.
|
||||||
|
payoutRatio := uint64((10000 - HouseEdgeBP) << 32 / 10000)
|
||||||
|
|
||||||
|
// crash = payoutRatio / (u / 2^32), computed as (payoutRatio * 2^32) / u
|
||||||
|
// through a 128-bit intermediate so no precision is lost.
|
||||||
|
hi, lo := bits.Mul64(payoutRatio, 1<<32)
|
||||||
|
q, _ := bits.Div64(hi, lo, u)
|
||||||
|
|
||||||
|
// The quotient can exceed int64 for the very smallest u — at u=1 it wraps
|
||||||
|
// negative, which would silently turn the rarest and most valuable outcome
|
||||||
|
// into an instant loss. Compare in unsigned space before converting.
|
||||||
|
//
|
||||||
|
// The cap is the largest multiplier the curve can express. Anything above
|
||||||
|
// it is unreachable anyway: the round would hit its tick ceiling first.
|
||||||
|
// It also bounds the maximum payout, so a single round cannot demand more
|
||||||
|
// than the house can hold.
|
||||||
|
maxCP := MaxMultiplier()
|
||||||
|
if q >= uint64(maxCP) {
|
||||||
|
return maxCP
|
||||||
|
}
|
||||||
|
|
||||||
|
cp := fixed.F(q)
|
||||||
|
if cp < fixed.One {
|
||||||
|
cp = fixed.One
|
||||||
|
}
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiplierAt returns the multiplier displayed at a given tick.
|
||||||
|
//
|
||||||
|
// m(t) = 1 / (1 - t/T)^2
|
||||||
|
//
|
||||||
|
// It starts at 1.0, rises slowly at first, and accelerates without bound as t
|
||||||
|
// approaches T. That acceleration is the tension: the longer you hold, the
|
||||||
|
// faster the number moves away from you, and the less time you have to react.
|
||||||
|
// It is also O(1), so a long round costs no more per tick than a short one.
|
||||||
|
func MultiplierAt(tick int) fixed.F {
|
||||||
|
if tick <= 0 {
|
||||||
|
return fixed.One
|
||||||
|
}
|
||||||
|
// Clamp the tick, not the value: clamping the value would make the curve
|
||||||
|
// step backwards at the boundary if rounding put the last computed point
|
||||||
|
// above the nominal ceiling.
|
||||||
|
if tick >= RoundTicks {
|
||||||
|
tick = RoundTicks - 1
|
||||||
|
}
|
||||||
|
// remaining = 1 - tick/T, always in (0, 1].
|
||||||
|
remaining := fixed.One - fixed.FromInt(int64(tick)).Div(fixed.FromInt(RoundTicks))
|
||||||
|
return fixed.One.Div(remaining.Mul(remaining))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicksToMultiplier returns the first tick at which MultiplierAt reaches m,
|
||||||
|
// inverting the curve: t = T * (1 - 1/sqrt(m)).
|
||||||
|
func TicksToMultiplier(m fixed.F) int {
|
||||||
|
if m <= fixed.One {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if m >= MaxMultiplier() {
|
||||||
|
return RoundTicks
|
||||||
|
}
|
||||||
|
inv := fixed.One.Div(fixed.Sqrt(m))
|
||||||
|
t := fixed.FromInt(RoundTicks).Mul(fixed.One - inv).Int()
|
||||||
|
|
||||||
|
// Rounding in fixed point can land a tick early; step forward to the first
|
||||||
|
// tick that genuinely reaches the target.
|
||||||
|
tick := int(t)
|
||||||
|
for tick > 0 && MultiplierAt(tick-1) >= m {
|
||||||
|
tick--
|
||||||
|
}
|
||||||
|
for tick < RoundTicks && MultiplierAt(tick) < m {
|
||||||
|
tick++
|
||||||
|
}
|
||||||
|
return tick
|
||||||
|
}
|
||||||
165
pkg/sim/crash_test.go
Normal file
165
pkg/sim/crash_test.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrashPointNeverBelowOne(t *testing.T) {
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0], seed[1] = byte(i), byte(i>>8)
|
||||||
|
if cp := CrashPoint(seed); cp < 1<<32 {
|
||||||
|
t.Fatalf("seed %d: crash point %v below 1.0", i, cp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrashPointIsDeterministic(t *testing.T) {
|
||||||
|
var seed [32]byte
|
||||||
|
copy(seed[:], "repeatable")
|
||||||
|
first := CrashPoint(seed)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if got := CrashPoint(seed); got != first {
|
||||||
|
t.Fatalf("run %d: %v != %v", i, got, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a 2% house edge, a player cashing out at exactly 2.00x should win
|
||||||
|
// slightly under half the time. This pins the payout distribution.
|
||||||
|
func TestHouseEdgeAtTwoX(t *testing.T) {
|
||||||
|
const n = 200000
|
||||||
|
target := int64(2) << 32
|
||||||
|
wins := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0], seed[1], seed[2] = byte(i), byte(i>>8), byte(i>>16)
|
||||||
|
if int64(CrashPoint(seed)) >= target {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pct := float64(wins) * 100 / n
|
||||||
|
if pct < 48.5 || pct > 51.0 {
|
||||||
|
t.Fatalf("win rate at 2.00x = %.2f%%, want ~49.5%%", pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expected return at any cash-out target should be about 98%.
|
||||||
|
func TestExpectedReturnMatchesEdge(t *testing.T) {
|
||||||
|
const n = 200000
|
||||||
|
for _, targetX := range []int64{2, 3, 5} {
|
||||||
|
target := targetX << 32
|
||||||
|
var returned float64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0], seed[1], seed[2], seed[3] = byte(i), byte(i>>8), byte(i>>16), byte(targetX)
|
||||||
|
if int64(CrashPoint(seed)) >= target {
|
||||||
|
returned += float64(targetX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rtp := returned * 100 / n
|
||||||
|
if rtp < 97.0 || rtp > 101.0 {
|
||||||
|
t.Fatalf("RTP at %dx = %.2f%%, want ~99%%", targetX, rtp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiplierStartsAtOne(t *testing.T) {
|
||||||
|
if got := MultiplierAt(0); got != 1<<32 {
|
||||||
|
t.Fatalf("MultiplierAt(0) = %v, want 1.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiplierIsMonotonic(t *testing.T) {
|
||||||
|
prev := MultiplierAt(0)
|
||||||
|
for tick := 1; tick < 5000; tick++ {
|
||||||
|
cur := MultiplierAt(tick)
|
||||||
|
if cur < prev {
|
||||||
|
t.Fatalf("tick %d: multiplier decreased %v -> %v", tick, prev, cur)
|
||||||
|
}
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicksToMultiplierRoundTrips(t *testing.T) {
|
||||||
|
for _, m := range []int64{2, 5, 10} {
|
||||||
|
target := fixed.FromInt(m)
|
||||||
|
tick := TicksToMultiplier(target)
|
||||||
|
if MultiplierAt(tick) < target {
|
||||||
|
t.Fatalf("tick %d does not reach %dx", tick, m)
|
||||||
|
}
|
||||||
|
if tick > 0 && MultiplierAt(tick-1) >= target {
|
||||||
|
t.Fatalf("tick %d is not the first to reach %dx", tick, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No round may outlast the ceiling, however extreme the crash point.
|
||||||
|
func TestRoundLengthIsBounded(t *testing.T) {
|
||||||
|
if got := MultiplierAt(RoundTicks); got != MaxMultiplier() {
|
||||||
|
t.Fatalf("curve past the ceiling = %v, want %v", got, MaxMultiplier())
|
||||||
|
}
|
||||||
|
// Even the most extreme crash point settles within the ceiling.
|
||||||
|
if tick := TicksToMultiplier(MaxMultiplier()); tick > RoundTicks {
|
||||||
|
t.Fatalf("extreme crash point needs %d ticks, ceiling is %d", tick, RoundTicks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timings that matter for how the game feels.
|
||||||
|
func TestCurveTimings(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
multiplier int64
|
||||||
|
maxSeconds float64
|
||||||
|
}{
|
||||||
|
{2, 20}, // the common case should arrive quickly
|
||||||
|
{10, 45},
|
||||||
|
{100, 56},
|
||||||
|
} {
|
||||||
|
tick := TicksToMultiplier(fixed.FromInt(c.multiplier))
|
||||||
|
secs := float64(tick) / TickHz
|
||||||
|
if secs > c.maxSeconds {
|
||||||
|
t.Errorf("%dx takes %.1fs, want under %.0fs", c.multiplier, secs, c.maxSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The crash point must never be negative or below 1.0, at any seed. An
|
||||||
|
// unsigned quotient exceeding int64 previously wrapped negative here.
|
||||||
|
func TestCrashPointNeverOverflows(t *testing.T) {
|
||||||
|
// Drive the derivation across seeds chosen to produce very small u, which
|
||||||
|
// is where the quotient is largest.
|
||||||
|
for i := 0; i < 200000; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
for j := 0; j < 32; j++ {
|
||||||
|
seed[j] = byte(i >> (8 * (j % 4)))
|
||||||
|
}
|
||||||
|
cp := CrashPoint(seed)
|
||||||
|
if cp < fixed.One {
|
||||||
|
t.Fatalf("seed %d produced crash point %v, below 1.0", i, cp)
|
||||||
|
}
|
||||||
|
if cp > MaxMultiplier() {
|
||||||
|
t.Fatalf("seed %d produced crash point %v, above the ceiling %v",
|
||||||
|
i, cp, MaxMultiplier())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payout a single round can demand must be bounded, so settlement can
|
||||||
|
// always be covered.
|
||||||
|
func TestMaximumPayoutIsBounded(t *testing.T) {
|
||||||
|
max := MaxMultiplier()
|
||||||
|
if max <= 0 {
|
||||||
|
t.Fatalf("ceiling is not positive: %v", max)
|
||||||
|
}
|
||||||
|
// A 1000-sat stake at the ceiling must stay well inside int64.
|
||||||
|
const stakeMsat = int64(1_000_000)
|
||||||
|
payout := stakeMsat * int64(max) / int64(fixed.One)
|
||||||
|
if payout <= 0 {
|
||||||
|
t.Fatalf("payout at the ceiling overflowed: %d", payout)
|
||||||
|
}
|
||||||
|
if payout > 1<<62 {
|
||||||
|
t.Fatalf("payout at the ceiling is %d, unreasonably large", payout)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
pkg/sim/rng.go
Normal file
75
pkg/sim/rng.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RNG is a deterministic xoshiro256** generator seeded from 32 bytes.
|
||||||
|
// It uses only integer operations, so a browser replaying a round reproduces
|
||||||
|
// the server's stream exactly.
|
||||||
|
//
|
||||||
|
// This is the expansion function, not the entropy source: the seed itself comes
|
||||||
|
// from the commit-reveal protocol, which is what makes outcomes unriggable.
|
||||||
|
type RNG struct {
|
||||||
|
state [4]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRNG creates a reproducible generator from a 32-byte seed.
|
||||||
|
//
|
||||||
|
// The seed is run through SplitMix64 rather than copied into the state
|
||||||
|
// directly. Copying directly leaves the first output depending only on
|
||||||
|
// state[1], so seeds differing in other bytes produce identical first draws —
|
||||||
|
// which would make CrashPoint blind to most of its own seed.
|
||||||
|
func NewRNG(seed [32]byte) *RNG {
|
||||||
|
// Fold every seed byte into a single accumulator first, so all 32 bytes
|
||||||
|
// influence all four state words.
|
||||||
|
acc := uint64(0x9E3779B97F4A7C15)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
acc ^= binary.LittleEndian.Uint64(seed[i*8 : i*8+8])
|
||||||
|
acc = splitMix64(&acc)
|
||||||
|
}
|
||||||
|
r := &RNG{}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
r.state[i] = splitMix64(&acc)
|
||||||
|
}
|
||||||
|
// An all-zero state is a fixed point of the recurrence. SplitMix64 makes
|
||||||
|
// this vanishingly unlikely, but the guard costs nothing.
|
||||||
|
if r.state[0]|r.state[1]|r.state[2]|r.state[3] == 0 {
|
||||||
|
r.state[0] = 0x9E3779B97F4A7C15
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitMix64 advances x and returns a well-mixed 64-bit value. Every input bit
|
||||||
|
// affects every output bit, which is the property the state expansion needs.
|
||||||
|
func splitMix64(x *uint64) uint64 {
|
||||||
|
*x += 0x9E3779B97F4A7C15
|
||||||
|
z := *x
|
||||||
|
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
|
||||||
|
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
|
||||||
|
return z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uint64 returns the next 64 bits of the stream.
|
||||||
|
func (r *RNG) Uint64() uint64 {
|
||||||
|
s := &r.state
|
||||||
|
result := rotl(s[1]*5, 7) * 9
|
||||||
|
t := s[1] << 17
|
||||||
|
s[2] ^= s[0]
|
||||||
|
s[3] ^= s[1]
|
||||||
|
s[1] ^= s[2]
|
||||||
|
s[0] ^= s[3]
|
||||||
|
s[2] ^= t
|
||||||
|
s[3] = rotl(s[3], 45)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotl(x uint64, k uint) uint64 { return (x << k) | (x >> (64 - k)) }
|
||||||
|
|
||||||
|
// Unit returns a fixed-point value uniformly distributed over [0, 1).
|
||||||
|
// Taking the top 32 bits places them exactly in the fractional field.
|
||||||
|
func (r *RNG) Unit() fixed.F {
|
||||||
|
return fixed.F(r.Uint64() >> 32)
|
||||||
|
}
|
||||||
51
pkg/sim/rng_test.go
Normal file
51
pkg/sim/rng_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRNGIsDeterministic(t *testing.T) {
|
||||||
|
var seed [32]byte
|
||||||
|
copy(seed[:], "quantum-arcade-test-seed")
|
||||||
|
a, b := NewRNG(seed), NewRNG(seed)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
if x, y := a.Uint64(), b.Uint64(); x != y {
|
||||||
|
t.Fatalf("iteration %d: %d != %d", i, x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDifferentSeedsDiverge(t *testing.T) {
|
||||||
|
var s1, s2 [32]byte
|
||||||
|
copy(s1[:], "seed-one")
|
||||||
|
copy(s2[:], "seed-two")
|
||||||
|
a, b := NewRNG(s1), NewRNG(s2)
|
||||||
|
same := 0
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if a.Uint64() == b.Uint64() {
|
||||||
|
same++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same > 1 {
|
||||||
|
t.Fatalf("streams collided %d times in 100 draws", same)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroSeedDoesNotDegenerate(t *testing.T) {
|
||||||
|
var seed [32]byte // all zeros
|
||||||
|
r := NewRNG(seed)
|
||||||
|
first := r.Uint64()
|
||||||
|
if first == 0 && r.Uint64() == 0 {
|
||||||
|
t.Fatal("zero seed produced a degenerate all-zero stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnitInRange(t *testing.T) {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0] = 9
|
||||||
|
r := NewRNG(seed)
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
u := r.Unit()
|
||||||
|
if u < 0 || u >= 1<<32 {
|
||||||
|
t.Fatalf("Unit() = %v out of [0,1)", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
440
pkg/tournament/tournament.go
Normal file
440
pkg/tournament/tournament.go
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// Package tournament runs scheduled competitive events.
|
||||||
|
//
|
||||||
|
// A tournament collects entry fees into a prize pool and pays them out to the
|
||||||
|
// best performers over a window of ordinary rounds. Players keep playing the
|
||||||
|
// same games; the tournament simply scores what they do.
|
||||||
|
//
|
||||||
|
// The prize pool is a real ledger account rather than a number in a row. Entry
|
||||||
|
// fees move into it and prizes move out of it, so tournament money obeys the
|
||||||
|
// same double-entry invariants as everything else: it cannot be created,
|
||||||
|
// cannot be lost, and every movement is explained by a posting.
|
||||||
|
//
|
||||||
|
// Two properties the tests pin down, because they are where this kind of code
|
||||||
|
// usually goes wrong:
|
||||||
|
//
|
||||||
|
// - Every millisatoshi collected is paid out. Integer division of a pool
|
||||||
|
// across percentage shares leaves a remainder, and a remainder that is
|
||||||
|
// silently dropped is money that vanishes.
|
||||||
|
// - A tournament settles exactly once, even if two instances try at the
|
||||||
|
// same moment.
|
||||||
|
package tournament
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotRegistering = errors.New("tournament: registration is not open")
|
||||||
|
ErrAlreadyEntered = errors.New("tournament: already entered")
|
||||||
|
ErrFull = errors.New("tournament: entrant limit reached")
|
||||||
|
ErrNotFinished = errors.New("tournament: has not finished yet")
|
||||||
|
ErrAlreadySettled = errors.New("tournament: already settled")
|
||||||
|
ErrBadPayoutSplit = errors.New("tournament: payout shares must sum to 10000 basis points")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusScheduled Status = "scheduled"
|
||||||
|
StatusRegistering Status = "registering"
|
||||||
|
StatusRunning Status = "running"
|
||||||
|
StatusSettled Status = "settled"
|
||||||
|
StatusCancelled Status = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tournament is a scheduled event.
|
||||||
|
type Tournament struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
EntryFeeMsat int64 `json:"entry_fee_msat"`
|
||||||
|
PoolAccountID int64 `json:"-"`
|
||||||
|
PayoutBP []int32 `json:"payout_bp"`
|
||||||
|
MaxEntrants *int32 `json:"max_entrants"`
|
||||||
|
RegistersAt time.Time `json:"registers_at"`
|
||||||
|
StartsAt time.Time `json:"starts_at"`
|
||||||
|
EndsAt time.Time `json:"ends_at"`
|
||||||
|
PoolMsat int64 `json:"pool_msat"`
|
||||||
|
Entrants int `json:"entrants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standing is one player's place on the board.
|
||||||
|
type Standing struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
ScoreMsat int64 `json:"score_msat"`
|
||||||
|
RoundsPlayed int `json:"rounds_played"`
|
||||||
|
PrizeMsat int64 `json:"prize_msat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool, l *ledger.Ledger) *Service {
|
||||||
|
return &Service{pool: pool, ledger: l}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create schedules a tournament and opens its prize pool account.
|
||||||
|
func (s *Service) Create(ctx context.Context, name, game string,
|
||||||
|
entryFeeMsat int64, payoutBP []int32, maxEntrants *int32,
|
||||||
|
registersAt, startsAt, endsAt time.Time) (*Tournament, error) {
|
||||||
|
|
||||||
|
var total int32
|
||||||
|
for _, bp := range payoutBP {
|
||||||
|
if bp <= 0 {
|
||||||
|
return nil, fmt.Errorf("%w: share %d is not positive", ErrBadPayoutSplit, bp)
|
||||||
|
}
|
||||||
|
total += bp
|
||||||
|
}
|
||||||
|
if total != 10000 {
|
||||||
|
return nil, fmt.Errorf("%w: shares sum to %d", ErrBadPayoutSplit, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is a named ledger account, so it appears in the books and in
|
||||||
|
// any audit alongside every other account.
|
||||||
|
poolName := fmt.Sprintf("tournament_pool_%d_%s", time.Now().UnixNano(), game)
|
||||||
|
var poolID int64
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO accounts (kind, name) VALUES ('house', $1) RETURNING id`,
|
||||||
|
poolName).Scan(&poolID); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating prize pool account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &Tournament{
|
||||||
|
Name: name, Game: game, Status: StatusScheduled,
|
||||||
|
EntryFeeMsat: entryFeeMsat, PoolAccountID: poolID, PayoutBP: payoutBP,
|
||||||
|
MaxEntrants: maxEntrants,
|
||||||
|
RegistersAt: registersAt, StartsAt: startsAt, EndsAt: endsAt,
|
||||||
|
}
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO tournaments
|
||||||
|
(name, game, entry_fee_msat, pool_account_id, payout_bp,
|
||||||
|
max_entrants, registers_at, starts_at, ends_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||||
|
name, game, entryFeeMsat, poolID, payoutBP, maxEntrants,
|
||||||
|
registersAt, startsAt, endsAt).Scan(&t.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating tournament: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter registers a player, moving their entry fee into the prize pool.
|
||||||
|
func (s *Service) Enter(ctx context.Context, tournamentID, accountID int64) error {
|
||||||
|
t, err := s.Get(ctx, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Status != StatusRegistering {
|
||||||
|
return fmt.Errorf("%w: status is %s", ErrNotRegistering, t.Status)
|
||||||
|
}
|
||||||
|
if t.MaxEntrants != nil && t.Entrants >= int(*t.MaxEntrants) {
|
||||||
|
return ErrFull
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the seat before taking the money. The unique constraint makes a
|
||||||
|
// double entry impossible, and failing here means nothing was charged.
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO tournament_entries (tournament_id, account_id) VALUES ($1, $2)`,
|
||||||
|
tournamentID, accountID); err != nil {
|
||||||
|
return ErrAlreadyEntered
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.EntryFeeMsat > 0 {
|
||||||
|
if _, err := s.ledger.Post(ctx, "tournament_entry", nil, []ledger.Posting{
|
||||||
|
{AccountID: accountID, AmountMsat: -t.EntryFeeMsat},
|
||||||
|
{AccountID: t.PoolAccountID, AmountMsat: t.EntryFeeMsat},
|
||||||
|
}); err != nil {
|
||||||
|
// Could not pay: release the seat so the player can retry once
|
||||||
|
// funded, rather than holding a place they never paid for.
|
||||||
|
if _, derr := s.pool.Exec(ctx,
|
||||||
|
`DELETE FROM tournament_entries
|
||||||
|
WHERE tournament_id = $1 AND account_id = $2`,
|
||||||
|
tournamentID, accountID); derr != nil {
|
||||||
|
fmt.Printf("tournament: could not release unpaid seat: %v\n", derr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordResult adds a round's net result to a player's tournament score.
|
||||||
|
//
|
||||||
|
// Called by settlement for every entrant playing the tournament's game inside
|
||||||
|
// its window. A losing round lowers the score; the board is net profit, so
|
||||||
|
// grinding many small wins and taking one large loss is not a way to climb.
|
||||||
|
func (s *Service) RecordResult(ctx context.Context, tournamentID, accountID, netMsat int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournament_entries
|
||||||
|
SET score_msat = score_msat + $3,
|
||||||
|
rounds_played = rounds_played + 1
|
||||||
|
WHERE tournament_id = $1 AND account_id = $2`,
|
||||||
|
tournamentID, accountID, netMsat)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaderboard returns the current standings, best first.
|
||||||
|
func (s *Service) Leaderboard(ctx context.Context, tournamentID int64, limit int) ([]Standing, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT e.account_id, COALESCE(a.nickname, ''), e.score_msat,
|
||||||
|
e.rounds_played, e.prize_msat
|
||||||
|
FROM tournament_entries e
|
||||||
|
JOIN accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.tournament_id = $1
|
||||||
|
ORDER BY e.score_msat DESC, e.rounds_played ASC, e.id ASC
|
||||||
|
LIMIT $2`, tournamentID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Standing
|
||||||
|
pos := 0
|
||||||
|
for rows.Next() {
|
||||||
|
pos++
|
||||||
|
st := Standing{Position: pos}
|
||||||
|
if err := rows.Scan(&st.AccountID, &st.Nickname, &st.ScoreMsat,
|
||||||
|
&st.RoundsPlayed, &st.PrizeMsat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle pays the prize pool out to the leaders and closes the tournament.
|
||||||
|
//
|
||||||
|
// The entire pool is distributed. Integer division of a pool across percentage
|
||||||
|
// shares leaves a remainder, and dropping it would quietly destroy money and
|
||||||
|
// break the ledger's conservation check, so the remainder goes to first place.
|
||||||
|
func (s *Service) Settle(ctx context.Context, tournamentID int64) ([]Standing, error) {
|
||||||
|
// Claim the tournament first: an UPDATE that only matches an unsettled row
|
||||||
|
// means two instances cannot both pay out.
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'settled', settled_at = now()
|
||||||
|
WHERE id = $1 AND status IN ('running', 'registering')
|
||||||
|
AND ends_at <= now()`, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
var status Status
|
||||||
|
var endsAt time.Time
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT status, ends_at FROM tournaments WHERE id = $1`,
|
||||||
|
tournamentID).Scan(&status, &endsAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status == StatusSettled {
|
||||||
|
return nil, ErrAlreadySettled
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: ends at %s", ErrNotFinished, endsAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.Get(ctx, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := s.Leaderboard(ctx, tournamentID, len(t.PayoutBP))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if poolMsat == 0 || len(board) == 0 {
|
||||||
|
return board, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute each share, then hand the rounding remainder to first place so
|
||||||
|
// the pool empties exactly.
|
||||||
|
postings := make([]ledger.Posting, 0, len(board)+1)
|
||||||
|
var distributed int64
|
||||||
|
prizes := make([]int64, len(board))
|
||||||
|
for i := range board {
|
||||||
|
if i >= len(t.PayoutBP) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
prize := poolMsat * int64(t.PayoutBP[i]) / 10000
|
||||||
|
prizes[i] = prize
|
||||||
|
distributed += prize
|
||||||
|
}
|
||||||
|
if remainder := poolMsat - distributed; remainder > 0 {
|
||||||
|
prizes[0] += remainder
|
||||||
|
distributed = poolMsat
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, st := range board {
|
||||||
|
if prizes[i] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
board[i].PrizeMsat = prizes[i]
|
||||||
|
postings = append(postings, ledger.Posting{
|
||||||
|
AccountID: st.AccountID, AmountMsat: prizes[i]})
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournament_entries SET prize_msat = $3
|
||||||
|
WHERE tournament_id = $1 AND account_id = $2`,
|
||||||
|
tournamentID, st.AccountID, prizes[i]); err != nil {
|
||||||
|
return nil, fmt.Errorf("recording prize: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if distributed > 0 {
|
||||||
|
postings = append(postings, ledger.Posting{
|
||||||
|
AccountID: t.PoolAccountID, AmountMsat: -distributed})
|
||||||
|
if _, err := s.ledger.Post(ctx, "tournament_prize", nil, postings); err != nil {
|
||||||
|
return nil, fmt.Errorf("paying prizes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool must be empty. Anything left would be money stranded in an
|
||||||
|
// account nobody can reach.
|
||||||
|
left, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if left != 0 {
|
||||||
|
return nil, fmt.Errorf("tournament %d settled with %d msat stranded in its pool",
|
||||||
|
tournamentID, left)
|
||||||
|
}
|
||||||
|
return board, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel refunds every entry fee and closes the tournament.
|
||||||
|
func (s *Service) Cancel(ctx context.Context, tournamentID int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'cancelled', settled_at = now()
|
||||||
|
WHERE id = $1 AND status NOT IN ('settled', 'cancelled')`, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrAlreadySettled
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.Get(ctx, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if poolMsat == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT account_id FROM tournament_entries WHERE tournament_id = $1`,
|
||||||
|
tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var entrants []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entrants = append(entrants, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(entrants) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund the fee each player paid. The pool holds exactly the sum of
|
||||||
|
// those fees, so refunding the entry fee to each empties it precisely.
|
||||||
|
postings := make([]ledger.Posting, 0, len(entrants)+1)
|
||||||
|
var total int64
|
||||||
|
for _, id := range entrants {
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: id, AmountMsat: t.EntryFeeMsat})
|
||||||
|
total += t.EntryFeeMsat
|
||||||
|
}
|
||||||
|
if total != poolMsat {
|
||||||
|
return fmt.Errorf("tournament %d: pool holds %d but refunds total %d",
|
||||||
|
tournamentID, poolMsat, total)
|
||||||
|
}
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: t.PoolAccountID, AmountMsat: -total})
|
||||||
|
|
||||||
|
_, err = s.ledger.Post(ctx, "tournament_refund", nil, postings)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get loads a tournament with its live pool balance and entrant count.
|
||||||
|
func (s *Service) Get(ctx context.Context, id int64) (*Tournament, error) {
|
||||||
|
var t Tournament
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, name, game, status, entry_fee_msat, pool_account_id,
|
||||||
|
payout_bp, max_entrants, registers_at, starts_at, ends_at
|
||||||
|
FROM tournaments WHERE id = $1`, id).
|
||||||
|
Scan(&t.ID, &t.Name, &t.Game, &t.Status, &t.EntryFeeMsat, &t.PoolAccountID,
|
||||||
|
&t.PayoutBP, &t.MaxEntrants, &t.RegistersAt, &t.StartsAt, &t.EndsAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("tournament %d not found: %w", id, err)
|
||||||
|
}
|
||||||
|
t.PoolMsat, _ = s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM tournament_entries WHERE tournament_id = $1`,
|
||||||
|
id).Scan(&t.Entrants)
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvanceSchedules moves tournaments through their lifecycle by wall clock.
|
||||||
|
// Any instance may run it; the updates are idempotent.
|
||||||
|
func (s *Service) AdvanceSchedules(ctx context.Context) error {
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'registering'
|
||||||
|
WHERE status = 'scheduled' AND registers_at <= now()`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'running'
|
||||||
|
WHERE status = 'registering' AND starts_at <= now()`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active lists tournaments a player can currently see or join.
|
||||||
|
func (s *Service) Active(ctx context.Context) ([]Tournament, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id FROM tournaments
|
||||||
|
WHERE status IN ('scheduled', 'registering', 'running')
|
||||||
|
ORDER BY starts_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
out := make([]Tournament, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
t, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, *t)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
529
pkg/tournament/tournament_test.go
Normal file
529
pkg/tournament/tournament_test.go
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
package tournament_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/tournament"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
||||||
|
|
||||||
|
func testPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("ARCADE_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(context.Background()); err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
t *testing.T
|
||||||
|
svc *tournament.Service
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
pool := testPool(t)
|
||||||
|
l := ledger.New(pool)
|
||||||
|
return &fixture{t: t, svc: tournament.New(pool, l), ledger: l,
|
||||||
|
pool: pool, ctx: context.Background()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) player(label string, fundMsat int64) int64 {
|
||||||
|
f.t.Helper()
|
||||||
|
pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label))
|
||||||
|
id, err := f.ledger.EnsurePlayer(f.ctx, pk)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fundMsat > 0 {
|
||||||
|
if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// open creates a tournament already accepting entries and ending in the past,
|
||||||
|
// so tests can settle without waiting.
|
||||||
|
func (f *fixture) open(entryFee int64, split []int32, ended bool) *tournament.Tournament {
|
||||||
|
f.t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
ends := now.Add(time.Hour)
|
||||||
|
if ended {
|
||||||
|
ends = now.Add(-time.Minute)
|
||||||
|
}
|
||||||
|
t, err := f.svc.Create(f.ctx, "Test Cup", "rocket", entryFee, split, nil,
|
||||||
|
now.Add(-time.Hour), now.Add(-30*time.Minute), ends)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Registration must be open for entries; AdvanceSchedules may have moved
|
||||||
|
// it straight to running.
|
||||||
|
if _, err := f.pool.Exec(f.ctx,
|
||||||
|
`UPDATE tournaments SET status = 'registering' WHERE id = $1`, t.ID); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- creation ---------------- */
|
||||||
|
|
||||||
|
func TestPayoutSplitMustSumToWhole(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
for _, split := range [][]int32{
|
||||||
|
{5000, 3000}, // 80%
|
||||||
|
{6000, 5000}, // 110%
|
||||||
|
{10000, 1}, // over
|
||||||
|
{}, // nothing
|
||||||
|
} {
|
||||||
|
_, err := f.svc.Create(f.ctx, "bad", "rocket", 1000, split, nil,
|
||||||
|
now, now.Add(time.Minute), now.Add(time.Hour))
|
||||||
|
if !errors.Is(err, tournament.ErrBadPayoutSplit) {
|
||||||
|
t.Errorf("split %v gave %v, want ErrBadPayoutSplit", split, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOpensAPrizePool(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(0, []int32{10000}, false)
|
||||||
|
if tn.PoolMsat != 0 {
|
||||||
|
t.Fatalf("new pool holds %d, want 0", tn.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- entry ---------------- */
|
||||||
|
|
||||||
|
func TestEntryFeeMovesIntoThePool(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(10_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if before-after != 10_000 {
|
||||||
|
t.Fatalf("entry cost %d, want 10000", before-after)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 10_000 {
|
||||||
|
t.Fatalf("pool holds %d, want 10000", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotEnterTwice(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(5_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrAlreadyEntered) {
|
||||||
|
t.Fatalf("got %v, want ErrAlreadyEntered", err)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 5_000 {
|
||||||
|
t.Fatalf("pool holds %d after a duplicate attempt, want 5000", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A player who cannot afford the fee must not hold a seat.
|
||||||
|
func TestUnfundedEntryTakesNoSeat(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(50_000, []int32{10000}, false)
|
||||||
|
id := f.player("broke", 100)
|
||||||
|
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err == nil {
|
||||||
|
t.Fatal("an unfunded player entered")
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.Entrants != 0 {
|
||||||
|
t.Fatalf("%d entrants after a failed payment, want 0", got.Entrants)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And they can enter properly once funded.
|
||||||
|
if _, err := f.ledger.Deposit(f.ctx, id, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatalf("could not enter after funding: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentEntriesChargeOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(10_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 1_000_000)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]error, 8)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[i] = f.svc.Enter(f.ctx, tn.ID, id)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
ok := 0
|
||||||
|
for _, err := range results {
|
||||||
|
if err == nil {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok != 1 {
|
||||||
|
t.Fatalf("%d concurrent entries succeeded, want 1", ok)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 10_000 {
|
||||||
|
t.Fatalf("pool holds %d, want a single fee of 10000", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntrantLimitIsEnforced(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
max := int32(2)
|
||||||
|
tn, err := f.svc.Create(f.ctx, "small", "rocket", 1_000, []int32{10000}, &max,
|
||||||
|
now.Add(-time.Hour), now.Add(time.Hour), now.Add(2*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.pool.Exec(f.ctx,
|
||||||
|
`UPDATE tournaments SET status = 'registering' WHERE id = $1`, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, f.player(fmt.Sprintf("p%d", i), 100_000)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, f.player("late", 100_000)); !errors.Is(err, tournament.ErrFull) {
|
||||||
|
t.Fatalf("got %v, want ErrFull", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- scoring ---------------- */
|
||||||
|
|
||||||
|
func TestLeaderboardOrdersByScore(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(0, []int32{10000}, false)
|
||||||
|
|
||||||
|
scores := map[string]int64{"low": -5_000, "mid": 2_000, "high": 50_000}
|
||||||
|
ids := map[string]int64{}
|
||||||
|
for name, score := range scores {
|
||||||
|
id := f.player(name, 100_000)
|
||||||
|
ids[name] = id
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, score); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := f.svc.Leaderboard(f.ctx, tn.ID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(board) != 3 {
|
||||||
|
t.Fatalf("board has %d entries, want 3", len(board))
|
||||||
|
}
|
||||||
|
if board[0].AccountID != ids["high"] {
|
||||||
|
t.Fatalf("leader is %d, want %d", board[0].AccountID, ids["high"])
|
||||||
|
}
|
||||||
|
if board[2].AccountID != ids["low"] {
|
||||||
|
t.Fatalf("last is %d, want %d", board[2].AccountID, ids["low"])
|
||||||
|
}
|
||||||
|
if board[0].Position != 1 {
|
||||||
|
t.Fatalf("leader position = %d, want 1", board[0].Position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scores accumulate across rounds, and losses count against you.
|
||||||
|
func TestScoresAccumulateIncludingLosses(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(0, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range []int64{10_000, -3_000, 5_000, -1_000} {
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
board, _ := f.svc.Leaderboard(f.ctx, tn.ID, 1)
|
||||||
|
if board[0].ScoreMsat != 11_000 {
|
||||||
|
t.Fatalf("score = %d, want 11000", board[0].ScoreMsat)
|
||||||
|
}
|
||||||
|
if board[0].RoundsPlayed != 4 {
|
||||||
|
t.Fatalf("rounds = %d, want 4", board[0].RoundsPlayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- settlement ---------------- */
|
||||||
|
|
||||||
|
// The whole pool must be paid out. Integer division of a pool across shares
|
||||||
|
// leaves a remainder, and a dropped remainder is money destroyed.
|
||||||
|
func TestSettlementDistributesTheEntirePool(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
// 3333/3333/3334 across a pool that does not divide evenly.
|
||||||
|
tn := f.open(3_333, []int32{5000, 3000, 2000}, true)
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||||
|
ids = append(ids, id)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64((3-i)*1000)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poolBefore, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
before := make([]int64, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
before[i], _ = f.ledger.Balance(f.ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := f.svc.Settle(f.ctx, tn.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var paid int64
|
||||||
|
for i, id := range ids {
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
paid += after - before[i]
|
||||||
|
}
|
||||||
|
if paid != poolBefore.PoolMsat {
|
||||||
|
t.Fatalf("paid out %d of a %d pool — %d msat vanished",
|
||||||
|
paid, poolBefore.PoolMsat, poolBefore.PoolMsat-paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if after.PoolMsat != 0 {
|
||||||
|
t.Fatalf("%d msat stranded in the pool after settlement", after.PoolMsat)
|
||||||
|
}
|
||||||
|
if board[0].PrizeMsat <= board[1].PrizeMsat {
|
||||||
|
t.Fatalf("first place won %d, second %d", board[0].PrizeMsat, board[1].PrizeMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotSettleBeforeItEnds(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(1_000, []int32{10000}, false) // ends in an hour
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrNotFinished) {
|
||||||
|
t.Fatalf("got %v, want ErrNotFinished", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettlingTwiceIsRefused(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(1_000, []int32{10000}, true)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterFirst, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) {
|
||||||
|
t.Fatalf("got %v, want ErrAlreadySettled", err)
|
||||||
|
}
|
||||||
|
afterSecond, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if afterSecond != afterFirst {
|
||||||
|
t.Fatalf("a second settlement paid again: %d -> %d", afterFirst, afterSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two instances settling at once must pay out exactly once.
|
||||||
|
func TestConcurrentSettlementPaysOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(2_000, []int32{10000}, true)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
ok := make([]bool, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := f.svc.Settle(f.ctx, tn.ID)
|
||||||
|
ok[i] = err == nil
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
wins := 0
|
||||||
|
for _, v := range ok {
|
||||||
|
if v {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wins != 1 {
|
||||||
|
t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
|
||||||
|
}
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after-before != 2_000 {
|
||||||
|
t.Fatalf("player received %d, want the single 2000 pool", after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBooksBalanceAfterSettlement(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(7_777, []int32{6000, 4000}, true)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64(i)*100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total, err := f.ledger.ConservationCheck(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("books do not balance after tournament settlement: %d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- cancellation ---------------- */
|
||||||
|
|
||||||
|
func TestCancelRefundsEveryEntrant(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(12_000, []int32{10000}, false)
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
var before []int64
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||||
|
b, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
before = append(before, b)
|
||||||
|
ids = append(ids, id)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.svc.Cancel(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, id := range ids {
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after != before[i] {
|
||||||
|
t.Fatalf("entrant %d has %d after cancellation, want their original %d",
|
||||||
|
id, after, before[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 0 {
|
||||||
|
t.Fatalf("%d msat stranded after cancellation", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelIsIdempotent(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(1_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.svc.Cancel(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterFirst, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if err := f.svc.Cancel(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) {
|
||||||
|
t.Fatalf("got %v, want ErrAlreadySettled", err)
|
||||||
|
}
|
||||||
|
afterSecond, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if afterSecond != afterFirst {
|
||||||
|
t.Fatalf("a second cancellation refunded again: %d -> %d", afterFirst, afterSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- lifecycle ---------------- */
|
||||||
|
|
||||||
|
func TestSchedulesAdvanceByClock(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
tn, err := f.svc.Create(f.ctx, "later", "rocket", 0, []int32{10000}, nil,
|
||||||
|
now.Add(-time.Minute), now.Add(-30*time.Second), now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.Status != tournament.StatusRunning {
|
||||||
|
t.Fatalf("status = %s, want running once the start time has passed", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotEnterOnceRunning(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
tn, err := f.svc.Create(f.ctx, "started", "rocket", 1_000, []int32{10000}, nil,
|
||||||
|
now.Add(-time.Hour), now.Add(-time.Minute), now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := f.player("late", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrNotRegistering) {
|
||||||
|
t.Fatalf("got %v, want ErrNotRegistering", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user