-
-
Save aaroncox/d74a73b3d9fbc20836c32ea9deda5d70 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 { | |
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