`Transaction.prove` and `Transaction.send` result method chaining
harrysolovay opened this issue · comments
Hi o1js community and maintainers. I'm new to this library + zkApps in general, so please take my thoughts with a grain of salt.
I'm contemplating the following code.
let tx = await Mina.transaction(id, () => { /* ... */ })
await tx.prove()
let pending = await tx
.sign([key])
.send()
await pending.wait()
Could we enable a fully-chained experience?
await Mina
.transaction(id, () => { /* ... */ })
.prove()
.sign([key])
.send()
.wait()
I suppose prove
and send
would need to return promise subtypes (ProveResult
and SendResult
respectively).
type ProveResult = Promise<(Proof<ZkappPublicInput, undefined> | undefined)[]> & {
sign: (additionalKeys?: PrivateKey[] | undefined) => Promise<Transaction> & {
send(): SendResult
}
}
type SendResult = Promise<Mina.PendingTransaction> & {
wait: (options?: {
maxAttempts?: number | undefined;
interval?: number | undefined;
} | undefined) => Promise<Mina.IncludedTransaction>
}
Some follow-up thoughts + questions:
- this would be non-breaking
- are there reasons to keep these steps non-chainable?
- a given promise subtype could contain a reference to the step/promise off of which it was chained, incase a direct reference is required
to me this seems to add complexity without a clear benefit. With prettier
formatting your non-chained example even uses less lines of code than the chained one
Apologies for not clearly articulating the benefits:
- 1
await
instead of 4await
s. - No need to define (and therefore come up with) variable names for (A) the tx and (B) the pending counterpart. This would be especially useful in scopes that create/prove/sign/send/wait multiple txs, as devs wouldn't need to spend time considering names for variables that are not subsequently referenced.
- Legibility gains
This last benefit I listed is subjective... but I do believe the chained/fluent equivalent would be––for most developers––more legible/preferable.
I can close this issue if you still disagree. Thank you for hearing me out + considering this approach.
I don't necessarily disagree. Not opposed to adding these, and it probably wouldn't be that hard to write a generic wrapper a la
mapPromise<K extends string, T extends { [k in K]: ((...args: any) => any) }>(p: Promise<T>, methodsToProxy: K[]): Promise<T> & { [k in K]: Promise<ReturnType<T[k]> };
That utility would work great! Alternatively, we could create dedicated promise subclasses, as to avoid bloating the return type (the intersection of Promise<T>
with the mapped type might be a bit intimidating).
const a = Mina.transaction(id, () => { /* ... */ }) // `CreateTransactionPending`
const b = a.prove() // `ProoveTransationPending`
const c = b.sign([key]) // `SignTransactionPending`
const d = c.send() // `SendTransactionPending`
const e = d.wait() // `Promise<FinalizedTransaction>`
The downside of dedicated subclasses: it obfuscates thenability... the Pending
postfix should mitigate this downside. Still a risk. The return signature you suggested is clearer, although more verbose. What's preferable?
The return signature you suggested is clearer, although more verbose
It would only be that verbose in the source code, in the compiled type it would be more similar to your suggestion and fine I think (better than a custom class)
Agreed: mapPromise
also allows the proxied method tsdocs to flow through. Quick question re. implementation:
I'm looking at the overload signatures of src/lib/mina/transaction.ts#transaction
––how would you recommend modifying these overloads? My sense was that we would rely on inference from mapPromise
, but that doesn't seem to be an option given how it's currently set up.
I'm looking at the overload signatures of src/lib/mina/transaction.ts#transaction––how would you recommend modifying these overloads?
Maybe the return of mapPromise
should be made its own type MapPromise<T, K>
and that used?
We may need an additional promise-mapping utility for cases such as prove
(which is meant to have a sign
method that refers to the tx, not to the result of prove
). Seems we have a nice start though!
lib/util/mapPromise.ts
import { AnyFunction, AssertFunction } from './types.js';
export { type FunctionKey, type MapPromise, mapPromise };
type FunctionKey<T> = keyof {
[K in keyof T as T[K] extends AnyFunction ? K : never]: T[K];
};
type MapPromise<T, K extends FunctionKey<T>> = Promise<T> & {
[k in K]: (
...args: Parameters<AssertFunction<T[k]>>
) => Promise<Awaited<ReturnType<AssertFunction<T[k]>>>>;
};
function mapPromise<T, K extends FunctionKey<T>>(
p: Promise<T>,
methodsToProxy: K[]
): MapPromise<T, K> {
return Object.assign(
p.then(),
Object.fromEntries(
methodsToProxy.map((m) => [
m,
(...args: any[]) => p.then((v) => (v[m] as any)(...args)),
])
)
) as any;
}
The src/bindings
submodule is failing to clone on my machine, so I can't test atm. Will follow up in the next few days. Thank you for the guidance @mitschabaude!
Hey @harrysolovay do you want to create a PR? this looks like a great start!
The src/bindings submodule is failing to clone on my machine
It's just very big so might take a while
Just wanted to follow up. I intend to take a pass at this soon.
I have been second-guessing mapPromise
. The promise subclassing approach may be more straight-forward. Currently, PendingTransaction
, IncludedTransaction
and RejectedTransaction
––stages in the desired chaining DX––are the result of using the Pick
utility type and intersecting on a common Transaction
type. This + mapPromise
might start to break down in some cases. For instance...
await Mina .transaction(id, () => { /* ... */ }) .prove() .sign([...privateKeys])