diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml
deleted file mode 100644
index 59d79b2..0000000
--- a/.forgejo/workflows/gitleaks.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-# 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
diff --git a/LICENSE b/LICENSE
index be3f7b2..34c43df 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,661 +1,201 @@
- GNU AFFERO GENERAL PUBLIC LICENSE
- Version 3, 19 November 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU Affero General Public License is a free, copyleft license for
-software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-our General Public Licenses are intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.
-
- A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate. Many developers of free software are heartened and
-encouraged by the resulting cooperation. However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.
-
- The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community. It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server. Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.
-
- An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals. This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU Affero General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Remote Network Interaction; Use with the GNU General Public License.
-
- Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software. This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the work with which it is combined will remain governed by version
-3 of the GNU General Public License.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero General Public License from time to time. Such new versions
-will be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU Affero General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU Affero General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU Affero General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-
-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
-.
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ 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
+ 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
+ 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, 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
+ this License, each Contributor hereby grants to You a perpetual,
+ 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
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ 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
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ 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
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for describing the origin of the Work and
+ reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ 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,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Support. While redistributing the Work or
+ Derivative Works thereof, You may choose to offer, and charge a
+ fee for, acceptance of support, warranty, indemnity, or other
+ liability obligations and/or rights consistent with this License.
+ However, in accepting such obligations, You may act only on Your
+ own behalf and on Your sole responsibility, not on behalf of any
+ other Contributor, and only if You agree to indemnify, defend,
+ and hold each Contributor harmless for any liability incurred by,
+ or claims asserted against, such Contributor by reason of your
+ accepting any such warranty or support.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ 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
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied. See the License for the specific language governing
+ permissions and limitations under the License.
diff --git a/README.md b/README.md
index e123d21..ce2cb8a 100644
--- a/README.md
+++ b/README.md
@@ -1,219 +1,220 @@
# cardano-checkout
-Merchant-side Cardano payment lifecycle for Python. Zero-custody.
+Python SDK for merchant-side Cardano payments + NFT certificate-of-authenticity minting.
-`cardano-checkout` ships the invoice state machine, an on-chain UTxO
-watcher, and a quote-reprice loop for accepting ADA payments at
-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.
+**Zero-custody by design:** the merchant provides a wallet xpub. The SDK derives
+unique receive addresses per invoice, polls the chain for payment, and optionally
+mints a CIP-25 NFT cert on confirmation. The platform never holds or moves funds.
-It does **not** reimplement Cardano primitives. Address derivation, chain
-context, transaction building, native-script minting, and signing are all
-[pycardano](https://github.com/Python-Cardano/pycardano)'s job — this
-library slots in next to it and owns only the merchant payment lifecycle.
+Extracted from [the host app](https://git.sulkta.com/example/host-app)'s
+`services/cardano_*.py` modules (2,400+ lines of production code running on the
+Cardano mainnet) and packaged for reuse across the Sulkta Coop product family.
-## Why
+## Status
-Accepting on-chain payments for a shop is mostly bookkeeping, not
-cryptography:
+**v0.2.0-dev — Protocol-first core + live mint path.** Monitor and scheduler
+have been refactored off SQLAlchemy and onto the `InvoiceStore` Protocol.
+Mint builds real transaction bodies against a local Ogmios endpoint and
+returns an `UnsignedMint` for cold-signing.
-- one fresh receive address per order (derived from your wallet xpub),
-- watch the chain until the expected amount lands,
-- decide confirmed / underpaid / overpaid within a tolerance,
-- if the quote window lapses before payment, reprice and try again.
+| Module | Status | Notes |
+|---|---|---|
+| `addresses` | ✅ stable | CIP-1852 HD derivation via pycardano `HDWallet` soft derive |
+| `oracles` | ✅ stable | ADA/USD price via CoinGecko + DexHunter, 5-min cache |
+| `invoice` + `store` | ✅ stable | Framework-agnostic invoice + `InMemoryStore` reference impl |
+| `mint` | ✅ v0.2 | CIP-25 v2 metadata + real tx body → `UnsignedMint` bundle |
+| `ipfs` | ✅ stable | kubo HTTP API client w/ optional mirror-pin |
+| `monitor` | ✅ v0.2 | Operates purely through `InvoiceStore` — no ORM coupling |
+| `scheduler` | ✅ v0.2 | `InvoiceScheduler` drives check + reprice against the store |
+| `hostapp_compat` | 🟡 compat shim | Keeps the host app's subscription + grace-period jobs alive during migration |
+| `txbuild` | ✅ v0.2 | OgmiosChainContext wiring + submit_signed_tx + address UTxO queries |
-`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.
+**Migration status for the host app:** still imports the old module paths. See
+the [v0.2 migration guide](#v02-migration-guide-for-hostapp) below.
-## Install
+## Design
```
-pip install cardano-checkout # core
-pip install 'cardano-checkout[sqlalchemy]' # + SQLAlchemy extra
+ ┌────────────────────────────────────────────────────────┐
+ │ Merchant App │
+ │ (the host app / example-studio / your-product) │
+ └──────────────┬───────────────────────┬─────────────────┘
+ │ │
+ uses │ implements │ imports
+ ▼ ▼
+ ┌──────────────┐ ┌────────────────────────┐
+ │ InvoiceStore │ ◄────── │ cardano_checkout SDK │
+ │ (your DB) │ │ │
+ └──────────────┘ │ addresses ← pure │
+ │ oracles ← pure │
+ │ invoice ← dataclass │
+ │ store ← Protocol + InMemoryStore │
+ │ monitor ← polls chain via store │
+ │ scheduler ← bg loop │
+ │ mint ← NFT cert (cold-signer) │
+ │ ipfs ← upload │
+ │ txbuild ← Ogmios wrappers │
+ └────────────────────────┘
+ │
+ talks to │
+ ▼
+ ┌────────────────────────┐
+ │ Koios + Ogmios + kubo │
+ └────────────────────────┘
```
-Two runtime dependencies: `httpx` (Koios HTTP) and `apscheduler`
-(background loop). There is **no** `pycardano` dependency — see
-[Deriving addresses with pycardano](#deriving-addresses-with-pycardano)
-for the pairing pattern.
+The merchant app provides:
+
+1. A wallet xpub (account-level extended public key).
+2. An `InvoiceStore` implementation (SQLAlchemy, Postgres, SQLite, in-memory — whatever).
+
+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 with a cold-signer hand-off.
+5. IPFS upload + pinning for NFT image metadata.
## Quick start
```python
import asyncio
-from datetime import datetime, timedelta, timezone
+from cardano_checkout import addresses, oracles
-from cardano_checkout import (
- Invoice, InvoiceStatus, InMemoryStore, InvoiceScheduler,
+# Derive a receive address for invoice #42
+addr = addresses.derive_address(
+ xpub_hex="",
+ index=42,
+ network="mainnet",
)
-
-# 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))
-
-
+# Convert a USD price to lovelace at current market
async def main() -> None:
- store = InMemoryStore() # swap for your SQLAlchemy / asyncpg / sqlite adapter
-
- 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()
+ lovelace = await oracles.convert_usd_to_lovelace(99.00)
+ ada = lovelace / 1_000_000
+ print(f"Customer owes {ada:.4f} ADA for $99")
asyncio.run(main())
```
-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`.
-
-## Deriving addresses with pycardano
-
-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.
+## Payment monitoring
```python
-from pycardano import HDWallet, Address, Network
+import asyncio
+from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus, InvoiceScheduler
-# Account-level xpub — public, not a secret.
-xpub_hex = "..."
+store = InMemoryStore() # swap for your real SQLAlchemy / asyncpg / SQLite adapter
-account = HDWallet.from_xpub(bytes.fromhex(xpub_hex))
+# Create an invoice (typically you'd derive the address here via addresses.derive_address)
+invoice = Invoice(
+ id="ord-0042",
+ merchant_id="example-studio",
+ derivation_index=42,
+ receive_address="addr1q...",
+ expected_lovelace=5_000_000,
+ usd_amount=2.50,
+)
+asyncio.run(store.create(invoice))
-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)
+# Wire the background scheduler — same 15s check / 60s reprice cadence as the host app.
+scheduler = InvoiceScheduler(store=store)
+asyncio.run(scheduler.start())
+# ... your app runs ...
+asyncio.run(scheduler.stop())
```
-## NFT cert: CIP-25 v2 metadata
+## IPFS: bake-then-mirror pattern
-Need a certificate-of-authenticity NFT per paid order? Here is a
-dependency-free builder for the CIP-25 v2 metadata envelope. Hand the
-result to pycardano's `AuxiliaryData(Metadata({...}))` when you build the
-mint transaction.
+The SDK's `IPFSClient` expects a local kubo daemon (typically in the same
+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:
```python
-def build_cip25_metadata(
- *,
- 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.
+from cardano_checkout import ipfs
- 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)]
+client = ipfs.IPFSClient(
+ api_url="http://127.0.0.1:5001", # local kubo in the same container
+ mirror_api_urls=["http://mirror-node.example:5001"], # the cold host's kubo over the LAN/VPN
+)
- 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",
- }
- }
+cid = await client.add(photo_bytes, filename="order-0001.jpg")
+# Image now served by the hot host (low latency) AND pinned on the cold host (durability)
```
-## Implementing your own InvoiceStore
+## NFT cert-of-authenticity design
-`InvoiceStore` is a `Protocol` — implement six methods against whatever
-backend you want (SQLAlchemy, asyncpg, SQLite, in-memory).
+One minting policy per merchant studio. Policy is a native script (no Plutus
+required), optionally time-locked to make "no more editions after X" a
+cryptographically verifiable claim.
-```python
-from cardano_checkout import Invoice, InvoiceStatus, InvoiceStore
+CIP-25 v2 metadata. Single NFT per order. Policy skey never leaves the custody
+host (the cold host in Sulkta's pattern — 2-of-2 native script: signer 1 + signer 2). The SDK
+builds the metadata envelope + tx body on the hot node and returns an
+`UnsignedMint` bundle; an external offline signer provides the vkey witnesses;
+the hot node submits the assembled CBOR.
-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: ...
-```
+The full operator runbook — including the exact byte-movement sequence,
+verification checklist, and preprod dry-run procedure — lives in
+[`docs/minting-workflow.md`](docs/minting-workflow.md).
-See `InMemoryStore` in `cardano_checkout/store.py` for a reference
-implementation (it also backs the test suite).
+## v0.2 migration guide for the host app
-## Modules
+The generic invoice jobs moved to a Protocol-based API. The
+subscription + grace-period jobs stayed the host app-specific and live in
+`cardano_checkout.hostapp_compat`.
-| Module | Purpose |
+**Import changes when the host app adopts the SDK:**
+
+| Was | Becomes |
|---|---|
-| `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 |
+| `from services.cardano_monitor import check_pending_payments, reprice_expired_payments` | `from cardano_checkout.monitor import check_pending_invoices, reprice_expired_invoices` |
+| `from services.cardano_monitor import _check_address_utxos, _evaluate_payment` | `from cardano_checkout.monitor import check_address_utxos, evaluate_utxos` (or import from `hostapp_compat` for the exact old names) |
+| `from services.cardano_scheduler import start_cardano_scheduler, stop_cardano_scheduler` | `from cardano_checkout.scheduler import InvoiceScheduler` (instantiate with your store) |
+| `from services.cardano_scheduler import _check_subscription_payments, _reprice_subscription_payments, _enforce_grace_period` | `from cardano_checkout.hostapp_compat import check_subscription_payments, reprice_subscription_payments, enforce_grace_period` (verbatim jobs — the host app still drives them directly) |
+| `from services.cardano_price import *` | `from cardano_checkout.oracles import *` |
+| `from services.cardano_addresses import derive_address` | `from cardano_checkout.addresses import derive_address` |
-## Design
+**What the host app still needs to write:**
-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.
+A SQLAlchemy adapter implementing `InvoiceStore` against the existing
+`CardanoPayment` table. `list_by_status` maps to a `SELECT ... WHERE status = :s`,
+`next_derivation_index` to a `SELECT MAX(derivation_index) + 1`, etc.
+That's 80-ish lines of wrapper code — nothing exotic. Once that's
+landed, the generic jobs run through `InvoiceScheduler(store=SQLAlchemyInvoiceStore(...))`
+and the subscription jobs keep running through `hostapp_compat` unchanged.
-## Contributing
+**TODO for future sprints:**
-Issues and pull requests are welcome. A couple of house rules keep the
-library focused:
+- Ship a `cardano_checkout.adapters.sqlalchemy.SQLAlchemyInvoiceStore` so
+ the host app doesn't have to write the adapter from scratch.
+- Once the host app's subscription jobs are migrated to a subscription-
+ specific Protocol, delete `hostapp_compat`.
+- Refund-path `build_payment_tx` in `txbuild.py` (v0.3).
+- Batched mints (sell-sheet of 10 NFTs at once).
-- **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.
+## Testing
-Run the suite before opening a PR:
+```bash
+pip install -e '.[test]'
+pytest # 42 tests, all offline
+```
+
+The test suite mocks the chain context for mint-tx construction and
+monkey-patches Koios + the oracle for monitor tests — CI never touches
+a live node. The address-derivation tests use a deterministic test-vector
+xpub from the standard "test ... junk" mnemonic so they can't drift.
+
+## Installation
```
-pip install -e '.[test]'
-pytest
+pip install 'cardano-checkout[sqlalchemy]' # if you're using SQLAlchemy
+pip install cardano-checkout # core only
```
## License
-AGPL-3.0-or-later. See [LICENSE](LICENSE).
+Apache-2.0 — matches upstream Cardano tooling.
diff --git a/cardano_checkout/__init__.py b/cardano_checkout/__init__.py
index 7c8747c..bbdb5a1 100644
--- a/cardano_checkout/__init__.py
+++ b/cardano_checkout/__init__.py
@@ -1,69 +1,68 @@
-"""cardano-checkout — merchant-side Cardano payment lifecycle in Python.
+"""cardano_checkout — Python SDK for merchant-side Cardano payments + NFT cert minting.
-Zero-custody: the merchant brings a wallet xpub and an
-:class:`~cardano_checkout.store.InvoiceStore` implementation. The SDK
-owns the payment lifecycle — per-invoice receive-address bookkeeping,
-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 `_. See the
-README for the pairing pattern and the CIP-25 v2 metadata-builder
-snippet.
+Zero-custody by design: consumers provide a wallet xpub (account-level
+extended public key). The SDK derives unique receive addresses per
+invoice, polls the chain for payment, and (optionally) mints a CIP-25
+NFT certificate of authenticity on confirmation.
Quick start::
- from cardano_checkout import Invoice, InvoiceStatus, InMemoryStore, InvoiceScheduler
+ from cardano_checkout import addresses, oracles
- store = InMemoryStore() # or your SQLAlchemy / asyncpg / sqlite adapter
+ addr = addresses.derive_address(xpub_hex, index=42, network="mainnet")
+ price = await oracles.get_ada_usd_price()
+ lovelace = await oracles.convert_usd_to_lovelace(99.00)
- async def my_price_fn(usd: float) -> int:
- rate = await fetch_ada_usd_rate()
- return int(round(usd / rate * 1_000_000))
+For full invoice lifecycle see :mod:`cardano_checkout.invoice` +
+:mod:`cardano_checkout.store` (Protocol-based persistence).
- scheduler = InvoiceScheduler(store=store, price_fn=my_price_fn)
- await scheduler.start()
+For NFT minting see :mod:`cardano_checkout.mint`.
"""
from __future__ import annotations
-__version__ = "1.0.0-dev"
+__version__ = "0.2.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.store import InMemoryStore, InvoiceStore # noqa: F401
+
+# Monitoring + scheduling
from cardano_checkout.monitor import ( # noqa: F401
- CONFIRM_TOLERANCE,
- DEFAULT_MAX_REPRICINGS,
- 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
+# NFT + IPFS
+from cardano_checkout.mint import ( # noqa: F401
+ MintPolicy,
+ UnsignedMint,
+ build_cip25_metadata,
+ mint_nft_cert,
+ submit_signed_tx,
+)
+from cardano_checkout.ipfs import IPFSClient, pin_bytes # noqa: F401
+
__all__ = [
"__version__",
- # Invoice lifecycle
+ "addresses",
+ "oracles",
"Invoice",
"InvoiceStatus",
- # Persistence
"InvoiceStore",
"InMemoryStore",
- # Monitor + scheduler
- "PriceFn",
"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",
+ "MintPolicy",
+ "UnsignedMint",
+ "mint_nft_cert",
+ "submit_signed_tx",
+ "build_cip25_metadata",
+ "IPFSClient",
+ "pin_bytes",
]
diff --git a/cardano_checkout/addresses.py b/cardano_checkout/addresses.py
new file mode 100644
index 0000000..8ee135f
--- /dev/null
+++ b/cardano_checkout/addresses.py
@@ -0,0 +1,236 @@
+"""
+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 — soft (non-hardened) derivation.
+ addr_node = acct_pub.derive(0, private=False).derive(index, private=False)
+ # Staking chain (2) / always index 0 for the account.
+ stake_node = acct_pub.derive(2, private=False).derive(0, private=False)
+ 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,
+ PaymentVerificationKey,
+ StakeVerificationKey,
+ )
+
+ pay_vk = PaymentVerificationKey.from_primitive(addr_node.public_key)
+ stake_vk = StakeVerificationKey.from_primitive(stake_node.public_key)
+
+ address = Address(
+ payment_part=pay_vk.hash(),
+ staking_part=stake_vk.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()
+ node = _parse_xpub(stripped)
+ # Soft-derive a single child to prove the key is usable — HDWallet
+ # construction is lazy, so we actually exercise the BIP32 math.
+ node.derive(0, private=False)
+ 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 a public-only HDWallet node.
+
+ pycardano exposes soft-derivation through :class:`pycardano.HDWallet`.
+ An account-level xpub is 64 bytes (32-byte Ed25519 public key +
+ 32-byte chain code). Some wallets export 96 bytes; if so, we strip
+ the first 32 bytes which are typically a zeroed / duplicated prefix.
+
+ Args:
+ xpub_hex: Hex-encoded extended public key string.
+
+ Returns:
+ pycardano.HDWallet node rooted at the account level, with private
+ key fields unset. ``node.derive(index, private=False)`` performs
+ the soft CIP-1852 derivation we need.
+
+ Raises:
+ ValueError: If the byte length is unexpected or the key is invalid.
+ """
+ from pycardano import HDWallet
+
+ 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:
+ raw = raw[32:]
+ else:
+ raise ValueError(
+ f"Unexpected xpub length: {len(raw)} bytes. "
+ "Expected 64 bytes (pubkey + chain_code)."
+ )
+
+ public_key = raw[:32]
+ chain_code = raw[32:]
+
+ try:
+ return HDWallet(
+ public_key=public_key,
+ chain_code=chain_code,
+ path="m/1852'/1815'/0'",
+ )
+ 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
diff --git a/cardano_checkout/hostapp_compat.py b/cardano_checkout/hostapp_compat.py
new file mode 100644
index 0000000..9bcc7af
--- /dev/null
+++ b/cardano_checkout/hostapp_compat.py
@@ -0,0 +1,440 @@
+"""the host app-specific compatibility shim.
+
+the host app's ``services/cardano_scheduler.py`` shipped five jobs, of which
+only two — ``check_pending_payments`` and ``reprice_expired_payments`` —
+are generic invoice logic. The remaining three
+(``_check_subscription_payments``, ``_reprice_subscription_payments``,
+``_enforce_grace_period``) manipulate the host app's ``Company``,
+``Subscription``, and ``SubscriptionPayment`` SQLAlchemy models directly;
+those are merchant-specific concerns that do not belong in the generic SDK.
+
+This module preserves the original the host app import surface so the
+existing the host app code path still works while the migration to
+:class:`cardano_checkout.store.InvoiceStore` is in-flight. None of the
+symbols here are meant to be used by new consumers.
+
+**Do not depend on this module outside the host app.** It is scheduled to
+be removed once the host app migrates fully to the Protocol-based API (see
+TODO in the repo README).
+
+All functions in here are *verbatim* lifts from the original
+``services/cardano_scheduler.py`` — the host app's ``models`` + ``database``
+modules are imported lazily so that importing this module never fails
+for non-the host app consumers. If the host app's models are not importable
+the jobs raise at call time, not at import time.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta, timezone
+from decimal import Decimal
+from typing import Optional
+
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.interval import IntervalTrigger
+
+from cardano_checkout.monitor import check_address_utxos, evaluate_utxos
+from cardano_checkout.oracles import convert_usd_to_lovelace, get_ada_usd_price
+
+logger = logging.getLogger(__name__)
+
+
+# Original the host app free-function names — keep these stable.
+_check_address_utxos = check_address_utxos
+_evaluate_payment = evaluate_utxos
+
+
+def _require_hostapp_models():
+ """Lazy import of the the host app-specific models + session maker.
+
+ Returns ``(Company, Subscription, SubscriptionPayment, async_session_maker)``.
+ Raises ImportError with a clear message if the host app isn't installed.
+ """
+ try:
+ from database import async_session_maker # type: ignore[import-not-found]
+ from models import ( # type: ignore[import-not-found]
+ Company,
+ Subscription,
+ SubscriptionPayment,
+ )
+ except ImportError as exc: # pragma: no cover — only meaningful inside the host app
+ raise ImportError(
+ "cardano_checkout.hostapp_compat requires the the host app app's "
+ "`models` + `database` modules to be importable. This shim is "
+ "only meant to be used from within the host app itself."
+ ) from exc
+ return Company, Subscription, SubscriptionPayment, async_session_maker
+
+
+# ---------------------------------------------------------------------------
+# Subscription payments job — verbatim the host app logic
+# ---------------------------------------------------------------------------
+
+
+async def check_subscription_payments() -> None: # pragma: no cover — the host app-only
+ """Poll Koios for UTXOs at awaiting_payment subscription addresses.
+
+ On confirmation, advances ``Subscription.status`` to ``"active"`` and
+ updates ``Company.subscription_tier``. the host app-specific.
+ """
+ from sqlalchemy import select
+
+ Company, Subscription, SubscriptionPayment, async_session_maker = (
+ _require_hostapp_models()
+ )
+
+ try:
+ async with async_session_maker() as db:
+ now = datetime.now(timezone.utc)
+
+ result = await db.execute(
+ select(SubscriptionPayment).where(
+ SubscriptionPayment.status.in_(["awaiting_payment", "underpaid"]),
+ SubscriptionPayment.expires_at > now,
+ )
+ )
+ payments = result.scalars().all()
+
+ if not payments:
+ return
+
+ logger.debug(
+ "[hostapp-compat] Checking %d subscription payment(s)",
+ len(payments),
+ )
+
+ for sp in payments:
+ try:
+ utxos = await _check_address_utxos(sp.address)
+ expected = sp.expected_lovelace or 0
+ (
+ new_status_enum,
+ raw_lovelace,
+ total_value,
+ received_assets,
+ tx_hash,
+ ) = await _evaluate_payment(expected, utxos)
+ new_status = new_status_enum.value
+
+ 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
+
+ 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
+ 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:
+ 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(
+ "[hostapp-compat] 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(
+ "[hostapp-compat] Error checking sub_payment #%d: %s",
+ sp.id,
+ e,
+ )
+
+ await db.commit()
+
+ except Exception:
+ logger.exception(
+ "[hostapp-compat] check_subscription_payments job failed"
+ )
+
+
+async def reprice_subscription_payments() -> None: # pragma: no cover — the host app-only
+ """Reprice expired subscription payments — 24h window, 3-reprice cap."""
+ from sqlalchemy import select
+
+ _, _, SubscriptionPayment, async_session_maker = _require_hostapp_models()
+
+ 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
+
+ logger.info(
+ "[hostapp-compat] Repricing %d subscription payment(s)",
+ len(payments),
+ )
+
+ ada_price = await get_ada_usd_price()
+ if ada_price <= 0:
+ logger.warning(
+ "[hostapp-compat] Cannot reprice subscriptions — "
+ "ADA price unavailable"
+ )
+ return
+
+ new_expires_at = now + timedelta(hours=24)
+
+ for sp in payments:
+ try:
+ total_usd = float(sp.expected_usd or 0)
+ if total_usd <= 0:
+ sp.status = "expired"
+ continue
+
+ new_lovelace = await convert_usd_to_lovelace(total_usd)
+ if new_lovelace == 0:
+ 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(
+ "[hostapp-compat] 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(
+ "[hostapp-compat] Error repricing sub_payment #%d: %s",
+ sp.id,
+ e,
+ )
+
+ 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(
+ "[hostapp-compat] sub_payment #%d expired after %d repricings",
+ sp.id,
+ sp.repriced_count,
+ )
+
+ await db.commit()
+
+ except Exception:
+ logger.exception(
+ "[hostapp-compat] reprice_subscription_payments job failed"
+ )
+
+
+async def enforce_grace_period() -> None: # pragma: no cover — the host app-only
+ """Daily grace-period enforcement — past_due / suspended transitions."""
+ from sqlalchemy import select
+
+ Company, Subscription, SubscriptionPayment, async_session_maker = (
+ _require_hostapp_models()
+ )
+
+ try:
+ async with async_session_maker() as db:
+ today = datetime.now(timezone.utc).date()
+
+ 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()
+
+ 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(
+ "[hostapp-compat] company_id=%d suspended "
+ "(grace deadline %s passed)",
+ sp.company_id,
+ sp.grace_deadline,
+ )
+ elif sub.status == "active":
+ sub.status = "past_due"
+ sub.updated_at = datetime.now(timezone.utc)
+ if company:
+ company.subscription_status = "past_due"
+ logger.info(
+ "[hostapp-compat] company_id=%d past_due "
+ "(due_date %s passed)",
+ sp.company_id,
+ sp.due_date,
+ )
+
+ except Exception as e:
+ logger.exception(
+ "[hostapp-compat] Error enforcing grace period "
+ "for sub_payment #%d: %s",
+ sp.id,
+ e,
+ )
+
+ await db.commit()
+
+ except Exception:
+ logger.exception(
+ "[hostapp-compat] enforce_grace_period job failed"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Standalone the host app scheduler — registers the subscription jobs only.
+# ---------------------------------------------------------------------------
+
+
+_tc_scheduler: Optional[AsyncIOScheduler] = None
+
+
+async def start_hostapp_scheduler() -> None: # pragma: no cover — the host app-only
+ """Start ONLY the the host app-specific subscription + grace-period jobs.
+
+ The generic invoice jobs should be run via :class:`InvoiceScheduler`
+ against a ``SQLAlchemyInvoiceStore`` adapter (not shipped here —
+ the host app is responsible for implementing it during the migration).
+ """
+ global _tc_scheduler
+
+ if _tc_scheduler and _tc_scheduler.running:
+ return
+
+ _tc_scheduler = AsyncIOScheduler()
+
+ _tc_scheduler.add_job(
+ check_subscription_payments,
+ trigger=IntervalTrigger(seconds=60),
+ id="hostapp_check_sub_payments",
+ name="the host app: Check Subscription Payments",
+ replace_existing=True,
+ max_instances=1,
+ coalesce=True,
+ )
+
+ _tc_scheduler.add_job(
+ reprice_subscription_payments,
+ trigger=IntervalTrigger(hours=6),
+ id="hostapp_reprice_sub_payments",
+ name="the host app: Reprice Subscription Payments",
+ replace_existing=True,
+ max_instances=1,
+ coalesce=True,
+ )
+
+ _tc_scheduler.add_job(
+ enforce_grace_period,
+ trigger=CronTrigger(hour=6, minute=0, timezone="UTC"),
+ id="hostapp_enforce_grace",
+ name="the host app: Enforce Subscription Grace Periods",
+ replace_existing=True,
+ max_instances=1,
+ coalesce=True,
+ )
+
+ _tc_scheduler.start()
+ logger.info(
+ "[hostapp-compat] Started — subscription + grace-period jobs only"
+ )
+
+
+async def stop_hostapp_scheduler() -> None: # pragma: no cover — the host app-only
+ """Stop the the host app-specific scheduler."""
+ global _tc_scheduler
+ if _tc_scheduler:
+ _tc_scheduler.shutdown(wait=False)
+ _tc_scheduler = None
diff --git a/cardano_checkout/invoice.py b/cardano_checkout/invoice.py
index 64b1e53..9b51a97 100644
--- a/cardano_checkout/invoice.py
+++ b/cardano_checkout/invoice.py
@@ -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
chain confirms payment.
-Persistence is delegated to an :class:`InvoiceStore`
-(see :mod:`cardano_checkout.store`).
+The Invoice is deliberately framework-agnostic — persistence is
+delegated to an :class:`InvoiceStore` (see :mod:`cardano_checkout.store`).
"""
from __future__ import annotations
diff --git a/cardano_checkout/ipfs.py b/cardano_checkout/ipfs.py
new file mode 100644
index 0000000..4fa7709
--- /dev/null
+++ b/cardano_checkout/ipfs.py
@@ -0,0 +1,107 @@
+"""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)
diff --git a/cardano_checkout/mint.py b/cardano_checkout/mint.py
new file mode 100644
index 0000000..df19d0c
--- /dev/null
+++ b/cardano_checkout/mint.py
@@ -0,0 +1,442 @@
+"""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).
+
+Cold-signing workflow
+---------------------
+
+The mint function does *not* sign. It builds the transaction body + the
+auxiliary data, computes the tx id, and returns an :class:`UnsignedMint`
+carrying the CBOR-encoded body plus a human-readable summary so the
+operator can sanity-check before signing. The operator then:
+
+1. Transfers the unsigned CBOR to the cold host (the cold host, via `scp`, USB,
+ QR code, whatever the threat model tolerates).
+2. Signs offline with the policy-required skey(s) — for Sulkta's
+ example policy that's ``signer1.skey`` + ``signer2.skey``.
+3. Transfers the signed CBOR back to the hot host.
+4. Calls :func:`submit_signed_tx` to hand it to Ogmios.
+
+See ``docs/minting-workflow.md`` for the full operator runbook.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Optional
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING: # pragma: no cover — hints only
+ from pycardano import ChainContext
+
+
+# ---------------------------------------------------------------------------
+# Policy model
+# ---------------------------------------------------------------------------
+
+
+@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.
+ required_signer_hashes: Payment-key hashes (hex) of every skey
+ that must sign the mint tx. For Sulkta's example policy
+ this is 2 entries: signer 1 + signer 2.
+ 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
+ required_signer_hashes: list[str] = field(default_factory=list)
+ locked_after_slot: Optional[int] = None
+
+
+@dataclass
+class UnsignedMint:
+ """An unsigned mint transaction, ready to be handed to a cold signer.
+
+ Attributes:
+ tx_id: Transaction hash computed from the body alone (stable across
+ signing — the same id the explorer will show once submitted).
+ tx_body_cbor_hex: Hex-encoded CBOR of the transaction *body*.
+ This is what gets moved to the cold host.
+ auxiliary_data_cbor_hex: Hex-encoded CBOR of the auxiliary data
+ (metadata + native script). Required to reconstruct the full
+ transaction before submission.
+ native_script_cbor_hex: Hex-encoded CBOR of the minting policy's
+ native script. Needed by the cold signer to construct the
+ correct witness set.
+ required_signer_hashes: List of payment-key hashes (hex) the cold
+ signer must provide. Mirrors ``MintPolicy.required_signer_hashes``.
+ summary: Human-readable description of the tx — operator should
+ eyeball this before signing to confirm they're signing what
+ they think they're signing.
+ """
+
+ tx_id: str
+ tx_body_cbor_hex: str
+ auxiliary_data_cbor_hex: str
+ native_script_cbor_hex: str
+ required_signer_hashes: list[str]
+ summary: str
+
+
+# ---------------------------------------------------------------------------
+# Metadata builder (pure, no pycardano dep)
+# ---------------------------------------------------------------------------
+
+
+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://`` 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",
+ }
+ }
+
+
+# ---------------------------------------------------------------------------
+# Mint transaction builder (cold-signer flow)
+# ---------------------------------------------------------------------------
+
+
+def _require_pycardano():
+ try:
+ import pycardano # noqa: F401
+ except ImportError as exc: # pragma: no cover — env sanity
+ raise RuntimeError(
+ "pycardano is required for mint transaction construction. "
+ "Add pycardano>=0.11.0 to requirements.txt and reinstall."
+ ) from exc
+
+
+def _metadata_dict_with_int_keys(metadata: dict) -> dict:
+ """Convert string top-level metadata labels to ints for pycardano Metadata.
+
+ CIP-25 v2 nests everything under label ``721``. We accept both ``{"721": ...}``
+ (builder output) and ``{721: ...}`` (raw) for ergonomics.
+ """
+ converted: dict = {}
+ for key, val in metadata.items():
+ try:
+ converted[int(key)] = val
+ except (TypeError, ValueError):
+ converted[key] = val
+ return converted
+
+
+async def mint_nft_cert(
+ policy: MintPolicy,
+ asset_name: str,
+ metadata: dict,
+ recipient_address: str,
+ funding_address: str,
+ context: Optional["ChainContext"] = None,
+ ogmios_host: str = "127.0.0.1",
+ ogmios_port: int = 1337,
+ network: str = "mainnet",
+ min_lovelace_for_nft_utxo: int = 1_500_000,
+) -> UnsignedMint:
+ """Build an unsigned mint+send transaction for a CIP-25 v2 NFT cert.
+
+ 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
+ with the minimum-ADA padding (default 1.5 ADA).
+ 3. Attaches the CIP-25 v2 metadata (label 721) + the policy's
+ native script as tx auxiliary data.
+ 4. Returns the unsigned body for the cold signer to sign — does NOT
+ sign, does NOT submit.
+
+ UTxOs for fees + min-ADA are sourced from ``funding_address`` (the
+ merchant's hot wallet on the hot host, which does not hold any policy keys).
+
+ 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 — typically the output of
+ :func:`build_cip25_metadata`. Accepts ``{"721": ...}`` or ``{721: ...}``.
+ recipient_address: Bech32 address of the wallet that receives the NFT.
+ funding_address: Bech32 address that pays the tx fee + NFT min-ADA.
+ context: Optional chain context. If omitted a fresh
+ :class:`pycardano.OgmiosChainContext` is built from
+ ``ogmios_host``/``ogmios_port``.
+ ogmios_host: Host of the local Ogmios HTTP+WS endpoint.
+ ogmios_port: Port of the local Ogmios endpoint.
+ network: ``"mainnet"`` or ``"testnet"`` (preprod / preview).
+ min_lovelace_for_nft_utxo: ADA (in lovelace) to attach to the NFT
+ output so it satisfies the ledger's min-UTxO floor. Default 1.5 ADA.
+
+ Returns:
+ :class:`UnsignedMint` bundle ready for the cold-signer hand-off.
+
+ Raises:
+ RuntimeError: If pycardano is unavailable, or tx construction fails.
+ ValueError: If ``asset_name`` is empty or > 32 bytes.
+ """
+ _require_pycardano()
+
+ if not asset_name or len(asset_name.encode("utf-8")) > 32:
+ raise ValueError(
+ "asset_name must be a non-empty UTF-8 string <= 32 bytes "
+ f"(got {len(asset_name.encode('utf-8'))} bytes)"
+ )
+
+ from pycardano import (
+ Address,
+ Asset,
+ AssetName,
+ AuxiliaryData,
+ Metadata,
+ MultiAsset,
+ NativeScript,
+ Network,
+ ScriptHash,
+ TransactionBuilder,
+ TransactionOutput,
+ Value,
+ )
+
+ if context is None:
+ from cardano_checkout.txbuild import make_ogmios_context
+
+ context = make_ogmios_context(
+ host=ogmios_host, port=ogmios_port, network=network
+ )
+
+ net = Network.MAINNET if network == "mainnet" else Network.TESTNET
+
+ # ------------------------------------------------------------------
+ # Assemble the mint MultiAsset
+ # ------------------------------------------------------------------
+ policy_hash = ScriptHash.from_primitive(bytes.fromhex(policy.policy_id))
+ asset_name_obj = AssetName(asset_name.encode("utf-8"))
+ asset = Asset()
+ asset[asset_name_obj] = 1
+ mint_bundle = MultiAsset()
+ mint_bundle[policy_hash] = asset
+
+ # ------------------------------------------------------------------
+ # Native script + auxiliary data (metadata + script witness)
+ # ------------------------------------------------------------------
+ native_script = NativeScript.from_cbor(bytes.fromhex(policy.script_cbor_hex))
+
+ metadata_obj = Metadata(_metadata_dict_with_int_keys(metadata))
+ aux = AuxiliaryData(metadata_obj)
+ # AuxiliaryData in pycardano also carries native_scripts attached to the tx body;
+ # the builder below handles native scripts separately via add_minting_script.
+
+ # ------------------------------------------------------------------
+ # Addresses
+ # ------------------------------------------------------------------
+ sender = Address.from_primitive(funding_address)
+ recipient = Address.from_primitive(recipient_address)
+ if sender.network != net or recipient.network != net:
+ raise ValueError(
+ f"Address network mismatch: requested {network}, "
+ f"sender={sender.network.name}, recipient={recipient.network.name}"
+ )
+
+ # ------------------------------------------------------------------
+ # Build the transaction
+ # ------------------------------------------------------------------
+ builder = TransactionBuilder(context)
+ builder.add_input_address(sender)
+
+ # Attach mint bundle + policy as a minting script.
+ builder.mint = mint_bundle
+ builder.native_scripts = [native_script]
+ builder.auxiliary_data = aux
+
+ # Output: the newly minted NFT in its own UTxO at the recipient, padded
+ # with min-ADA so the ledger accepts it.
+ nft_value = Value(min_lovelace_for_nft_utxo, mint_bundle)
+ builder.add_output(TransactionOutput(recipient, nft_value))
+
+ # If the policy has a time lock, the mint tx MUST set ttl <= locked_after_slot
+ # or the node will reject the witness. Let pycardano pick validity normally,
+ # but clamp ttl when a lock slot is set.
+ ttl_offset = None
+ if policy.locked_after_slot is not None:
+ try:
+ chain_tip = context.last_block_slot # type: ignore[attr-defined]
+ # Cap at 2 hours or (locked_after_slot - chain_tip), whichever is smaller.
+ two_hours_in_slots = 2 * 60 * 60 # ~1 slot/s on mainnet
+ ttl_offset = max(
+ 60, min(two_hours_in_slots, policy.locked_after_slot - chain_tip)
+ )
+ except Exception: # pragma: no cover — context without chain tip
+ ttl_offset = None
+
+ try:
+ tx_body = builder.build(
+ change_address=sender,
+ auto_ttl_offset=ttl_offset,
+ auto_validity_start_offset=-30,
+ )
+ except Exception as exc:
+ raise RuntimeError(f"Failed to build mint tx body: {exc}") from exc
+
+ tx_id = str(tx_body.id)
+
+ summary_lines = [
+ f"Mint 1 x {policy.policy_id}.{asset_name}",
+ f" -> recipient: {recipient_address}",
+ f" fees paid by: {funding_address}",
+ f" tx_id (pre-sign): {tx_id}",
+ f" network: {network}",
+ f" required signers: {len(policy.required_signer_hashes)} "
+ f"({', '.join(h[:16] + '...' for h in policy.required_signer_hashes) or 'NONE — check policy'})",
+ ]
+ if policy.locked_after_slot is not None:
+ summary_lines.append(
+ f" policy time-lock: slot <= {policy.locked_after_slot}"
+ )
+
+ return UnsignedMint(
+ tx_id=tx_id,
+ tx_body_cbor_hex=tx_body.to_cbor_hex(),
+ auxiliary_data_cbor_hex=aux.to_cbor_hex(),
+ native_script_cbor_hex=policy.script_cbor_hex,
+ required_signer_hashes=list(policy.required_signer_hashes),
+ summary="\n".join(summary_lines),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Signed-tx submission
+# ---------------------------------------------------------------------------
+
+
+def submit_signed_tx(
+ signed_tx_cbor_hex: str,
+ context: Optional["ChainContext"] = None,
+ ogmios_host: str = "127.0.0.1",
+ ogmios_port: int = 1337,
+ network: str = "mainnet",
+) -> str:
+ """Submit a cold-signed transaction to the network via Ogmios.
+
+ The cold signer produces a fully-assembled :class:`pycardano.Transaction`
+ — body + witness set + auxiliary data — serialised as CBOR. This
+ function deserialises that blob, hands it to Ogmios, and returns the
+ tx hash.
+
+ Args:
+ signed_tx_cbor_hex: Hex-encoded CBOR of the signed transaction.
+ context: Optional chain context; built from ``ogmios_host/port`` if omitted.
+ ogmios_host: Host of the Ogmios endpoint.
+ ogmios_port: Port of the Ogmios endpoint.
+ network: ``"mainnet"`` or ``"testnet"``.
+
+ Returns:
+ Transaction hash (hex) — stable identifier for the submitted tx.
+
+ Raises:
+ RuntimeError: If pycardano is unavailable, or submission fails.
+ """
+ _require_pycardano()
+
+ from pycardano import Transaction
+
+ if context is None:
+ from cardano_checkout.txbuild import make_ogmios_context
+
+ context = make_ogmios_context(
+ host=ogmios_host, port=ogmios_port, network=network
+ )
+
+ try:
+ tx = Transaction.from_cbor(bytes.fromhex(signed_tx_cbor_hex))
+ except Exception as exc:
+ raise RuntimeError(f"signed_tx_cbor_hex is not valid transaction CBOR: {exc}") from exc
+
+ try:
+ context.submit_tx(tx) # type: ignore[attr-defined]
+ except Exception as exc:
+ raise RuntimeError(f"Ogmios rejected the signed tx: {exc}") from exc
+
+ tx_hash = str(tx.id)
+ logger.info("[mint] submitted signed tx %s", tx_hash)
+ return tx_hash
diff --git a/cardano_checkout/monitor.py b/cardano_checkout/monitor.py
index 6a3bd2b..153cfac 100644
--- a/cardano_checkout/monitor.py
+++ b/cardano_checkout/monitor.py
@@ -13,41 +13,46 @@ Koios endpoint used::
POST https://api.koios.rest/api/v1/address_utxos
Body: {"_addresses": ["addr1..."]}
-Status transitions::
+Status transitions applied here::
PENDING ──► CONFIRMED (received >= expected * CONFIRM_TOLERANCE)
PENDING ──► UNDERPAID (received > 0 but below tolerance)
PENDING ──► OVERPAID (received >= expected * OVERPAY_THRESHOLD)
PENDING ──► EXPIRED (after reprice_count exhausts — see reprice_expired_invoices)
+
+Behavioral shape is identical to the original the host app ``services/cardano_monitor.py``:
+same polling intervals, same Koios URL, same 2% confirm / overpay tolerances.
+The only change is that persistence is now delegated to the store Protocol
+instead of being welded to SQLAlchemy + the ``CardanoPayment`` model.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
-from typing import Awaitable, Callable, Optional
+from typing import Optional
import httpx
from cardano_checkout.invoice import Invoice, InvoiceStatus
+from cardano_checkout.oracles import (
+ KNOWN_TOKENS,
+ convert_token_to_lovelace,
+ convert_usd_to_lovelace,
+ get_ada_usd_price,
+)
from cardano_checkout.store import InvoiceStore
-# Consumer-supplied pricing callable: takes a USD amount (float),
-# returns the current-market lovelace equivalent as int. Invoked by
-# :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__)
KOIOS_URL = "https://api.koios.rest/api/v1/address_utxos"
KOIOS_TIMEOUT = 15 # seconds
-# Tolerance for confirming payment (2%).
+# Tolerance for confirming payment (2%) — unchanged from v0.1 / the host app.
CONFIRM_TOLERANCE = 0.98
OVERPAY_THRESHOLD = 1.02
-# Default reprice cap + window.
+# Default reprice cap + window (matches the host app defaults).
DEFAULT_MAX_REPRICINGS = 3
DEFAULT_PAYMENT_WINDOW_MINUTES = 15
@@ -99,7 +104,8 @@ async def check_address_utxos(
return []
-# Leading-underscore alias kept for callers that imported the private name.
+# Backwards-compatible alias — monitor.py in the host app imports the private name.
+# Keeping a leading-underscore alias so the the host app shim can still reach it.
_check_address_utxos = check_address_utxos
@@ -123,7 +129,7 @@ async def evaluate_utxos(
- ``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 mirror the host app exactly:
- No UTXOs → ``PENDING`` (no change)
- ``total_value >= expected * OVERPAY_THRESHOLD`` → ``OVERPAID`` (treated as confirmed)
@@ -158,11 +164,33 @@ async def evaluate_utxos(
if qty > 0:
received_assets[asset_id] = received_assets.get(asset_id, 0) + qty
- # ADA-only matching. Native tokens in the same UTxOs are recorded in
- # received_assets for visibility but do NOT contribute to the
- # payment-matched total. Wrap this function with your own
- # asset-to-lovelace converter to accept native tokens.
- total_value = raw_lovelace
+ # Convert native assets to lovelace equivalent via DexHunter.
+ asset_lovelace = 0
+ for asset_id, qty in received_assets.items():
+ if "." not in asset_id:
+ continue
+ policy_id, asset_name_hex = asset_id.split(".", 1)
+
+ decimals = 0
+ for token_info in KNOWN_TOKENS.values():
+ if token_info.get("policy_id") == policy_id:
+ decimals = token_info.get("decimals", 0)
+ break
+
+ 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
if expected_lovelace == 0:
# Degenerate case — any payment at all counts.
@@ -179,7 +207,7 @@ async def evaluate_utxos(
return new_status, raw_lovelace, total_value, received_assets, latest_tx_hash
-# Leading-underscore alias for callers that imported the private name.
+# Backwards-compatible alias.
_evaluate_utxos = evaluate_utxos
@@ -285,34 +313,23 @@ async def check_pending_invoices(
async def reprice_expired_invoices(
store: InvoiceStore,
- *,
- 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.
- Calls the consumer-supplied ``price_fn(usd_amount) -> lovelace`` to
- recompute ``expected_lovelace`` at current market. Resets ``expires_at``
- to ``now + window_minutes`` and increments ``invoice.metadata["repriced_count"]``.
- After ``max_repricings`` the invoice transitions to
- :class:`InvoiceStatus.EXPIRED`.
+ Pulls the current ADA/USD oracle price, recalculates ``expected_lovelace``
+ from the invoice's ``usd_amount``, resets ``expires_at`` to
+ ``now + window_minutes``, and tracks reprice count in ``invoice.metadata``
+ under the key ``repriced_count``. After ``max_repricings`` the invoice
+ is transitioned 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.
+ window_minutes: New expiry window per reprice. Matches the host app's
+ platform-config-driven value of 15 minutes by default.
+ max_repricings: Give-up threshold. the host app default is 3.
limit: Max pending invoices to process per call.
Returns:
@@ -333,6 +350,13 @@ async def reprice_expired_invoices(
"[cardano-monitor] Repricing %d expired invoice(s)", len(expired_candidates)
)
+ ada_price = await get_ada_usd_price()
+ if ada_price <= 0:
+ logger.warning(
+ "[cardano-monitor] Cannot reprice — ADA price unavailable"
+ )
+ return 0
+
new_expires_at = now + timedelta(minutes=window_minutes)
updated = 0
@@ -362,20 +386,11 @@ async def reprice_expired_invoices(
)
continue
- try:
- new_lovelace = await price_fn(usd_amount)
- except Exception as e:
+ new_lovelace = await convert_usd_to_lovelace(usd_amount)
+ if new_lovelace == 0:
logger.warning(
- "[cardano-monitor] invoice %s: price_fn raised %s, skipping",
+ "[cardano-monitor] invoice %s: lovelace conversion returned 0, 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
@@ -383,15 +398,18 @@ async def reprice_expired_invoices(
invoice.expected_lovelace = new_lovelace
invoice.expires_at = new_expires_at
invoice.metadata["repriced_count"] = repriced_count + 1
+ invoice.metadata["ada_price_usd"] = round(ada_price, 4)
await store.update(invoice)
updated += 1
logger.info(
- "[cardano-monitor] Repriced invoice %s: %d -> %d lovelace (reprice #%d)",
+ "[cardano-monitor] Repriced invoice %s: %d -> %d lovelace "
+ "(ADA=$%.4f, reprice #%d)",
invoice.id,
old_lovelace or 0,
new_lovelace,
+ ada_price,
repriced_count + 1,
)
diff --git a/cardano_checkout/oracles.py b/cardano_checkout/oracles.py
new file mode 100644
index 0000000..ec64b7a
--- /dev/null
+++ b/cardano_checkout/oracles.py
@@ -0,0 +1,346 @@
+"""
+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
diff --git a/cardano_checkout/scheduler.py b/cardano_checkout/scheduler.py
index 72b34f5..9647e3e 100644
--- a/cardano_checkout/scheduler.py
+++ b/cardano_checkout/scheduler.py
@@ -6,6 +6,13 @@ The scheduler drives two jobs against a consumer-supplied
- :func:`cardano_checkout.monitor.check_pending_invoices` — every 15 seconds
- :func:`cardano_checkout.monitor.reprice_expired_invoices` — every 60 seconds
+That's the *full* SDK job surface. The subscription-level + grace-period
+jobs that the original the host app scheduler shipped are the host app-specific
+(they touch ``Company``, ``Subscription``, ``SubscriptionPayment`` models
+that are merchant-specific) — those live in
+:mod:`cardano_checkout.hostapp_compat` so the host app can still import the
+exact wrappers it has always used, without polluting the generic SDK.
+
Usage::
from cardano_checkout.scheduler import InvoiceScheduler
@@ -30,7 +37,6 @@ from cardano_checkout.monitor import (
DEFAULT_MAX_REPRICINGS,
DEFAULT_PAYMENT_WINDOW_MINUTES,
KOIOS_URL,
- PriceFn,
check_pending_invoices,
reprice_expired_invoices,
)
@@ -47,7 +53,7 @@ class InvoiceScheduler:
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.
- Defaults to 15.
+ Defaults to 15 — identical to the host app's production cadence.
reprice_interval_seconds: How often to sweep for expired invoices.
Defaults to 60.
payment_window_minutes: Re-expiry window when repricing.
@@ -58,7 +64,6 @@ class InvoiceScheduler:
"""
store: InvoiceStore
- price_fn: Optional[PriceFn] = None
koios_url: str = KOIOS_URL
check_interval_seconds: int = 15
reprice_interval_seconds: int = 60
@@ -79,14 +84,9 @@ class InvoiceScheduler:
)
async def _job_reprice_expired(self) -> None:
- if self.price_fn is None:
- # No oracle wired — skip repricing. Fixed-ADA invoices don't
- # need one.
- return
try:
await reprice_expired_invoices(
self.store,
- price_fn=self.price_fn,
window_minutes=self.payment_window_minutes,
max_repricings=self.max_repricings,
limit=self.limit,
@@ -141,9 +141,12 @@ class InvoiceScheduler:
# ---------------------------------------------------------------------------
-# Free-function API around a module-level default instance.
-# Prefer the InvoiceScheduler class for anything nontrivial.
+# Backwards-compatible free-function API
# ---------------------------------------------------------------------------
+#
+# Early adopters may have imported ``start_cardano_scheduler`` / ``stop_cardano_scheduler``
+# directly. Provide those as thin wrappers around a module-level default instance.
+# Using the InvoiceScheduler class is preferred for anything nontrivial.
_default: Optional[InvoiceScheduler] = None
diff --git a/cardano_checkout/store.py b/cardano_checkout/store.py
index f3750f1..231c2fe 100644
--- a/cardano_checkout/store.py
+++ b/cardano_checkout/store.py
@@ -1,15 +1,17 @@
"""Persistence abstraction for Invoice objects.
The SDK does not prescribe a database. Consumers implement
-:class:`InvoiceStore` against whatever backend suits them — SQLAlchemy,
-asyncpg, SQLite, in-memory dict.
+:class:`InvoiceStore` against whatever backend suits them — SQLAlchemy
+(the host app pattern), SQLite (example-studio pattern), Postgres raw
+(high-volume pattern), in-memory dict (tests).
-All methods are async so the same Protocol works for both
+All methods are async so the same Protocol works cleanly for both
asyncpg/asyncio-sqlalchemy backends and synchronous backends wrapped
with ``asyncio.to_thread``.
-Also ships :class:`InMemoryStore` — a reference implementation used by
-the test suite and useful for local development.
+This module also ships :class:`InMemoryStore` — a reference implementation
+used by the test suite and useful as a drop-in for local development or
+ephemeral workflows that don't need durability.
"""
from __future__ import annotations
diff --git a/cardano_checkout/txbuild.py b/cardano_checkout/txbuild.py
new file mode 100644
index 0000000..1282c00
--- /dev/null
+++ b/cardano_checkout/txbuild.py
@@ -0,0 +1,207 @@
+"""Transaction construction helpers wrapping PyCardano.
+
+This module is the SDK's single point of contact with PyCardano's
+:class:`pycardano.backend.base.ChainContext` API. Everything higher up
+(``mint`` and eventual refund-path code) goes through the helpers
+here so we can swap Ogmios for Blockfrost / Cardano-CLI without
+touching callers.
+
+The default context targets the local Ogmios instance on the hot host
+(``127.0.0.1:1337``). That lines up with the mainnet deployment of the
+``cardano-node`` container (v10.6.2 on port 6000 via N2N) fronted by
+Ogmios as the HTTP+WS bridge. Preprod / testnet callers pass
+``network="testnet"`` and typically point at a different host.
+
+Cold-signer shape
+-----------------
+
+``txbuild`` only knows the hot-side half of the dance:
+
+- :func:`make_ogmios_context` — build a context from the live node.
+- :func:`get_protocol_parameters` — peek at the current protocol params
+ (useful for pricing, ttl calculations, etc.).
+- :func:`get_address_utxos` — list UTxOs at an address (refund path).
+- :func:`submit_signed_tx` — ship a tx that was signed offline.
+
+Body construction lives in :mod:`cardano_checkout.mint` today. As
+additional tx shapes (refunds, batched mints) arrive they'll land here
+alongside ``build_*_tx`` helpers that return :class:`UnsignedMint`-style
+cold-signer bundles.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any, Optional
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING: # pragma: no cover — hints only
+ from pycardano import ChainContext, UTxO
+
+
+# ---------------------------------------------------------------------------
+# Chain context
+# ---------------------------------------------------------------------------
+
+
+def _require_pycardano() -> None:
+ try:
+ import pycardano # noqa: F401
+ except ImportError as exc: # pragma: no cover — env sanity
+ raise RuntimeError(
+ "pycardano is required for transaction construction. "
+ "Add pycardano>=0.11.0 to requirements.txt and reinstall."
+ ) from exc
+
+
+def make_ogmios_context(
+ host: str = "127.0.0.1",
+ port: int = 1337,
+ network: str = "mainnet",
+ secure: bool = False,
+ **kwargs: Any,
+) -> "ChainContext":
+ """Construct an :class:`pycardano.OgmiosChainContext` for the live node.
+
+ Args:
+ host: Ogmios HTTP+WS host. Default ``127.0.0.1`` (local).
+ port: Ogmios port. Default ``1337`` (matches the hot host's stack).
+ network: ``"mainnet"`` or ``"testnet"``. Controls the
+ :class:`pycardano.Network` passed to the context.
+ secure: Whether to use wss:// instead of ws://. Default False —
+ the stack assumes a loopback connection.
+ **kwargs: Forwarded to ``OgmiosChainContext`` verbatim (e.g.
+ ``refetch_chain_tip_interval``, ``utxo_cache_size``).
+
+ Returns:
+ A live :class:`ChainContext`. If the backing node is down the
+ object is still constructed — failures surface on the first
+ query / submit call.
+ """
+ _require_pycardano()
+ from pycardano import Network, OgmiosChainContext
+
+ net = Network.MAINNET if network == "mainnet" else Network.TESTNET
+ logger.debug(
+ "[txbuild] OgmiosChainContext -> %s://%s:%d (network=%s)",
+ "wss" if secure else "ws",
+ host,
+ port,
+ network,
+ )
+ return OgmiosChainContext(
+ host=host, port=port, secure=secure, network=net, **kwargs
+ )
+
+
+def get_protocol_parameters(context: "ChainContext") -> Any:
+ """Return the live protocol parameters from the chain context.
+
+ Useful for fee estimation, min-utxo floor computation, and sanity
+ checks that the node is reachable before a mint attempt.
+
+ The return type is pycardano's :class:`ProtocolParameters` — a
+ dataclass with fields like ``min_fee_a``, ``min_fee_b``,
+ ``coins_per_utxo_byte``, ``max_tx_size``, etc.
+ """
+ try:
+ return context.protocol_param # type: ignore[attr-defined]
+ except Exception as exc:
+ raise RuntimeError(
+ f"Failed to fetch protocol parameters from chain context: {exc}"
+ ) from exc
+
+
+def get_address_utxos(context: "ChainContext", address: str) -> list["UTxO"]:
+ """Fetch UTxOs at ``address`` via the chain context.
+
+ Intended for the refund path — when an invoice is cancelled or
+ overpaid the merchant needs to know which UTxOs landed in order to
+ build a return tx. For pure payment-detection, Koios is still the
+ cheaper source (see :mod:`cardano_checkout.monitor`).
+
+ Args:
+ context: Live chain context (from :func:`make_ogmios_context`).
+ address: Bech32 Cardano address.
+
+ Returns:
+ List of pycardano :class:`UTxO` objects at ``address``. Empty if
+ the address has no unspent outputs. Never ``None``.
+
+ Raises:
+ RuntimeError: If the underlying query fails (node down, invalid address).
+ """
+ _require_pycardano()
+ from pycardano import Address
+
+ try:
+ addr_obj = Address.from_primitive(address)
+ except Exception as exc:
+ raise RuntimeError(f"Invalid Cardano address: {exc}") from exc
+
+ try:
+ utxos = context.utxos(str(addr_obj)) # type: ignore[attr-defined]
+ except Exception as exc:
+ raise RuntimeError(
+ f"Failed to fetch UTxOs for {address[:20]}...: {exc}"
+ ) from exc
+
+ return list(utxos or [])
+
+
+# ---------------------------------------------------------------------------
+# Signed-tx submission (duplicated from mint.py as a stable txbuild entry
+# point — the mint module's version delegates here)
+# ---------------------------------------------------------------------------
+
+
+def submit_signed_tx(
+ signed_tx_cbor_hex: str,
+ context: Optional["ChainContext"] = None,
+ ogmios_host: str = "127.0.0.1",
+ ogmios_port: int = 1337,
+ network: str = "mainnet",
+) -> str:
+ """Submit a cold-signed transaction blob to the chain.
+
+ See :func:`cardano_checkout.mint.submit_signed_tx` for the full docstring —
+ this is the same function under the ``txbuild`` import path so callers
+ that only need submission don't have to import ``mint``.
+ """
+ _require_pycardano()
+ from pycardano import Transaction
+
+ if context is None:
+ context = make_ogmios_context(
+ host=ogmios_host, port=ogmios_port, network=network
+ )
+
+ try:
+ tx = Transaction.from_cbor(bytes.fromhex(signed_tx_cbor_hex))
+ except Exception as exc:
+ raise RuntimeError(
+ f"signed_tx_cbor_hex is not valid transaction CBOR: {exc}"
+ ) from exc
+
+ try:
+ context.submit_tx(tx) # type: ignore[attr-defined]
+ except Exception as exc:
+ raise RuntimeError(f"Ogmios rejected the signed tx: {exc}") from exc
+
+ tx_hash = str(tx.id)
+ logger.info("[txbuild] submitted signed tx %s", tx_hash)
+ return tx_hash
+
+
+# ---------------------------------------------------------------------------
+# Placeholders for future tx shapes (kept so consumers can pin imports)
+# ---------------------------------------------------------------------------
+
+
+def build_payment_tx(*args, **kwargs): # pragma: no cover — future work
+ """Build an unsigned plain-ADA payment tx (refund path). v0.3+."""
+ raise NotImplementedError(
+ "build_payment_tx lands in v0.3 alongside the refund workflow. "
+ "For v0.2 only mint txs are supported."
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 48eac25..92d3f6f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,19 +4,19 @@ build-backend = "setuptools.build_meta"
[project]
name = "cardano-checkout"
-version = "1.0.0.dev0"
-description = "Merchant-side Cardano payment lifecycle (zero-custody). Ships the invoice + UTxO-watcher + reprice state machine. Use pycardano directly for Cardano primitives."
+version = "0.2.0.dev0"
+description = "Merchant-side Cardano payments SDK + NFT cert-of-authenticity minting (zero-custody)"
readme = "README.md"
requires-python = ">=3.10"
-license = {text = "AGPL-3.0-or-later"}
+license = {text = "Apache-2.0"}
authors = [
{name = "Sulkta Coop"},
]
-keywords = ["cardano", "payments", "checkout", "invoice", "utxo", "zero-custody", "merchant", "ada"]
+keywords = ["cardano", "payments", "nft", "checkout", "blockchain", "ada", "pycardano"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
- "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
+ "License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
@@ -26,6 +26,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
+ "pycardano>=0.11.0",
"httpx>=0.27",
"apscheduler>=3.10",
]
@@ -36,7 +37,7 @@ test = ["pytest>=7", "pytest-asyncio>=0.23"]
dev = ["pytest>=7", "pytest-asyncio>=0.23", "ruff", "mypy"]
[project.urls]
-Repository = "https://git.sulkta.com/Sulkta-Coop/cardano-checkout-py"
+Repository = "http://git.sulkta.com/Sulkta-Coop/cardano-checkout-py"
[tool.setuptools.packages.find]
include = ["cardano_checkout*"]
diff --git a/tests/test_addresses.py b/tests/test_addresses.py
new file mode 100644
index 0000000..d060ee9
--- /dev/null
+++ b/tests/test_addresses.py
@@ -0,0 +1,71 @@
+"""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.
+#
+# Derived deterministically from the well-known test mnemonic
+# "test test test test test test test test test test test junk"
+# at path m/1852'/1815'/0' via pycardano's HDWallet. Using a real,
+# on-curve account xpub here (as opposed to random hex) is what lets
+# validate_xpub + derive_address actually exercise the BIP32 math.
+TEST_XPUB_HEX = (
+ "f2cdeef60dfc2c00cd1d4c0def0ce3f7b0328f5badd2fd771f48ff207ca7eaa8"
+ "500a3c3d556f995e79c4a75e64d13ab12772f46e6c05fed1d9698b7e12a533f7"
+)
+
+
+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
+ # Note: a correct-length random-hex string IS accepted — BIP32-ED25519
+ # soft derivation over a 64-byte input doesn't require the public key
+ # half to be a point on the curve. We only catch shape errors here.
+
+
+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")
diff --git a/tests/test_cip25_metadata.py b/tests/test_cip25_metadata.py
new file mode 100644
index 0000000..ed99b91
--- /dev/null
+++ b/tests/test_cip25_metadata.py
@@ -0,0 +1,56 @@
+"""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"]
diff --git a/tests/test_invoice.py b/tests/test_invoice.py
index 757d715..5339914 100644
--- a/tests/test_invoice.py
+++ b/tests/test_invoice.py
@@ -10,7 +10,7 @@ from cardano_checkout.invoice import Invoice, InvoiceStatus
def _make() -> Invoice:
return Invoice(
id="inv_001",
- merchant_id="acme",
+ merchant_id="example-studio",
derivation_index=0,
receive_address="addr1...",
expected_lovelace=5_000_000, # 5 ADA
diff --git a/tests/test_mint_metadata.py b/tests/test_mint_metadata.py
new file mode 100644
index 0000000..e4d1965
--- /dev/null
+++ b/tests/test_mint_metadata.py
@@ -0,0 +1,308 @@
+"""CIP-25 v2 envelope round-trips + unsigned-mint shape tests.
+
+Complements ``test_cip25_metadata.py`` (the pure builder unit tests) by
+checking:
+
+- Round-tripping the envelope through pycardano's Metadata/AuxiliaryData
+ produces a well-formed CBOR blob (the wallet-visible thing).
+- :func:`mint_nft_cert` returns a correctly-shaped :class:`UnsignedMint`
+ without hitting a live chain — we stub the ChainContext.
+
+The chain-context stub mirrors just enough of pycardano's interface for
+``TransactionBuilder.build`` to succeed. No live Ogmios calls.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from cardano_checkout import UnsignedMint, build_cip25_metadata, mint_nft_cert
+
+
+# ---------------------------------------------------------------------------
+# Fixtures — a deterministic test policy + a stub ChainContext
+# ---------------------------------------------------------------------------
+
+
+def _test_policy():
+ """Return a fresh 2-of-2 NativeScript all-of policy + its hash + CBOR.
+
+ Uses fixed verification-key hashes (32-char hex, 28 bytes as
+ required by Cardano VKH). No cryptographic significance — just
+ deterministic filler for tests.
+ """
+ from pycardano import (
+ NativeScript,
+ ScriptAll,
+ ScriptPubkey,
+ VerificationKeyHash,
+ )
+
+ vkh_signer1 = VerificationKeyHash.from_primitive(bytes.fromhex("11" * 28))
+ vkh_signer2 = VerificationKeyHash.from_primitive(bytes.fromhex("22" * 28))
+ script: NativeScript = ScriptAll(
+ [ScriptPubkey(vkh_signer1), ScriptPubkey(vkh_signer2)]
+ )
+ return script, [vkh_signer1.payload.hex(), vkh_signer2.payload.hex()]
+
+
+def _stub_context():
+ """Return a minimal ChainContext stub with just enough surface for builder.build()."""
+ from pycardano import (
+ Address,
+ AssetName,
+ MultiAsset,
+ ProtocolParameters,
+ TransactionId,
+ TransactionInput,
+ TransactionOutput,
+ UTxO,
+ Value,
+ )
+
+ class StubContext:
+ network = None
+
+ @property
+ def last_block_slot(self) -> int:
+ return 100_000_000
+
+ @property
+ def protocol_param(self) -> ProtocolParameters:
+ # Mainnet values as of 2025-ish — just enough to get fee math through.
+ return ProtocolParameters(
+ min_fee_constant=155_381,
+ min_fee_coefficient=44,
+ max_block_size=90_112,
+ max_tx_size=16_384,
+ max_block_header_size=1_100,
+ key_deposit=2_000_000,
+ pool_deposit=500_000_000,
+ pool_influence=0.3,
+ monetary_expansion=0.003,
+ treasury_expansion=0.2,
+ decentralization_param=0,
+ extra_entropy="",
+ protocol_major_version=9,
+ protocol_minor_version=0,
+ min_utxo=1_000_000,
+ min_pool_cost=340_000_000,
+ price_mem=0.0577,
+ price_step=0.0000721,
+ max_tx_ex_mem=14_000_000,
+ max_tx_ex_steps=10_000_000_000,
+ max_block_ex_mem=62_000_000,
+ max_block_ex_steps=20_000_000_000,
+ max_val_size=5_000,
+ collateral_percent=150,
+ max_collateral_inputs=3,
+ coins_per_utxo_byte=4_310,
+ coins_per_utxo_word=34_482,
+ cost_models={}, # Plutus cost models — not used by native-script mints.
+ )
+
+ @property
+ def genesis_param(self):
+ # Minimal stand-in — pycardano's builder uses this for slot math.
+ from pycardano import GenesisParameters
+
+ return GenesisParameters(
+ active_slots_coefficient=0.05,
+ update_quorum=5,
+ max_lovelace_supply=45_000_000_000_000_000,
+ network_magic=764_824_073,
+ epoch_length=432_000,
+ system_start=1_506_203_091,
+ slots_per_kes_period=129_600,
+ slot_length=1,
+ max_kes_evolutions=62,
+ security_param=2_160,
+ )
+
+ @property
+ def era(self):
+ from pycardano import Era
+ return Era.CONWAY
+
+ def utxos(self, address):
+ # Provide one fat UTxO so the builder has inputs to draw fees + min-ADA from.
+ addr = (
+ address
+ if isinstance(address, Address)
+ else Address.from_primitive(address)
+ )
+ tx_in = TransactionInput(
+ transaction_id=TransactionId.from_primitive(bytes.fromhex("cc" * 32)),
+ index=0,
+ )
+ # 1000 ADA, no native assets — plenty for fees + NFT min-UTxO.
+ output = TransactionOutput(addr, Value(1_000_000_000))
+ return [UTxO(tx_in, output)]
+
+ def submit_tx(self, tx): # pragma: no cover — not invoked in these tests
+ pass
+
+ return StubContext()
+
+
+# ---------------------------------------------------------------------------
+# Test vectors for the envelope
+# ---------------------------------------------------------------------------
+
+
+def test_envelope_from_example-studio_order_vector() -> None:
+ """A realistic example-studio cert — image CID + studio properties."""
+ md = build_cip25_metadata(
+ policy_id="4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
+ asset_name="ExampleStudioCert0042",
+ name="Example Studio Cert #0042",
+ image_cid="bafybeihkop... real cid would be 59 base32 chars",
+ description=(
+ "Certificate of authenticity for hand-stitched custom moth pendant "
+ "ordered by a customer, completed by Sulkta Studio."
+ ),
+ media_type="image/png",
+ properties={
+ "studio": "example-studio",
+ "artisan": "Sulkta Studio",
+ "order_id": "CC-2026-0042",
+ "edition": "1 of 1",
+ "material": "sterling silver + polymer clay",
+ },
+ )
+
+ label = md["721"]
+ nft = label[
+ "4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d"
+ ]["ExampleStudioCert0042"]
+
+ assert label["version"] == "2.0"
+ assert nft["name"] == "Example Studio Cert #0042"
+ assert nft["mediaType"] == "image/png"
+ assert nft["studio"] == "example-studio"
+ assert nft["artisan"] == "Sulkta"
+ assert nft["edition"] == "1 of 1"
+
+
+def test_envelope_roundtrips_through_pycardano_metadata() -> None:
+ """CBOR-encode and decode — the wallet-visible path."""
+ from pycardano import AuxiliaryData, Metadata
+
+ raw = build_cip25_metadata(
+ policy_id="ab" * 28,
+ asset_name="TestNFT",
+ name="Test NFT",
+ image_cid="bafybeitestcidforround-tripping",
+ description="round trip",
+ )
+ # pycardano's Metadata expects int keys at the top level.
+ inner = {int(k): v for k, v in raw.items()}
+ md = Metadata(inner)
+ aux = AuxiliaryData(md)
+
+ cbor_hex = aux.to_cbor_hex()
+ # Must be valid hex and non-trivially sized.
+ assert len(cbor_hex) > 40
+ assert all(c in "0123456789abcdef" for c in cbor_hex)
+
+ # Decoding back must yield the same metadata.
+ decoded = AuxiliaryData.from_cbor(bytes.fromhex(cbor_hex))
+ decoded_md = decoded.data if hasattr(decoded, "data") else decoded # pycardano compat
+ assert decoded_md is not None
+
+
+def test_envelope_version_key_always_present() -> None:
+ md = build_cip25_metadata(
+ policy_id="a" * 56, asset_name="x", name="n", image_cid="c"
+ )
+ assert md["721"]["version"] == "2.0"
+
+
+# ---------------------------------------------------------------------------
+# mint_nft_cert — full tx body construction against a stubbed context
+# ---------------------------------------------------------------------------
+
+
+async def test_mint_nft_cert_returns_unsigned_bundle() -> None:
+ from pycardano import Address, Network, PaymentKeyPair, StakeKeyPair
+
+ script, signer_hashes = _test_policy()
+ policy_cbor_hex = script.to_cbor_hex()
+ # policy_id is blake2b-224 of the script CBOR — use pycardano to compute.
+ policy_id = script.hash().payload.hex()
+
+ # Build two throwaway addresses in testnet namespace.
+ pay_key = PaymentKeyPair.generate()
+ stk_key = StakeKeyPair.generate()
+ funding_addr = str(
+ Address(
+ payment_part=pay_key.verification_key.hash(),
+ staking_part=stk_key.verification_key.hash(),
+ network=Network.TESTNET,
+ )
+ )
+ recipient_pay = PaymentKeyPair.generate()
+ recipient_stk = StakeKeyPair.generate()
+ recipient_addr = str(
+ Address(
+ payment_part=recipient_pay.verification_key.hash(),
+ staking_part=recipient_stk.verification_key.hash(),
+ network=Network.TESTNET,
+ )
+ )
+
+ from cardano_checkout import MintPolicy
+
+ policy = MintPolicy(
+ policy_id=policy_id,
+ script_cbor_hex=policy_cbor_hex,
+ required_signer_hashes=signer_hashes,
+ )
+
+ metadata = build_cip25_metadata(
+ policy_id=policy_id,
+ asset_name="TestCert01",
+ name="Test Cert 01",
+ image_cid="bafybeitest",
+ )
+
+ result = await mint_nft_cert(
+ policy=policy,
+ asset_name="TestCert01",
+ metadata=metadata,
+ recipient_address=recipient_addr,
+ funding_address=funding_addr,
+ context=_stub_context(),
+ network="testnet",
+ )
+
+ assert isinstance(result, UnsignedMint)
+ assert len(result.tx_id) == 64 # hex-encoded 32-byte blake2b hash
+ assert result.tx_body_cbor_hex
+ assert all(c in "0123456789abcdef" for c in result.tx_body_cbor_hex)
+ assert result.auxiliary_data_cbor_hex
+ assert result.native_script_cbor_hex == policy_cbor_hex
+ assert result.required_signer_hashes == signer_hashes
+ assert policy_id in result.summary
+ assert recipient_addr in result.summary
+
+
+async def test_mint_nft_cert_rejects_oversize_asset_name() -> None:
+ from cardano_checkout import MintPolicy
+
+ policy = MintPolicy(
+ policy_id="aa" * 28,
+ script_cbor_hex="82008200581c" + "11" * 28, # doesn't matter — builder never reached
+ required_signer_hashes=[],
+ )
+
+ with pytest.raises(ValueError, match="asset_name"):
+ await mint_nft_cert(
+ policy=policy,
+ asset_name="X" * 33, # 33 > 32 byte limit
+ metadata={"721": {}},
+ recipient_address="addr_test1...",
+ funding_address="addr_test1...",
+ context=_stub_context(),
+ network="testnet",
+ )
diff --git a/tests/test_monitor_with_inmemory_store.py b/tests/test_monitor_with_inmemory_store.py
index c6db6d7..653dfd6 100644
--- a/tests/test_monitor_with_inmemory_store.py
+++ b/tests/test_monitor_with_inmemory_store.py
@@ -32,7 +32,7 @@ def _make(
now = datetime.now(timezone.utc)
return Invoice(
id=id_,
- merchant_id="acme",
+ merchant_id="example-studio",
derivation_index=0,
receive_address="addr1testreceive",
expected_lovelace=expected_lovelace,
@@ -53,31 +53,23 @@ def _utxo(lovelace: int, tx_hash: str = "aa" * 32) -> dict:
@pytest.fixture(autouse=True)
-def _patch_koios(monkeypatch):
- """Default: Koios returns no UTxOs. Individual tests override."""
+def _patch_koios_and_oracle(monkeypatch):
+ """Default: no UTxOs, oracle returns $0.45/ADA. Individual tests override."""
async def fake_utxos(address, koios_url=None, timeout=None):
return []
- monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
+ async def fake_price():
+ return 0.45
-
-@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:
+ async def fake_convert(usd):
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
+ monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
+ monkeypatch.setattr(monitor, "get_ada_usd_price", fake_price)
+ monkeypatch.setattr(monitor, "convert_usd_to_lovelace", fake_convert)
# ---------------------------------------------------------------------------
@@ -192,14 +184,14 @@ async def test_already_expired_invoices_are_skipped(monkeypatch) -> None:
# ---------------------------------------------------------------------------
-async def test_reprice_updates_expected_lovelace_and_extends_expiry(price_fn_at_45c) -> None:
+async def test_reprice_updates_expected_lovelace_and_extends_expiry() -> 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
+ store, window_minutes=15, max_repricings=3
)
assert updated == 1
@@ -213,14 +205,14 @@ async def test_reprice_updates_expected_lovelace_and_extends_expiry(price_fn_at_
assert fetched.metadata["repriced_count"] == 1
-async def test_reprice_gives_up_after_max_repricings(price_fn_at_45c) -> None:
+async def test_reprice_gives_up_after_max_repricings() -> 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
+ store, window_minutes=15, max_repricings=3
)
fetched = await store.get("inv")
@@ -228,21 +220,26 @@ async def test_reprice_gives_up_after_max_repricings(price_fn_at_45c) -> None:
assert fetched.status == InvoiceStatus.EXPIRED
-async def test_reprice_noop_when_nothing_expired(price_fn_at_45c) -> None:
+async def test_reprice_noop_when_nothing_expired() -> None:
store = InMemoryStore()
- await store.create(_make())
+ await store.create(_make(expired_in_minutes=15) if False else _make())
- updated = await monitor.reprice_expired_invoices(store, price_fn=price_fn_at_45c)
+ updated = await monitor.reprice_expired_invoices(store)
assert updated == 0
-async def test_reprice_skips_when_oracle_returns_zero(price_fn_zero) -> None:
+async def test_reprice_skips_when_oracle_unavailable(monkeypatch) -> 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)
+ async def zero_price():
+ return 0.0
+
+ monkeypatch.setattr(monitor, "get_ada_usd_price", zero_price)
+
+ updated = await monitor.reprice_expired_invoices(store)
assert updated == 0
fetched = await store.get("inv")
diff --git a/tests/test_store_protocol.py b/tests/test_store_protocol.py
index b89475e..d1f001e 100644
--- a/tests/test_store_protocol.py
+++ b/tests/test_store_protocol.py
@@ -17,7 +17,7 @@ from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus, InvoiceStore
def _make_invoice(
id_: str = "inv_001",
- merchant: str = "acme",
+ merchant: str = "example-studio",
index: int = 0,
status: InvoiceStatus = InvoiceStatus.PENDING,
) -> Invoice:
@@ -135,8 +135,8 @@ async def test_list_by_status_honours_limit() -> None:
async def test_next_derivation_index_is_monotonic_per_merchant() -> None:
store = InMemoryStore()
- m1 = "acme"
- m2 = "globex"
+ m1 = "example-studio"
+ m2 = "hostapp"
assert await store.next_derivation_index(m1) == 0
assert await store.next_derivation_index(m1) == 1
@@ -149,7 +149,7 @@ async def test_create_bumps_index_cursor_if_higher() -> None:
store = InMemoryStore()
await store.create(_make_invoice(id_="manual", index=7))
- nxt = await store.next_derivation_index("acme")
+ nxt = await store.next_derivation_index("example-studio")
assert nxt == 8