-
Notifications
You must be signed in to change notification settings - Fork 52
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #5 from nervosnetwork/transaction-generation-skeleton
feat: Add basic transaction generation skeleton
- Loading branch information
Showing
17 changed files
with
862 additions
and
31 deletions.
There are no files selected for viewing
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
# `@ckb-lumos/common-scripts` | ||
|
||
Common script implementation for lumos. | ||
|
||
## Usage | ||
|
||
``` | ||
const commonScripts = require('@ckb-lumos/common-scripts'); | ||
// TODO: DEMONSTRATE API | ||
``` |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module.exports = { | ||
secp256k1Blake160: require("./secp256k1_blake160"), | ||
}; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,319 @@ | ||
const { | ||
configs, | ||
parseAddress, | ||
locateCellDep, | ||
minimalCellCapacity, | ||
createTransactionFromSkeleton, | ||
} = require("@ckb-lumos/helpers"); | ||
const { LINA } = configs; | ||
const { core, values, utils } = require("@ckb-lumos/types"); | ||
const { CKBHasher, ckbHash } = utils; | ||
const { ScriptValue } = values; | ||
const { normalizers, Reader } = require("ckb-js-toolkit"); | ||
const { Set } = require("immutable"); | ||
|
||
const SIGNATURE_PLACEHOLDER = | ||
"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; | ||
|
||
function ensureSecp256k1Script(script, config) { | ||
const template = config.SCRIPTS.SECP256K1_BLAKE160.SCRIPT; | ||
if ( | ||
template.code_hash !== script.code_hash || | ||
template.hash_type !== script.hash_type | ||
) { | ||
throw new Error("Provided script is not SECP256K1_BLAKE160 script!"); | ||
} | ||
} | ||
|
||
async function transfer( | ||
txSkeleton, | ||
fromAddress, | ||
toAddress, | ||
amount, | ||
{ config = LINA, requireToAddress = true } = {} | ||
) { | ||
if (!config.SCRIPTS.SECP256K1_BLAKE160) { | ||
throw new Error( | ||
"Provided config does not have SECP256K1_BLAKE160 script setup!" | ||
); | ||
} | ||
|
||
const cellDep = txSkeleton.get("cellDeps").find((cellDep) => { | ||
return ( | ||
cellDep.dep_type === config.SCRIPTS.SECP256K1_BLAKE160.DEP_TYPE && | ||
new values.OutPointValue(cellDep.out_point, { validate: false }).equals( | ||
new values.OutPointValue(config.SCRIPTS.SECP256K1_BLAKE160.OUT_POINT, { | ||
validate: false, | ||
}) | ||
) | ||
); | ||
}); | ||
if (!cellDep) { | ||
txSkeleton = txSkeleton.update("cellDeps", (cellDeps) => { | ||
return cellDeps.push({ | ||
out_point: config.SCRIPTS.SECP256K1_BLAKE160.OUT_POINT, | ||
dep_type: config.SCRIPTS.SECP256K1_BLAKE160.DEP_TYPE, | ||
}); | ||
}); | ||
} | ||
|
||
amount = BigInt(amount); | ||
const fromScript = parseAddress(fromAddress, { config }); | ||
ensureSecp256k1Script(fromScript, config); | ||
|
||
if (requireToAddress && !toAddress) { | ||
throw new Error("You must provide a to address!"); | ||
} | ||
if (toAddress) { | ||
const toScript = parseAddress(toAddress, { config }); | ||
ensureSecp256k1Script(toScript, config); | ||
|
||
txSkeleton = txSkeleton.update("outputs", (outputs) => { | ||
return outputs.push({ | ||
cell_output: { | ||
capacity: "0x" + amount.toString(16), | ||
lock: toScript, | ||
type: null, | ||
}, | ||
data: "0x", | ||
out_point: null, | ||
block_hash: null, | ||
}); | ||
}); | ||
} | ||
|
||
/* | ||
* First, check if there is any output cells that contains enough capacity | ||
* for us to tinker with. | ||
* | ||
* TODO: the solution right now won't cover all cases, some outputs before the | ||
* last output might still be tinkerable, right now we are working on the | ||
* simple solution, later we can change this for more optimizations. | ||
*/ | ||
const lastFreezedOutput = txSkeleton | ||
.get("fixedEntries") | ||
.filter(({ field }) => field === "outputs") | ||
.maxBy(({ index }) => index); | ||
let i = lastFreezedOutput ? lastFreezedOutput.index + 1 : 0; | ||
for (; i < txSkeleton.get("outputs").size && amount > 0; i++) { | ||
const output = txSkeleton.get("outputs").get(i); | ||
if ( | ||
new ScriptValue(output.cell_output.lock, { validate: false }).equals( | ||
new ScriptValue(fromScript, { validate: false }) | ||
) | ||
) { | ||
const cellCapacity = BigInt(output.cell_output.capacity); | ||
let deductCapacity; | ||
if (amount >= cellCapacity) { | ||
deductCapacity = cellCapacity; | ||
} else { | ||
deductCapacity = cellCapacity - minimalCellCapacity(output); | ||
if (deductCapacity > amount) { | ||
deductCapacity = amount; | ||
} | ||
} | ||
amount -= deductCapacity; | ||
output.cell_output.capacity = | ||
"0x" + (cellCapacity - deductCapacity).toString(16); | ||
} | ||
} | ||
// Remove all output cells with capacity equal to 0 | ||
txSkeleton = txSkeleton.update("outputs", (outputs) => { | ||
return outputs.filter( | ||
(output) => BigInt(output.cell_output.capacity) !== BigInt(0) | ||
); | ||
}); | ||
/* | ||
* Collect and add new input cells so as to prepare remaining capacities. | ||
*/ | ||
if (amount > 0) { | ||
const cellProvider = txSkeleton.get("cellProvider"); | ||
if (!cellProvider) { | ||
throw new Error("Cell provider is missing!"); | ||
} | ||
const cellCollector = cellProvider.collector({ | ||
lock: fromScript, | ||
}); | ||
const changeCell = { | ||
cell_output: { | ||
capacity: "0x0", | ||
lock: fromScript, | ||
type: null, | ||
}, | ||
data: "0x", | ||
out_point: null, | ||
block_hash: null, | ||
}; | ||
let changeCapacity = BigInt(0); | ||
for await (const inputCell of cellCollector.collect()) { | ||
txSkeleton = txSkeleton.update("inputs", (inputs) => | ||
inputs.push(inputCell) | ||
); | ||
txSkeleton = txSkeleton.update("witnesses", (witnesses) => | ||
witnesses.push("0x") | ||
); | ||
const inputCapacity = BigInt(inputCell.cell_output.capacity); | ||
let deductCapacity = inputCapacity; | ||
if (deductCapacity > amount) { | ||
deductCapacity = amount; | ||
} | ||
amount -= deductCapacity; | ||
changeCapacity += inputCapacity - deductCapacity; | ||
if ( | ||
amount === BigInt(0) && | ||
(changeCapacity === BigInt(0) || | ||
changeCapacity > minimalCellCapacity(changeCell)) | ||
) { | ||
break; | ||
} | ||
} | ||
if (changeCapacity > BigInt(0)) { | ||
changeCell.cell_output.capacity = "0x" + changeCapacity.toString(16); | ||
txSkeleton = txSkeleton.update("outputs", (outputs) => | ||
outputs.push(changeCell) | ||
); | ||
} | ||
} | ||
if (amount > 0) { | ||
throw new Error("Not enough capacity in from address!"); | ||
} | ||
/* | ||
* Modify the skeleton, so the first witness of the fromAddress script group | ||
* has a WitnessArgs construct with 65-byte zero filled values. While this | ||
* is not required, it helps in transaction fee estimation. | ||
*/ | ||
const firstIndex = txSkeleton | ||
.get("inputs") | ||
.findIndex((input) => | ||
new ScriptValue(input.cell_output.lock, { validate: false }).equals( | ||
new ScriptValue(fromScript, { validate: false }) | ||
) | ||
); | ||
if (firstIndex !== -1) { | ||
while (firstIndex >= txSkeleton.get("witnesses").size) { | ||
txSkeleton = txSkeleton.update("witnesses", (witnesses) => | ||
witnesses.push("0x") | ||
); | ||
} | ||
let witness = txSkeleton.get("witnesses").get(firstIndex); | ||
const newWitnessArgs = { | ||
/* 65 zeros in hex */ | ||
lock: SIGNATURE_PLACEHOLDER, | ||
}; | ||
if (witness !== "0x") { | ||
const witnessArgs = new core.WitnessArgs(new Reader(witness)); | ||
const lock = witnessArgs.getLock(); | ||
if ( | ||
lock.hasValue() && | ||
new Reader(lock.value().raw()).serializeJson() !== newWitnessArgs.lock | ||
) { | ||
throw new Error( | ||
"Lock field in first witness is set aside for signature!" | ||
); | ||
} | ||
const inputType = witnessArgs.getInputType(); | ||
if (inputType.hasValue()) { | ||
newWitnessArgs.input_type = new Reader( | ||
inputType.value().raw() | ||
).serializeJson(); | ||
} | ||
const outputType = witnessArgs.getOutputType(); | ||
if (outputType.hasValue()) { | ||
newWitnessArgs.output_type = new Reader( | ||
outputType.value().raw() | ||
).serializeJson(); | ||
} | ||
} | ||
witness = new Reader( | ||
core.SerializeWitnessArgs( | ||
normalizers.NormalizeWitnessArgs(newWitnessArgs) | ||
) | ||
).serializeJson(); | ||
txSkeleton = txSkeleton.update("witnesses", (witnesses) => | ||
witnesses.set(firstIndex, witness) | ||
); | ||
} | ||
return txSkeleton; | ||
} | ||
|
||
async function payFee(txSkeleton, fromAddress, amount, { config = LINA } = {}) { | ||
return await transfer(txSkeleton, fromAddress, null, amount, { | ||
config, | ||
requireToAddress: false, | ||
}); | ||
} | ||
|
||
function hashWitness(hasher, witness) { | ||
const lengthBuffer = new ArrayBuffer(8); | ||
const view = new DataView(lengthBuffer); | ||
view.setBigUint64(0, BigInt(new Reader(witness).length()), true); | ||
hasher.update(lengthBuffer); | ||
hasher.update(witness); | ||
} | ||
|
||
function prepareSigningEntries(txSkeleton, { config = LINA } = {}) { | ||
if (!config.SCRIPTS.SECP256K1_BLAKE160) { | ||
throw new Error( | ||
"Provided config does not have SECP256K1_BLAKE160 script setup!" | ||
); | ||
} | ||
const template = config.SCRIPTS.SECP256K1_BLAKE160.SCRIPT; | ||
let processedArgs = Set(); | ||
const tx = createTransactionFromSkeleton(txSkeleton); | ||
const txHash = ckbHash( | ||
core.SerializeRawTransaction(normalizers.NormalizeRawTransaction(tx)) | ||
).serializeJson(); | ||
const inputs = txSkeleton.get("inputs"); | ||
const witnesses = txSkeleton.get("witnesses"); | ||
let signingEntries = txSkeleton.get("signingEntries"); | ||
for (let i = 0; i < inputs.size; i++) { | ||
const input = inputs.get(i); | ||
if ( | ||
template.code_hash === input.cell_output.lock.code_hash && | ||
template.hash_type === input.cell_output.lock.hash_type && | ||
!processedArgs.has(input.cell_output.lock.args) | ||
) { | ||
processedArgs = processedArgs.add(input.cell_output.lock.args); | ||
const lockValue = new values.ScriptValue(input.cell_output.lock, { | ||
validate: false, | ||
}); | ||
const hasher = new CKBHasher(); | ||
hasher.update(txHash); | ||
if (i >= witnesses.size) { | ||
throw new Error( | ||
`The first witness in the script group starting at input index ${i} does not exist, maybe some other part has invalidly tampered the transaction?` | ||
); | ||
} | ||
hashWitness(hasher, witnesses.get(i)); | ||
for (let j = i + 1; j < inputs.size && j < witnesses.size; j++) { | ||
const otherInput = inputs.get(j); | ||
if ( | ||
lockValue.equals( | ||
new values.ScriptValue(otherInput.cell_output.lock, { | ||
validate: false, | ||
}) | ||
) | ||
) { | ||
hashWitness(hasher, witnesses.get(j)); | ||
} | ||
} | ||
for (let j = inputs.size; j < witnesses.size; j++) { | ||
hashWitness(hasher, witnesses.get(j)); | ||
} | ||
const signingEntry = { | ||
type: "witness_args_lock", | ||
index: i, | ||
message: hasher.digestHex(), | ||
}; | ||
signingEntries = signingEntries.push(signingEntry); | ||
} | ||
} | ||
txSkeleton = txSkeleton.set("signingEntries", signingEntries); | ||
return txSkeleton; | ||
} | ||
|
||
module.exports = { | ||
transfer, | ||
payFee, | ||
prepareSigningEntries, | ||
}; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.