Skip to content

Commit

Permalink
feat: nonInvokerAuthEntries + signAuthEntriesFor
Browse files Browse the repository at this point in the history
  • Loading branch information
chadoh committed Nov 2, 2023
1 parent 3ae4774 commit 561ffc4
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import {
Account,
Address,
Contract,
Keypair,
Operation,
Server,
SorobanRpc,
StrKey,
TimeoutInfinite,
TransactionBuilder,
assembleTransaction,
hash,
nativeToScVal,
xdr
} from "soroban-client";
import type { Memo, MemoType, Transaction } from "soroban-client";
Expand All @@ -25,6 +30,7 @@ export class NeedsMoreSignaturesError extends Error { }
export class WalletDisconnectedError extends Error { }
export class SendResultOnlyError extends Error { }
export class SendFailedError extends Error { }
export class NoUnsignedNonInvokerAuthEntriesError extends Error { }

type SendTx = SorobanRpc.SendTransactionResponse;
type GetTx = SorobanRpc.GetTransactionResponse;
Expand All @@ -50,6 +56,7 @@ export class AssembledTransaction<T> {
public getTransactionResponse?: GetTx
public getTransactionResponseAll?: GetTx[]
public server: Server
public signatureExpirationLedger?: number

static async fromSimulation<T>(options: AssembledTransactionClassOptions<T>): Promise<AssembledTransaction<T>> {
const tx = new AssembledTransaction(options)
Expand Down Expand Up @@ -146,6 +153,12 @@ export class AssembledTransaction<T> {
throw new WalletDisconnectedError('Wallet is not connected')
}

if (await this.nonInvokerAuthEntries()) {
throw new NeedsMoreSignaturesError(
'Transaction requires more signatures. See `nonInvokerAuthEntries` for details.'
)
}

// throw errors if transaction needs restore first or if it's otherwise not an expected simulation type
this.ensureSuccessfulHostFunctionSimulation()

Expand Down Expand Up @@ -233,6 +246,250 @@ export class AssembledTransaction<T> {
return this;
}

getStorageExpiration = async (storageType: 'temporary' | 'persistent') => {
const key = xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract: new Address(this.options.contractId).toScAddress(),
key: xdr.ScVal.scvLedgerKeyContractInstance(),
durability: xdr.ContractDataDurability[storageType](),
}),
)

const expirationKey = xdr.LedgerKey.expiration(
new xdr.LedgerKeyExpiration({ keyHash: hash(key.toXDR()) }),
)

const entryRes = await this.server.getLedgerEntries(expirationKey)
if (!(entryRes.entries && entryRes.entries.length)) throw new Error('failed to get ledger entry')

const parsed = xdr.LedgerEntryData.fromXDR(
entryRes.entries[0].xdr,
"base64",
)
return parsed.expiration().expirationLedgerSeq()
}

/**
* Get a Map of public keys to preimages for all auth entries in this
* transaction that need to be signed by someone other than the
* invoker/source/signer of the initial simulation, and which haven't already
* been signed.
*
* Assumes that the same invoker/source account will sign the final
* transaction envelope.
*
* One at a time, for each key in this object, serialize the transaction,
* send to the owner of that key, and call
* `tx.signAuthEntriesFor(theirPublicKey, signingFunction)`.
*/
nonInvokerAuthEntries = async ({
includeAlreadySigned = false,
expiration = this.signatureExpirationLedger ??
this.getStorageExpiration('persistent')
}: {
/**
* Whether or not to include auth entries that have already been signed. Default: false
*/
includeAlreadySigned?: boolean
/**
* When to set each auth entry to expire. Could be any number of blocks in
* the future. Can be supplied as a promise or a raw number. Default:
* contract's current `persistent` storage expiration date/ledger
* number/block.
* Will be saved to the object so it can be serialized/deserialized and
* used in future operations on other machines, such as calls to
* `signAuthEntriesFor`
*/
expiration?: number | Promise<number>
} = {}): Promise<PubKeyToPreimagesUnsigned | undefined> => {
if (!this.txUnsigned) {
throw new Error('Transaction has not yet been simulated')
}

// We expect that any transaction constructed by these libraries has a
// single operation, which is an InvokeHostFunction operation. The host
// function being invoked is the contract method call.
if (
!this.ensureSuccessfulHostFunctionSimulation() ||
!("operations" in this.txUnsigned)
) {
throw new Error(
`Unexpected Transaction type; no operations: ${
JSON.stringify(this.txUnsigned)
}`
)
}
const rawInvokeHostFunctionOp = this.txUnsigned
.operations[0] as Operation.InvokeHostFunction

const authEntries = rawInvokeHostFunctionOp.auth ?? []

const nonSourceAuthEntriesFlat = authEntries.filter(entry =>
entry.credentials().switch() ===
xdr.SorobanCredentialsType.sorobanCredentialsAddress() &&
(
includeAlreadySigned ||
entry.credentials().address().signature().switch().name === 'scvVoid'
)
)

let nonSourceAuthEntries: undefined | PubKeyToPreimagesUnsigned = undefined

if (nonSourceAuthEntriesFlat.length > 0) {
this.signatureExpirationLedger = await expiration
nonSourceAuthEntries = nonSourceAuthEntriesFlat.reduce(
(map, entry) => {
const pk = StrKey.encodeEd25519PublicKey(
entry.credentials().address().address().accountId().ed25519()
)
const preimage = this.preImageFor(entry)
map.set(pk, [...map.get(pk) ?? [], preimage])
return map
},
new Map() as PubKeyToPreimagesUnsigned
)
}

return nonSourceAuthEntries
}

