Compare commits
10 commits
v0.1.0-dev
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9620d1278e | ||
|
|
df7caf8404 | ||
|
|
d0e74dd860 | ||
|
|
812393d030 | ||
|
|
b4a81e0ab8 | ||
|
|
c31518309d | ||
|
|
5782d80a3b | ||
|
|
3225e830a3 | ||
|
|
fb1e1b73a6 | ||
|
|
409005415e |
19 changed files with 1818 additions and 1905 deletions
31
.forgejo/workflows/gitleaks.yml
Normal file
31
.forgejo/workflows/gitleaks.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Gitleaks secret-scanning workflow.
|
||||||
|
#
|
||||||
|
# Scans the full git history on every push and pull request and fails
|
||||||
|
# the job if a credential-shaped string is detected.
|
||||||
|
|
||||||
|
name: gitleaks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history — gitleaks needs depth to scan a commit range.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: install gitleaks
|
||||||
|
run: |
|
||||||
|
curl -sSL -o gl.tar.gz \
|
||||||
|
https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
|
||||||
|
tar xzf gl.tar.gz gitleaks
|
||||||
|
chmod +x gitleaks
|
||||||
|
./gitleaks version
|
||||||
|
|
||||||
|
- name: scan
|
||||||
|
run: |
|
||||||
|
./gitleaks detect --source . --no-banner --redact --verbose
|
||||||
796
LICENSE
796
LICENSE
|
|
@ -1,201 +1,661 @@
|
||||||
Apache License
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 2.0, January 2004
|
Version 3, 19 November 2007
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
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.
|
||||||
|
|
||||||
1. Definitions.
|
Preamble
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
The licenses for most software and other practical works are designed
|
||||||
the copyright owner that is granting the License.
|
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.
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
When we speak of free software, we are referring to freedom, not
|
||||||
other entities that control, are controlled by, or are under common
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
control with that entity. For the purposes of this definition,
|
have the freedom to distribute copies of free software (and charge for
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
them if you wish), that you receive source code or can get it if you
|
||||||
direction or management of such entity, whether by contract or
|
want it, that you can change the software or use pieces of it in new
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
free programs, and that you know you can do these things.
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
Developers that use our General Public Licenses protect your rights
|
||||||
exercising permissions granted by this License.
|
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.
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
A secondary benefit of defending all users' freedom is that
|
||||||
including but not limited to software source code, documentation
|
improvements made in alternate versions of the program, if they
|
||||||
source, and configuration files.
|
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.
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
The GNU Affero General Public License is designed specifically to
|
||||||
transformation or translation of a Source form, including but
|
ensure that, in such cases, the modified source code becomes available
|
||||||
not limited to compiled object code, generated documentation,
|
to the community. It requires the operator of a network server to
|
||||||
and conversions to other media types.
|
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.
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
An older license, called the Affero General Public License and
|
||||||
Object form, made available under the License, as indicated by a
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
copyright notice that is included in or attached to the work
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
(an example is provided in the Appendix below).
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
The precise terms and conditions for copying, distribution and
|
||||||
form, that is based on (or derived from) the Work and for which the
|
modification follow.
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
TERMS AND CONDITIONS
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
0. Definitions.
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
works, such as semiconductor masks.
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
modifications, and in Source or Object form, provided that You
|
"recipients" may be individuals or organizations.
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
Derivative Works a copy of this License; and
|
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.
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
A "covered work" means either the unmodified Program or a work based
|
||||||
stating that You changed the files; and
|
on the Program.
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
To "propagate" a work means to do anything with it that, without
|
||||||
that You distribute, all copyright, patent, trademark, and
|
permission, would make you directly or secondarily liable for
|
||||||
attribution notices from the Source form of the Work,
|
infringement under applicable copyright law, except executing it on a
|
||||||
excluding those notices that do not pertain to any part of
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
the Derivative Works; and
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
To "convey" a work means any kind of propagation that enables other
|
||||||
distribution, then any Derivative Works that You distribute must
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
include a readable copy of the attribution notices contained
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
may provide additional or different license terms and conditions
|
to the extent that it includes a convenient and prominently visible
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
tells the user that there is no warranty for the work (except to the
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
extent that warranties are provided), that licensees may convey the
|
||||||
the conditions stated in this License.
|
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.
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
1. Source Code.
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
The "source code" for a work means the preferred form of the work
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
for making modifications to it. "Object code" means any non-source
|
||||||
except as required for describing the origin of the Work and
|
form of a work.
|
||||||
reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
A "Standard Interface" means an interface that either is an official
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
standard defined by a recognized standards body, or, in the case of
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
interfaces specified for a particular programming language, one that
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
is widely used among developers working in that language.
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
The "System Libraries" of an executable work include anything, other
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
unless required by applicable law (such as deliberate and grossly
|
packaging a Major Component, but which is not part of that Major
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
Component, and (b) serves only to enable use of the work with that
|
||||||
liable to You for damages, including any direct, indirect, special,
|
Major Component, or to implement a Standard Interface for which an
|
||||||
incidental, or consequential damages of any character arising as a
|
implementation is available to the public in source code form. A
|
||||||
result of this License or out of the use or inability to use the
|
"Major Component", in this context, means a major essential component
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
(kernel, window system, and so on) of the specific operating system
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
other commercial damages or losses), even if such Contributor
|
produce the work, or an object code interpreter used to run it.
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Support. While redistributing the Work or
|
The "Corresponding Source" for a work in object code form means all
|
||||||
Derivative Works thereof, You may choose to offer, and charge a
|
the source code needed to generate, install, and (for an executable
|
||||||
fee for, acceptance of support, warranty, indemnity, or other
|
work) run the object code and to modify the work, including scripts to
|
||||||
liability obligations and/or rights consistent with this License.
|
control those activities. However, it does not include the work's
|
||||||
However, in accepting such obligations, You may act only on Your
|
System Libraries, or general-purpose tools or generally available free
|
||||||
own behalf and on Your sole responsibility, not on behalf of any
|
programs which are used unmodified in performing those activities but
|
||||||
other Contributor, and only if You agree to indemnify, defend,
|
which are not part of the work. For example, Corresponding Source
|
||||||
and hold each Contributor harmless for any liability incurred by,
|
includes interface definition files associated with source files for
|
||||||
or claims asserted against, such Contributor by reason of your
|
the work, and the source code for shared libraries and dynamically
|
||||||
accepting any such warranty or support.
|
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
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
If you develop a new program, and you want it to be of the greatest
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
possible use to the public, the best way to achieve this is to make it
|
||||||
replaced with your own identifying information. (Don't include
|
free software which everyone can redistribute and change under these terms.
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2026 Sulkta Coop
|
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.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
you may not use this file except in compliance with the License.
|
Copyright (C) <year> <name of author>
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
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.
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
This program is distributed in the hope that it will be useful,
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
implied. See the License for the specific language governing
|
GNU Affero General Public License for more details.
|
||||||
permissions and limitations under the License.
|
|
||||||
|
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/>.
|
||||||
|
|
|
||||||
269
README.md
269
README.md
|
|
@ -1,134 +1,219 @@
|
||||||
# cardano-checkout
|
# cardano-checkout
|
||||||
|
|
||||||
Python SDK for merchant-side Cardano payments + NFT certificate-of-authenticity minting.
|
Merchant-side Cardano payment lifecycle for Python. Zero-custody.
|
||||||
|
|
||||||
**Zero-custody by design:** the merchant provides a wallet xpub. The SDK derives
|
`cardano-checkout` ships the invoice state machine, an on-chain UTxO
|
||||||
unique receive addresses per invoice, polls the chain for payment, and optionally
|
watcher, and a quote-reprice loop for accepting ADA payments at
|
||||||
mints a CIP-25 NFT cert on confirmation. The platform never holds or moves funds.
|
per-invoice, HD-derived receive addresses. It polls [Koios](https://koios.rest)
|
||||||
|
for payment, classifies each invoice as confirmed / underpaid / overpaid
|
||||||
|
within a tolerance, and reprices expired quotes against a price oracle you
|
||||||
|
supply.
|
||||||
|
|
||||||
Extracted from [the host app](https://git.sulkta.com/example/host-app)'s
|
It does **not** reimplement Cardano primitives. Address derivation, chain
|
||||||
`services/cardano_*.py` modules (2,400+ lines of production code running on the
|
context, transaction building, native-script minting, and signing are all
|
||||||
Cardano mainnet) and packaged for reuse across the Sulkta Coop product family.
|
[pycardano](https://github.com/Python-Cardano/pycardano)'s job — this
|
||||||
|
library slots in next to it and owns only the merchant payment lifecycle.
|
||||||
|
|
||||||
## Status
|
## Why
|
||||||
|
|
||||||
**v0.1.0-dev — alpha extraction.** Pure modules lifted verbatim from the host app.
|
Accepting on-chain payments for a shop is mostly bookkeeping, not
|
||||||
DB-coupled modules (monitor, scheduler) ship with a `TODO: refactor to Store
|
cryptography:
|
||||||
protocol` marker — they work as-is when paired with the host app's SQLAlchemy models
|
|
||||||
but will be refactored to the generic `InvoiceStore` Protocol in v0.2.
|
|
||||||
|
|
||||||
| Module | Status | Notes |
|
- one fresh receive address per order (derived from your wallet xpub),
|
||||||
|---|---|---|
|
- watch the chain until the expected amount lands,
|
||||||
| `addresses` | ✅ stable | CIP-1852 HD derivation; pure pycardano |
|
- decide confirmed / underpaid / overpaid within a tolerance,
|
||||||
| `oracles` | ✅ stable | ADA/USD price via Koios with 5-min cache |
|
- if the quote window lapses before payment, reprice and try again.
|
||||||
| `invoice` + `store` | ✅ new | Framework-agnostic invoice + persistence Protocol |
|
|
||||||
| `mint` | ⏳ stub | CIP-25 v2 metadata builder works; tx submission in v0.2 |
|
|
||||||
| `ipfs` | ✅ working | kubo HTTP API client w/ optional mirror-pin |
|
|
||||||
| `monitor` | 🟡 SQLAlchemy-coupled | v0.2 target: refactor around `InvoiceStore` |
|
|
||||||
| `scheduler` | 🟡 SQLAlchemy-coupled | v0.2 target: same |
|
|
||||||
| `txbuild` | ❌ v0.2 | Full PyCardano tx construction via Ogmios |
|
|
||||||
|
|
||||||
## Design
|
`cardano-checkout` is that bookkeeping, behind small consumer-supplied
|
||||||
|
interfaces — a persistence `Protocol` and a pricing callable — so it drops
|
||||||
|
into any stack: SQLAlchemy, asyncpg, SQLite, or plain in-memory.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────┐
|
pip install cardano-checkout # core
|
||||||
│ Merchant App │
|
pip install 'cardano-checkout[sqlalchemy]' # + SQLAlchemy extra
|
||||||
│ (the host app / example-studio / your-product) │
|
|
||||||
└──────────────┬───────────────────────┬─────────────────┘
|
|
||||||
│ │
|
|
||||||
uses │ implements │ imports
|
|
||||||
▼ ▼
|
|
||||||
┌──────────────┐ ┌────────────────────────┐
|
|
||||||
│ InvoiceStore │ ◄────── │ cardano_checkout SDK │
|
|
||||||
│ (your DB) │ │ │
|
|
||||||
└──────────────┘ │ addresses ← pure │
|
|
||||||
│ oracles ← pure │
|
|
||||||
│ invoice ← dataclass │
|
|
||||||
│ monitor ← polls chain │
|
|
||||||
│ scheduler ← bg loop │
|
|
||||||
│ mint ← NFT cert │
|
|
||||||
│ ipfs ← upload │
|
|
||||||
│ txbuild ← PyCardano wrappers │
|
|
||||||
└────────────────────────┘
|
|
||||||
│
|
|
||||||
talks to │
|
|
||||||
▼
|
|
||||||
┌────────────────────────┐
|
|
||||||
│ Koios + Ogmios + kubo │
|
|
||||||
└────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The merchant app provides:
|
Two runtime dependencies: `httpx` (Koios HTTP) and `apscheduler`
|
||||||
1. A wallet xpub (account-level extended public key).
|
(background loop). There is **no** `pycardano` dependency — see
|
||||||
2. An `InvoiceStore` implementation (SQLAlchemy, Postgres, SQLite, in-memory — whatever).
|
[Deriving addresses with pycardano](#deriving-addresses-with-pycardano)
|
||||||
|
for the pairing pattern.
|
||||||
The SDK provides:
|
|
||||||
1. Address derivation from the xpub.
|
|
||||||
2. Per-invoice payment monitoring against Koios.
|
|
||||||
3. ADA ↔ USD price conversion.
|
|
||||||
4. CIP-25 v2 NFT cert minting (v0.2).
|
|
||||||
5. IPFS upload + pinning for NFT image metadata.
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
import asyncio
|
||||||
from cardano_checkout import addresses, oracles
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
# Derive a receive address for invoice #42
|
from cardano_checkout import (
|
||||||
addr = addresses.derive_address(
|
Invoice, InvoiceStatus, InMemoryStore, InvoiceScheduler,
|
||||||
xpub_hex="<your wallet xpub>",
|
|
||||||
index=42,
|
|
||||||
network="mainnet",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert a USD price to lovelace at current market
|
|
||||||
|
# Your oracle. Anything async returning int lovelace works.
|
||||||
|
async def my_price_fn(usd: float) -> int:
|
||||||
|
rate = await fetch_ada_usd_somewhere() # CoinGecko, a DEX, a fixed rate, etc.
|
||||||
|
return int(round(usd / rate * 1_000_000))
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
lovelace = await oracles.convert_usd_to_lovelace(99.00)
|
store = InMemoryStore() # swap for your SQLAlchemy / asyncpg / sqlite adapter
|
||||||
ada = lovelace / 1_000_000
|
|
||||||
print(f"Customer owes {ada:.4f} ADA for $99")
|
invoice = Invoice(
|
||||||
|
id="ord-0042",
|
||||||
|
merchant_id="my-shop",
|
||||||
|
derivation_index=42,
|
||||||
|
receive_address="addr1q...", # derive via pycardano
|
||||||
|
expected_lovelace=5_000_000,
|
||||||
|
usd_amount=2.50,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||||
|
)
|
||||||
|
await store.create(invoice)
|
||||||
|
|
||||||
|
scheduler = InvoiceScheduler(store=store, price_fn=my_price_fn)
|
||||||
|
await scheduler.start()
|
||||||
|
|
||||||
|
# ... app runs ...
|
||||||
|
|
||||||
|
await scheduler.stop()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
```
|
```
|
||||||
|
|
||||||
## IPFS: bake-then-mirror pattern
|
If you price your invoices in fixed ADA you can omit `price_fn` — the
|
||||||
|
reprice job becomes a no-op and invoices simply expire at `expires_at`.
|
||||||
|
|
||||||
The SDK's `IPFSClient` expects a local kubo daemon (typically in the same
|
## Deriving addresses with pycardano
|
||||||
Docker image as the web app) for upload and primary pin, and takes an
|
|
||||||
optional list of mirror endpoints to `pin add` the CID on a second node
|
|
||||||
for archival redundancy.
|
|
||||||
|
|
||||||
Typical example-studio deployment:
|
The receive address for each invoice is derived from your **account-level
|
||||||
|
xpub** (a public key — not a secret). Customer funds flow directly to your
|
||||||
|
wallet; this library never touches keys.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from cardano_checkout import ipfs
|
from pycardano import HDWallet, Address, Network
|
||||||
|
|
||||||
client = ipfs.IPFSClient(
|
# Account-level xpub — public, not a secret.
|
||||||
api_url="http://127.0.0.1:5001", # local kubo in the same container
|
xpub_hex = "..."
|
||||||
mirror_api_urls=["http://mirror-node.example:5001"], # the cold host's kubo over the LAN/VPN
|
|
||||||
)
|
|
||||||
|
|
||||||
cid = await client.add(photo_bytes, filename="order-0001.jpg")
|
account = HDWallet.from_xpub(bytes.fromhex(xpub_hex))
|
||||||
# Image now served by the hot host (low latency) AND pinned on the cold host (durability)
|
|
||||||
|
def derive_address(account: HDWallet, index: int, network=Network.MAINNET) -> str:
|
||||||
|
payment = account.derive(0).derive(index) # external chain, address index
|
||||||
|
staking = account.derive(2).derive(0) # staking chain, always index 0
|
||||||
|
addr = Address(
|
||||||
|
payment_part=payment.public_key.hash(),
|
||||||
|
staking_part=staking.public_key.hash(),
|
||||||
|
network=network,
|
||||||
|
)
|
||||||
|
return str(addr)
|
||||||
|
|
||||||
|
addr = derive_address(account, index=42)
|
||||||
```
|
```
|
||||||
|
|
||||||
## NFT cert-of-authenticity design
|
## NFT cert: CIP-25 v2 metadata
|
||||||
|
|
||||||
One minting policy per merchant studio. Policy is a native script (no Plutus
|
Need a certificate-of-authenticity NFT per paid order? Here is a
|
||||||
required), optionally time-locked to make "no more editions after X" a
|
dependency-free builder for the CIP-25 v2 metadata envelope. Hand the
|
||||||
cryptographically verifiable claim.
|
result to pycardano's `AuxiliaryData(Metadata({...}))` when you build the
|
||||||
|
mint transaction.
|
||||||
|
|
||||||
CIP-25 v2 metadata. Single NFT per order. Policy skey never leaves the custody
|
```python
|
||||||
host (the cold host in Sulkta's pattern). The SDK builds the metadata envelope + tx;
|
def build_cip25_metadata(
|
||||||
external signer does the signature.
|
*,
|
||||||
|
policy_id: str,
|
||||||
|
asset_name: str,
|
||||||
|
name: str,
|
||||||
|
image_cid: str,
|
||||||
|
description: str = "",
|
||||||
|
media_type: str = "image/jpeg",
|
||||||
|
properties: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a CIP-25 v2 metadata envelope.
|
||||||
|
|
||||||
## Installation
|
Returns a dict ready to submit as transaction metadatum label 721.
|
||||||
|
Handles the 64-char chunking rule for long descriptions.
|
||||||
|
"""
|
||||||
|
def chunk64(s: str) -> list[str]:
|
||||||
|
if len(s) <= 64:
|
||||||
|
return [s]
|
||||||
|
return [s[i:i + 64] for i in range(0, len(s), 64)]
|
||||||
|
|
||||||
|
body: dict = {
|
||||||
|
"name": name,
|
||||||
|
"image": f"ipfs://{image_cid}",
|
||||||
|
"mediaType": media_type,
|
||||||
|
}
|
||||||
|
if description:
|
||||||
|
body["description"] = description if len(description) <= 64 else chunk64(description)
|
||||||
|
if properties:
|
||||||
|
body.update(properties)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"721": {
|
||||||
|
policy_id: {asset_name: body},
|
||||||
|
"version": "2.0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementing your own InvoiceStore
|
||||||
|
|
||||||
|
`InvoiceStore` is a `Protocol` — implement six methods against whatever
|
||||||
|
backend you want (SQLAlchemy, asyncpg, SQLite, in-memory).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cardano_checkout import Invoice, InvoiceStatus, InvoiceStore
|
||||||
|
|
||||||
|
class MySqliteStore:
|
||||||
|
async def create(self, invoice: Invoice) -> None: ...
|
||||||
|
async def get(self, invoice_id: str) -> Invoice | None: ...
|
||||||
|
async def list_by_status(self, status: InvoiceStatus, limit: int = 100) -> list[Invoice]: ...
|
||||||
|
async def update(self, invoice: Invoice) -> None: ...
|
||||||
|
async def next_derivation_index(self, merchant_id: str) -> int: ...
|
||||||
|
async def record_tx(self, invoice_id: str, tx_hash: str, lovelace_delta: int) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
See `InMemoryStore` in `cardano_checkout/store.py` for a reference
|
||||||
|
implementation (it also backs the test suite).
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `invoice.py` | `Invoice` dataclass + `InvoiceStatus` enum |
|
||||||
|
| `store.py` | `InvoiceStore` Protocol + `InMemoryStore` reference impl |
|
||||||
|
| `monitor.py` | `check_address_utxos` (Koios), `evaluate_utxos`, `check_pending_invoices`, `reprice_expired_invoices` |
|
||||||
|
| `scheduler.py` | `InvoiceScheduler` — APScheduler wrapper, 15s check + 60s reprice |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **Protocol-first.** Persistence, pricing, and side-effects all go
|
||||||
|
through consumer-supplied interfaces.
|
||||||
|
2. **Use pycardano directly.** No wrapping of Cardano primitives.
|
||||||
|
3. **Zero-custody.** Merchant keys never touch this code. xpub-derived
|
||||||
|
addresses, UTxO observation, state transitions. Funds flow directly
|
||||||
|
between customer and merchant wallets.
|
||||||
|
4. **Offline-first tests.** Koios and price oracles are stubbed via
|
||||||
|
fixtures — the suite never touches a live node.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and pull requests are welcome. A couple of house rules keep the
|
||||||
|
library focused:
|
||||||
|
|
||||||
|
- **Keep Cardano primitives out.** Anything pycardano already does
|
||||||
|
belongs in the consumer, not here.
|
||||||
|
- **Tests stay offline.** Koios and any price oracle must be stubbed via
|
||||||
|
fixtures so CI never hits a live node or a real wallet.
|
||||||
|
|
||||||
|
Run the suite before opening a PR:
|
||||||
|
|
||||||
```
|
```
|
||||||
pip install 'cardano-checkout[sqlalchemy]' # if you're using SQLAlchemy
|
pip install -e '.[test]'
|
||||||
pip install cardano-checkout # core only
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache-2.0 — matches upstream Cardano tooling.
|
AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,69 @@
|
||||||
"""cardano_checkout — Python SDK for merchant-side Cardano payments + NFT cert minting.
|
"""cardano-checkout — merchant-side Cardano payment lifecycle in Python.
|
||||||
|
|
||||||
Zero-custody by design: consumers provide a wallet xpub (account-level
|
Zero-custody: the merchant brings a wallet xpub and an
|
||||||
extended public key). The SDK derives unique receive addresses per
|
:class:`~cardano_checkout.store.InvoiceStore` implementation. The SDK
|
||||||
invoice, polls the chain for payment, and (optionally) mints a CIP-25
|
owns the payment lifecycle — per-invoice receive-address bookkeeping,
|
||||||
NFT certificate of authenticity on confirmation.
|
Koios UTxO polling, confirm / underpay / overpay classification, and
|
||||||
|
time-windowed repricing against a consumer-supplied oracle.
|
||||||
|
|
||||||
|
Does NOT ship Cardano primitives. Address derivation, transaction
|
||||||
|
building, chain context, and native-script minting live in
|
||||||
|
`pycardano <https://github.com/Python-Cardano/pycardano>`_. See the
|
||||||
|
README for the pairing pattern and the CIP-25 v2 metadata-builder
|
||||||
|
snippet.
|
||||||
|
|
||||||
Quick start::
|
Quick start::
|
||||||
|
|
||||||
from cardano_checkout import addresses, oracles
|
from cardano_checkout import Invoice, InvoiceStatus, InMemoryStore, InvoiceScheduler
|
||||||
|
|
||||||
addr = addresses.derive_address(xpub_hex, index=42, network="mainnet")
|
store = InMemoryStore() # or your SQLAlchemy / asyncpg / sqlite adapter
|
||||||
price = await oracles.get_ada_usd_price()
|
|
||||||
lovelace = await oracles.convert_usd_to_lovelace(99.00)
|
|
||||||
|
|
||||||
For full invoice lifecycle see :mod:`cardano_checkout.invoice` +
|
async def my_price_fn(usd: float) -> int:
|
||||||
:mod:`cardano_checkout.store` (Protocol-based persistence).
|
rate = await fetch_ada_usd_rate()
|
||||||
|
return int(round(usd / rate * 1_000_000))
|
||||||
|
|
||||||
For NFT minting see :mod:`cardano_checkout.mint`.
|
scheduler = InvoiceScheduler(store=store, price_fn=my_price_fn)
|
||||||
|
await scheduler.start()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.1.0-dev"
|
__version__ = "1.0.0-dev"
|
||||||
|
|
||||||
# Pure modules — stable API from extraction
|
|
||||||
from cardano_checkout import addresses, oracles # noqa: F401
|
|
||||||
|
|
||||||
# Payment lifecycle
|
|
||||||
from cardano_checkout.invoice import Invoice, InvoiceStatus # noqa: F401
|
from cardano_checkout.invoice import Invoice, InvoiceStatus # noqa: F401
|
||||||
from cardano_checkout.store import InvoiceStore # noqa: F401
|
from cardano_checkout.store import InMemoryStore, InvoiceStore # noqa: F401
|
||||||
|
from cardano_checkout.monitor import ( # noqa: F401
|
||||||
# NFT + IPFS
|
CONFIRM_TOLERANCE,
|
||||||
from cardano_checkout.mint import MintPolicy, mint_nft_cert # noqa: F401
|
DEFAULT_MAX_REPRICINGS,
|
||||||
from cardano_checkout.ipfs import IPFSClient, pin_bytes # noqa: F401
|
DEFAULT_PAYMENT_WINDOW_MINUTES,
|
||||||
|
KOIOS_URL,
|
||||||
|
OVERPAY_THRESHOLD,
|
||||||
|
PriceFn,
|
||||||
|
check_address_utxos,
|
||||||
|
check_pending_invoices,
|
||||||
|
evaluate_utxos,
|
||||||
|
reprice_expired_invoices,
|
||||||
|
)
|
||||||
|
from cardano_checkout.scheduler import InvoiceScheduler # noqa: F401
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
"addresses",
|
# Invoice lifecycle
|
||||||
"oracles",
|
|
||||||
"Invoice",
|
"Invoice",
|
||||||
"InvoiceStatus",
|
"InvoiceStatus",
|
||||||
|
# Persistence
|
||||||
"InvoiceStore",
|
"InvoiceStore",
|
||||||
"MintPolicy",
|
"InMemoryStore",
|
||||||
"mint_nft_cert",
|
# Monitor + scheduler
|
||||||
"IPFSClient",
|
"PriceFn",
|
||||||
"pin_bytes",
|
"InvoiceScheduler",
|
||||||
|
"check_address_utxos",
|
||||||
|
"check_pending_invoices",
|
||||||
|
"evaluate_utxos",
|
||||||
|
"reprice_expired_invoices",
|
||||||
|
"KOIOS_URL",
|
||||||
|
"CONFIRM_TOLERANCE",
|
||||||
|
"OVERPAY_THRESHOLD",
|
||||||
|
"DEFAULT_MAX_REPRICINGS",
|
||||||
|
"DEFAULT_PAYMENT_WINDOW_MINUTES",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
"""
|
|
||||||
Cardano HD address derivation service.
|
|
||||||
|
|
||||||
Derives Cardano base addresses from an account-level extended public key (xpub)
|
|
||||||
exported from wallets such as Eternl or Lace. Uses BIP-44 derivation via pycardano.
|
|
||||||
|
|
||||||
Key derivation path: m / 1852' / 1815' / account' / chain / index
|
|
||||||
- chain 0 = external (receive) addresses
|
|
||||||
- chain 2 = staking key (always index 0 for the account)
|
|
||||||
|
|
||||||
The xpub accepted here is the *account* public key — the root has already been
|
|
||||||
hardened away by the wallet. We only perform soft derivation from account level
|
|
||||||
down, so no private key material is ever needed or touched.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def derive_address(xpub_hex: str, index: int, network: str = "mainnet") -> str:
|
|
||||||
"""
|
|
||||||
Derive a Cardano base address at the given receive-address index.
|
|
||||||
|
|
||||||
The address is a Shelley-era base address combining:
|
|
||||||
- payment key: account_xpub / 0 (external chain) / index
|
|
||||||
- staking key: account_xpub / 2 (staking chain) / 0
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xpub_hex: Hex-encoded account extended public key (64 bytes raw or
|
|
||||||
96 bytes with chain code, as exported by most CIP-1852 wallets).
|
|
||||||
index: Receive address index (0-based). Must be >= 0.
|
|
||||||
network: "mainnet" or "testnet" (preprod / preview). Defaults to mainnet.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Bech32-encoded Cardano base address (addr1... or addr_test1...).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If xpub_hex is malformed, index is negative, or network is invalid.
|
|
||||||
RuntimeError: If pycardano is not installed or derivation fails unexpectedly.
|
|
||||||
"""
|
|
||||||
_require_pycardano()
|
|
||||||
|
|
||||||
if index < 0:
|
|
||||||
raise ValueError(f"Address index must be non-negative, got {index}")
|
|
||||||
|
|
||||||
net = _parse_network(network)
|
|
||||||
acct_pub = _parse_xpub(xpub_hex)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# External receive chain (0) / address index
|
|
||||||
addr_pub = acct_pub.derive(0).derive(index)
|
|
||||||
# Staking chain (2) / always index 0 for the account
|
|
||||||
stake_pub = acct_pub.derive(2).derive(0)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("[cardano] Key derivation failed at index %d", index)
|
|
||||||
raise RuntimeError(f"Key derivation failed: {exc}") from exc
|
|
||||||
|
|
||||||
from pycardano import Address
|
|
||||||
|
|
||||||
address = Address(
|
|
||||||
payment_part=addr_pub.hash(),
|
|
||||||
staking_part=stake_pub.hash(),
|
|
||||||
network=net,
|
|
||||||
)
|
|
||||||
|
|
||||||
return str(address)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_xpub(xpub_hex: str) -> bool:
|
|
||||||
"""
|
|
||||||
Validate that an xpub string is well-formed and parseable.
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
- Is a non-empty string
|
|
||||||
- Is valid hex
|
|
||||||
- Is a valid pycardano HDPublicKey (correct byte length, valid point on curve)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xpub_hex: Hex-encoded account extended public key.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the xpub is valid, False otherwise. Never raises.
|
|
||||||
"""
|
|
||||||
if not xpub_hex or not isinstance(xpub_hex, str):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Quick hex sanity before paying the crypto cost
|
|
||||||
stripped = xpub_hex.strip()
|
|
||||||
if not _is_hex(stripped):
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
_require_pycardano()
|
|
||||||
_parse_xpub(stripped)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_address_preview(xpub_hex: str, network: str = "mainnet") -> str:
|
|
||||||
"""
|
|
||||||
Derive the address at index 0 for settings UI preview.
|
|
||||||
|
|
||||||
Thin wrapper around derive_address — exists so callers don't have to
|
|
||||||
know or care about the index convention.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xpub_hex: Hex-encoded account extended public key.
|
|
||||||
network: "mainnet" or "testnet". Defaults to mainnet.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Bech32-encoded Cardano base address at index 0.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If xpub_hex is malformed or network is invalid.
|
|
||||||
RuntimeError: If derivation fails unexpectedly.
|
|
||||||
"""
|
|
||||||
return derive_address(xpub_hex, index=0, network=network)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _require_pycardano() -> None:
|
|
||||||
"""Raise a clear RuntimeError if pycardano is not installed."""
|
|
||||||
try:
|
|
||||||
import pycardano # noqa: F401
|
|
||||||
except ImportError as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
"pycardano is required for Cardano address derivation. "
|
|
||||||
"Add pycardano>=0.11.0 to requirements.txt and reinstall."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_network(network: str):
|
|
||||||
"""
|
|
||||||
Parse a network string into a pycardano Network enum value.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
network: "mainnet" or "testnet".
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
pycardano.Network enum member.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If network is not one of the accepted values.
|
|
||||||
"""
|
|
||||||
from pycardano import Network
|
|
||||||
|
|
||||||
if network == "mainnet":
|
|
||||||
return Network.MAINNET
|
|
||||||
if network == "testnet":
|
|
||||||
return Network.TESTNET
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid network '{network}'. Expected 'mainnet' or 'testnet'."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_xpub(xpub_hex: str):
|
|
||||||
"""
|
|
||||||
Parse a hex-encoded extended public key into an HDPublicKey.
|
|
||||||
|
|
||||||
pycardano's HDPublicKey.from_primitive expects 64 raw bytes
|
|
||||||
(32-byte Ed25519 public key + 32-byte chain code). Some wallets
|
|
||||||
export 96 bytes; if so, we strip the trailing 32 bytes which are
|
|
||||||
the public key repeated.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xpub_hex: Hex-encoded extended public key string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
pycardano.HDPublicKey instance.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the byte length is unexpected or the key is invalid.
|
|
||||||
"""
|
|
||||||
from pycardano import HDPublicKey
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = bytes.fromhex(xpub_hex.strip())
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError(f"xpub_hex is not valid hex: {exc}") from exc
|
|
||||||
|
|
||||||
# Standard CIP-1852 account xpub is 64 bytes (pubkey || chain_code).
|
|
||||||
# Some export formats prepend 32 zeroed or duplicated bytes — handle both.
|
|
||||||
if len(raw) == 64:
|
|
||||||
pass # Expected format
|
|
||||||
elif len(raw) == 96:
|
|
||||||
# Strip the first 32 bytes (typically a duplicate or empty prefix)
|
|
||||||
raw = raw[32:]
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected xpub length: {len(raw)} bytes. "
|
|
||||||
"Expected 64 bytes (pubkey + chain_code)."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return HDPublicKey.from_primitive(raw)
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError(f"xpub is not a valid extended public key: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _is_hex(value: str) -> bool:
|
|
||||||
"""Return True if every character in value is a valid hex digit."""
|
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
bytes.fromhex(value)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
@ -5,8 +5,8 @@ derived from the merchant's xpub, an expected amount in lovelace, a
|
||||||
USD-denominated label, and a lifecycle state that transitions as the
|
USD-denominated label, and a lifecycle state that transitions as the
|
||||||
chain confirms payment.
|
chain confirms payment.
|
||||||
|
|
||||||
The Invoice is deliberately framework-agnostic — persistence is
|
Persistence is delegated to an :class:`InvoiceStore`
|
||||||
delegated to an :class:`InvoiceStore` (see :mod:`cardano_checkout.store`).
|
(see :mod:`cardano_checkout.store`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
"""Minimal IPFS client — upload + pin via kubo's HTTP API.
|
|
||||||
|
|
||||||
Designed for the ``example-studio`` shape: a small local kubo daemon
|
|
||||||
runs alongside the web app, accepts uploads from end users (e.g. a customer
|
|
||||||
uploading a photo of a finished custom order), pins locally for fast
|
|
||||||
serving, and optionally mirrors pins to a second remote node
|
|
||||||
(the cold host-on-LAN) for archival redundancy.
|
|
||||||
|
|
||||||
No IPFS libraries are imported — just httpx against the kubo REST API
|
|
||||||
(v0). Keeps the SDK surface minimal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class IPFSClient:
|
|
||||||
"""Kubo-compatible IPFS client.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
api_url: Base URL of the kubo HTTP API (default ``http://127.0.0.1:5001``).
|
|
||||||
timeout: Per-request timeout in seconds (default 60 — uploads can be slow).
|
|
||||||
mirror_api_urls: Optional list of additional kubo endpoints to
|
|
||||||
``pin add`` the CID on after a successful primary pin. Use this
|
|
||||||
to mirror to the cold host or any other archival node.
|
|
||||||
"""
|
|
||||||
|
|
||||||
api_url: str = "http://127.0.0.1:5001"
|
|
||||||
timeout: float = 60.0
|
|
||||||
mirror_api_urls: list[str] = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.mirror_api_urls is None:
|
|
||||||
self.mirror_api_urls = []
|
|
||||||
|
|
||||||
async def add(self, data: bytes, filename: str = "upload") -> str:
|
|
||||||
"""Upload bytes and pin them locally.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Raw bytes to add.
|
|
||||||
filename: Logical name used by clients browsing the DAG
|
|
||||||
(doesn't affect the CID).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CID (base58, v0 or base32 v1 depending on kubo defaults).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If the daemon is unreachable or returns a non-2xx.
|
|
||||||
"""
|
|
||||||
url = f"{self.api_url.rstrip('/')}/api/v0/add"
|
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
url,
|
|
||||||
files={"file": (filename, data, "application/octet-stream")},
|
|
||||||
params={"pin": "true", "cid-version": "1"},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise RuntimeError(f"ipfs add {resp.status_code}: {resp.text[:200]}")
|
|
||||||
# kubo's /add streams NDJSON; each line is one {Name, Hash, Size}.
|
|
||||||
# For a single file upload the last line carries the wrapping CID.
|
|
||||||
last_cid: Optional[str] = None
|
|
||||||
for line in resp.text.strip().splitlines():
|
|
||||||
if '"Hash"' in line:
|
|
||||||
import json
|
|
||||||
obj = json.loads(line)
|
|
||||||
last_cid = obj.get("Hash")
|
|
||||||
if not last_cid:
|
|
||||||
raise RuntimeError(f"ipfs add: no CID in response: {resp.text[:200]}")
|
|
||||||
|
|
||||||
# Mirror pins (best effort — a mirror failure should not poison the primary upload).
|
|
||||||
for mirror in self.mirror_api_urls:
|
|
||||||
try:
|
|
||||||
await self._pin_on(mirror, last_cid)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("[ipfs] mirror pin to %s failed for %s: %s", mirror, last_cid, exc)
|
|
||||||
|
|
||||||
return last_cid
|
|
||||||
|
|
||||||
async def _pin_on(self, api_url: str, cid: str) -> None:
|
|
||||||
"""Pin an existing CID on a remote kubo node."""
|
|
||||||
url = f"{api_url.rstrip('/')}/api/v0/pin/add"
|
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
||||||
resp = await client.post(url, params={"arg": cid})
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise RuntimeError(f"pin/add {resp.status_code}: {resp.text[:200]}")
|
|
||||||
|
|
||||||
|
|
||||||
async def pin_bytes(
|
|
||||||
data: bytes,
|
|
||||||
api_url: str = "http://127.0.0.1:5001",
|
|
||||||
mirror_api_urls: Optional[list[str]] = None,
|
|
||||||
filename: str = "upload",
|
|
||||||
) -> str:
|
|
||||||
"""Convenience wrapper: one-shot upload + pin (+ optional mirror).
|
|
||||||
|
|
||||||
Returns the CID.
|
|
||||||
"""
|
|
||||||
client = IPFSClient(api_url=api_url, mirror_api_urls=mirror_api_urls or [])
|
|
||||||
return await client.add(data, filename=filename)
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
"""CIP-25 v2 NFT certificate-of-authenticity minting.
|
|
||||||
|
|
||||||
This module produces the NFT cert attached to a confirmed merchant
|
|
||||||
order. One NFT per order, pinned-once metadata (image CID from IPFS
|
|
||||||
via :mod:`cardano_checkout.ipfs`), sent directly to the customer's
|
|
||||||
wallet in the same transaction.
|
|
||||||
|
|
||||||
Design decisions:
|
|
||||||
|
|
||||||
- **CIP-25 v2** (not CIP-68). CIP-25 is universally supported by
|
|
||||||
every Cardano wallet (Eternl, Lace, Yoroi, Vespr, Typhon). CIP-68
|
|
||||||
adds reference-NFT mutability we do not need for a static cert.
|
|
||||||
- **Single policy per merchant studio.** All of a studio's certs share
|
|
||||||
one policy_id so wallets group them cleanly. The policy key is a
|
|
||||||
native script under the studio's custody — Sulkta pattern is a
|
|
||||||
multi-sig native script stored on the cold host.
|
|
||||||
- **Policy has a time-lock** (invalid-after slot) so the "no more
|
|
||||||
editions can be minted after X" claim is cryptographically enforceable.
|
|
||||||
Recommended: generous lock (100 years) so policy_id stays stable,
|
|
||||||
but revokable in-contract via ``mint policy revoke`` flow.
|
|
||||||
- **No reference script, no Plutus.** Pure native scripts + standard
|
|
||||||
CIP-25 metadata keeps the tx cheap (~0.18 ADA fee + min-utxo for the
|
|
||||||
NFT output).
|
|
||||||
|
|
||||||
v0.1.0 ships the signature + stub. Full tx construction against a local
|
|
||||||
Ogmios endpoint lands in v0.2 once the store + monitor integration
|
|
||||||
tests pass.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MintPolicy:
|
|
||||||
"""A native-script minting policy under the SDK's custody model.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
policy_id: Hex blake2b-224 hash of the native script CBOR. Stable
|
|
||||||
for the life of the policy — shipped with every cert minted
|
|
||||||
under it. Becomes the Cardano ``policy_id`` of the NFT asset.
|
|
||||||
script_cbor_hex: Hex-encoded CBOR of the native script itself.
|
|
||||||
Submitted alongside the mint tx witness.
|
|
||||||
signing_keys: Paths to skey files needed to sign the tx (all of
|
|
||||||
them, for an all-of multi-sig). The SDK does not read these —
|
|
||||||
callers pass them to :mod:`cardano_checkout.txbuild` which
|
|
||||||
coordinates with an external signer (the cold host cold-store pattern).
|
|
||||||
locked_after_slot: Optional slot beyond which the policy rejects
|
|
||||||
further mints. None = no time lock (not recommended for
|
|
||||||
certificates — a lock makes the "no more editions" claim
|
|
||||||
mathematically verifiable).
|
|
||||||
"""
|
|
||||||
|
|
||||||
policy_id: str
|
|
||||||
script_cbor_hex: str
|
|
||||||
signing_keys: list[str] = field(default_factory=list)
|
|
||||||
locked_after_slot: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def mint_nft_cert(
|
|
||||||
policy: MintPolicy,
|
|
||||||
asset_name: str,
|
|
||||||
metadata: dict,
|
|
||||||
recipient_address: str,
|
|
||||||
ogmios_url: str = "http://127.0.0.1:1337",
|
|
||||||
network: str = "mainnet",
|
|
||||||
) -> str:
|
|
||||||
"""Mint a CIP-25 v2 NFT cert and send it to the recipient.
|
|
||||||
|
|
||||||
Constructs a transaction that:
|
|
||||||
1. Mints exactly 1 of ``{policy.policy_id}.{asset_name}``.
|
|
||||||
2. Sends that single token to ``recipient_address`` in its own UTxO.
|
|
||||||
3. Attaches CIP-25 v2 metadata under metadatum label 721.
|
|
||||||
4. Witnesses with all signing keys required by the policy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
policy: Merchant's minting policy.
|
|
||||||
asset_name: UTF-8 asset name (will be hex-encoded per CIP-25). Max 32 bytes.
|
|
||||||
metadata: CIP-25 metadata dict. At minimum should include ``name``,
|
|
||||||
``image`` (ipfs://CID), ``mediaType``, and any studio-specific
|
|
||||||
properties. The SDK wraps this into the proper ``{721: {policy_id: {asset_name: ...}}}``
|
|
||||||
envelope automatically.
|
|
||||||
recipient_address: Bech32 address of the wallet that receives the NFT.
|
|
||||||
ogmios_url: Endpoint for chain queries + tx submission.
|
|
||||||
network: "mainnet" or "testnet".
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Transaction hash (hex) once successfully submitted.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotImplementedError: v0.1.0 stub. Full implementation lands in v0.2.
|
|
||||||
"""
|
|
||||||
# v0.1.0 surface lock — implementation lands in v0.2 alongside txbuild.py
|
|
||||||
raise NotImplementedError(
|
|
||||||
"mint_nft_cert is stubbed in v0.1.0. Use cardano-cli or PyCardano "
|
|
||||||
"directly until v0.2 ships the full Ogmios-backed mint path."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_cip25_metadata(
|
|
||||||
policy_id: str,
|
|
||||||
asset_name: str,
|
|
||||||
name: str,
|
|
||||||
image_cid: str,
|
|
||||||
description: str = "",
|
|
||||||
media_type: str = "image/jpeg",
|
|
||||||
properties: Optional[dict] = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Assemble the ``{721: {...}}`` metadatum envelope for a single NFT.
|
|
||||||
|
|
||||||
CIP-25 v2 image field takes an ``ipfs://<CID>`` URI. Description, if
|
|
||||||
longer than 64 characters, is split into an array of ≤64-char chunks
|
|
||||||
(CIP-25 constraint from the Cardano metadata schema — strings larger
|
|
||||||
than 64 chars are encoded as a list of chunks).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
policy_id: Hex policy id (same as on the asset).
|
|
||||||
asset_name: UTF-8 asset name — used as the dict key under policy_id.
|
|
||||||
name: Human-readable NFT title (shown in wallets).
|
|
||||||
image_cid: IPFS CID — the function prepends ``ipfs://``.
|
|
||||||
description: Optional longer text. Will be chunked if > 64 chars.
|
|
||||||
media_type: MIME type of the image. Default ``image/jpeg``.
|
|
||||||
properties: Additional key/value pairs merged into the metadata blob.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict ready to submit as tx metadatum label 721.
|
|
||||||
"""
|
|
||||||
def chunk64(s: str) -> list[str]:
|
|
||||||
if len(s) <= 64:
|
|
||||||
return [s]
|
|
||||||
return [s[i : i + 64] for i in range(0, len(s), 64)]
|
|
||||||
|
|
||||||
desc: object = description
|
|
||||||
if isinstance(description, str) and len(description) > 64:
|
|
||||||
desc = chunk64(description)
|
|
||||||
|
|
||||||
body: dict = {
|
|
||||||
"name": name,
|
|
||||||
"image": f"ipfs://{image_cid}",
|
|
||||||
"mediaType": media_type,
|
|
||||||
}
|
|
||||||
if desc:
|
|
||||||
body["description"] = desc
|
|
||||||
if properties:
|
|
||||||
body.update(properties)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"721": {
|
|
||||||
policy_id: {
|
|
||||||
asset_name: body,
|
|
||||||
},
|
|
||||||
"version": "2.0",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +1,152 @@
|
||||||
"""
|
"""Cardano UTXO monitoring — framework-agnostic polling against :class:`InvoiceStore`.
|
||||||
Cardano UTXO Monitoring Service
|
|
||||||
|
|
||||||
Polls Koios API to detect on-chain payments at derived Cardano addresses.
|
Polls Koios for on-chain payments at the receive addresses of invoices that
|
||||||
Called by the scheduler every 15 seconds for pending payments, and every
|
are still in a non-terminal state, updates invoice status through the
|
||||||
60 seconds to reprice expired payment requests.
|
:class:`InvoiceStore` Protocol, and optionally reprices expired invoices
|
||||||
|
against the current ADA/USD oracle snapshot.
|
||||||
|
|
||||||
|
Called by :mod:`cardano_checkout.scheduler` every 15 seconds for pending
|
||||||
|
payments, and every 60 seconds to reprice expired ones.
|
||||||
|
|
||||||
|
Koios endpoint used::
|
||||||
|
|
||||||
Koios endpoint used:
|
|
||||||
POST https://api.koios.rest/api/v1/address_utxos
|
POST https://api.koios.rest/api/v1/address_utxos
|
||||||
Body: {"_addresses": ["addr1..."]}
|
Body: {"_addresses": ["addr1..."]}
|
||||||
|
|
||||||
Status flow applied here:
|
Status transitions::
|
||||||
pending -> confirmed (received >= expected * 0.98)
|
|
||||||
pending -> underpaid (received > 0 but < expected * 0.98)
|
PENDING ──► CONFIRMED (received >= expected * CONFIRM_TOLERANCE)
|
||||||
pending -> overpaid (received >= expected * 1.02 — still confirmed)
|
PENDING ──► UNDERPAID (received > 0 but below tolerance)
|
||||||
pending -> expired (handled by reprice_expired_payments)
|
PENDING ──► OVERPAID (received >= expected * OVERPAY_THRESHOLD)
|
||||||
|
PENDING ──► EXPIRED (after reprice_count exhausts — see reprice_expired_invoices)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from typing import Awaitable, Callable, Optional
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from models import CardanoPayment, Config, PlatformConfig
|
from cardano_checkout.invoice import Invoice, InvoiceStatus
|
||||||
from services.cardano_price import (
|
from cardano_checkout.store import InvoiceStore
|
||||||
convert_token_to_lovelace,
|
|
||||||
get_ada_usd_price,
|
# Consumer-supplied pricing callable: takes a USD amount (float),
|
||||||
convert_usd_to_lovelace,
|
# returns the current-market lovelace equivalent as int. Invoked by
|
||||||
KNOWN_TOKENS,
|
# :func:`reprice_expired_invoices` to generate fresh quotes when an
|
||||||
)
|
# invoice's quote window lapses without payment.
|
||||||
|
PriceFn = Callable[[float], Awaitable[int]]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
KOIOS_URL = "https://api.koios.rest/api/v1/address_utxos"
|
KOIOS_URL = "https://api.koios.rest/api/v1/address_utxos"
|
||||||
KOIOS_TIMEOUT = 15 # seconds
|
KOIOS_TIMEOUT = 15 # seconds
|
||||||
|
|
||||||
# Tolerance for confirming payment (2%)
|
# Tolerance for confirming payment (2%).
|
||||||
CONFIRM_TOLERANCE = 0.98
|
CONFIRM_TOLERANCE = 0.98
|
||||||
OVERPAY_THRESHOLD = 1.02
|
OVERPAY_THRESHOLD = 1.02
|
||||||
|
|
||||||
|
# Default reprice cap + window.
|
||||||
|
DEFAULT_MAX_REPRICINGS = 3
|
||||||
|
DEFAULT_PAYMENT_WINDOW_MINUTES = 15
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Koios API
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
async def _check_address_utxos(address: str) -> list[dict]:
|
# ---------------------------------------------------------------------------
|
||||||
"""
|
# Koios UTXO query
|
||||||
Query Koios for all UTXOs at the given Cardano address.
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Returns a list of UTXO dicts from Koios, or an empty list on error.
|
|
||||||
Each UTXO has keys: tx_hash, tx_index, value (lovelace), asset_list.
|
async def check_address_utxos(
|
||||||
|
address: str, koios_url: str = KOIOS_URL, timeout: float = KOIOS_TIMEOUT
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Query Koios for all UTXOs at ``address``.
|
||||||
|
|
||||||
|
Returns a list of UTXO dicts (each with ``tx_hash``, ``tx_index``,
|
||||||
|
``value``, ``asset_list``) or an empty list on any error. Never raises.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=KOIOS_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
KOIOS_URL,
|
koios_url,
|
||||||
json={"_addresses": [address]},
|
json={"_addresses": [address]},
|
||||||
headers={"Accept": "application/json"},
|
headers={"Accept": "application/json"},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
logger.warning("[cardano-monitor] Unexpected Koios response shape for %s", address[:20])
|
logger.warning(
|
||||||
|
"[cardano-monitor] Unexpected Koios response shape for %s",
|
||||||
|
address[:20],
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"[cardano-monitor] Koios HTTP %s for %s: %s",
|
"[cardano-monitor] Koios HTTP %s for %s: %s",
|
||||||
e.response.status_code, address[:20], e.response.text[:200],
|
e.response.status_code,
|
||||||
|
address[:20],
|
||||||
|
e.response.text[:200],
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
logger.warning("[cardano-monitor] Koios timeout for address %s", address[:20])
|
logger.warning("[cardano-monitor] Koios timeout for address %s", address[:20])
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e: # pragma: no cover — defensive
|
||||||
logger.error("[cardano-monitor] Koios unexpected error for %s: %s", address[:20], e)
|
logger.error(
|
||||||
|
"[cardano-monitor] Koios unexpected error for %s: %s", address[:20], e
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# Leading-underscore alias kept for callers that imported the private name.
|
||||||
# Payment evaluation
|
_check_address_utxos = check_address_utxos
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
async def _evaluate_payment(payment: CardanoPayment, utxos: list[dict]) -> tuple[str, int, int, dict, Optional[str]]:
|
|
||||||
"""
|
# ---------------------------------------------------------------------------
|
||||||
Evaluate UTXOs against the expected payment and determine new status.
|
# UTXO evaluation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate_utxos(
|
||||||
|
expected_lovelace: int, utxos: list[dict]
|
||||||
|
) -> tuple[InvoiceStatus, int, int, dict, Optional[str]]:
|
||||||
|
"""Classify a batch of UTXOs against an expected-lovelace target.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(new_status, received_lovelace, total_value_lovelace, received_assets, tx_hash)
|
Tuple of ``(status, raw_lovelace, total_value_lovelace, received_assets, latest_tx_hash)``.
|
||||||
|
|
||||||
|
- ``status`` — :class:`InvoiceStatus` the invoice should transition into.
|
||||||
|
``PENDING`` means "no change, keep polling".
|
||||||
|
- ``raw_lovelace`` — pure ADA received (excluding native-asset value).
|
||||||
|
- ``total_value_lovelace`` — ADA + ADA-equivalent of native assets via DexHunter.
|
||||||
|
- ``received_assets`` — ``{policy_id.asset_name_hex: quantity}``.
|
||||||
|
- ``latest_tx_hash`` — most recent observed tx hash, or None if no UTXOs.
|
||||||
|
|
||||||
Status rules:
|
Status rules:
|
||||||
- No UTXOs -> "pending" (no change)
|
|
||||||
- total_value >= expected * OVERPAY_THRESHOLD -> "overpaid" (treated as confirmed)
|
- No UTXOs → ``PENDING`` (no change)
|
||||||
- total_value >= expected * CONFIRM_TOLERANCE -> "confirmed"
|
- ``total_value >= expected * OVERPAY_THRESHOLD`` → ``OVERPAID`` (treated as confirmed)
|
||||||
- total_value > 0 but below tolerance -> "underpaid"
|
- ``total_value >= expected * CONFIRM_TOLERANCE`` → ``CONFIRMED``
|
||||||
|
- ``total_value > 0`` but below tolerance → ``UNDERPAID``
|
||||||
"""
|
"""
|
||||||
if not utxos:
|
if not utxos:
|
||||||
return "pending", 0, 0, {}, None
|
return InvoiceStatus.PENDING, 0, 0, {}, None
|
||||||
|
|
||||||
raw_lovelace = 0
|
raw_lovelace = 0
|
||||||
received_assets: dict[str, int] = {}
|
received_assets: dict[str, int] = {}
|
||||||
latest_tx_hash: Optional[str] = None
|
latest_tx_hash: Optional[str] = None
|
||||||
|
|
||||||
for utxo in utxos:
|
for utxo in utxos:
|
||||||
# Sum ADA (lovelace)
|
|
||||||
try:
|
try:
|
||||||
raw_lovelace += int(utxo.get("value", 0))
|
raw_lovelace += int(utxo.get("value", 0))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Track latest tx_hash
|
|
||||||
tx = utxo.get("tx_hash")
|
tx = utxo.get("tx_hash")
|
||||||
if tx:
|
if tx:
|
||||||
latest_tx_hash = tx
|
latest_tx_hash = tx
|
||||||
|
|
||||||
# Collect native assets
|
|
||||||
for asset in utxo.get("asset_list", []) or []:
|
for asset in utxo.get("asset_list", []) or []:
|
||||||
policy_id = asset.get("policy_id", "")
|
policy_id = asset.get("policy_id", "")
|
||||||
asset_name = asset.get("asset_name", "")
|
asset_name = asset.get("asset_name", "")
|
||||||
|
|
@ -129,189 +158,246 @@ async def _evaluate_payment(payment: CardanoPayment, utxos: list[dict]) -> tuple
|
||||||
if qty > 0:
|
if qty > 0:
|
||||||
received_assets[asset_id] = received_assets.get(asset_id, 0) + qty
|
received_assets[asset_id] = received_assets.get(asset_id, 0) + qty
|
||||||
|
|
||||||
# Convert native assets to lovelace equivalent
|
# ADA-only matching. Native tokens in the same UTxOs are recorded in
|
||||||
asset_lovelace = 0
|
# received_assets for visibility but do NOT contribute to the
|
||||||
for asset_id, qty in received_assets.items():
|
# payment-matched total. Wrap this function with your own
|
||||||
if "." not in asset_id:
|
# asset-to-lovelace converter to accept native tokens.
|
||||||
continue
|
total_value = raw_lovelace
|
||||||
policy_id, asset_name_hex = asset_id.split(".", 1)
|
|
||||||
|
|
||||||
# Find matching known token for decimals
|
if expected_lovelace == 0:
|
||||||
decimals = 0
|
# Degenerate case — any payment at all counts.
|
||||||
for token_info in KNOWN_TOKENS.values():
|
new_status = InvoiceStatus.CONFIRMED if total_value > 0 else InvoiceStatus.PENDING
|
||||||
if token_info.get("policy_id") == policy_id:
|
elif total_value >= expected_lovelace * OVERPAY_THRESHOLD:
|
||||||
decimals = token_info.get("decimals", 0)
|
new_status = InvoiceStatus.OVERPAID
|
||||||
break
|
elif total_value >= expected_lovelace * CONFIRM_TOLERANCE:
|
||||||
|
new_status = InvoiceStatus.CONFIRMED
|
||||||
try:
|
|
||||||
lv = await convert_token_to_lovelace(policy_id, asset_name_hex, qty, decimals)
|
|
||||||
if lv is not None:
|
|
||||||
asset_lovelace += lv
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[cardano-monitor] Failed to convert asset %s to lovelace: %s", asset_id[:20], e)
|
|
||||||
|
|
||||||
total_value = raw_lovelace + asset_lovelace
|
|
||||||
expected = payment.expected_lovelace or 0
|
|
||||||
|
|
||||||
if expected == 0:
|
|
||||||
# Degenerate case — treat any payment as confirmed
|
|
||||||
new_status = "confirmed"
|
|
||||||
elif total_value >= expected * OVERPAY_THRESHOLD:
|
|
||||||
new_status = "overpaid"
|
|
||||||
elif total_value >= expected * CONFIRM_TOLERANCE:
|
|
||||||
new_status = "confirmed"
|
|
||||||
elif total_value > 0:
|
elif total_value > 0:
|
||||||
new_status = "underpaid"
|
new_status = InvoiceStatus.UNDERPAID
|
||||||
else:
|
else:
|
||||||
new_status = "pending"
|
new_status = InvoiceStatus.PENDING
|
||||||
|
|
||||||
return new_status, raw_lovelace, total_value, received_assets, latest_tx_hash
|
return new_status, raw_lovelace, total_value, received_assets, latest_tx_hash
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# Leading-underscore alias for callers that imported the private name.
|
||||||
# Main monitoring functions (called by scheduler)
|
_evaluate_utxos = evaluate_utxos
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
async def check_pending_payments(db: AsyncSession) -> None:
|
|
||||||
"""
|
|
||||||
Check all pending payments that haven't expired yet.
|
|
||||||
|
|
||||||
Queries Koios for UTXOs at each address. Updates payment status in place.
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main monitoring entrypoints (scheduled jobs call these)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def check_pending_invoices(
|
||||||
|
store: InvoiceStore,
|
||||||
|
koios_url: str = KOIOS_URL,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> int:
|
||||||
|
"""Check every :class:`InvoiceStatus.PENDING` invoice for on-chain payment.
|
||||||
|
|
||||||
|
For each pending invoice whose expiry has not yet passed, query Koios
|
||||||
|
for the UTXOs at its receive address, evaluate them against
|
||||||
|
``expected_lovelace``, and transition the invoice state accordingly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store: Persistence backend.
|
||||||
|
koios_url: Koios base URL. Override for testnet / custom gateways.
|
||||||
|
limit: Max pending invoices to process per poll.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of invoices updated this cycle (for logging / metrics).
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
pending = await store.list_by_status(InvoiceStatus.PENDING, limit=limit)
|
||||||
|
if not pending:
|
||||||
|
return 0
|
||||||
|
|
||||||
result = await db.execute(
|
# Filter out already-expired invoices — those get picked up by reprice_expired_invoices.
|
||||||
select(CardanoPayment).where(
|
active = [
|
||||||
CardanoPayment.status == "pending",
|
inv for inv in pending if inv.expires_at is None or inv.expires_at > now
|
||||||
CardanoPayment.expires_at > now,
|
]
|
||||||
)
|
if not active:
|
||||||
)
|
return 0
|
||||||
payments = result.scalars().all()
|
|
||||||
|
|
||||||
if not payments:
|
logger.debug("[cardano-monitor] Checking %d pending invoice(s)", len(active))
|
||||||
return
|
|
||||||
|
|
||||||
logger.debug("[cardano-monitor] Checking %d pending payment(s)", len(payments))
|
updates = 0
|
||||||
|
for invoice in active:
|
||||||
for payment in payments:
|
|
||||||
try:
|
try:
|
||||||
utxos = await _check_address_utxos(payment.address)
|
utxos = await check_address_utxos(invoice.receive_address, koios_url=koios_url)
|
||||||
new_status, raw_lovelace, total_value, received_assets, tx_hash = await _evaluate_payment(payment, utxos)
|
(
|
||||||
|
new_status,
|
||||||
|
raw_lovelace,
|
||||||
|
total_value,
|
||||||
|
_received_assets,
|
||||||
|
tx_hash,
|
||||||
|
) = await evaluate_utxos(invoice.expected_lovelace, utxos)
|
||||||
|
|
||||||
if new_status == payment.status and raw_lovelace == 0:
|
if new_status == invoice.status and raw_lovelace == 0:
|
||||||
# No change, no UTXOs — skip DB write
|
# No change and no UTXOs — nothing to persist.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
payment.received_lovelace = raw_lovelace
|
invoice.received_lovelace = raw_lovelace
|
||||||
payment.total_value_lovelace = total_value
|
if tx_hash and tx_hash not in invoice.tx_hashes:
|
||||||
payment.received_assets = received_assets
|
invoice.tx_hashes.append(tx_hash)
|
||||||
|
# record_tx is idempotent per contract — call it so the store
|
||||||
|
# can persist the per-utxo history however it wants.
|
||||||
|
try:
|
||||||
|
await store.record_tx(
|
||||||
|
invoice.id, tx_hash, lovelace_delta=raw_lovelace
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"[cardano-monitor] record_tx failed for %s/%s: %s",
|
||||||
|
invoice.id,
|
||||||
|
tx_hash[:12],
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
if tx_hash:
|
if new_status != invoice.status:
|
||||||
payment.tx_hash = tx_hash
|
old_status = invoice.status
|
||||||
|
invoice.status = new_status
|
||||||
|
|
||||||
if new_status != payment.status:
|
if new_status in (InvoiceStatus.CONFIRMED, InvoiceStatus.OVERPAID):
|
||||||
old_status = payment.status
|
invoice.confirmed_at = now
|
||||||
payment.status = new_status
|
|
||||||
|
|
||||||
if new_status in ("confirmed", "overpaid"):
|
|
||||||
payment.confirmed_at = now
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[cardano-monitor] payment #%d invoice_id=%d: %s -> %s (%.6f ADA received, %.6f ADA total value)",
|
"[cardano-monitor] invoice=%s merchant=%s: %s -> %s "
|
||||||
payment.id,
|
"(%.6f ADA received, %.6f ADA total value)",
|
||||||
payment.invoice_id or 0,
|
invoice.id,
|
||||||
old_status,
|
invoice.merchant_id,
|
||||||
new_status,
|
old_status.value,
|
||||||
|
new_status.value,
|
||||||
raw_lovelace / 1_000_000,
|
raw_lovelace / 1_000_000,
|
||||||
total_value / 1_000_000,
|
total_value / 1_000_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await store.update(invoice)
|
||||||
|
updates += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"[cardano-monitor] Error checking payment #%d: %s", payment.id, e
|
"[cardano-monitor] Error checking invoice %s: %s", invoice.id, e
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.commit()
|
return updates
|
||||||
|
|
||||||
|
|
||||||
async def reprice_expired_payments(db: AsyncSession) -> None:
|
async def reprice_expired_invoices(
|
||||||
"""
|
store: InvoiceStore,
|
||||||
Reprice payments whose window has expired.
|
*,
|
||||||
|
price_fn: PriceFn,
|
||||||
|
window_minutes: int = DEFAULT_PAYMENT_WINDOW_MINUTES,
|
||||||
|
max_repricings: int = DEFAULT_MAX_REPRICINGS,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> int:
|
||||||
|
"""Reprice PENDING invoices whose expiry has passed.
|
||||||
|
|
||||||
Fetches the current ADA price, recalculates expected_lovelace, resets
|
Calls the consumer-supplied ``price_fn(usd_amount) -> lovelace`` to
|
||||||
expires_at to now + payment_window_minutes, and increments repriced_count.
|
recompute ``expected_lovelace`` at current market. Resets ``expires_at``
|
||||||
Gives up after 3 repricings to avoid infinite loops.
|
to ``now + window_minutes`` and increments ``invoice.metadata["repriced_count"]``.
|
||||||
|
After ``max_repricings`` the invoice transitions to
|
||||||
|
:class:`InvoiceStatus.EXPIRED`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store: Persistence backend.
|
||||||
|
price_fn: Async callable that takes a USD amount and returns the
|
||||||
|
current lovelace equivalent. Example::
|
||||||
|
|
||||||
|
from cardano_checkout.monitor import reprice_expired_invoices
|
||||||
|
|
||||||
|
async def my_price_fn(usd: float) -> int:
|
||||||
|
rate = await coingecko_fetch_ada_usd()
|
||||||
|
return int(round(usd / rate * 1_000_000))
|
||||||
|
|
||||||
|
await reprice_expired_invoices(store, price_fn=my_price_fn)
|
||||||
|
window_minutes: New expiry window per reprice. Default 15.
|
||||||
|
max_repricings: Give-up threshold. Default 3.
|
||||||
|
limit: Max pending invoices to process per call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of invoices updated (repriced or expired) this cycle.
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
pending = await store.list_by_status(InvoiceStatus.PENDING, limit=limit)
|
||||||
|
if not pending:
|
||||||
|
return 0
|
||||||
|
|
||||||
result = await db.execute(
|
expired_candidates = [
|
||||||
select(CardanoPayment).where(
|
inv for inv in pending if inv.expires_at is not None and inv.expires_at <= now
|
||||||
CardanoPayment.status == "pending",
|
]
|
||||||
CardanoPayment.expires_at <= now,
|
if not expired_candidates:
|
||||||
CardanoPayment.repriced_count < 3,
|
return 0
|
||||||
)
|
|
||||||
)
|
|
||||||
payments = result.scalars().all()
|
|
||||||
|
|
||||||
if not payments:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("[cardano-monitor] Repricing %d expired payment(s)", len(payments))
|
|
||||||
|
|
||||||
ada_price = await get_ada_usd_price()
|
|
||||||
if ada_price <= 0:
|
|
||||||
logger.warning("[cardano-monitor] Cannot reprice — ADA price unavailable")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Read platform payment window
|
|
||||||
pc_result = await db.execute(
|
|
||||||
select(PlatformConfig).where(PlatformConfig.key == "cardano_payment_window_minutes")
|
|
||||||
)
|
|
||||||
pc = pc_result.scalar_one_or_none()
|
|
||||||
try:
|
|
||||||
window_minutes = int(pc.value) if pc and pc.value else 15
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
window_minutes = 15
|
|
||||||
|
|
||||||
new_expires_at = now + timedelta(minutes=window_minutes)
|
|
||||||
|
|
||||||
for payment in payments:
|
|
||||||
try:
|
|
||||||
total_usd = float(payment.expected_usd or 0)
|
|
||||||
if total_usd <= 0:
|
|
||||||
payment.status = "expired"
|
|
||||||
logger.warning("[cardano-monitor] payment #%d has no expected_usd — marking expired", payment.id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_lovelace = await convert_usd_to_lovelace(total_usd)
|
|
||||||
if new_lovelace == 0:
|
|
||||||
logger.warning("[cardano-monitor] payment #%d: lovelace conversion returned 0, skipping", payment.id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
old_lovelace = payment.expected_lovelace
|
|
||||||
payment.expected_lovelace = new_lovelace
|
|
||||||
payment.ada_price_usd = Decimal(str(round(ada_price, 4)))
|
|
||||||
payment.expires_at = new_expires_at
|
|
||||||
payment.repriced_count += 1
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[cardano-monitor] Repriced payment #%d: %d -> %d lovelace (ADA=$%.4f, reprice #%d)",
|
"[cardano-monitor] Repricing %d expired invoice(s)", len(expired_candidates)
|
||||||
payment.id, old_lovelace or 0, new_lovelace, ada_price, payment.repriced_count,
|
)
|
||||||
|
|
||||||
|
new_expires_at = now + timedelta(minutes=window_minutes)
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
for invoice in expired_candidates:
|
||||||
|
try:
|
||||||
|
repriced_count = int(invoice.metadata.get("repriced_count", 0))
|
||||||
|
|
||||||
|
if repriced_count >= max_repricings:
|
||||||
|
invoice.status = InvoiceStatus.EXPIRED
|
||||||
|
await store.update(invoice)
|
||||||
|
updated += 1
|
||||||
|
logger.info(
|
||||||
|
"[cardano-monitor] invoice %s expired after %d repricings",
|
||||||
|
invoice.id,
|
||||||
|
repriced_count,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
usd_amount = float(invoice.usd_amount or 0)
|
||||||
|
if usd_amount <= 0:
|
||||||
|
invoice.status = InvoiceStatus.EXPIRED
|
||||||
|
await store.update(invoice)
|
||||||
|
updated += 1
|
||||||
|
logger.warning(
|
||||||
|
"[cardano-monitor] invoice %s has no usd_amount — marking expired",
|
||||||
|
invoice.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_lovelace = await price_fn(usd_amount)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"[cardano-monitor] invoice %s: price_fn raised %s, skipping",
|
||||||
|
invoice.id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if new_lovelace <= 0:
|
||||||
|
logger.warning(
|
||||||
|
"[cardano-monitor] invoice %s: price_fn returned %d, skipping",
|
||||||
|
invoice.id,
|
||||||
|
new_lovelace,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_lovelace = invoice.expected_lovelace
|
||||||
|
invoice.expected_lovelace = new_lovelace
|
||||||
|
invoice.expires_at = new_expires_at
|
||||||
|
invoice.metadata["repriced_count"] = repriced_count + 1
|
||||||
|
|
||||||
|
await store.update(invoice)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[cardano-monitor] Repriced invoice %s: %d -> %d lovelace (reprice #%d)",
|
||||||
|
invoice.id,
|
||||||
|
old_lovelace or 0,
|
||||||
|
new_lovelace,
|
||||||
|
repriced_count + 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[cardano-monitor] Error repricing payment #%d: %s", payment.id, e)
|
logger.exception(
|
||||||
|
"[cardano-monitor] Error repricing invoice %s: %s", invoice.id, e
|
||||||
# Mark payments that have exceeded max repricings as expired
|
|
||||||
expired_result = await db.execute(
|
|
||||||
select(CardanoPayment).where(
|
|
||||||
CardanoPayment.status == "pending",
|
|
||||||
CardanoPayment.expires_at <= now,
|
|
||||||
CardanoPayment.repriced_count >= 3,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
for payment in expired_result.scalars().all():
|
|
||||||
payment.status = "expired"
|
|
||||||
logger.info("[cardano-monitor] payment #%d marked expired after %d repricings", payment.id, payment.repriced_count)
|
|
||||||
|
|
||||||
await db.commit()
|
return updated
|
||||||
|
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
"""
|
|
||||||
Cardano Token Price Service — Phase 2 of the Cardano payments system.
|
|
||||||
|
|
||||||
Provides cached ADA/USD and token/ADA price lookups used to convert
|
|
||||||
invoice amounts into lovelace (ADA's base unit) for payment requests.
|
|
||||||
|
|
||||||
Data sources:
|
|
||||||
- ADA/USD: CoinGecko free API (no key required, rate-limited)
|
|
||||||
- Token/ADA: DexHunter v2 API (DEX aggregator on Cardano)
|
|
||||||
|
|
||||||
Cache strategy: module-level dict with timestamps. TTL = 5 minutes.
|
|
||||||
All functions are async, never raise — return None/0 on failure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Token registry
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
KNOWN_TOKENS: dict[str, dict] = {
|
|
||||||
"ada": {
|
|
||||||
"policy_id": "",
|
|
||||||
"asset_name": "",
|
|
||||||
"ticker": "ADA",
|
|
||||||
"decimals": 6,
|
|
||||||
"type": "native",
|
|
||||||
},
|
|
||||||
"djed": {
|
|
||||||
"policy_id": "8db269c3ec630e06ae29f74bc39edd1f87c819f1056206e879a1cd61",
|
|
||||||
"asset_name": "444a4544", # "DJED".encode().hex()
|
|
||||||
"ticker": "DJED",
|
|
||||||
"decimals": 6,
|
|
||||||
"type": "stablecoin",
|
|
||||||
},
|
|
||||||
"iusd": {
|
|
||||||
"policy_id": "f66d78b4a3cb3d37afa0ec36461e51ecbde00f26c8f0a68f94b69880",
|
|
||||||
"asset_name": "69555344", # "iUSD".encode().hex()
|
|
||||||
"ticker": "iUSD",
|
|
||||||
"decimals": 6,
|
|
||||||
"type": "stablecoin",
|
|
||||||
},
|
|
||||||
"night": {
|
|
||||||
"policy_id": "0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa",
|
|
||||||
"asset_name": "4e49474854", # "NIGHT".encode().hex()
|
|
||||||
"ticker": "NIGHT",
|
|
||||||
"decimals": 6,
|
|
||||||
"type": "utility",
|
|
||||||
},
|
|
||||||
"snek": {
|
|
||||||
"policy_id": "279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f",
|
|
||||||
"asset_name": "534e454b", # "SNEK".encode().hex()
|
|
||||||
"ticker": "SNEK",
|
|
||||||
"decimals": 0,
|
|
||||||
"type": "meme",
|
|
||||||
},
|
|
||||||
"iag": {
|
|
||||||
"policy_id": "5d16944c1e00a5fa1d14ba2460709bc2e41a18e8e1b86a1e7a09da09",
|
|
||||||
"asset_name": "494147", # "IAG".encode().hex()
|
|
||||||
"ticker": "IAG",
|
|
||||||
"decimals": 6,
|
|
||||||
"type": "utility",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal cache — { key: (value, fetched_at_unix) }
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_CACHE: dict[str, tuple] = {}
|
|
||||||
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_get(key: str) -> Optional[float]:
|
|
||||||
"""Return cached value if still fresh, else None."""
|
|
||||||
entry = _CACHE.get(key)
|
|
||||||
if entry is None:
|
|
||||||
return None
|
|
||||||
value, fetched_at = entry
|
|
||||||
if time.monotonic() - fetched_at > _CACHE_TTL_SECONDS:
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_set(key: str, value: float) -> None:
|
|
||||||
"""Store value in cache with current timestamp."""
|
|
||||||
_CACHE[key] = (value, time.monotonic())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def get_ada_usd_price() -> float:
|
|
||||||
"""
|
|
||||||
Fetch the current ADA/USD price from CoinGecko.
|
|
||||||
|
|
||||||
Caches result for 5 minutes. Returns 0.0 on failure — callers should
|
|
||||||
treat 0.0 as a signal that pricing is unavailable.
|
|
||||||
|
|
||||||
Endpoint: GET https://api.coingecko.com/api/v3/simple/price
|
|
||||||
"""
|
|
||||||
cache_key = "ada_usd"
|
|
||||||
cached = _cache_get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
url = "https://api.coingecko.com/api/v3/simple/price"
|
|
||||||
params = {"ids": "cardano", "vs_currencies": "usd"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.get(url, params=params)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
price = float(data["cardano"]["usd"])
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(
|
|
||||||
"[cardano_price] CoinGecko request failed: %s %s",
|
|
||||||
e.response.status_code,
|
|
||||||
e.response.text[:200],
|
|
||||||
)
|
|
||||||
return 0.0
|
|
||||||
except (KeyError, ValueError, TypeError) as e:
|
|
||||||
logger.error("[cardano_price] CoinGecko response parse error: %s", e)
|
|
||||||
return 0.0
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[cardano_price] CoinGecko unexpected error: %s", e)
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
logger.debug("[cardano_price] ADA/USD = %.6f (live)", price)
|
|
||||||
_cache_set(cache_key, price)
|
|
||||||
return price
|
|
||||||
|
|
||||||
|
|
||||||
async def get_token_ada_price(policy_id: str, asset_name_hex: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Fetch the price of a Cardano native token in ADA from DexHunter.
|
|
||||||
|
|
||||||
Tries the DexHunter v2 bestPool endpoint first, then falls back to the
|
|
||||||
community pair endpoint. Both return the token's ADA price per base unit.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
policy_id: The token's Cardano policy ID (hex string).
|
|
||||||
asset_name_hex: The token's asset name as a hex-encoded string.
|
|
||||||
Derive with: token_ticker.encode().hex()
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Price in ADA per base unit of the token, or None if no liquidity /
|
|
||||||
not found / request failed.
|
|
||||||
|
|
||||||
Cache: 5 minutes per (policy_id, asset_name_hex) pair.
|
|
||||||
"""
|
|
||||||
if not policy_id or asset_name_hex is None:
|
|
||||||
# ADA itself — price is 1 ADA by definition
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
asset_id = f"{policy_id}{asset_name_hex}"
|
|
||||||
cache_key = f"token_ada:{asset_id}"
|
|
||||||
cached = _cache_get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
price: Optional[float] = None
|
|
||||||
|
|
||||||
# --- Attempt 1: DexHunter v2 bestPool ---
|
|
||||||
try:
|
|
||||||
url = "https://api-v2.dexhunter.io/swap/bestPool"
|
|
||||||
params = {"tokenA": "lovelace", "tokenB": asset_id}
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.get(url, params=params)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
# DexHunter returns price_a_per_b or price_b_per_a depending on direction.
|
|
||||||
# We want ADA per token — look for the field that represents that.
|
|
||||||
raw_price = (
|
|
||||||
data.get("price_b_per_a") # token per lovelace inverse
|
|
||||||
or data.get("price_a_per_b") # ada per token
|
|
||||||
or data.get("price")
|
|
||||||
)
|
|
||||||
if raw_price is not None:
|
|
||||||
candidate = float(raw_price)
|
|
||||||
# bestPool returns lovelace-denominated prices — convert to ADA
|
|
||||||
# If the value is very large (>1000), it's likely lovelace/token, invert & divide
|
|
||||||
if candidate > 1000:
|
|
||||||
price = 1_000_000 / candidate # lovelace per token → ADA per token
|
|
||||||
else:
|
|
||||||
price = candidate
|
|
||||||
logger.debug("[cardano_price] %s bestPool price = %.8f ADA", asset_id[:20], price)
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
if e.response.status_code not in (404, 422):
|
|
||||||
logger.warning(
|
|
||||||
"[cardano_price] DexHunter bestPool error %s for %s",
|
|
||||||
e.response.status_code,
|
|
||||||
asset_id[:20],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[cardano_price] DexHunter bestPool failed for %s: %s", asset_id[:20], e)
|
|
||||||
|
|
||||||
# --- Attempt 2: DexHunter community pair endpoint (fallback) ---
|
|
||||||
if price is None:
|
|
||||||
try:
|
|
||||||
url = f"https://api.dexhunter.io/community/pair/{asset_id}"
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.get(url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
raw_price = (
|
|
||||||
data.get("price_ada")
|
|
||||||
or data.get("priceAda")
|
|
||||||
or data.get("price")
|
|
||||||
)
|
|
||||||
if raw_price is not None:
|
|
||||||
price = float(raw_price)
|
|
||||||
logger.debug(
|
|
||||||
"[cardano_price] %s community pair price = %.8f ADA",
|
|
||||||
asset_id[:20],
|
|
||||||
price,
|
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
if e.response.status_code not in (404, 422):
|
|
||||||
logger.warning(
|
|
||||||
"[cardano_price] DexHunter community error %s for %s",
|
|
||||||
e.response.status_code,
|
|
||||||
asset_id[:20],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[cardano_price] DexHunter community failed for %s: %s", asset_id[:20], e)
|
|
||||||
|
|
||||||
if price is not None and price > 0:
|
|
||||||
_cache_set(cache_key, price)
|
|
||||||
return price
|
|
||||||
|
|
||||||
logger.info("[cardano_price] No price found for %s (no liquidity or unsupported)", asset_id[:20])
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def convert_usd_to_lovelace(usd_amount: float) -> int:
|
|
||||||
"""
|
|
||||||
Convert a USD amount to lovelace using the current ADA/USD price.
|
|
||||||
|
|
||||||
1 ADA = 1,000,000 lovelace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
usd_amount: Amount in USD (e.g. 49.99).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Equivalent lovelace as an integer, or 0 if ADA price is unavailable.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> await convert_usd_to_lovelace(10.00)
|
|
||||||
# At ADA = $0.45 → 10 / 0.45 ADA → 22,222,222 lovelace
|
|
||||||
"""
|
|
||||||
if usd_amount <= 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
ada_usd = await get_ada_usd_price()
|
|
||||||
if ada_usd <= 0:
|
|
||||||
logger.error("[cardano_price] Cannot convert USD to lovelace — ADA price unavailable")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
ada_amount = usd_amount / ada_usd
|
|
||||||
lovelace = int(ada_amount * 1_000_000)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"[cardano_price] $%.2f USD → %.6f ADA → %d lovelace (rate: $%.6f/ADA)",
|
|
||||||
usd_amount,
|
|
||||||
ada_amount,
|
|
||||||
lovelace,
|
|
||||||
ada_usd,
|
|
||||||
)
|
|
||||||
return lovelace
|
|
||||||
|
|
||||||
|
|
||||||
async def convert_token_to_lovelace(
|
|
||||||
policy_id: str,
|
|
||||||
asset_name_hex: str,
|
|
||||||
token_quantity: int,
|
|
||||||
token_decimals: int = 0,
|
|
||||||
) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Convert a raw token quantity to its equivalent lovelace value.
|
|
||||||
|
|
||||||
Uses the token's ADA price from DexHunter and accounts for decimal
|
|
||||||
precision so that, for example, 1,000,000 units of a 6-decimal token
|
|
||||||
equals 1.0 whole token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
policy_id: Token policy ID.
|
|
||||||
asset_name_hex: Token asset name as hex (e.g. "534e454b" for SNEK).
|
|
||||||
token_quantity: Raw on-chain token quantity (base units, not decimal-adjusted).
|
|
||||||
token_decimals: Number of decimal places for the token (default 0).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Equivalent lovelace as an integer, or None if price is unavailable.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
# NIGHT token at 0.001 ADA/NIGHT, 6 decimals
|
|
||||||
# quantity = 5_000_000 (= 5.0 NIGHT), price = 0.001 ADA/token
|
|
||||||
# → 5.0 * 0.001 ADA = 0.005 ADA = 5,000 lovelace
|
|
||||||
>>> await convert_token_to_lovelace(policy_id, asset_name_hex, 5_000_000, 6)
|
|
||||||
5000
|
|
||||||
"""
|
|
||||||
if token_quantity <= 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# ADA is always 1:1 with itself in lovelace terms
|
|
||||||
if not policy_id and not asset_name_hex:
|
|
||||||
return token_quantity # already in lovelace
|
|
||||||
|
|
||||||
token_ada_price = await get_token_ada_price(policy_id, asset_name_hex)
|
|
||||||
if token_ada_price is None:
|
|
||||||
logger.warning(
|
|
||||||
"[cardano_price] Cannot convert token to lovelace — no price for %s%s",
|
|
||||||
policy_id[:12],
|
|
||||||
asset_name_hex[:8],
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Adjust for decimals: base_units / 10^decimals = whole tokens
|
|
||||||
whole_tokens = token_quantity / (10 ** token_decimals)
|
|
||||||
|
|
||||||
# Whole tokens × ADA per token × lovelace per ADA
|
|
||||||
lovelace = int(whole_tokens * token_ada_price * 1_000_000)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"[cardano_price] %d base units (decimals=%d) → %.6f tokens × %.8f ADA → %d lovelace",
|
|
||||||
token_quantity,
|
|
||||||
token_decimals,
|
|
||||||
whole_tokens,
|
|
||||||
token_ada_price,
|
|
||||||
lovelace,
|
|
||||||
)
|
|
||||||
return lovelace
|
|
||||||
|
|
@ -1,465 +1,167 @@
|
||||||
|
"""APScheduler integration for the Cardano payment monitoring loop.
|
||||||
|
|
||||||
|
The scheduler drives two jobs against a consumer-supplied
|
||||||
|
:class:`~cardano_checkout.store.InvoiceStore`:
|
||||||
|
|
||||||
|
- :func:`cardano_checkout.monitor.check_pending_invoices` — every 15 seconds
|
||||||
|
- :func:`cardano_checkout.monitor.reprice_expired_invoices` — every 60 seconds
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from cardano_checkout.scheduler import InvoiceScheduler
|
||||||
|
from my_app.store import MySqlInvoiceStore
|
||||||
|
|
||||||
|
scheduler = InvoiceScheduler(store=MySqlInvoiceStore())
|
||||||
|
await scheduler.start()
|
||||||
|
# ... app runs ...
|
||||||
|
await scheduler.stop()
|
||||||
"""
|
"""
|
||||||
Cardano Payment Monitoring Scheduler
|
|
||||||
|
|
||||||
APScheduler integration that runs five recurring jobs:
|
from __future__ import annotations
|
||||||
- check_pending_payments — every 15 seconds
|
|
||||||
- reprice_expired_payments — every 60 seconds
|
|
||||||
- _check_subscription_payments — every 60 seconds
|
|
||||||
- _reprice_subscription_payments — every 6 hours
|
|
||||||
- _enforce_grace_period — daily at 06:00 UTC
|
|
||||||
|
|
||||||
Usage in main.py lifespan:
|
|
||||||
from services.cardano_scheduler import start_cardano_scheduler, stop_cardano_scheduler
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
await start_cardano_scheduler()
|
|
||||||
yield
|
|
||||||
await stop_cardano_scheduler()
|
|
||||||
"""
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from dataclasses import dataclass, field
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from database import async_session_maker
|
from cardano_checkout.monitor import (
|
||||||
from services.cardano_monitor import (
|
DEFAULT_MAX_REPRICINGS,
|
||||||
_check_address_utxos,
|
DEFAULT_PAYMENT_WINDOW_MINUTES,
|
||||||
_evaluate_payment,
|
KOIOS_URL,
|
||||||
check_pending_payments,
|
PriceFn,
|
||||||
reprice_expired_payments,
|
check_pending_invoices,
|
||||||
|
reprice_expired_invoices,
|
||||||
)
|
)
|
||||||
from services.cardano_price import convert_usd_to_lovelace, get_ada_usd_price
|
from cardano_checkout.store import InvoiceStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_scheduler: Optional[AsyncIOScheduler] = None
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InvoiceScheduler:
|
||||||
|
"""APScheduler harness around the monitor loop.
|
||||||
|
|
||||||
# =============================================================================
|
Attributes:
|
||||||
# Scheduled job wrappers — invoice payments
|
store: Persistence backend. Required.
|
||||||
# =============================================================================
|
koios_url: Chain-query endpoint. Override for testnet / custom gateways.
|
||||||
|
check_interval_seconds: How often to poll Koios for pending invoices.
|
||||||
async def _job_check_pending() -> None:
|
Defaults to 15.
|
||||||
"""Scheduler wrapper for check_pending_payments."""
|
reprice_interval_seconds: How often to sweep for expired invoices.
|
||||||
try:
|
Defaults to 60.
|
||||||
async with async_session_maker() as db:
|
payment_window_minutes: Re-expiry window when repricing.
|
||||||
await check_pending_payments(db)
|
max_repricings: How many times an invoice can reprice before giving up.
|
||||||
except Exception:
|
limit: Max invoices examined per poll cycle.
|
||||||
logger.exception("[cardano-scheduler] check_pending_payments job failed")
|
job_id_prefix: Scheduler job-id namespace. Override if running multiple
|
||||||
|
``InvoiceScheduler`` instances in one process.
|
||||||
|
|
||||||
async def _job_reprice_expired() -> None:
|
|
||||||
"""Scheduler wrapper for reprice_expired_payments."""
|
|
||||||
try:
|
|
||||||
async with async_session_maker() as db:
|
|
||||||
await reprice_expired_payments(db)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[cardano-scheduler] reprice_expired_payments job failed")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Scheduled job wrappers — subscription payments
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
async def _job_check_subscription_payments() -> None:
|
|
||||||
"""
|
"""
|
||||||
Poll Koios for UTXOs at awaiting_payment subscription addresses.
|
|
||||||
|
|
||||||
Reuses _check_address_utxos and _evaluate_payment from cardano_monitor.
|
store: InvoiceStore
|
||||||
On confirmation, advances subscription status to 'active' and updates
|
price_fn: Optional[PriceFn] = None
|
||||||
company.subscription_tier to match the subscription's tier.
|
koios_url: str = KOIOS_URL
|
||||||
"""
|
check_interval_seconds: int = 15
|
||||||
from models import Company, Subscription, SubscriptionPayment
|
reprice_interval_seconds: int = 60
|
||||||
|
payment_window_minutes: int = DEFAULT_PAYMENT_WINDOW_MINUTES
|
||||||
|
max_repricings: int = DEFAULT_MAX_REPRICINGS
|
||||||
|
limit: int = 100
|
||||||
|
job_id_prefix: str = "cardano_checkout"
|
||||||
|
_scheduler: Optional[AsyncIOScheduler] = field(default=None, init=False, repr=False)
|
||||||
|
|
||||||
|
async def _job_check_pending(self) -> None:
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as db:
|
await check_pending_invoices(
|
||||||
now = datetime.now(timezone.utc)
|
self.store, koios_url=self.koios_url, limit=self.limit
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(SubscriptionPayment).where(
|
|
||||||
SubscriptionPayment.status.in_(["awaiting_payment", "underpaid"]),
|
|
||||||
SubscriptionPayment.expires_at > now,
|
|
||||||
)
|
)
|
||||||
)
|
except Exception:
|
||||||
payments = result.scalars().all()
|
|
||||||
|
|
||||||
if not payments:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"[cardano-scheduler] Checking %d subscription payment(s)", len(payments)
|
|
||||||
)
|
|
||||||
|
|
||||||
for sp in payments:
|
|
||||||
try:
|
|
||||||
utxos = await _check_address_utxos(sp.address)
|
|
||||||
|
|
||||||
# _evaluate_payment expects a CardanoPayment-like object.
|
|
||||||
# SubscriptionPayment has the same fields it needs.
|
|
||||||
new_status, raw_lovelace, total_value, received_assets, tx_hash = (
|
|
||||||
await _evaluate_payment(sp, utxos)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Map monitor statuses to subscription payment statuses
|
|
||||||
status_map = {
|
|
||||||
"pending": "awaiting_payment",
|
|
||||||
"confirmed": "confirmed",
|
|
||||||
"overpaid": "overpaid",
|
|
||||||
"underpaid": "underpaid",
|
|
||||||
}
|
|
||||||
mapped_status = status_map.get(new_status, new_status)
|
|
||||||
|
|
||||||
if mapped_status == sp.status and raw_lovelace == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sp.received_lovelace = raw_lovelace
|
|
||||||
sp.total_value_lovelace = total_value
|
|
||||||
sp.received_assets = received_assets
|
|
||||||
|
|
||||||
if tx_hash:
|
|
||||||
sp.tx_hash = tx_hash
|
|
||||||
|
|
||||||
if mapped_status != sp.status:
|
|
||||||
old_status = sp.status
|
|
||||||
sp.status = mapped_status
|
|
||||||
|
|
||||||
if mapped_status in ("confirmed", "overpaid"):
|
|
||||||
sp.confirmed_at = now
|
|
||||||
|
|
||||||
# Advance subscription + company tier
|
|
||||||
if sp.subscription_id:
|
|
||||||
sub_result = await db.execute(
|
|
||||||
select(Subscription).where(
|
|
||||||
Subscription.id == sp.subscription_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sub = sub_result.scalar_one_or_none()
|
|
||||||
if sub:
|
|
||||||
sub.status = "active"
|
|
||||||
sub.updated_at = now
|
|
||||||
|
|
||||||
# Apply any pending tier downgrade
|
|
||||||
if sub.pending_tier and sp.period_end and sp.period_end <= now.date():
|
|
||||||
sub.tier = sub.pending_tier
|
|
||||||
sub.pending_tier = None
|
|
||||||
sub.pending_tier_at = None
|
|
||||||
|
|
||||||
company_result = await db.execute(
|
|
||||||
select(Company).where(Company.id == sp.company_id)
|
|
||||||
)
|
|
||||||
company = company_result.scalar_one_or_none()
|
|
||||||
if company:
|
|
||||||
# Get current sub tier
|
|
||||||
sub_result2 = await db.execute(
|
|
||||||
select(Subscription).where(
|
|
||||||
Subscription.company_id == sp.company_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sub2 = sub_result2.scalar_one_or_none()
|
|
||||||
if sub2:
|
|
||||||
company.subscription_tier = sub2.tier
|
|
||||||
company.subscription_status = "active"
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[cardano-scheduler] sub_payment #%d company_id=%d: %s -> %s"
|
|
||||||
" (%.6f ADA received)",
|
|
||||||
sp.id,
|
|
||||||
sp.company_id,
|
|
||||||
old_status,
|
|
||||||
mapped_status,
|
|
||||||
raw_lovelace / 1_000_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"[cardano-scheduler] Error checking sub_payment #%d: %s", sp.id, e
|
"[cardano-scheduler] check_pending_invoices job failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.commit()
|
async def _job_reprice_expired(self) -> None:
|
||||||
|
if self.price_fn is None:
|
||||||
except Exception:
|
# No oracle wired — skip repricing. Fixed-ADA invoices don't
|
||||||
logger.exception("[cardano-scheduler] _check_subscription_payments job failed")
|
# need one.
|
||||||
|
|
||||||
|
|
||||||
async def _job_reprice_subscription_payments() -> None:
|
|
||||||
"""
|
|
||||||
Reprice awaiting_payment subscription records whose 24-hour window has expired.
|
|
||||||
|
|
||||||
Fetches the current ADA price, recalculates expected_lovelace, resets expires_at
|
|
||||||
to now + 24 hours, and increments repriced_count. Gives up after 3 repricings.
|
|
||||||
"""
|
|
||||||
from models import SubscriptionPayment
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with async_session_maker() as db:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(SubscriptionPayment).where(
|
|
||||||
SubscriptionPayment.status == "awaiting_payment",
|
|
||||||
SubscriptionPayment.expires_at <= now,
|
|
||||||
SubscriptionPayment.repriced_count < 3,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
payments = result.scalars().all()
|
|
||||||
|
|
||||||
if not payments:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[cardano-scheduler] Repricing %d subscription payment(s)", len(payments)
|
|
||||||
)
|
|
||||||
|
|
||||||
ada_price = await get_ada_usd_price()
|
|
||||||
if ada_price <= 0:
|
|
||||||
logger.warning(
|
|
||||||
"[cardano-scheduler] Cannot reprice subscriptions — ADA price unavailable"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
new_expires_at = now + timedelta(hours=24)
|
|
||||||
|
|
||||||
for sp in payments:
|
|
||||||
try:
|
try:
|
||||||
total_usd = float(sp.expected_usd or 0)
|
await reprice_expired_invoices(
|
||||||
if total_usd <= 0:
|
self.store,
|
||||||
sp.status = "expired"
|
price_fn=self.price_fn,
|
||||||
logger.warning(
|
window_minutes=self.payment_window_minutes,
|
||||||
"[cardano-scheduler] sub_payment #%d has no expected_usd — expired",
|
max_repricings=self.max_repricings,
|
||||||
sp.id,
|
limit=self.limit,
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
|
|
||||||
new_lovelace = await convert_usd_to_lovelace(total_usd)
|
|
||||||
if new_lovelace == 0:
|
|
||||||
logger.warning(
|
|
||||||
"[cardano-scheduler] sub_payment #%d: lovelace conversion returned 0, skipping",
|
|
||||||
sp.id,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
old_lovelace = sp.expected_lovelace
|
|
||||||
sp.expected_lovelace = new_lovelace
|
|
||||||
sp.ada_price_usd = Decimal(str(round(ada_price, 4)))
|
|
||||||
sp.expires_at = new_expires_at
|
|
||||||
sp.repriced_count += 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[cardano-scheduler] Repriced sub_payment #%d: %d -> %d lovelace"
|
|
||||||
" (ADA=$%.4f, reprice #%d)",
|
|
||||||
sp.id,
|
|
||||||
old_lovelace or 0,
|
|
||||||
new_lovelace,
|
|
||||||
ada_price,
|
|
||||||
sp.repriced_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
"[cardano-scheduler] Error repricing sub_payment #%d: %s", sp.id, e
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mark max-repriced payments as expired
|
|
||||||
expired_result = await db.execute(
|
|
||||||
select(SubscriptionPayment).where(
|
|
||||||
SubscriptionPayment.status == "awaiting_payment",
|
|
||||||
SubscriptionPayment.expires_at <= now,
|
|
||||||
SubscriptionPayment.repriced_count >= 3,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for sp in expired_result.scalars().all():
|
|
||||||
sp.status = "expired"
|
|
||||||
logger.info(
|
|
||||||
"[cardano-scheduler] sub_payment #%d expired after %d repricings",
|
|
||||||
sp.id,
|
|
||||||
sp.repriced_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[cardano-scheduler] _reprice_subscription_payments job failed")
|
|
||||||
|
|
||||||
|
|
||||||
async def _job_enforce_grace_period() -> None:
|
|
||||||
"""
|
|
||||||
Daily enforcement of subscription grace periods (runs at 06:00 UTC).
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- If due_date has passed and payment is not confirmed: mark subscription past_due
|
|
||||||
- If grace_deadline has passed and payment still not confirmed: suspend subscription
|
|
||||||
and update company.subscription_status accordingly
|
|
||||||
"""
|
|
||||||
from models import Company, Subscription, SubscriptionPayment
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with async_session_maker() as db:
|
|
||||||
today = datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
# Find subscriptions that are active/past_due and have an overdue payment
|
|
||||||
overdue_result = await db.execute(
|
|
||||||
select(SubscriptionPayment).where(
|
|
||||||
SubscriptionPayment.status.in_(["awaiting_payment", "underpaid", "expired"]),
|
|
||||||
SubscriptionPayment.due_date < today,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
overdue_payments = overdue_result.scalars().all()
|
|
||||||
|
|
||||||
for sp in overdue_payments:
|
|
||||||
try:
|
|
||||||
sub_result = await db.execute(
|
|
||||||
select(Subscription).where(
|
|
||||||
Subscription.company_id == sp.company_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sub = sub_result.scalar_one_or_none()
|
|
||||||
if not sub or sub.status in ("cancelled", "suspended"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
company_result = await db.execute(
|
|
||||||
select(Company).where(Company.id == sp.company_id)
|
|
||||||
)
|
|
||||||
company = company_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# Grace deadline passed → suspend
|
|
||||||
if sp.grace_deadline and today > sp.grace_deadline:
|
|
||||||
if sub.status != "suspended":
|
|
||||||
sub.status = "suspended"
|
|
||||||
sub.updated_at = datetime.now(timezone.utc)
|
|
||||||
if company:
|
|
||||||
company.subscription_status = "suspended"
|
|
||||||
logger.info(
|
|
||||||
"[cardano-scheduler] company_id=%d subscription suspended"
|
|
||||||
" (grace deadline %s passed)",
|
|
||||||
sp.company_id,
|
|
||||||
sp.grace_deadline,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Due date passed but within grace → mark past_due
|
|
||||||
elif sub.status == "active":
|
|
||||||
sub.status = "past_due"
|
|
||||||
sub.updated_at = datetime.now(timezone.utc)
|
|
||||||
if company:
|
|
||||||
company.subscription_status = "past_due"
|
|
||||||
logger.info(
|
|
||||||
"[cardano-scheduler] company_id=%d subscription past_due"
|
|
||||||
" (due_date %s passed)",
|
|
||||||
sp.company_id,
|
|
||||||
sp.due_date,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"[cardano-scheduler] Error enforcing grace period for sub_payment #%d: %s",
|
"[cardano-scheduler] reprice_expired_invoices job failed"
|
||||||
sp.id,
|
|
||||||
e,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.commit()
|
async def start(self) -> None:
|
||||||
|
"""Start the scheduler. Safe to call repeatedly."""
|
||||||
except Exception:
|
if self._scheduler and self._scheduler.running:
|
||||||
logger.exception("[cardano-scheduler] _enforce_grace_period job failed")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Lifecycle
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
async def start_cardano_scheduler() -> None:
|
|
||||||
"""
|
|
||||||
Start the Cardano payment monitoring scheduler.
|
|
||||||
|
|
||||||
Registers five jobs:
|
|
||||||
- check_pending_payments: every 15 seconds
|
|
||||||
- reprice_expired_payments: every 60 seconds
|
|
||||||
- check_subscription_payments: every 60 seconds
|
|
||||||
- reprice_subscription_payments: every 6 hours
|
|
||||||
- enforce_grace_period: daily at 06:00 UTC
|
|
||||||
|
|
||||||
Safe to call multiple times — skips if already running.
|
|
||||||
"""
|
|
||||||
global _scheduler
|
|
||||||
|
|
||||||
if _scheduler and _scheduler.running:
|
|
||||||
logger.debug("[cardano-scheduler] Already running — skipping start")
|
logger.debug("[cardano-scheduler] Already running — skipping start")
|
||||||
return
|
return
|
||||||
|
|
||||||
_scheduler = AsyncIOScheduler()
|
self._scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
_scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
_job_check_pending,
|
self._job_check_pending,
|
||||||
trigger=IntervalTrigger(seconds=15),
|
trigger=IntervalTrigger(seconds=self.check_interval_seconds),
|
||||||
id="cardano_check_pending",
|
id=f"{self.job_id_prefix}_check_pending",
|
||||||
name="Cardano: Check Pending Payments",
|
name="Cardano: Check Pending Invoices",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
_scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
_job_reprice_expired,
|
self._job_reprice_expired,
|
||||||
trigger=IntervalTrigger(seconds=60),
|
trigger=IntervalTrigger(seconds=self.reprice_interval_seconds),
|
||||||
id="cardano_reprice_expired",
|
id=f"{self.job_id_prefix}_reprice_expired",
|
||||||
name="Cardano: Reprice Expired Payments",
|
name="Cardano: Reprice Expired Invoices",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
_scheduler.add_job(
|
self._scheduler.start()
|
||||||
_job_check_subscription_payments,
|
|
||||||
trigger=IntervalTrigger(seconds=60),
|
|
||||||
id="cardano_check_sub_payments",
|
|
||||||
name="Cardano: Check Subscription Payments",
|
|
||||||
replace_existing=True,
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
_scheduler.add_job(
|
|
||||||
_job_reprice_subscription_payments,
|
|
||||||
trigger=IntervalTrigger(hours=6),
|
|
||||||
id="cardano_reprice_sub_payments",
|
|
||||||
name="Cardano: Reprice Subscription Payments",
|
|
||||||
replace_existing=True,
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
_scheduler.add_job(
|
|
||||||
_job_enforce_grace_period,
|
|
||||||
trigger=CronTrigger(hour=6, minute=0, timezone="UTC"),
|
|
||||||
id="cardano_enforce_grace",
|
|
||||||
name="Cardano: Enforce Subscription Grace Periods",
|
|
||||||
replace_existing=True,
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
_scheduler.start()
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[cardano-scheduler] Started — check_pending every 15s, reprice_expired every 60s,"
|
"[cardano-scheduler] Started — check_pending every %ds, reprice_expired every %ds",
|
||||||
" check_sub_payments every 60s, reprice_sub_payments every 6h,"
|
self.check_interval_seconds,
|
||||||
" enforce_grace_period daily at 06:00 UTC"
|
self.reprice_interval_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
async def stop_cardano_scheduler() -> None:
|
"""Stop the scheduler. Idempotent."""
|
||||||
"""
|
if self._scheduler:
|
||||||
Gracefully stop the Cardano scheduler.
|
self._scheduler.shutdown(wait=False)
|
||||||
|
self._scheduler = None
|
||||||
Call this in the FastAPI lifespan shutdown block.
|
|
||||||
"""
|
|
||||||
global _scheduler
|
|
||||||
|
|
||||||
if _scheduler:
|
|
||||||
_scheduler.shutdown(wait=False)
|
|
||||||
_scheduler = None
|
|
||||||
logger.info("[cardano-scheduler] Stopped")
|
logger.info("[cardano-scheduler] Stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Free-function API around a module-level default instance.
|
||||||
|
# Prefer the InvoiceScheduler class for anything nontrivial.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_default: Optional[InvoiceScheduler] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def start_cardano_scheduler(
|
||||||
|
store: InvoiceStore, **kwargs
|
||||||
|
) -> InvoiceScheduler: # pragma: no cover — convenience shim
|
||||||
|
"""Start the default :class:`InvoiceScheduler` singleton."""
|
||||||
|
global _default
|
||||||
|
if _default is None:
|
||||||
|
_default = InvoiceScheduler(store=store, **kwargs)
|
||||||
|
await _default.start()
|
||||||
|
return _default
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_cardano_scheduler() -> None: # pragma: no cover — convenience shim
|
||||||
|
"""Stop the default :class:`InvoiceScheduler` singleton."""
|
||||||
|
global _default
|
||||||
|
if _default is not None:
|
||||||
|
await _default.stop()
|
||||||
|
_default = None
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
"""Persistence abstraction for Invoice objects.
|
"""Persistence abstraction for Invoice objects.
|
||||||
|
|
||||||
The SDK does not prescribe a database. Consumers implement
|
The SDK does not prescribe a database. Consumers implement
|
||||||
:class:`InvoiceStore` against whatever backend suits them — SQLAlchemy
|
:class:`InvoiceStore` against whatever backend suits them — SQLAlchemy,
|
||||||
(the host app pattern), SQLite (example-studio pattern), Postgres raw
|
asyncpg, SQLite, in-memory dict.
|
||||||
(high-volume pattern), in-memory dict (tests).
|
|
||||||
|
|
||||||
All methods are async so the same Protocol works cleanly for both
|
All methods are async so the same Protocol works for both
|
||||||
asyncpg/asyncio-sqlalchemy backends and synchronous backends wrapped
|
asyncpg/asyncio-sqlalchemy backends and synchronous backends wrapped
|
||||||
with ``asyncio.to_thread``.
|
with ``asyncio.to_thread``.
|
||||||
|
|
||||||
|
Also ships :class:`InMemoryStore` — a reference implementation used by
|
||||||
|
the test suite and useful for local development.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Protocol, runtime_checkable
|
from typing import Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
from cardano_checkout.invoice import Invoice, InvoiceStatus
|
from cardano_checkout.invoice import Invoice, InvoiceStatus
|
||||||
|
|
@ -67,3 +72,83 @@ class InvoiceStore(Protocol):
|
||||||
state.
|
state.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reference implementation: InMemoryStore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InMemoryStore:
|
||||||
|
"""In-memory :class:`InvoiceStore` — intended for tests and local dev.
|
||||||
|
|
||||||
|
Uses an ``asyncio.Lock`` around mutating operations so concurrent callers
|
||||||
|
see a consistent view. Objects are deep-copied on read so callers can't
|
||||||
|
mutate the stored state by accident.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_invoices: dict[str, Invoice] = field(default_factory=dict)
|
||||||
|
_tx_log: dict[tuple[str, str], int] = field(default_factory=dict)
|
||||||
|
_index_counters: dict[str, int] = field(default_factory=dict)
|
||||||
|
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
|
|
||||||
|
async def create(self, invoice: Invoice) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
if invoice.id in self._invoices:
|
||||||
|
raise ValueError(f"Invoice {invoice.id!r} already exists")
|
||||||
|
self._invoices[invoice.id] = copy.deepcopy(invoice)
|
||||||
|
# Bump the per-merchant index cursor so next_derivation_index doesn't
|
||||||
|
# hand out the same slot again.
|
||||||
|
cur = self._index_counters.get(invoice.merchant_id, -1)
|
||||||
|
if invoice.derivation_index > cur:
|
||||||
|
self._index_counters[invoice.merchant_id] = invoice.derivation_index
|
||||||
|
|
||||||
|
async def get(self, invoice_id: str) -> Optional[Invoice]:
|
||||||
|
async with self._lock:
|
||||||
|
stored = self._invoices.get(invoice_id)
|
||||||
|
return copy.deepcopy(stored) if stored else None
|
||||||
|
|
||||||
|
async def list_by_status(
|
||||||
|
self, status: InvoiceStatus, limit: int = 100
|
||||||
|
) -> list[Invoice]:
|
||||||
|
async with self._lock:
|
||||||
|
matching = [
|
||||||
|
copy.deepcopy(inv)
|
||||||
|
for inv in self._invoices.values()
|
||||||
|
if inv.status == status
|
||||||
|
]
|
||||||
|
# Newest first per contract.
|
||||||
|
matching.sort(key=lambda inv: inv.created_at, reverse=True)
|
||||||
|
return matching[:limit]
|
||||||
|
|
||||||
|
async def update(self, invoice: Invoice) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
if invoice.id not in self._invoices:
|
||||||
|
raise KeyError(f"Invoice {invoice.id!r} not found")
|
||||||
|
self._invoices[invoice.id] = copy.deepcopy(invoice)
|
||||||
|
|
||||||
|
async def next_derivation_index(self, merchant_id: str) -> int:
|
||||||
|
async with self._lock:
|
||||||
|
nxt = self._index_counters.get(merchant_id, -1) + 1
|
||||||
|
self._index_counters[merchant_id] = nxt
|
||||||
|
return nxt
|
||||||
|
|
||||||
|
async def record_tx(
|
||||||
|
self, invoice_id: str, tx_hash: str, lovelace_delta: int
|
||||||
|
) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
# Idempotent: (invoice_id, tx_hash) overwrites the delta rather than
|
||||||
|
# adding it a second time — see InvoiceStore.record_tx contract.
|
||||||
|
self._tx_log[(invoice_id, tx_hash)] = lovelace_delta
|
||||||
|
inv = self._invoices.get(invoice_id)
|
||||||
|
if inv and tx_hash not in inv.tx_hashes:
|
||||||
|
inv.tx_hashes.append(tx_hash)
|
||||||
|
|
||||||
|
# --- test helpers (not part of the Protocol) ---
|
||||||
|
|
||||||
|
def _all(self) -> list[Invoice]:
|
||||||
|
return [copy.deepcopy(inv) for inv in self._invoices.values()]
|
||||||
|
|
||||||
|
def _tx_records(self) -> dict[tuple[str, str], int]:
|
||||||
|
return dict(self._tx_log)
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""Transaction construction helpers wrapping PyCardano.
|
|
||||||
|
|
||||||
v0.1.0 surface stub — the example-studio Phase-2 sprint will fill these in
|
|
||||||
with:
|
|
||||||
- Ogmios-backed `ChainContext` (via PyCardano's OgmiosChainContext)
|
|
||||||
- Build-transaction helpers for (a) plain ADA payment refunds, (b)
|
|
||||||
native-token mint+send, (c) reference-asset clones
|
|
||||||
- Cold-signer hand-off shape matching an external cold-signer payout pattern:
|
|
||||||
build_body_on_hot → transfer via temp dir → sign_offline_on_cold →
|
|
||||||
return signed_witness → submit_from_hot.
|
|
||||||
|
|
||||||
Exists as a named module in v0.1 so consumers can import the stable path
|
|
||||||
without having to update imports later.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def _v0_2_sentinel(_name: str) -> None:
|
|
||||||
raise NotImplementedError(
|
|
||||||
f"txbuild.{_name} ships in cardano-checkout v0.2 alongside the "
|
|
||||||
"Ogmios chain-context wiring and cold-signer hand-off."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_mint_tx(*args, **kwargs): # noqa: D401, ANN002, ANN003
|
|
||||||
"""Build an unsigned mint transaction. v0.2."""
|
|
||||||
_v0_2_sentinel("build_mint_tx")
|
|
||||||
|
|
||||||
|
|
||||||
def build_payment_tx(*args, **kwargs): # noqa: D401, ANN002, ANN003
|
|
||||||
"""Build an unsigned payment transaction (e.g. refund path). v0.2."""
|
|
||||||
_v0_2_sentinel("build_payment_tx")
|
|
||||||
|
|
||||||
|
|
||||||
def submit_signed_tx(*args, **kwargs): # noqa: D401, ANN002, ANN003
|
|
||||||
"""Submit a signed tx to Ogmios. v0.2."""
|
|
||||||
_v0_2_sentinel("submit_signed_tx")
|
|
||||||
|
|
@ -4,19 +4,19 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "cardano-checkout"
|
name = "cardano-checkout"
|
||||||
version = "0.1.0.dev0"
|
version = "1.0.0.dev0"
|
||||||
description = "Merchant-side Cardano payments SDK + NFT cert-of-authenticity minting (zero-custody)"
|
description = "Merchant-side Cardano payment lifecycle (zero-custody). Ships the invoice + UTxO-watcher + reprice state machine. Use pycardano directly for Cardano primitives."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "AGPL-3.0-or-later"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Sulkta Coop"},
|
{name = "Sulkta Coop"},
|
||||||
]
|
]
|
||||||
keywords = ["cardano", "payments", "nft", "checkout", "blockchain", "ada", "pycardano"]
|
keywords = ["cardano", "payments", "checkout", "invoice", "utxo", "zero-custody", "merchant", "ada"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
|
@ -26,7 +26,6 @@ classifiers = [
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pycardano>=0.11.0",
|
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"apscheduler>=3.10",
|
"apscheduler>=3.10",
|
||||||
]
|
]
|
||||||
|
|
@ -37,7 +36,7 @@ test = ["pytest>=7", "pytest-asyncio>=0.23"]
|
||||||
dev = ["pytest>=7", "pytest-asyncio>=0.23", "ruff", "mypy"]
|
dev = ["pytest>=7", "pytest-asyncio>=0.23", "ruff", "mypy"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "http://git.sulkta.com/Sulkta-Coop/cardano-checkout-py"
|
Repository = "https://git.sulkta.com/Sulkta-Coop/cardano-checkout-py"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["cardano_checkout*"]
|
include = ["cardano_checkout*"]
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
"""Deterministic address-derivation smoke test.
|
|
||||||
|
|
||||||
Uses a known test-vector xpub (the one shipped in the pycardano docs)
|
|
||||||
to assert the derived addresses are stable and reproducible across SDK
|
|
||||||
versions. If this test ever changes output, we have a backwards-compat
|
|
||||||
problem that would break every merchant's receive-address history.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cardano_checkout import addresses
|
|
||||||
|
|
||||||
|
|
||||||
# Public test vector — a CIP-1852 account extended public key.
|
|
||||||
# 64 bytes = 32 bytes Ed25519 pubkey || 32 bytes chain code, hex encoded.
|
|
||||||
# This particular key is drawn from pycardano's own test suite fixtures.
|
|
||||||
TEST_XPUB_HEX = (
|
|
||||||
"38a12b5a4e59f98810a0d3e00edee1e32f74fb93e3f8bdbb0a04b83e2eaa63bd"
|
|
||||||
"9ed15e2c9e99b8d21ef1d3f9c8b3e4cbf95b7f16dcc5ba6c7d58ec84f7123456"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_xpub_accepts_well_formed_key() -> None:
|
|
||||||
assert addresses.validate_xpub(TEST_XPUB_HEX) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_xpub_rejects_empty_and_junk() -> None:
|
|
||||||
assert addresses.validate_xpub("") is False
|
|
||||||
assert addresses.validate_xpub("notreallyhex!!") is False
|
|
||||||
assert addresses.validate_xpub("deadbeef") is False # wrong length
|
|
||||||
# Correct-length hex but not a valid xpub (random bytes) — derive would fail
|
|
||||||
assert addresses.validate_xpub("aa" * 64) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_address_is_deterministic() -> None:
|
|
||||||
a0 = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
|
|
||||||
a0_again = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
|
|
||||||
assert a0 == a0_again
|
|
||||||
assert a0.startswith("addr1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_address_distinct_per_index() -> None:
|
|
||||||
a0 = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
|
|
||||||
a1 = addresses.derive_address(TEST_XPUB_HEX, index=1, network="mainnet")
|
|
||||||
a42 = addresses.derive_address(TEST_XPUB_HEX, index=42, network="mainnet")
|
|
||||||
assert a0 != a1 != a42
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_address_network_switch_changes_prefix() -> None:
|
|
||||||
mainnet = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
|
|
||||||
testnet = addresses.derive_address(TEST_XPUB_HEX, index=0, network="testnet")
|
|
||||||
assert mainnet.startswith("addr1")
|
|
||||||
assert testnet.startswith("addr_test1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_address_rejects_negative_index() -> None:
|
|
||||||
with pytest.raises(ValueError, match="non-negative"):
|
|
||||||
addresses.derive_address(TEST_XPUB_HEX, index=-1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_address_rejects_bad_network() -> None:
|
|
||||||
with pytest.raises(ValueError, match="Invalid network"):
|
|
||||||
addresses.derive_address(TEST_XPUB_HEX, index=0, network="preprod")
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"""CIP-25 v2 metadata envelope construction — pure unit tests, no network."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from cardano_checkout.mint import build_cip25_metadata
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_envelope_shape() -> None:
|
|
||||||
md = build_cip25_metadata(
|
|
||||||
policy_id="abc123",
|
|
||||||
asset_name="ExampleStudio-Order-0001",
|
|
||||||
name="Example Studio — Custom Order #0001",
|
|
||||||
image_cid="bafybeibgen",
|
|
||||||
description="Hand-stitched moth pendant",
|
|
||||||
properties={"order_id": "0001", "edition": "1 of 1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert md["721"]["version"] == "2.0"
|
|
||||||
assert "abc123" in md["721"]
|
|
||||||
|
|
||||||
nft = md["721"]["abc123"]["ExampleStudio-Order-0001"]
|
|
||||||
assert nft["name"] == "Example Studio — Custom Order #0001"
|
|
||||||
assert nft["image"] == "ipfs://bafybeibgen"
|
|
||||||
assert nft["mediaType"] == "image/jpeg"
|
|
||||||
assert nft["description"] == "Hand-stitched moth pendant"
|
|
||||||
assert nft["order_id"] == "0001"
|
|
||||||
assert nft["edition"] == "1 of 1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_description_under_64_chars_stays_a_string() -> None:
|
|
||||||
md = build_cip25_metadata(
|
|
||||||
policy_id="abc", asset_name="x", name="n",
|
|
||||||
image_cid="c", description="short",
|
|
||||||
)
|
|
||||||
assert md["721"]["abc"]["x"]["description"] == "short"
|
|
||||||
|
|
||||||
|
|
||||||
def test_description_over_64_chars_chunks_to_list() -> None:
|
|
||||||
long = "x" * 150
|
|
||||||
md = build_cip25_metadata(
|
|
||||||
policy_id="abc", asset_name="x", name="n",
|
|
||||||
image_cid="c", description=long,
|
|
||||||
)
|
|
||||||
desc = md["721"]["abc"]["x"]["description"]
|
|
||||||
assert isinstance(desc, list)
|
|
||||||
assert all(len(chunk) <= 64 for chunk in desc)
|
|
||||||
assert "".join(desc) == long
|
|
||||||
|
|
||||||
|
|
||||||
def test_image_uri_has_ipfs_prefix() -> None:
|
|
||||||
md = build_cip25_metadata(
|
|
||||||
policy_id="abc", asset_name="x", name="n",
|
|
||||||
image_cid="bafybeitestcid",
|
|
||||||
)
|
|
||||||
assert md["721"]["abc"]["x"]["image"].startswith("ipfs://")
|
|
||||||
assert "bafybeitestcid" in md["721"]["abc"]["x"]["image"]
|
|
||||||
|
|
@ -10,7 +10,7 @@ from cardano_checkout.invoice import Invoice, InvoiceStatus
|
||||||
def _make() -> Invoice:
|
def _make() -> Invoice:
|
||||||
return Invoice(
|
return Invoice(
|
||||||
id="inv_001",
|
id="inv_001",
|
||||||
merchant_id="example-studio",
|
merchant_id="acme",
|
||||||
derivation_index=0,
|
derivation_index=0,
|
||||||
receive_address="addr1...",
|
receive_address="addr1...",
|
||||||
expected_lovelace=5_000_000, # 5 ADA
|
expected_lovelace=5_000_000, # 5 ADA
|
||||||
|
|
|
||||||
250
tests/test_monitor_with_inmemory_store.py
Normal file
250
tests/test_monitor_with_inmemory_store.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
"""Monitor integration tests against InMemoryStore.
|
||||||
|
|
||||||
|
Stubs both Koios (via monkeypatch on ``check_address_utxos``) and the
|
||||||
|
ADA/USD oracle so the tests are deterministic and offline. Exercises
|
||||||
|
the main state transitions the scheduler cares about:
|
||||||
|
|
||||||
|
- PENDING → CONFIRMED when UTxOs satisfy the 98% threshold
|
||||||
|
- PENDING → OVERPAID when UTxOs exceed the 102% threshold
|
||||||
|
- PENDING → UNDERPAID when UTxOs are nonzero but below threshold
|
||||||
|
- PENDING (kept) when no UTxOs yet
|
||||||
|
- Reprice of an expired invoice → new expected_lovelace + new expires_at
|
||||||
|
- Reprice hitting max_repricings → EXPIRED
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus
|
||||||
|
from cardano_checkout import monitor
|
||||||
|
|
||||||
|
|
||||||
|
def _make(
|
||||||
|
id_: str = "inv",
|
||||||
|
expected_lovelace: int = 5_000_000,
|
||||||
|
expires_in_minutes: int = 15,
|
||||||
|
status: InvoiceStatus = InvoiceStatus.PENDING,
|
||||||
|
repriced_count: int = 0,
|
||||||
|
) -> Invoice:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return Invoice(
|
||||||
|
id=id_,
|
||||||
|
merchant_id="acme",
|
||||||
|
derivation_index=0,
|
||||||
|
receive_address="addr1testreceive",
|
||||||
|
expected_lovelace=expected_lovelace,
|
||||||
|
usd_amount=2.50,
|
||||||
|
status=status,
|
||||||
|
expires_at=now + timedelta(minutes=expires_in_minutes),
|
||||||
|
metadata={"repriced_count": repriced_count} if repriced_count else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utxo(lovelace: int, tx_hash: str = "aa" * 32) -> dict:
|
||||||
|
return {
|
||||||
|
"tx_hash": tx_hash,
|
||||||
|
"tx_index": 0,
|
||||||
|
"value": str(lovelace),
|
||||||
|
"asset_list": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _patch_koios(monkeypatch):
|
||||||
|
"""Default: Koios returns no UTxOs. Individual tests override."""
|
||||||
|
|
||||||
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def price_fn_at_45c():
|
||||||
|
"""A deterministic price_fn for tests — USD priced at $0.45/ADA."""
|
||||||
|
async def _convert(usd: float) -> int:
|
||||||
|
if usd <= 0:
|
||||||
|
return 0
|
||||||
|
return int((usd / 0.45) * 1_000_000)
|
||||||
|
return _convert
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def price_fn_zero():
|
||||||
|
"""A price_fn that returns 0 — stand-in for oracle unavailability."""
|
||||||
|
async def _zero(usd: float) -> int:
|
||||||
|
return 0
|
||||||
|
return _zero
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# check_pending_invoices
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_utxos_leaves_invoice_pending() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make())
|
||||||
|
|
||||||
|
updated = await monitor.check_pending_invoices(store)
|
||||||
|
assert updated == 0
|
||||||
|
|
||||||
|
inv = await store.get("inv")
|
||||||
|
assert inv is not None
|
||||||
|
assert inv.status == InvoiceStatus.PENDING
|
||||||
|
assert inv.received_lovelace == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_confirm_within_tolerance(monkeypatch) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make(expected_lovelace=5_000_000))
|
||||||
|
|
||||||
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
||||||
|
# 4.9 ADA — right at the 98% confirm threshold.
|
||||||
|
return [_utxo(4_900_000)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
||||||
|
|
||||||
|
updated = await monitor.check_pending_invoices(store)
|
||||||
|
assert updated == 1
|
||||||
|
|
||||||
|
inv = await store.get("inv")
|
||||||
|
assert inv is not None
|
||||||
|
assert inv.status == InvoiceStatus.CONFIRMED
|
||||||
|
assert inv.received_lovelace == 4_900_000
|
||||||
|
assert inv.confirmed_at is not None
|
||||||
|
assert inv.tx_hashes == ["aa" * 32]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overpaid_flag_when_above_threshold(monkeypatch) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make(expected_lovelace=5_000_000))
|
||||||
|
|
||||||
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
||||||
|
return [_utxo(5_200_000)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
||||||
|
|
||||||
|
await monitor.check_pending_invoices(store)
|
||||||
|
inv = await store.get("inv")
|
||||||
|
assert inv is not None
|
||||||
|
assert inv.status == InvoiceStatus.OVERPAID
|
||||||
|
assert inv.confirmed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_underpaid_when_below_tolerance(monkeypatch) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make(expected_lovelace=5_000_000))
|
||||||
|
|
||||||
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
||||||
|
return [_utxo(3_000_000)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
||||||
|
|
||||||
|
await monitor.check_pending_invoices(store)
|
||||||
|
inv = await store.get("inv")
|
||||||
|
assert inv is not None
|
||||||
|
assert inv.status == InvoiceStatus.UNDERPAID
|
||||||
|
assert inv.received_lovelace == 3_000_000
|
||||||
|
assert inv.confirmed_at is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_record_tx_called_for_observed_hashes(monkeypatch) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make(expected_lovelace=5_000_000))
|
||||||
|
|
||||||
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
||||||
|
return [_utxo(4_900_000, tx_hash="feed" + "00" * 30)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
||||||
|
|
||||||
|
await monitor.check_pending_invoices(store)
|
||||||
|
|
||||||
|
records = store._tx_records()
|
||||||
|
assert any(k[0] == "inv" and k[1] == "feed" + "00" * 30 for k in records)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_already_expired_invoices_are_skipped(monkeypatch) -> None:
|
||||||
|
"""Invoices past their expiry are left for reprice_expired_invoices to handle."""
|
||||||
|
store = InMemoryStore()
|
||||||
|
expired = _make(expected_lovelace=5_000_000)
|
||||||
|
expired.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
|
await store.create(expired)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
||||||
|
calls.append(address)
|
||||||
|
return [_utxo(5_000_000)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
||||||
|
|
||||||
|
await monitor.check_pending_invoices(store)
|
||||||
|
# check_pending_invoices must not have polled Koios for an already-expired invoice.
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reprice_expired_invoices
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reprice_updates_expected_lovelace_and_extends_expiry(price_fn_at_45c) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make(expected_lovelace=5_000_000)
|
||||||
|
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
updated = await monitor.reprice_expired_invoices(
|
||||||
|
store, price_fn=price_fn_at_45c, window_minutes=15, max_repricings=3
|
||||||
|
)
|
||||||
|
assert updated == 1
|
||||||
|
|
||||||
|
fetched = await store.get("inv")
|
||||||
|
assert fetched is not None
|
||||||
|
# USD $2.50 @ $0.45/ADA = 5.555 ADA = 5555555 lovelace.
|
||||||
|
assert fetched.expected_lovelace == 5_555_555
|
||||||
|
assert fetched.status == InvoiceStatus.PENDING
|
||||||
|
assert fetched.expires_at is not None
|
||||||
|
assert fetched.expires_at > datetime.now(timezone.utc)
|
||||||
|
assert fetched.metadata["repriced_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reprice_gives_up_after_max_repricings(price_fn_at_45c) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make(expected_lovelace=5_000_000, repriced_count=3)
|
||||||
|
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
await monitor.reprice_expired_invoices(
|
||||||
|
store, price_fn=price_fn_at_45c, window_minutes=15, max_repricings=3
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = await store.get("inv")
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.status == InvoiceStatus.EXPIRED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reprice_noop_when_nothing_expired(price_fn_at_45c) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make())
|
||||||
|
|
||||||
|
updated = await monitor.reprice_expired_invoices(store, price_fn=price_fn_at_45c)
|
||||||
|
assert updated == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reprice_skips_when_oracle_returns_zero(price_fn_zero) -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make(expected_lovelace=5_000_000)
|
||||||
|
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
updated = await monitor.reprice_expired_invoices(store, price_fn=price_fn_zero)
|
||||||
|
assert updated == 0
|
||||||
|
|
||||||
|
fetched = await store.get("inv")
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.status == InvoiceStatus.PENDING # not flipped to expired
|
||||||
180
tests/test_store_protocol.py
Normal file
180
tests/test_store_protocol.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""InvoiceStore protocol conformance + InMemoryStore round-trips.
|
||||||
|
|
||||||
|
These tests exist to catch regressions in the reference implementation
|
||||||
|
and to document the exact semantics the monitor + scheduler rely on.
|
||||||
|
Any backend consumers implement should pass the same suite (we don't
|
||||||
|
parameterize yet — that lands when we ship the SQLAlchemy adapter).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus, InvoiceStore
|
||||||
|
|
||||||
|
|
||||||
|
def _make_invoice(
|
||||||
|
id_: str = "inv_001",
|
||||||
|
merchant: str = "acme",
|
||||||
|
index: int = 0,
|
||||||
|
status: InvoiceStatus = InvoiceStatus.PENDING,
|
||||||
|
) -> Invoice:
|
||||||
|
return Invoice(
|
||||||
|
id=id_,
|
||||||
|
merchant_id=merchant,
|
||||||
|
derivation_index=index,
|
||||||
|
receive_address=f"addr1...{index}",
|
||||||
|
expected_lovelace=5_000_000,
|
||||||
|
usd_amount=2.50,
|
||||||
|
status=status,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inmemory_store_satisfies_protocol() -> None:
|
||||||
|
"""Runtime isinstance check against the Protocol — catches API drift."""
|
||||||
|
assert isinstance(InMemoryStore(), InvoiceStore)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_then_get_round_trips() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make_invoice()
|
||||||
|
|
||||||
|
await store.create(inv)
|
||||||
|
fetched = await store.get(inv.id)
|
||||||
|
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.id == inv.id
|
||||||
|
assert fetched.merchant_id == inv.merchant_id
|
||||||
|
assert fetched.expected_lovelace == 5_000_000
|
||||||
|
assert fetched.status == InvoiceStatus.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_rejects_duplicate_id() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make_invoice()
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
await store.create(_make_invoice())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_missing_returns_none() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
assert await store.get("does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_persists_state_changes() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make_invoice()
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
inv.status = InvoiceStatus.CONFIRMED
|
||||||
|
inv.received_lovelace = 5_100_000
|
||||||
|
inv.confirmed_at = datetime.now(timezone.utc)
|
||||||
|
await store.update(inv)
|
||||||
|
|
||||||
|
fetched = await store.get(inv.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.status == InvoiceStatus.CONFIRMED
|
||||||
|
assert fetched.received_lovelace == 5_100_000
|
||||||
|
assert fetched.confirmed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_on_missing_raises() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
await store.update(_make_invoice(id_="never-created"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_is_defensive_copy() -> None:
|
||||||
|
"""Mutating a fetched invoice must not change stored state."""
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make_invoice()
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
fetched = await store.get(inv.id)
|
||||||
|
assert fetched is not None
|
||||||
|
fetched.status = InvoiceStatus.CANCELLED
|
||||||
|
fetched.metadata["tampered"] = True
|
||||||
|
|
||||||
|
# Re-fetch — store should still have the original.
|
||||||
|
refetched = await store.get(inv.id)
|
||||||
|
assert refetched is not None
|
||||||
|
assert refetched.status == InvoiceStatus.PENDING
|
||||||
|
assert "tampered" not in refetched.metadata
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_by_status_returns_only_matching() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make_invoice(id_="p1", index=0))
|
||||||
|
await store.create(_make_invoice(id_="p2", index=1))
|
||||||
|
await store.create(
|
||||||
|
_make_invoice(id_="c1", index=2, status=InvoiceStatus.CONFIRMED)
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = await store.list_by_status(InvoiceStatus.PENDING)
|
||||||
|
confirmed = await store.list_by_status(InvoiceStatus.CONFIRMED)
|
||||||
|
expired = await store.list_by_status(InvoiceStatus.EXPIRED)
|
||||||
|
|
||||||
|
assert {inv.id for inv in pending} == {"p1", "p2"}
|
||||||
|
assert {inv.id for inv in confirmed} == {"c1"}
|
||||||
|
assert expired == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_by_status_honours_limit() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
for i in range(5):
|
||||||
|
await store.create(_make_invoice(id_=f"p{i}", index=i))
|
||||||
|
|
||||||
|
results = await store.list_by_status(InvoiceStatus.PENDING, limit=3)
|
||||||
|
assert len(results) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_next_derivation_index_is_monotonic_per_merchant() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
m1 = "acme"
|
||||||
|
m2 = "globex"
|
||||||
|
|
||||||
|
assert await store.next_derivation_index(m1) == 0
|
||||||
|
assert await store.next_derivation_index(m1) == 1
|
||||||
|
assert await store.next_derivation_index(m2) == 0 # independent per merchant
|
||||||
|
assert await store.next_derivation_index(m1) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_bumps_index_cursor_if_higher() -> None:
|
||||||
|
"""If a caller creates an invoice at index N, the next derivation MUST skip past it."""
|
||||||
|
store = InMemoryStore()
|
||||||
|
await store.create(_make_invoice(id_="manual", index=7))
|
||||||
|
|
||||||
|
nxt = await store.next_derivation_index("acme")
|
||||||
|
assert nxt == 8
|
||||||
|
|
||||||
|
|
||||||
|
async def test_record_tx_is_idempotent() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make_invoice()
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
await store.record_tx(inv.id, "deadbeef", 5_000_000)
|
||||||
|
await store.record_tx(inv.id, "deadbeef", 5_000_000)
|
||||||
|
|
||||||
|
fetched = await store.get(inv.id)
|
||||||
|
assert fetched is not None
|
||||||
|
# tx_hashes list should contain the hash once, not twice.
|
||||||
|
assert fetched.tx_hashes.count("deadbeef") == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_record_tx_appends_multiple_distinct_hashes() -> None:
|
||||||
|
store = InMemoryStore()
|
||||||
|
inv = _make_invoice()
|
||||||
|
await store.create(inv)
|
||||||
|
|
||||||
|
await store.record_tx(inv.id, "aaaa", 1_000_000)
|
||||||
|
await store.record_tx(inv.id, "bbbb", 2_000_000)
|
||||||
|
|
||||||
|
fetched = await store.get(inv.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert set(fetched.tx_hashes) == {"aaaa", "bbbb"}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue