Skip to content

Instantly share code, notes, and snippets.

@aaroncox
Last active April 16, 2021 01:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save aaroncox/e2f48c5164ba369ec74a42ee5129327c to your computer and use it in GitHub Desktop.
Save aaroncox/e2f48c5164ba369ec74a42ee5129327c to your computer and use it in GitHub Desktop.
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