preImageFor(entry: xdr.SorobanAuthorizationEntry): xdr.HashIdPreimage {
const addrAuth = entry.credentials().address()
return xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
new xdr.HashIdPreimageSorobanAuthorization({
networkId: hash(Buffer.from(this.options.networkPassphrase)),
nonce: addrAuth.nonce(),
invocation: entry.rootInvocation(),
signatureExpirationLedger: this.signatureExpirationLedger!,
}),
)
}

/**
* If `this.nonInvokerAuthEntries` returns a non-empty map, you can serialize
* the transaction (TODO: how?), send it to the owner of one of the public keys
* in the map, and call this method on their machine, passing in the function to
* use to sign each of their auth entries.
*
* Then re-serialize the transaction and send it back to the original
* machine, where it can either be sent to the next `nonInvokerAuthEntries`
* owner, or submitted to the network.
*
* Sending to all `nonInvokerAuthEntries` owners in parallel is not currently
* supported!
*/
signAuthEntriesFor = async (
/**
* The public key of the account with auth entries that need to be signed.
*
* Must correspond to a key from `await this.nonInvokerAuthEntries()`.
*/
publicKey: string,
/**
* Signing function to use to sign each auth entry that `publicKey` account
holder needs to sign.
*/
sign: (payload: Buffer) => Buffer | Promise<Buffer>
) => {
if (!this.txUnsigned) throw new Error('Transaction has not yet been assembled or simulated')
const nonInvokerAuthEntries = await this.nonInvokerAuthEntries()

if (!nonInvokerAuthEntries) throw new NoUnsignedNonInvokerAuthEntriesError('No unsigned non-invoker auth entries; maybe you already signed?')
if (!nonInvokerAuthEntries.has(publicKey)) throw new Error('No auth entries for this public key')

const rawInvokeHostFunctionOp = this.txUnsigned
.operations[0] as Operation.InvokeHostFunction

const originalAuthEntries = rawInvokeHostFunctionOp.auth ?? []

const signedAuthEntries: xdr.SorobanAuthorizationEntry[] = []

for (const entry of originalAuthEntries) {
if (
entry.credentials().switch() !==
xdr.SorobanCredentialsType.sorobanCredentialsAddress()
) {
// if the invoker/source account, then the entry doesn't need explicit
// signature, since the tx envelope is already signed by the source
// account
signedAuthEntries.push(entry)
} else {
const pk = StrKey.encodeEd25519PublicKey(
entry.credentials().address().address().accountId().ed25519()
)
if (pk !== publicKey) {
// this auth entry needs to be signed by a different account
// (or maybe already was!)
signedAuthEntries.push(entry)
} else {
const preimage = this.preImageFor(entry)
const payload = hash(preimage.toXDR())
const signature = await sign(payload)

// TODO: do we need this check?
if (!Keypair.fromPublicKey(pk).verify(payload, signature)) {
throw new Error(
`Signature doesn't match payload!\n\n` +
` publicKey: ${pk}\n` +
` signature: ${signature}`
)
}

const sigScVal = nativeToScVal(
{
public_key: StrKey.decodeEd25519PublicKey(pk),
signature,
},
{
// force the keys to be interpreted as symbols (expected for
// Soroban [contracttype]s)
// Pr open to fix this type in the gen'd xdr
type: {
public_key: ["symbol", null],
signature: ["symbol", null],
},
},
)
// get a reference to the auth entry, then mutate it
const addrAuth = entry.credentials().address()
addrAuth.signature(xdr.ScVal.scvVec([sigScVal]))

addrAuth.signatureExpirationLedger(
preimage.sorobanAuthorization().signatureExpirationLedger()
)

signedAuthEntries.push(entry)
}
}
}

const builder = TransactionBuilder.cloneFrom(this.txUnsigned)
builder.clearOperations().addOperation(
Operation.invokeHostFunction({
...rawInvokeHostFunctionOp,
auth: signedAuthEntries,
}),
)

this.txUnsigned = builder.build()

// if these were the final signatures needed, resimulate & reassemble
if (!await this.nonInvokerAuthEntries()) {
let txSim = await this.server.simulateTransaction(this.txUnsigned)
if (!SorobanRpc.isSimulationSuccess(txSim)) {
throw new Error('Resimulating the transaction failed!\n\n' + JSON.stringify(txSim, null, 2))
}
txSim = txSim as SorobanRpc.SimulateTransactionSuccessResponse

this.txUnsigned = assembleTransaction(
this.txUnsigned,
this.options.networkPassphrase,
txSim
).build()
}
}

get isViewCall(): boolean {
const simulation = this.ensureSuccessfulHostFunctionSimulation()
const authsCount = simulation.result.auth.length;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import { invoke, submitTx } from './invoke.js';
import type { PubKeyToPreimagesUnsigned, PubKeyToPreimagesSigned, SubmitResponse } from './invoke.js';
import type { ResponseTypes, Wallet, ClassOptions, XDR_BASE64 } from './method-options.js';

export * from './invoke.js';
export * from './assembled-tx.js';
export * from './method-options.js';

export { hash } from 'soroban-client';

export type u32 = number;
export type i32 = number;
export type u64 = bigint;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const publicKey = root.keypair.publicKey();
const contract = new Contract({ ...networks.standalone, rpcUrl, wallet });

test('hello', async t => {
t.is((await contract.hello({ hello: 'tests' })).result, 'tests')
const tx = await contract.hello({ hello: 'tests' })
t.is(tx.result, 'tests')
})

test('woid', async t => {
Expand Down
Loading

0 comments on commit 561ffc4

Please sign in to comment.