-
-
Save aaroncox/e2f48c5164ba369ec74a42ee5129327c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const ecc = require('eosjs-ecc') | |
const fetch = require('node-fetch') | |
const util = require('util') | |
const { Api, JsonRpc, Serialize } = require('eosjs') | |
const { JsSignatureProvider } = require('eosjs/dist/eosjs-jssig') | |
const { TextDecoder, TextEncoder } = util | |
// The generic nodeos API node to use | |
const httpEndpoint = 'https://eos.greymass.com' | |
const rpc = new JsonRpc(httpEndpoint, { fetch }) | |
// The Resource Provider API to request transactions from | |
// https://forums.eoscommunity.org/t/initial-specification-for-the-resource-provider-api-endpoint/1546 | |
// | |
// The URL used below is the Fuel API endpoint (https://greymass.com/en/fuel) | |
const resourceProviderEndpoint = | |
'https://eos.greymass.com/v1/resource_provider/request_transaction' | |
// The ID of the blockchain | |
// This could be dynamic, but saving an API call here | |
const chainId = | |
'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906' | |
// The authority used to sign the test transaction | |
const signer = { | |
actor: 'test.gm', | |
permission: 'active', | |
} | |
// The private key used to sign transactions for the signer | |
const privateKey = 'WIF_PRIVATE_KEY' | |
const publicKey = ecc.privateToPublic(privateKey) | |
const signatureProvider = new JsSignatureProvider([privateKey]) | |
// The maximum fee per transaction this script is willing to accept | |
const maxFee = 0.005 | |
// Establish the API alongwith the rpc and signature provider | |
const api = new Api({ | |
rpc, | |
signatureProvider, | |
textDecoder: new TextDecoder(), | |
textEncoder: new TextEncoder(), | |
}) | |
// An example transaction to perform | |
const action = { | |
account: 'eosio.token', | |
name: 'transfer', | |
authorization: [ | |
{ | |
actor: signer.actor, | |
permission: signer.permission, | |
}, | |
], | |
data: { | |
from: signer.actor, | |
to: 'teamgreymass', | |
quantity: '0.0001 EOS', | |
memo: '', | |
}, | |
} | |
const actions = [action] | |
const exampleTransaction = { actions } | |
async function main() { | |
try { | |
// Generate the desired unsigned transaction | |
const transaction = await api.transact(exampleTransaction, { | |
// Use the last irreverisible block | |
useLastIrreversible: true, | |
// Expiration of 5 minutes | |
expireSeconds: 300, | |
// Do not broadcast | |
broadcast: false, | |
// Do not sign | |
sign: false, | |
}) | |
// Deserialize the transaction | |
const deserializedTransaction = api.deserializeTransaction( | |
transaction.serializedTransaction | |
) | |
// Submit the transaction to the resource provider endpoint | |
const cosigned = await rpc.fetchBuiltin(resourceProviderEndpoint, { | |
body: JSON.stringify({ | |
signer, | |
transaction: deserializedTransaction, | |
}), | |
method: 'POST', | |
}) | |
// Interpret the resulting JSON | |
const json = await cosigned.json() | |
console.log( | |
`\n\nResponse (${json.code}) from resource provider api...\n` | |
) | |
console.log(json) | |
// Pull the modified transaction from the API response | |
const { data } = json | |
const [, modifiedTransaction] = data.request | |
/* | |
Based on the response code, perform different functions | |
200 = Signature provided, no fee required (a free transaction) | |
400 = Resource Provider refused to provide a signature (for any reason) | |
402 = Signature provided, but a fee is required (fee-based transaction) | |
*/ | |
switch (json.code) { | |
case 402: { | |
console.log( | |
`\n\nResource Provider provided signature in exchange for a fee\n` | |
) | |
// Ensure the modifed transaction is what the application expects | |
// These validation methods will throw an exception if invalid data exists | |
await validateTransaction( | |
signer, | |
modifiedTransaction, | |
deserializedTransaction, | |
data.costs | |
) | |
// Sign the modified transaction | |
const signedTransaction = await signModifiedTransaction( | |
modifiedTransaction | |
) | |
// Merge signatures from the user and the cosigned responsetab | |
signedTransaction.signatures = [ | |
...signedTransaction.signatures, | |
...data.signatures, | |
] | |
console.log( | |
`\n\nSigned transaction using both cosigner and specified account\n` | |
) | |
console.log(signedTransaction) | |
// Broadcast the signed transaction to the blockchain | |
const response = await api.pushSignedTransaction( | |
signedTransaction | |
) | |
console.log(`\n\nBroadcast response from API:\n`) | |
console.log(response) | |
break | |
} | |
case 200: { | |
console.log( | |
`\n\nResource Provider provided signature for free\n` | |
) | |
// Ensure the modifed transaction is what the application expects | |
// These validation methods will throw an exception if invalid data exists | |
await validateTransaction( | |
signer, | |
modifiedTransaction, | |
deserializedTransaction, | |
data.costs | |
) | |
// Sign the modified transaction | |
const signedTransaction = await signModifiedTransaction( | |
modifiedTransaction | |
) | |
// Merge signatures from the user and the cosigned responsetab | |
signedTransaction.signatures = [ | |
...signedTransaction.signatures, | |
...data.signatures, | |
] | |
console.log( | |
`\n\nSigned transaction using both cosigner and specified account\n` | |
) | |
console.log(signedTransaction) | |
// Broadcast the signed transaction to the blockchain | |
const response = await api.pushSignedTransaction( | |
signedTransaction | |
) | |
console.log(`\n\nBroadcast response from API:\n`) | |
console.log(response) | |
break | |
} | |
default: | |
// Request Refused | |
case 400: { | |
console.log( | |
`\n\nResource Provider refused to sign the transaction, attempting without\n` | |
) | |
const transaction = await api.transact(exampleTransaction, { | |
useLastIrreversible: true, | |
expireSeconds: 300, | |
}) | |
console.log(`\n\nBroadcast response from API:\n`) | |
console.log(transaction) | |
break | |
} | |
} | |
} catch (e) { | |
console.log(e) | |
} | |
} | |
async function signModifiedTransaction(modifiedTransaction) { | |
const abis = await api.getTransactionAbis(modifiedTransaction) | |
const serializedContextFreeData = api.serializeContextFreeData( | |
modifiedTransaction.context_free_data | |
) | |
const serializedTransaction = api.serializeTransaction({ | |
...modifiedTransaction, | |
context_free_actions: await api.serializeActions( | |
modifiedTransaction.context_free_actions || [] | |
), | |
actions: modifiedTransaction.actions, | |
}) | |
const signedTransaction = await api.signatureProvider.sign({ | |
chainId, | |
requiredKeys: [publicKey], | |
serializedTransaction, | |
serializedContextFreeData, | |
abis, | |
}) | |
return signedTransaction | |
} | |
// Validate the transaction | |
async function validateTransaction( | |
signer, | |
modifiedTransaction, | |
serializedTransaction, | |
costs = false | |
) { | |
// Ensure the first action is the `greymassnoop:noop` | |
validateNoop(modifiedTransaction) | |
// Ensure the actions within the transaction match what was provided | |
await validateActions( | |
signer, | |
modifiedTransaction, | |
serializedTransaction, | |
costs | |
) | |
} | |
// Validate the actions of the modified transaction vs the original transaction | |
async function validateActions( | |
signer, | |
modifiedTransaction, | |
deserializedTransaction, | |
costs | |
) { | |
// Determine how many actions we expect to have been added to the transaction based on the costs | |
const expectedNewActions = determineExpectedActionsLength(costs) | |
// Ensure the proper number of actions was returned | |
validateActionsLength( | |
expectedNewActions, | |
modifiedTransaction, | |
deserializedTransaction | |
) | |
// Ensure the appended actions were expected | |
await validateActionsContent( | |
signer, | |
expectedNewActions, | |
modifiedTransaction, | |
deserializedTransaction | |
) | |
} | |
// Validate the number of actions is the number expected | |
function determineExpectedActionsLength(costs) { | |
// By default, 1 new action is appended (noop) | |
let expectedNewActions = 1 | |
// If there are costs associated with this transaction, 1 new actions is added (the fee) | |
if (costs) { | |
expectedNewActions += 1 | |
// If there is a RAM cost associated with this transaction, 1 new actio is added (the ram purchase) | |
console.log(costs) | |
if (costs.ram !== '0.0000 EOS') { | |
expectedNewActions += 1 | |
} | |
} | |
return expectedNewActions | |
} | |
// Validate the contents of each action | |
async function validateActionsContent( | |
signer, | |
expectedNewActions, | |
modifiedTransaction, | |
deserializedTransaction | |
) { | |
// Make sure the originally requested actions are still intact and unmodified | |
validateActionsOriginalContent( | |
expectedNewActions, | |
modifiedTransaction, | |
deserializedTransaction | |
) | |
// If a fee has been added, ensure the fee is set properly | |
if (expectedNewActions > 1) { | |
await validateActionsFeeContent(signer, modifiedTransaction) | |
// If a ram purchase has been added, ensure the purchase was set properly | |
if (expectedNewActions > 2) { | |
await validateActionsRamContent(signer, modifiedTransaction) | |
} | |
} | |
} | |
// Ensure the transaction fee transfer is valid | |
async function validateActionsFeeContent(signer, modifiedTransaction) { | |
const [feeAction] = await api.deserializeActions([ | |
modifiedTransaction.actions[1], | |
]) | |
const amount = parseFloat(feeAction.data.quantity.split(' ')[0]) | |
if (amount > maxFee) { | |
throw new Error( | |
`Fee of ${amount} exceeds the maximum fee of ${maxFee}.` | |
) | |
} | |
if ( | |
feeAction.account !== 'eosio.token' || | |
feeAction.name !== 'transfer' || | |
feeAction.data.to !== 'fuel.gm' | |
) { | |
throw new Error('Fee action was deemed invalid.') | |
} | |
} | |
// Ensure the RAM purchasing action is valid | |
async function validateActionsRamContent(signer, modifiedTransaction) { | |
const [ramAction] = await api.deserializeActions([ | |
modifiedTransaction.actions[2], | |
]) | |
if ( | |
ramAction.account !== 'eosio' || | |
!['buyram', 'buyrambytes'].includes(ramAction.name) || | |
ramAction.data.payer !== 'greymassfuel' || | |
ramAction.data.receiver !== signer.actor | |
) { | |
throw new Error('RAM action was deemed invalid.') | |
} | |
} | |
// Make sure the actions returned in the API response match what was submitted | |
function validateActionsOriginalContent( | |
expectedNewActions, | |
modifiedTransaction, | |
deserializedTransaction | |
) { | |
for (const [i, v] of modifiedTransaction.actions.entries()) { | |
// Skip the expected new actions | |
if (i < expectedNewActions) continue | |
// Compare each action to the originally generated actions | |
if ( | |
!modifiedTransaction.actions[i] || | |
modifiedTransaction.actions[i].account !== | |
deserializedTransaction.actions[i - expectedNewActions] | |
.account || | |
modifiedTransaction.actions[i].name !== | |
deserializedTransaction.actions[i - expectedNewActions].name || | |
modifiedTransaction.actions[i].authorization.length !== | |
deserializedTransaction.actions[i - expectedNewActions] | |
.authorization.length || | |
modifiedTransaction.actions[i].authorization[0].actor !== | |
deserializedTransaction.actions[i - expectedNewActions] | |
.authorization[0].actor || | |
modifiedTransaction.actions[i].authorization[0].permission !== | |
deserializedTransaction.actions[i - expectedNewActions] | |
.authorization[0].permission || | |
modifiedTransaction.actions[i].data.toLowerCase() !== | |
deserializedTransaction.actions[ | |
i - expectedNewActions | |
].data.toLowerCase() | |
) { | |
const { account, name } = deserializedTransaction.actions[ | |
i - expectedNewActions | |
] | |
throw new Error( | |
`Transaction returned by API has non-matching action at index ${i} (${account}:${name})` | |
) | |
} | |
} | |
} | |
// Ensure no unexpected actions were appended in the response | |
function validateActionsLength( | |
expectedNewActions, | |
modifiedTransaction, | |
deserializedTransaction | |
) { | |
if ( | |
modifiedTransaction.actions.length !== | |
deserializedTransaction.actions.length + expectedNewActions | |
) { | |
throw new Error('Transaction returned contains additional actions.') | |
} | |
} | |
// Make sure the first action is the greymassnoop:noop and properly defined | |
function validateNoop(modifiedTransaction) { | |
if ( | |
modifiedTransaction.actions[0].account !== 'greymassnoop' || | |
modifiedTransaction.actions[0].name !== 'noop' || | |
modifiedTransaction.actions[0].authorization[0].actor !== | |
'greymassfuel' || | |
modifiedTransaction.actions[0].authorization[0].permission !== | |
'cosign' || | |
modifiedTransaction.actions[0].data !== '' | |
) { | |
throw new Error( | |
'First action within transaction response is not valid greymassnoop:noop.' | |
) | |
} | |
} | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment