o1-labs / o1js

TypeScript framework for zk-SNARKs and zkApps

Home Page:https://docs.minaprotocol.com/en/zkapps/how-to-write-a-zkapp

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

`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 4 awaits.
  • 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])