Skip to content

Functional one-time payments and tests#12

Open
turukawa wants to merge 13 commits intointerledger:mainfrom
turukawa:core-payments
Open

Functional one-time payments and tests#12
turukawa wants to merge 13 commits intointerledger:mainfrom
turukawa:core-payments

Conversation

@turukawa
Copy link
Copy Markdown

@turukawa turukawa commented Dec 5, 2025

This is a fairly substantial pull request to get to a working version of the SDK. The updates can be divided into the following components:

  1. Model refactoring and fixes: generated code resulted in models that don't return data, or duplications and deeply-nested RootModels much of which could be deleted and simplified.
  2. New convenience functions: testing the API effectively requires building a full purchase workflow, but such a workflow is also useful to developers using the SDK. The README in the process folder details the main objectives and roadmap, and this update implements the simplest possible payments workflow for purchase and sale.
  3. Updated tests: all the tests should be refactored, especially as private endpoints have been committed to the repo. Tests for the convenience functions run through a complete workflow, and use the new environment variables to safely do a real-world test on the Rafiki test server.

I have commented the code I've introduced, with references, and hope this is straightforward to read.

All tests green. To run your own tests, please review the .env.example and add your own values.

`http-sfv` is deprecated in favour of `http-sf`. Replaced accordingly.
`classmethod` must return the value holding class data.
Optional fields are optional and not always returned.
These take in a variety of fixes:

- Row alignment and spacing
- Correct dependency import path

Note that this is only the most basic of fixes and much more needs to be done to fix duplicate functions and inefficient models.
It is good practice to place all environment variables - even demo ones used in 'test' - into a `.env` file that is excluded in the `.gitignore`. This commit adds:

- `pydantic-settings` to import environment variables from a `.env`
- `.env.example` for others to add their own settings
- `config.py` to import the settings and make them available in tests.
There are numerous changes:

- Removal of all unnecessary `RootModel` classes and refactoring for simple inheritence.
- Removal of duplicated `WalletAddress` class from `resource.py` and `auth.py`.
- Simplification of bizarre duplication of `LimitsOutgoing1`, `LimitsOutgoing2`, etc into a single class.

And multiple other quality of life fixes and improves. I have no doubt this could be improved further, but this is significantly easier to navigate and read.
The parser is useful for:

- Normalising wallet addresses from the form `$ilp.` to `https://ilp`.
- Converting stored private keys back to PEM format.
- Verifying payment response hash to validate buyer authentication.
This is the simplest function for processing a one-off payment between a purchaser and seller.

Includes:

- Pydantic models defining a seller account, and a pending incoming payment transaction that can be used to store stateless interactions (i.e. generate approval link -> third-party approval -> redirect for transaction completion)
- One-time payment process workflow.

A README covers the context and future functionality for this feature.
New dev dependencies required: `selenium` for authenticating purchase transactions via web, and `jupyterlab` for dev testing of code.

the `.env` requires a test wallet login and password to test the purchase approval workflow.
@johngian
Copy link
Copy Markdown
Contributor

johngian commented Mar 7, 2026

Other than the code specific comments. I suggest you squash the 2 black/auto-formating related commits to one.

@johngian
Copy link
Copy Markdown
Contributor

johngian commented Mar 7, 2026

I added a bunch of comments. The model refactoring is not easily reviewable after all the changes so I am OK with merging it as it is. My only concern is that we diverge a lot from the OpenAPI specs, which is fine if that improves usability and the actual spec doesn't change much to be problematic. I suggest we move forward to have a working client and then nitpick the spec.

@johngian
Copy link
Copy Markdown
Contributor

johngian commented Mar 7, 2026

I also found some time to pick up some work and tried to test with real world data here: #14

There is some overlap on the base model changes (I only noticed your PR after my changes unfortunately). I have a cherry-picked version of my peer-to-peer transactions example with a bunch of fixes, but lets merge this one first.

