-
Notifications
You must be signed in to change notification settings - Fork 12
/
Copy pathpact-request.ts
471 lines (414 loc) · 20.3 KB
/
pact-request.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
/* PACT API REQUEST BUILDER
The pact-lang-api library provides helper functions for sending an ExecCmd for
evaluation on a Chainweb node. The functions provided by the library are
primitive, so this file implements some helpers that make it easier make a
request and track its result — whether it's pending, has failed, or has returned
a success result.
This file provides two simple request types we can use to describe Pact code we
want to execute on a Chainweb node and PactAPI class to help you send those
requests with minimal bookkeeping.
Goliath and Charkha both build on top of this helper library, but it's not tied
to either project and feel free to copy this code into your own projects.
*/
import * as Pact from "pact-lang-api";
import { formatPactCode, isPactDecimal, isPactInt, PactCode } from "./pact-code";
/* 1. REQUEST TYPES
The Pact API on a Chainweb node exposes two endpoints for executing Pact code:
* /local is for non-transactional execution, ie. for Pact code that does not
need to be mined into a block and can be run on a single node. It corresponds
with the "local" requests in the 01-faucet-contract project.
https://api.chainweb.com/openapi/pact.html#tag/endpoint-local
* /send is for transactional execution, ie. for Pact code that changes the state
of the blockchain and must be mined into a block. It corresponds with the
"send" requests from project 1.
https://api.chainweb.com/openapi/pact.html#tag/endpoint-send
*/
// A local request is executed on the Chainweb node only and can only read data.
// It can't modify the state of the blockchain, so it does not get broadcast to
// other nodes, does not cost gas, and does not need a sender. You should execute
// 'send' requests on the /local endpoint as a pre-flight check (we do that in
// the PactAPI class).
export interface LocalRequest<a> {
// The code that should be executed on the Chainweb node
code: PactCode | PactCode[] | string;
// Data that should be included on the transaction so Pact code can read it
data?: { [key: string]: Pact.PactValue | Pact.KeySet };
// Signers of the transaction along with any capabilities the signatures
// should be scoped to.
signers?: Pact.KeyPairCapabilities | Pact.KeyPairCapabilities[];
// An optional maximum amount of gas this request is allowed to take
gasLimit?: number;
transformResponse: (response: Pact.PactValue) => a;
}
// A 'send' request is executed on the Chainweb node and broadcast to other
// nodes. It modifies the state of the blockchain, costs gas, and requires a
// sender.
export interface SendRequest<a> {
// The sender is the address responsible for paying the gas on the transaction
sender: string;
// The maximum amount of gas this request is allowed to take
gasLimit: number;
// The code that should be executed on the Chainweb node
code: PactCode | PactCode[] | string;
// Data that should be included on the transaction so Pact code can read it
data?: { [key: string]: Pact.PactValue | Pact.KeySet };
// Signers of the transaction along with any capabilities the signatures
// should be scoped to.
signers: Pact.KeyPairCapabilities | Pact.KeyPairCapabilities[];
transformResponse: (response: Pact.PactValue) => a;
}
// A helper function to coerce the result of a successful Pact execution into
// the specified type, which can be used for 'transformResponse' when you know the
// result can be mapped exactly JSON.
export const coercePactValue = <a>(data: Pact.PactValue): a => {
return JSON.parse(JSON.stringify(data)) as a;
};
// The Chainweb endpoints can return numbers in a variety of formats. If you
// always want a number then this function can help.
export const coercePactNumber = (value: Pact.PactValue | number): number => {
if (isPactInt(value)) {
return parseFloat(value.int);
} else if (isPactDecimal(value)) {
return parseFloat(value.decimal);
} else {
return value as number;
}
};
export const coercePactObject = (value: Pact.PactValue): { [x: string]: Pact.PactValue } => {
return coercePactValue(value);
};
export type PactRequest<a> = LocalRequest<a> | SendRequest<a>;
export function isSendRequest<a>(req: PactRequest<a>): req is SendRequest<a> {
return (req as SendRequest<a>).sender ? true : false;
}
export function isLocalRequest<a>(req: PactRequest<a>): req is LocalRequest<a> {
return isSendRequest(req) ? false : true;
}
/* 2. REQUEST STATUS TYPES
Let's capture the possible responses from the node when we send a /local or a
/send request. There are a few possible states:
* Pending: The request has been formatted and sent, but we have not received a
response yet. We'll use this status to render a spinner in the UI.
* Request Error: Request errors indicate an issue with the formatted request
reaching the Pact API. They occur when we can't connect to the node (ie. we
forgot to start devnet), or the request was badly formatted, or the Pact code
has syntax errors or calls a function that does not exist. For example, we
will receive a request error if we try to call the faucet without deploying it
to devnet first. We receive a simple string as our error.
* Exec Error: Execution errors indicate that our Pact code ran, but failed. For
example, we may have forgotten to sign for a capability, or we may have failed
an (enforce) check in the contract we called. Execution errors provide a more
structured response including an error message and source span.
* Success: A success result indicates that our Pact code ran and returned a
result to us. We can parse the result and use it.
*/
export type Status = "PENDING" | "REQUEST_ERROR" | "EXEC_ERROR" | "SUCCESS";
export const PENDING = "PENDING";
export const REQUEST_ERROR = "REQUEST_ERROR";
export const EXEC_ERROR = "EXEC_ERROR";
export const SUCCESS = "SUCCESS";
// A transaction is pending when we have sent it to a Chainweb node but haven't
// yet received a response.
export interface Pending {
request: Pact.ExecCmd;
status: "PENDING";
}
// A request may fail if we are unable to reach a Chainweb node, in which case
// we will receive an error string that allows us to diagnose the error.
export interface RequestError {
request: Pact.ExecCmd;
status: "REQUEST_ERROR";
message: string;
}
// A request may fail if the Pact code in the transaction is no good — for
// example, if we didn't sign a required capability. A failed transaction still
// consumes gas and produces metadata, so we can provide details in the case
// of transaction failure.
export interface ExecError {
request: Pact.ExecCmd;
status: "EXEC_ERROR";
response: Pact.FailedLocalResponse | Pact.FailedExecResponse;
}
// If a request succeeds, then we store the response so that we can parse the
// result later for display in the UI.
export interface Success<a> {
request: Pact.ExecCmd;
status: "SUCCESS";
response: Pact.SuccessLocalResponse | Pact.SuccessExecResponse;
parsed: a;
}
export const mergeStatuses = (a: Status, b: Status): Status => {
if (a === PENDING || b === PENDING) return PENDING;
if (a === REQUEST_ERROR || b === REQUEST_ERROR) return REQUEST_ERROR;
if (a === EXEC_ERROR || b === EXEC_ERROR) return EXEC_ERROR;
return SUCCESS;
};
// The possible result of sending a request for execution
export type RequestResult<a> = RequestError | ExecError | Success<a>;
// The possible statuses of sending a request for execution
export type RequestStatus<a> = Pending | RequestResult<a>;
/* 3. EXECUTING REQUESTS
Alright! We've created types to capture the Pact code we will send to our
Chainweb node for executaion and the possible results of that request. Now it's
time to implement the requests themselves.
We will implement a PactAPI class that can be configured once and then used
to send requests. This class provides four ways to make a request:
* local: execute a LocalRequest
* send: execute a SendRequest
* localWithCallback: execute a LocalRequest, getting notified of each status
change on the request.
* sendWithCallback: execute a SendRequest, getting notified of each status
change on the request.
Along the way we'll implement helper functions for formatting a Pact request. I
recommend you read through this class to see concretely how to send a request to
the Pact endpoint and interpret the possible responses.
*/
// The hostname is the location of the Chainweb node. We always run a devnet
// node from localhost:8080 in our local applications.
export type HostName = string;
// Common fields that are required when sending Pact code to the Pact API on a
// Chainweb node, but which have a sensible default value. Most of these fields
// should be familiar from the request files in Project 1. See also:
// https://pact-language.readthedocs.io/en/stable/pact-reference.html#request-yaml-file-format
export interface PactAPIConfig {
// The location of the Chainweb node we're targeting. In our case this is the
// host where devnet is running, but for production this would be the server
// where you are hosting a Chainweb node.
hostname: HostName;
// Which network to target. For production code targeting the main Kadena
// network, use "mainnet01". For devnet use "development."
networkId: Pact.NetworkId;
// Which chain the Pact code should be executed on. Should be the same chain
// as where the target module has been deployed.
chainId: string;
// The maximum price we are willing to pay per unit of gas. To find the current
// gas prices on Chainweb, look at the recent transactions here:
// https://explorer.chainweb.com/mainnet/
gasPrice: number;
// The maximum number of seconds it can take for this transaction to be
// processed. If it is not processed in time then it will be rejected by the
// Chainweb node.
ttl: number;
}
// Create a new instance of the PactAPI class, which helps you send Pact code
// for the local (read-only) and send (writable) endpoints of a Chainweb node.
export class PactAPI {
constructor(config?: Partial<PactAPIConfig>) {
// A set of reasonable defaults for use with the PactAPI class. These can be
// overridden on a per-request basis.
this.defaults = {
// Our devnet node runs on localhost:8080
hostname: config?.hostname || "localhost:8080",
// We're typically targeting devnet, so we use the "development" network
networkId: config?.networkId || "development",
// We usually deploy to chain 0 for convenience.
chainId: config?.chainId || "0",
// This is a typical gas price.
gasPrice: config?.gasPrice || 0.0000001,
// We don't want our transactions to process if they get stuck for more than
// 3 minutes, if for no other reason than that it will make our UI slow.
ttl: config?.ttl || 300,
};
}
// The default configuration for use in each request. Any value in the config
// can be overridden by setting it as part of the request.
defaults: PactAPIConfig;
// Format the full API URL for the Pact endpoint on a Chainweb node
private apiHost = (host: HostName, networkId: Pact.NetworkId, chainId: string): string =>
`http://${host}/chainweb/0.0/${networkId}/chain/${chainId}/pact`;
// Set the creation time to right now (with a slight delay in case the node
// and your local clocks are somewhat out of sync).
private creationTime = () => {
return Math.round(new Date().getTime() / 1000 - 15);
};
// Format a request into an ExecCmd suitable for use with pact-lang-api
// functions.
format<a>(
request: PactRequest<a>,
sender: string,
gasLimit: number,
config?: Partial<PactAPIConfig>
): Pact.ExecCmd {
const networkId = config?.networkId || this.defaults.networkId;
const chainId = config?.chainId || this.defaults.chainId;
return {
networkId,
pactCode: typeof request.code === "string" ? request.code : formatPactCode(request.code),
envData: request.data,
keyPairs: request.signers,
meta: {
chainId,
sender,
gasLimit,
creationTime: this.creationTime(),
gasPrice: config?.gasPrice || this.defaults.gasPrice,
ttl: config?.ttl || this.defaults.ttl,
},
};
}
// We'll start with the /local endpoint:
// https://api.chainweb.com/openapi/pact.html#tag/endpoint-local
//
// When we send Pact code in a local request it will be executed on the node
// and the result of evaluation will be returned. For example, if we use
// (get-limits) from the goliath-faucet contract we will receive an object of
// our account limits in return.
//
// The pact-lang-api library provides a `local` function that takes an ExecCmd and
// the URL of the Pact endpoint to hit and makes an HTTP request on our behalf.
//
// Our request function is a wrapper around Pact.fetch.local that accepts a
// LocalRequest as input, formats it (including setting the creation time),
// makes the request with `local`, and then parses the result.
async local<a>(
request: LocalRequest<a>,
config?: Partial<PactAPIConfig>
): Promise<RequestResult<a>> {
const hostname = config?.hostname || this.defaults.hostname;
// We set the sender to "" and the gas limit to the maximum because they
// aren't needed for local requests.
const cmd = this.format(request, "", 150_000, config);
const endpoint = this.apiHost(hostname, cmd.networkId, cmd.meta.chainId);
try {
const response = await Pact.fetch.local(cmd, endpoint);
// If we receive a raw string as our result that indicates a request error.
// The returned string is the error message.
if (typeof response === "string") {
return { request: cmd, status: REQUEST_ERROR, message: response };
}
// Otherwise, we received a response object.
switch (response.result.status) {
case "failure":
const failure = response as Pact.FailedLocalResponse;
return { request: cmd, status: EXEC_ERROR, response: failure };
case "success":
const success = response as Pact.SuccessLocalResponse;
const parsed = request.transformResponse(success.result.data);
return { request: cmd, status: SUCCESS, response: success, parsed };
default:
const message = "Did not receive 'success' or 'failure' status.";
return { request: cmd, status: REQUEST_ERROR, message };
}
} catch (err) {
return { request: cmd, status: REQUEST_ERROR, message: `${err}` };
}
}
// Next, we'll implement requests to the /send endpoint:
// https://api.chainweb.com/openapi/pact.html#tag/endpoint-send
//
// When we send Pact code to the /send endpoint it will be executed on the node
// and the transaction will be mined into a block and synced with other nodes.
// These requests take some time (usually 30 to 90 seconds) to complete, so the
// endpoint doesn't return a result right away. Instead, it returns a request
// ID you can use to look up the transaction status later.
//
// There are two endpoints to look up the transaction status: /poll and /listen.
//
// The /poll endpoint is non-blocking. We ask for the transaction result, and if
// it is still processing then we get an empty response. We should wait a few
// seconds and then ask again. This is the preferred way to look up a
// transaction status, because it lets us do other useful work while we wait.
// https://api.chainweb.com/openapi/pact.html#tag/endpoint-poll
//
// The /listen endpoint is blocking. We ask for the transaction result, and if
// the transaction is still processing then the connection is held open until it
// completes and the result is returned.
// https://api.chainweb.com/openapi/pact.html#tag/endpoint-poll
async send<a>(
request: SendRequest<a>,
config?: Partial<PactAPIConfig>
): Promise<RequestResult<a>> {
const hostname = config?.hostname || this.defaults.hostname;
const cmd = this.format(request, request.sender, request.gasLimit, config);
const endpoint = this.apiHost(hostname, cmd.networkId, cmd.meta.chainId);
// We first send a local response so that we can see error messages, if any.
// The 'send' endpoints do not return them.
const localRequest = { ...request };
const localResponse = await this.local(localRequest, config);
// Errors should return immediately, but if the local response is successful
// then we can move on to actually process the transaction.
if (localResponse.status !== SUCCESS) return localResponse;
try {
const sendResponse = await Pact.fetch.send(cmd, endpoint);
// The 'send' endpoint returns a request key we can use to poll for results.
if (typeof sendResponse === "string") {
// If we received a string back, that means the request failed.
return { request: cmd, status: REQUEST_ERROR, message: sendResponse };
} else if (!sendResponse.requestKeys[0]) {
// If there are no request keys then this is an unexpected failure in the
// Chainweb node.
const message = "No request key received from Chainweb node.";
return { request: cmd, status: REQUEST_ERROR, message };
}
// If we *did* receive a request key in response, then we can use it to poll
// the Chainweb node until we receive a result.
const requestKey = sendResponse.requestKeys[0];
while (true) {
const pollCmd: Pact.PollCmd = { requestKeys: [requestKey] };
const pollResponse = await Pact.fetch.poll(pollCmd, endpoint);
if (typeof pollResponse === "string") {
// As before, if we received a string back, that means the request failed.
return { request: cmd, status: REQUEST_ERROR, message: pollResponse };
} else if (Object.keys(pollResponse).length === 0) {
// If the poll endpoint returns an empty object, that means there is not
// yet a result. We should pause, then poll again for results.
await (async () => new Promise((resolve) => setTimeout(resolve, 3_000)))();
continue;
} else if (!pollResponse[requestKey]) {
// If we received an object from the poll endpoint, then our transaction
// data is located under the request key we used to poll. If that key
// doesn't exist, this is an unexpected failure from the Chainweb node.
const message = "Request key used to poll not present in poll response.";
return { request: cmd, status: REQUEST_ERROR, message };
}
// If we *did* receive an response with the appropriate key, that means our
// transaction was processed! We can now determine whether the transaction
// succeeded. Transaction results are stored under the 'result' key. The
// 'status' key within the result tells us the fate of our transaction.
const response = pollResponse[requestKey];
switch (response.result.status) {
case "failure":
const failure = response as Pact.FailedExecResponse;
return { request: cmd, status: EXEC_ERROR, response: failure };
case "success":
const success = response as Pact.SuccessExecResponse;
const parsed = request.transformResponse(success.result.data);
return { request: cmd, status: SUCCESS, response: success, parsed };
default:
// And, of course, if we received an unexpected status then once again
// this is an unexpected error.
const message = "Did not receive 'failure' or 'success' result status.";
return { request: cmd, status: REQUEST_ERROR, message };
}
}
} catch (err) {
return { request: cmd, status: REQUEST_ERROR, message: `${err}` };
}
}
// Execute a local request given a callback to be notified as the request
// status changes.
async localWithCallback<a>(
request: LocalRequest<a>,
callback: (status: RequestStatus<a>) => any,
config?: Partial<PactAPIConfig>
): Promise<RequestResult<a>> {
const cmd = this.format(request, "", 150_000, config);
callback({ status: PENDING, request: cmd });
const result = await this.local(request, config);
callback(result);
return result;
}
// Execute a send request given a callback to be notified as the request
// status changes.
async sendWithCallback<a>(
request: SendRequest<a>,
callback: (status: RequestStatus<a>) => any,
config?: Partial<PactAPIConfig>
): Promise<RequestResult<a>> {
const cmd = this.format(request, request.sender, request.gasLimit, config);
callback({ status: PENDING, request: cmd });
const result = await this.send(request, config);
callback(result);
return result;
}
}