Skip to content

meta: payjoin-cli should be a better reference implementation #1482

@nothingmuch

Description

@nothingmuch

recently payjoin-cli has been getting a lot of pull requests that on their own seem reasonable for a user facing app, optimizing the user experience.

however, its intended users are developers (and inevitably agents). payjoin-cli is a reference implementation, as such it's main purpose should be to clarify to users of the library how to use the payjoin crate correctly in a real world setting.

sorry to be a bit vague and negative about this, but it really feels like efforts spent polishing up payjoin-cli to users or to make it more useful are actually detracting from its usefulness as a reference implementation, and meanwhile there are many low hanging fruit for refactoring it for clarity that are not being worked on (c.f. #478)

starting from payjoin-cli's main.rs, that file is not too obtuse but still full of feature gating related boilerplate. i guess that's unavoidable. then there's the cli/config parsing, that eventually leads to constructing of separate concrete variants of the app types, via the AppTrait interface but technically not relying on it (since the concrete types are used).

if you then go into app/v1.rs or app/v2/mod.rs, low level details interspersed with high level ideas, there's a shortage of whitespace separating chunks of code relating to a single logical idea from chunks of code that deal with something else, there's an overwhelming amount of boilerplate

just some observations from scrolling v2/mod.rs for about 2 mins:

  1. AppTrait hides some details/shared code between v1 and v2, and kinda has to be read before
  2. send command implementation precedes receive, even though the receiver initiates
  3. before getting there there's eyesores like this:
impl<Status: StatusText> fmt::Display for SessionHistoryRow<Status> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{:<W_ID$} {:<W_ROLE$} {:<W_DONE$} {:<W_STATUS$}",
            self.session_id.to_string(),
            self.role.as_str(),
            match self.completed_at {
                None => "Not Completed".to_string(),
                Some(secs) => {
                    // TODO: human readable time
                    secs.to_string()
                }
            },
            self.error_message.as_deref().unwrap_or(self.status.status_text())
        )
    }
}

which is of course necessary on some level but make no sense without context and make finding the entry point difficult
4.

                let http = http_agent(&self.config)?;
                let body = String::from_utf8(req.body.clone()).unwrap();
                println!("Sending fallback request to {}", &req.url);
                let response = http
                    .post(req.url)
                    .header("Content-Type", req.content_type)
                    .body(body.clone())
                    .send()
                    .await
                    .with_context(|| "HTTP request failed")?;

http_agent isn't clearly a constructor, it's not clear why req.body needs to be converted to a UTF8 string without an understanding of BIP 78 and some of the details. i guess the idea is to convey that req contains all the information needed to construct a request regardless of the HTTP client used, but http_agent doesn't convey that the HTTP client is arbitrary, or that this is converting things from generic types into specific ones. comments might help with this but i also think optimizing the code for readability, like destructuring req, and either having http_agent do more lifting wrt boiilerplate, or perhaps less (to emphasize that req is agnostic to the HTTP client's request type), but as it stands it's kinda neither here nor there
5. then there's just kinda awkward things, like:

        let session =
            ReceiverBuilder::new(address, self.config.v2()?.pj_directory.as_str(), ohttp_keys)?
                .with_amount(amount)
                .with_max_fee_rate(self.config.max_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN))
                .build()
                .save(&persister)?;

.with_max_fee_rate(self.config.max_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN))

why don't we have a config.max_fee_rate() that handles the optionality and FeeRate::BROADCAST_MIN instead of doing that in the AppTrait impl's top level functions?

i am familiar with these apis, i know know what to expect and i still feel like can't see the forest from the trees =(


anyway, to make this issue a bit more constructive and not just a list of complaints, how can we improve readability of payjoin-cli, to humans (and also to agents) so that it better achieves its stated goal? how can we do this while avoiding it becoming a maintenance burden?

this should of course not shy away from dealing with the messy stuff that reality imposes, this is after all an example for how to integrate the crate into a real world app (whereas doc examples or the tests can show how to deal with small self contained things)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions