@wharfkit/transact-plugin-resource-provider 0.3.0-ui-5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,289 @@
1
+ import {
2
+ AbstractTransactPlugin,
3
+ Action,
4
+ Asset,
5
+ AssetType,
6
+ ChainDefinition,
7
+ Name,
8
+ Serializer,
9
+ Signature,
10
+ SigningRequest,
11
+ Struct,
12
+ TransactContext,
13
+ TransactHookResponse,
14
+ TransactHookTypes,
15
+ Transaction,
16
+ } from '@wharfkit/session'
17
+
18
+ import {getNewActions, hasOriginalActions} from './utils'
19
+
20
+ interface ResourceProviderOptions {
21
+ allowFees?: boolean
22
+ // allowActions?: NameType[]
23
+ endpoints: Record<string, string>
24
+ maxFee?: AssetType
25
+ }
26
+
27
+ interface ResourceProviderResponseData {
28
+ request: [string, object]
29
+ signatures: string[]
30
+ version: unknown
31
+ fee?: AssetType
32
+ costs?: {
33
+ cpu: AssetType
34
+ net: AssetType
35
+ ram: AssetType
36
+ }
37
+ }
38
+
39
+ interface ResourceProviderResponse {
40
+ code: number
41
+ data: ResourceProviderResponseData
42
+ }
43
+
44
+ @Struct.type('transfer')
45
+ export class Transfer extends Struct {
46
+ @Struct.field(Name) from!: Name
47
+ @Struct.field(Name) to!: Name
48
+ @Struct.field(Asset) quantity!: Asset
49
+ @Struct.field('string') memo!: string
50
+ }
51
+
52
+ export default class ResourceProviderPlugin extends AbstractTransactPlugin {
53
+ readonly allowFees: boolean = false
54
+ // readonly allowActions: Name[] = [
55
+ // Name.from('eosio.token:transfer'),
56
+ // Name.from('eosio:buyrambytes'),
57
+ // ]
58
+ readonly maxFee?: Asset
59
+
60
+ readonly endpoints: Record<string, string> = {}
61
+
62
+ constructor(options: ResourceProviderOptions) {
63
+ super()
64
+ // Set the endpoints and chains available
65
+ this.endpoints = options.endpoints
66
+ if (typeof options?.allowFees !== 'undefined') {
67
+ this.allowFees = options.allowFees
68
+ }
69
+ // TODO: Allow contact/action combos to be passed in and checked against to ensure no rogue actions were appended.
70
+ // if (typeof options.allowActions !== 'undefined') {
71
+ // this.allowActions = options.allowActions.map((action) => Name.from(action))
72
+ // }
73
+ if (typeof options?.maxFee !== 'undefined') {
74
+ this.maxFee = Asset.from(options.maxFee)
75
+ }
76
+ }
77
+
78
+ register(context: TransactContext): void {
79
+ context.addHook(TransactHookTypes.beforeSign, (request, context) =>
80
+ this.request(request, context)
81
+ )
82
+ }
83
+
84
+ getEndpoint(chain: ChainDefinition): string {
85
+ return this.endpoints[String(chain.id)]
86
+ }
87
+
88
+ async request(
89
+ request: SigningRequest,
90
+ context: TransactContext
91
+ ): Promise<TransactHookResponse> {
92
+ // Validate that this request is valid for the resource provider
93
+ this.validateRequest(request, context)
94
+
95
+ // Determine appropriate URL for this request
96
+ const endpoint = this.getEndpoint(context.chain)
97
+
98
+ // If no endpoint was found, gracefully fail and return the original request.
99
+ if (!endpoint) {
100
+ return {
101
+ request,
102
+ }
103
+ }
104
+
105
+ // Assemble the request to the resource provider.
106
+ const url = `${endpoint}/v1/resource_provider/request_transaction`
107
+
108
+ // Perform the request to the resource provider.
109
+ const response = await context.fetch(url, {
110
+ method: 'POST',
111
+ body: JSON.stringify({
112
+ ref: 'unittest',
113
+ request,
114
+ signer: context.permissionLevel,
115
+ }),
116
+ })
117
+ const json: ResourceProviderResponse = await response.json()
118
+
119
+ // If the resource provider refused to process this request, return the original request without modification.
120
+ if (response.status === 400) {
121
+ return {
122
+ request,
123
+ }
124
+ }
125
+
126
+ const requiresPayment = response.status === 402
127
+ if (requiresPayment) {
128
+ // If the resource provider offered transaction with a fee, but plugin doesn't allow fees, return the original transaction.
129
+ if (!this.allowFees) {
130
+ // TODO: Notify the script somehow of this, maybe we need an optional logger?
131
+ // Notify that a fee was required but not allowed via allowFees: false.
132
+ return {
133
+ request,
134
+ }
135
+ }
136
+ }
137
+
138
+ // Retrieve the transaction from the response
139
+ const modifiedTransaction = this.getModifiedTransaction(json)
140
+ // Ensure the new transaction has an unmodified version of the original action(s)
141
+ const originalActionsIntact = hasOriginalActions(
142
+ request.getRawTransaction(),
143
+ modifiedTransaction
144
+ )
145
+
146
+ if (!originalActionsIntact) {
147
+ // TODO: Notify the script somehow of this, maybe we need an optional logger?
148
+ // Notify that the original actions requested were modified somehow, and reject the modification.
149
+ return {
150
+ request,
151
+ }
152
+ }
153
+
154
+ // Retrieve all newly appended actions from the modified transaction
155
+ const addedActions = getNewActions(request.getRawTransaction(), modifiedTransaction)
156
+
157
+ // TODO: Check that all the addedActions are allowed via this.allowActions
158
+
159
+ // Find any transfer actions that were added to the transaction, which we assume are fees
160
+ const addedFees = addedActions
161
+ .filter(
162
+ (action: Action) =>
163
+ action.account.equals('eosio.token') && action.name.equals('transfer')
164
+ )
165
+ .map(
166
+ (action: Action) =>
167
+ Serializer.decode({
168
+ data: action.data,
169
+ type: Transfer,
170
+ }).quantity
171
+ )
172
+ .reduce((total: Asset, fee: Asset) => {
173
+ total.units.add(fee.units)
174
+ return total
175
+ }, Asset.from('0.0000 EOS'))
176
+
177
+ // If the resource provider offered transaction with a fee, but the fee was higher than allowed, return the original transaction.
178
+ if (this.maxFee) {
179
+ if (addedFees.units > this.maxFee.units) {
180
+ // TODO: Notify the script somehow of this, maybe we need an optional logger?
181
+ // Notify that a fee was required but higher than allowed via maxFee.
182
+ return {
183
+ request,
184
+ }
185
+ }
186
+ }
187
+
188
+ // Validate that the response is valid for the session.
189
+ await this.validateResponseData(json)
190
+
191
+ // NYI: Interact with interface via context for fee based prompting
192
+
193
+ /* Psuedo-code for fee based prompting
194
+
195
+ if (response.status === 402) {
196
+
197
+ // Prompt for the fee acceptance
198
+ const promptResponse = context.userPrompt({
199
+ title: 'Transaction Fee Required',
200
+ message: `This transaction requires a fee of ${response.json.data.fee} EOS. Do you wish to accept this fee?`,
201
+ })
202
+
203
+ // If the user did not accept the fee, return the original request without modification.
204
+ if (!promptResponse) {
205
+ return {
206
+ request,
207
+ }
208
+ }
209
+ } */
210
+
211
+ // Create a new signing request based on the response to return to the session's transact flow.
212
+ const modified = await this.createRequest(json, context)
213
+
214
+ // Return the modified transaction and additional signatures
215
+ return {
216
+ request: modified,
217
+ signatures: json.data.signatures.map((sig) => Signature.from(sig)),
218
+ }
219
+ }
220
+
221
+ getModifiedTransaction(json): Transaction {
222
+ switch (json.data.request[0]) {
223
+ case 'action':
224
+ throw new Error('A resource provider providing an "action" is not supported.')
225
+ case 'actions':
226
+ throw new Error('A resource provider providing "actions" is not supported.')
227
+ case 'transaction':
228
+ return Transaction.from(json.data.request[1])
229
+ }
230
+ throw new Error('Invalid request type provided by resource provider.')
231
+ }
232
+
233
+ async createRequest(
234
+ response: ResourceProviderResponse,
235
+ context: TransactContext
236
+ ): Promise<SigningRequest> {
237
+ // Create a new signing request based on the response to return to the session's transact flow.
238
+ const request = await SigningRequest.create(
239
+ {transaction: response.data.request[1]},
240
+ context.esrOptions
241
+ )
242
+
243
+ // Set the required fee onto the request itself for wallets to process.
244
+ if (response.code === 402 && response.data.fee) {
245
+ request.setInfoKey('txfee', Asset.from(response.data.fee))
246
+ }
247
+
248
+ // If the fee costs exist, set them on the request for the signature provider to consume
249
+ if (response.data.costs) {
250
+ request.setInfoKey('txfeecpu', response.data.costs.cpu)
251
+ request.setInfoKey('txfeenet', response.data.costs.net)
252
+ request.setInfoKey('txfeeram', response.data.costs.ram)
253
+ }
254
+
255
+ return request
256
+ }
257
+ /**
258
+ * Perform validation against the request to ensure it is valid for the resource provider.
259
+ */
260
+ validateRequest(request: SigningRequest, context: TransactContext): void {
261
+ // Retrieve first authorizer and ensure it matches session context.
262
+ const firstAction = request.getRawActions()[0]
263
+ const firstAuthorizer = firstAction.authorization[0]
264
+ if (!firstAuthorizer.actor.equals(context.permissionLevel.actor)) {
265
+ throw new Error('The first authorizer of the transaction does not match this session.')
266
+ }
267
+ }
268
+ /**
269
+ * Perform validation against the response to ensure it is valid for the session.
270
+ */
271
+ async validateResponseData(response: Record<string, any>): Promise<void> {
272
+ // If the data wasn't provided in the response, throw an error.
273
+ if (!response) {
274
+ throw new Error('Resource provider did not respond to the request.')
275
+ }
276
+
277
+ // If a malformed response with a fee was provided, throw an error.
278
+ if (response.code === 402 && !response.data.fee) {
279
+ throw new Error(
280
+ 'Resource provider returned a response indicating required payment, but provided no fee amount.'
281
+ )
282
+ }
283
+
284
+ // If no signatures were provided, throw an error.
285
+ if (!response.data.signatures || !response.data.signatures[0]) {
286
+ throw new Error('Resource provider did not return a signature.')
287
+ }
288
+ }
289
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,53 @@
1
+ import {Action, Transaction} from '@wharfkit/session'
2
+
3
+ export function hasOriginalActions(original: Transaction, modified: Transaction): boolean {
4
+ return original.actions.every((originalAction: Action) => {
5
+ return modified.actions.some((modifiedAction: Action) => {
6
+ // Ensure the original contract account matches
7
+ const matchesOriginalContractAccount = originalAction.account.equals(
8
+ modifiedAction.account
9
+ )
10
+ // Ensure the original contract action matches
11
+ const matchesOriginalContractAction = originalAction.name.equals(modifiedAction.name)
12
+ // Ensure the original authorization is in tact
13
+ const matchesOriginalAuthorization =
14
+ originalAction.authorization.length === modifiedAction.authorization.length &&
15
+ originalAction.authorization[0].actor.equals(modifiedAction.authorization[0].actor)
16
+ // Ensure the original action data matches
17
+ const matchesOriginalActionData = originalAction.data.equals(modifiedAction.data)
18
+ // Return any action that does not match the original
19
+ return (
20
+ matchesOriginalContractAccount &&
21
+ matchesOriginalContractAction &&
22
+ matchesOriginalAuthorization &&
23
+ matchesOriginalActionData
24
+ )
25
+ })
26
+ })
27
+ }
28
+
29
+ export function getNewActions(original: Transaction, modified: Transaction): Action[] {
30
+ return modified.actions.filter((modifiedAction: Action) => {
31
+ return original.actions.some((originalAction: Action) => {
32
+ // Ensure the original contract account matches
33
+ const matchesOriginalContractAccount = originalAction.account.equals(
34
+ modifiedAction.account
35
+ )
36
+ // Ensure the original contract action matches
37
+ const matchesOriginalContractAction = originalAction.name.equals(modifiedAction.name)
38
+ // Ensure the original authorization is in tact
39
+ const matchesOriginalAuthorization =
40
+ originalAction.authorization.length === modifiedAction.authorization.length &&
41
+ originalAction.authorization[0].actor.equals(modifiedAction.authorization[0].actor)
42
+ // Ensure the original action data matches
43
+ const matchesOriginalActionData = originalAction.data.equals(modifiedAction.data)
44
+ // Return any action that does not match the original
45
+ return !(
46
+ matchesOriginalContractAccount &&
47
+ matchesOriginalContractAction &&
48
+ matchesOriginalAuthorization &&
49
+ matchesOriginalActionData
50
+ )
51
+ })
52
+ })
53
+ }