Bulk issuance support contract
emilbayes opened this issue · comments
Try developing a contract to batch issue tokens instead of many independent transactions. This will give us atomicity and potentially gas savings
The issuance was successful. Below is a small writeup of how it was done.
Issuing in bulking using a specialised EVM batch contract would allow us to save on gas usage and make issuance atomic. A ethereum transaction has a minimum cost of 21,000 gas, while a CALL
instruction without any transfer of eth is only 9,000 gas, hence, batching CALL
s can save a lot on transaction costs. Below is the YUL contract for doing the batches and the script for assembling and sending the batches:
object "BatchContract" {
code {
// Copy the runtime contract code into memory
datacopy(0, dataoffset("runtime"), datasize("runtime"))
// setimmutable allows one to modify all occurences of a placeholder in
// copied contract code. We do this twice for this contract, setting who
// can call the contract and where the contract should branch off to
setimmutable(0, "controller", caller())
// Loading arguments to the "constructor" (ie. this code) requires loading
// memory off the end of the expected initialisation code. Here we want to
// load the Ethereum address of the vesting contract, which will be appended
// to the byte code. We use `codecopy` as the EVM does not distinguish here
// between code and data
datacopy(datasize("runtime"), datasize("BatchContract"), 32)
// setimmutable can only replace with values from the stack, hence why we
// loaded the argument into memory above and then read that piece of memory
// onto the stack (what mload is)
setimmutable(0, "vesting_address", mload(datasize("runtime")))
// Return the range of the actual runtime code, even though we actually
// used more memory than that
return(0, datasize("runtime"))
}
object "runtime" {
code {
// check for authorised sender. Note that `invalid` is used here, as it is
// cheaper than revert(0,0), as invalid is a single instruction, while the
// other would be `PUSH1 0x0 DUP1 REVERT`
if iszero(eq(caller(), loadimmutable("controller"))) { invalid() }
// Input is buffer of 20 byte address, 1 byte tranche, 10 byte amount
// Memory layout is:
// 4 byte function selector
// 20 byte address right aligned to 32 bytes
// 1 bytes uint8 aligned to 32 bytes
// 10 byte amount right aligned to 32 byte amount
// Method ID for issue_into_tranche(address,uint8,uint256)
mstore(0, 0xe2de6e6d00000000000000000000000000000000000000000000000000000000)
let len := calldatasize()
for { let i := 0 } lt(i, len) { i := add(i, 31) }
{
// Load a word at a time (32 bytes). This means we will have a spare byte
// at the end of no use
let chunk := calldataload(i)
// shift over the 20 byte address to word size, clearing the top bits
// at the same time
let user_addr := shr(96, chunk)
mstore(4, user_addr)
// We can load the tranche id as a single byte without having to do bit
// hacking
let tranche := byte(20, chunk)
mstore8(67, tranche)
// We have to clear out the spare byte we read off the end
let amount := and(chunk, 0xffffffffffffffffffff00)
// this is why this number looks odd
mstore(69, amount)
// We are just going to send off all the available gas
let gas_stipend := gas()
let vesting_address := loadimmutable("vesting_address")
let inp := 0
let inpsize := 100 // 4 + 32 + 32 + 32
// We don't care about the result status or return data, so pop removes
// the result from the stack, and the two 0s at the end leaves no memory
// for the result to be written to
pop(call(gas_stipend, vesting_address, 0, inp, inpsize, 0, 0))
}
// like invalid, stop is a cheaper way to signal that execution has ended,
// but here without raising any issues
stop()
}
}
}
const serde = require('eth-serde')
const contract = require('./build/contract.json') // Compiled YUL contract
const helpers = require('eth-helpers')
const signer = require('eth-sign')
const keygen = require('eth-keygen')
const Nanoeth = require('nanoeth/http')
const pk = Buffer.from('...', 'hex')
const chainId = 1
const eth = new Nanoeth('http://localhost:8545')
const key = keygen(pk, chainId)
const raw = require('./data.json')
const batchContractAddr = '0x...'
for (const row of raw) {
if (/^0x[a-fA-F0-9]{40}$/.test(row.address) == false) throw new Error(JSON.stringify(row))
if (row.tranche_id < 0 || row.tranche_id > 9) throw new Error(JSON.stringify(row))
if (BigInt(row.amount).toString(16).length > 20) throw new Error(JSON.stringify(row))
}
const data = raw.map(r => r.address.slice(2) + r.tranche_id.toString(16).padStart(2, '0') + BigInt(r.amount).toString(16).padStart(20, '0'))
let full = ''
;(async () => {
var j = 1 // nonce
for (var i = 0; i < data.length; j++) {
let buf = data.slice(i, i + 160).join('')
i += buf.length / 62
while (true) {
let next = data.slice(i, i + 15).join('')
i += next.length / 62
if (next === '') {
const est = await eth.estimateGas({
from: key.address,
to: '0x' + batchContractAddr,
data: '0x' + buf
}, 'latest')
console.log(BigInt(est), buf.length / 62)
break
}
buf += next
const est = await eth.estimateGas({
from: key.address,
to: '0x' + batchContractAddr,
data: '0x' + buf
}, 'latest')
if (BigInt(est) >= 12500000n) {
console.log(BigInt(est), buf.length / 62)
break
}
}
const tx = {
nonce: Buffer.from(j.toString(16).padStart(10, '0'), 'hex'),
gasPrice: Buffer.from('08ff33a4e8', 'hex'),
gasLimit: Buffer.from('dd40a0', 'hex'),
to: Buffer.from(batchContractAddr, 'hex'),
value: Buffer.from('00', 'hex'),
data: Buffer.from(buf, 'hex')
}
const stx = signer.sign(tx, pk, chainId)
console.log(i)
full += buf
console.log(stx.raw.toString('hex'))
await eth.sendRawTransaction('0x' + stx.raw.toString('hex'))
}
console.log(full === data.join(''))
})()