Here is my working version of the end-to-end example using this PR as a base.
https://github.com/johngian/open-payments-python-sdk/tree/cherry-picked-peer-to-peer-with-pr12

@johngian
Copy link
Copy Markdown
Contributor

johngian commented Mar 7, 2026

Regarding the process implementation, overall i like the idea of abstracting it, but it feels very restrictive to a buyer/seller relationship. I would rather like to use the same abstraction but with languages that is more relevant to the existing docs to avoid confusion.

@johngian
Copy link
Copy Markdown
Contributor

johngian commented Mar 7, 2026

Heads up @Tymmmy @mkurapov, this review might be worth it to be prioritized because it brings the python support in a very much working state.

@Tymmmy
Copy link
Copy Markdown
Contributor

Tymmmy commented Mar 11, 2026

@turukawa can you update the PR with the comments applied from @johngian and then I can aproove and merge the PR?

@mkurapov
Copy link
Copy Markdown

Thank you @johngian! Did you end up hitting submit on the review? I cannot see the code specific comments

@turukawa
Copy link
Copy Markdown
Author

@Tymmmy @johngian @mkurapov Thanks, yes, will update (and I'm sure I'll have questions as I go). I'm busy with another project for the next week or so, but I'll get to this as quickly as I can.

Compute Digest
"""
request.headers["Content-Digest"] = str(http_sfv.Dictionary({"sha-512": hashlib.sha512(request.content).digest()}))
request.headers["Content-Digest"] = ser({"sha-512": hashlib.sha512(request.content).digest()})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since even the http_message_signatures lib doesn't use http_sf this, this can be simplified to one less dependency with:

digest = hashlib.sha512(request.content).digest()
request.headers["Content-Digest"] = f"sha-512=:{base64.b64encode(digest).decode('ascii')}:"

)
response = request = self.sign_request(request,("authorization",*get_default_covered_components()))
self.http_client.send(request=request)
req_headers = {**self.get_auth_header(access_token=access_token)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actual problem is a few lines above where response = request = <blah>. I guess this is an oversight of the initial bootstrapping codebase.

extra="forbid",
)
startCursor: Optional[constr(min_length=1)] = Field(
startCursor: Optional[str] = Field(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that this is a big problem, but this will validate even for empty string.

description="Cursor corresponding to the first element in the result array.",
)
endCursor: Optional[constr(min_length=1)] = Field(
endCursor: Optional[str] = Field(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

)
createdAt: datetime = Field(..., description="The date and time when the outgoing payment was created.")
updatedAt: Optional[datetime] = Field(None, description="The date and time when the outgoing payment was updated.")
model_config = ConfigDict()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 2 model configs defined here. Check at the start of the class definition. Ideally I would prefer extra=forbid to be more strict on validation.

if isinstance(wallet_address, AnyUrl):
return str(wallet_address)
wallet_address = wallet_address.strip("$").strip("/")
if wallet_address.startswith("https://"):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can use urlparse instead of assuming that its a valid address and using string manipulation, after removing the $ prefix.

except Exception:
return False

def convert_private_key_to_PEM(self, *, private_key: str | bytes, format: str = "PRIVATE KEY") -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the private key is submitted on sdk call, instead of trying to guess what the format is, maybe you can be more explicit and require the format to be defined before hand. Then there is no need to parse the key, if its base64 or pem according to the format, if not internal cryptography is going to fail.

return wallet_address
return f"https://{wallet_address}"

def isBase64(self, *, term: str | bytes) -> bool:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, i suggest we are explicit on the format instead of trying to identify it.

return value


class SellerOpenPaymentAccount(BaseModel):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that seller is a good naming pattern, since we are not only dealing with sales.

http_client = HttpClient(http_timeout=10.0)
self.http_client = http_client
self.seller = seller
self.buyer = paymentsparser.normalise_wallet_address(wallet_address=buyer)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, its not always a seller and a buyer, lets make it a bit more relevant to the overall API language.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants