Skip to content

Instantly share code, notes, and snippets.

@aaroncox
Created September 2, 2021 20:22
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/d74a73b3d9fbc20836c32ea9deda5d70 to your computer and use it in GitHub Desktop.
Save aaroncox/d74a73b3d9fbc20836c32ea9deda5d70 to your computer and use it in GitHub Desktop.
const {
APIClient,
Checksum256,
FetchProvider,
Name,
PackedTransaction,
PermissionLevel,
PrivateKey,
Serializer,
Transaction,
} = require('@greymass/eosio')
const fetch = require('node-fetch')
// The generic nodeos API node to use
const provider = new FetchProvider('https://jungle3.greymass.com', { fetch })
const client = new APIClient({ provider })
// The Resource Provider API to request transactions from
// https://forums.eoscommunity.org/t/initial-specification-for-the-resource-provider-api-endpoint/1546
const resourceProviderEndpoint =
'http://localhost:8080/v1/resource_provider/request_transaction'
// The ID of the blockchain
// This could be dynamic, but saving an API call here
const chainId =
'2a02a0053e5a8cf73a56ba0fda11e4d92e0238a4a2aa74fccf46d5a910746840'
// The authority used to sign the test transaction
const signer = PermissionLevel.from({
actor: 'cosigntest11',
permission: 'cosigntest',
})
// The private key used to sign transactions for the signer
const privateKey = PrivateKey.from(
'5JcpNyKcUhjjR5vz4ABKChjm4Njnfn89xjSKfN3bJmAwGMKiDUU'
)
// The maximum fee per transaction this script is willing to accept
const maxFee = 0.005
// The expected account to cosign the transaction
const expectedCosignerContract = Name.from('greymassnoop')
const expectedCosignerAction = Name.from('noop')
const expectedCosignerAccountName = Name.from('cosigncosign')
const expectedCosignerAccountPermission = Name.from('cosign')
// An example transaction to perform
const actionData = {
account: 'eosio.token',
name: 'transfer',
authorization: [
{
actor: signer.actor,
permission: signer.permission,
},
],
data: {
from: signer.actor,
to: 'teamgreymass',
quantity: '0.0001 EOS',
memo: '',
},
}
async function main() {
try {
// Retrieve transaction headers
const expireSeconds = 3600
const info = await client.v1.chain.get_info()
const header = info.getTransactionHeader(expireSeconds)
// Load ABI for contract from an API
const { abi } = await client.v1.chain.get_abi('eosio.token')
// Generate the desired unsigned transaction
const transaction = Transaction.from(
{
...header,
actions: [actionData],
},
[
{
contract: Name.from('eosio.token'),
abi,
},
]
)
// Pack the transaction for transport
const packedTransaction = PackedTransaction.from({
signatures: [],
packed_context_free_data: '',
packed_trx: Serializer.encode({ object: transaction }),
})
// Submit the transaction to the resource provider endpoint
const cosigned = await fetch(resourceProviderEndpoint, {
body: JSON.stringify({
signer,
packedTransaction,
}),
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 and convert into core type
const { data } = json
const [, returnedTransaction] = data.request
const modifiedTransaction = Transaction.from(returnedTransaction)
/*
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`
)
// Ensure the modifed transaction is what the application expects
// These validation methods will throw an exception if invalid data exists
await validateTransaction(
signer,
modifiedTransaction,
transaction,
data.costs
)
// Sign the modified transaction
const signedTransaction = await signModifiedTransaction(
modifiedTransaction
)
// Merge signatures from the user and the cosigned response
signedTransaction.signatures = [
...signedTransaction.signatures,
...data.signatures,
]
// Broadcast the signed transaction to the blockchain
const response = await client.v1.chain.push_transaction(
signedTransaction
)
console.log(`\n\nBroadcast response from API:\n`)
console.log(response)
break
}
case 200: {
console.log(`\n\nResource Provider provided signature for free`)
// Ensure the modifed transaction is what the application expects
// These validation methods will throw an exception if invalid data exists
await validateTransaction(
signer,
modifiedTransaction,
transaction,
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,
]
// Broadcast the signed transaction to the blockchain
const response = await client.v1.chain.push_transaction(
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, aborting\n`
)
break
}
}
} catch (e) {
console.log(e)
}
}
async function signModifiedTransaction(transaction) {
const digest = transaction.signingDigest(Checksum256.from(chainId))
return PackedTransaction.from({
signatures: [privateKey.signDigest(digest)],
packed_context_free_data: '',
packed_trx: Serializer.encode({ object: transaction }),
})
}
// Validate the transaction
async function validateTransaction(
signer,
modifiedTransaction,
transaction,
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, transaction, costs)
}
// Validate the actions of the modified transaction vs the original transaction
async function validateActions(
signer,
modifiedTransaction,
transaction,
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, transaction)
// Ensure the appended actions were expected
await validateActionsContent(
signer,
expectedNewActions,
modifiedTransaction,
transaction
)
}
// 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)
if (costs.ram !== '0.0000 EOS') {
expectedNewActions += 1
}
}
return expectedNewActions
}
// Validate the contents of each action
async function validateActionsContent(
signer,
expectedNewActions,
modifiedTransaction,
transaction
) {
// Make sure the originally requested actions are still intact and unmodified
validateActionsOriginalContent(
expectedNewActions,
modifiedTransaction,
transaction
)
// 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.equals(Name.from('eosio.token')) ||
!feeAction.name.equals(Name.from('transfer')) ||
!feeAction.data.to.equals(Name.from('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.equals(Name.from('eosio')) ||
!['buyram', 'buyrambytes'].includes(String(ramAction.name)) ||
!ramAction.data.payer.equals(Name.from('greymassfuel')) ||
!ramAction.data.receiver.equals(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,
transaction
) {
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
const original = transaction.actions[i - expectedNewActions]
const action = modifiedTransaction.actions[i]
const matchesAccount = action.account.equals(original.account)
const matchesAction = action.name.equals(original.name)
const matchesLength =
action.authorization.length === original.authorization.length
const matchesActor = action.authorization[0].actor.equals(
original.authorization[0].actor
)
const matchesPermission = action.authorization[0].permission.equals(
original.authorization[0].permission
)
const matchesData = action.data.equals(original.data)
if (
!action ||
!matchesAccount ||
!matchesAction ||
!matchesLength ||
!matchesActor ||
!matchesPermission ||
!matchesData
) {
const { account, name } = original
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,
transaction
) {
if (
modifiedTransaction.actions.length !==
transaction.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) {
const [firstAction] = modifiedTransaction.actions
const [firstAuthorization] = firstAction.authorization
if (
!firstAction.account.equals(expectedCosignerContract) ||
!firstAction.name.equals(expectedCosignerAction) ||
!firstAuthorization.actor.equals(expectedCosignerAccountName) ||
!firstAuthorization.permission.equals(
expectedCosignerAccountPermission
) ||
String(firstAction.data) !== ''
) {
throw new Error(
`First action within transaction response is not valid noop (${expectedCosignerContract}:${expectedCosignerAction} signed by ${expectedCosignerAccountName}:${expectedCosignerAccountPermission}).`
)
}
}
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment