diff --git a/web3.js/package.json b/web3.js/package.json index 864c651989..c52c0ceb8d 100644 --- a/web3.js/package.json +++ b/web3.js/package.json @@ -37,7 +37,8 @@ "lint": "eslint .", "lint:fix": "npm run lint --fix", "lint:watch": "watch 'npm run lint:fix' . --wait=1 --ignoreDirectoryPattern=/doc/", - "prepublish": "npm run clean && npm run test && npm run flow && npm run lint && npm run doc && npm run build" + "ok": "npm run lint && npm run flow && npm run test && npm run doc", + "prepublish": "npm run clean && npm run ok && npm run build" }, "dependencies": { "babel-runtime": "^6.26.0", diff --git a/web3.js/src/budget-contract.js b/web3.js/src/budget-contract.js new file mode 100644 index 0000000000..0e9c5042b9 --- /dev/null +++ b/web3.js/src/budget-contract.js @@ -0,0 +1,256 @@ +// @flow + +import {Transaction} from './transaction'; +import type {PublicKey} from './account'; + +/** + * Represents a condition that is met by executing a `applySignature()` + * transaction + */ +export type SignatureCondition = { + type: 'signature'; + from: PublicKey; +}; + +/** + * Represents a condition that is met by executing a `applyTimestamp()` + * transaction + */ +export type TimeStampCondition = { + type: 'timestamp'; + from: PublicKey; + when: Date; +}; + +/** + * Represents a payment to a given public key + */ +export type Payment = { + amount: number; + to: PublicKey; +} + +/** + * Conditions that can unlock a payment + */ +export type BudgetCondition = SignatureCondition | TimeStampCondition; + +/** + * @private + */ +function serializePayment(payment: Payment): Buffer { + const toData = Transaction.serializePublicKey(payment.to); + const userdata = Buffer.alloc(8 + toData.length); + userdata.writeUInt32LE(payment.amount, 0); + toData.copy(userdata, 8); + return userdata; +} + +/** + * @private + */ +function serializeDate( + when: Date +): Buffer { + const userdata = Buffer.alloc(8 + 20); + userdata.writeUInt32LE(20, 0); // size of timestamp as u64 + + function iso(date) { + function pad(number) { + if (number < 10) { + return '0' + number; + } + return number; + } + + return date.getUTCFullYear() + + '-' + pad(date.getUTCMonth() + 1) + + '-' + pad(date.getUTCDate()) + + 'T' + pad(date.getUTCHours()) + + ':' + pad(date.getUTCMinutes()) + + ':' + pad(date.getUTCSeconds()) + + 'Z'; + } + userdata.write(iso(when), 8); + return userdata; +} + +/** + * @private + */ +function serializeCondition(condition: BudgetCondition) { + switch(condition.type) { + case 'timestamp': + { + const date = serializeDate(condition.when); + const from = Transaction.serializePublicKey(condition.from); + + const userdata = Buffer.alloc(4 + date.length + from.length); + userdata.writeUInt32LE(0, 0); // Condition enum = Timestamp + date.copy(userdata, 4); + from.copy(userdata, 4 + date.length); + return userdata; + } + case 'signature': + { + const from = Transaction.serializePublicKey(condition.from); + + const userdata = Buffer.alloc(4 + from.length); + userdata.writeUInt32LE(1, 0); // Condition enum = Signature + from.copy(userdata, 4); + return userdata; + } + default: + throw new Error(`Unknown condition type: ${condition.type}`); + } +} + + +/** + * Factory class for transactions to interact with the Budget contract + */ +export class BudgetContract { + + /** + * Public key that identifies the Budget Contract + */ + static get contractId(): PublicKey { + return '4uQeVj5tqViQh7yWWGStvkEG1Zmhx6uasJtWCJziofM'; + } + + /** + * Creates a timestamp condition + */ + static timestampCondition(from: PublicKey, when: Date) : TimeStampCondition { + return { + type: 'timestamp', + from, + when, + }; + } + + /** + * Creates a signature condition + */ + static signatureCondition(from: PublicKey) : SignatureCondition { + return { + type: 'signature', + from, + }; + } + + /** + * Generates a transaction that transfer tokens once a set of conditions are + * met + */ + static pay( + from: PublicKey, + contract: PublicKey, + to: PublicKey, + amount: number, + ...conditions: Array + ): Transaction { + + const userdata = Buffer.alloc(1024); + let pos = 0; + userdata.writeUInt32LE(0, pos); // NewContract instruction + pos += 4; + + userdata.writeUInt32LE(amount, pos); // Contract.tokens + pos += 8; + + userdata.writeUInt32LE(0, pos); // Contract.plan = Budget + pos += 4; + + switch (conditions.length) { + case 0: + userdata.writeUInt32LE(0, pos); // Budget enum = Pay + pos += 4; + + { + const payment = serializePayment({amount, to}); + payment.copy(userdata, pos); + pos += payment.length; + } + + return new Transaction({ + fee: 0, + keys: [from, to], + contractId: this.contractId, + userdata: userdata.slice(0, pos), + }); + case 1: + userdata.writeUInt32LE(1, pos); // Budget enum = After + pos += 4; + { + const condition = conditions[0]; + + const conditionData = serializeCondition(condition); + conditionData.copy(userdata, pos); + pos += conditionData.length; + + const paymentData = serializePayment({amount, to}); + paymentData.copy(userdata, pos); + pos += paymentData.length; + } + + return new Transaction({ + fee: 0, + keys: [from, contract], + contractId: this.contractId, + userdata: userdata.slice(0, pos), + }); + + case 2: + userdata.writeUInt32LE(2, pos); // Budget enum = Ok + pos += 4; + + for (let condition of conditions) { + const conditionData = serializeCondition(condition); + conditionData.copy(userdata, pos); + pos += conditionData.length; + + const paymentData = serializePayment({amount, to}); + paymentData.copy(userdata, pos); + pos += paymentData.length; + } + + return new Transaction({ + fee: 0, + keys: [from, to], + contractId: this.contractId, + userdata: userdata.slice(0, pos), + }); + + default: + throw new Error(`A maximum of two conditions are support: ${conditions.length} provided`); + } + } + + static applyTimestamp(from: PublicKey, contract: PublicKey, to: PublicKey, when: Date): Transaction { + const whenData = serializeDate(when); + const userdata = Buffer.alloc(4 + whenData.length); + + userdata.writeUInt32LE(1, 0); // ApplyTimestamp instruction + whenData.copy(userdata, 4); + + return new Transaction({ + fee: 0, + keys: [from, contract, to], + contractId: this.contractId, + userdata, + }); + } + + static applySignature(from: PublicKey, contract: PublicKey, to: PublicKey): Transaction { + const userdata = Buffer.alloc(4); + userdata.writeUInt32LE(2, 0); // ApplySignature instruction + + return new Transaction({ + fee: 0, + keys: [from, contract, to], + contractId: this.contractId, + userdata, + }); + } +} diff --git a/web3.js/src/system-contract.js b/web3.js/src/system-contract.js new file mode 100644 index 0000000000..b2ccd2992b --- /dev/null +++ b/web3.js/src/system-contract.js @@ -0,0 +1,108 @@ +// @flow + +import assert from 'assert'; + +import {Transaction} from './transaction'; +import type {PublicKey} from './account'; + +/** + * Factory class for transactions to interact with the System contract + */ +export class SystemContract { + /** + * Public key that identifies the System Contract + */ + static get contractId(): PublicKey { + return '11111111111111111111111111111111'; + } + + /** + * Generate a Transaction that creates a new account + */ + static createAccount( + from: PublicKey, + newAccount: PublicKey, + tokens: number, + space: number, + contractId: ?PublicKey + ): Transaction { + const userdata = Buffer.alloc(4 + 8 + 8 + 1 + 32); + let pos = 0; + + userdata.writeUInt32LE(0, pos); // Create Account instruction + pos += 4; + + userdata.writeUInt32LE(tokens, pos); // tokens as i64 + pos += 8; + + userdata.writeUInt32LE(space, pos); // space as u64 + pos += 8; + + if (contractId) { + userdata.writeUInt8(1, pos); // 'Some' + pos += 1; + + const contractIdBytes = Transaction.serializePublicKey(contractId); + contractIdBytes.copy(userdata, pos); + pos += 32; + } else { + userdata.writeUInt8(0, pos); // 'None' + pos += 1; + } + + assert(pos <= userdata.length); + + return new Transaction({ + fee: 0, + keys: [from, newAccount], + contractId: SystemContract.contractId, + userdata, + }); + } + + /** + * Generate a Transaction that moves tokens from one account to another + */ + static move(from: PublicKey, to: PublicKey, amount: number): Transaction { + const userdata = Buffer.alloc(4 + 8); + let pos = 0; + userdata.writeUInt32LE(2, pos); // Move instruction + pos += 4; + + userdata.writeUInt32LE(amount, pos); // amount as u64 + pos += 8; + + assert(pos === userdata.length); + + return new Transaction({ + fee: 0, + keys: [from, to], + contractId: SystemContract.contractId, + userdata, + }); + } + + /** + * Generate a Transaction that assigns an account to a contract id + */ + static assign(from: PublicKey, contractId: PublicKey): Transaction { + const userdata = Buffer.alloc(4 + 32); + let pos = 0; + + userdata.writeUInt32LE(1, pos); // Assign instruction + pos += 4; + + const contractIdBytes = Transaction.serializePublicKey(contractId); + contractIdBytes.copy(userdata, pos); + pos += contractIdBytes.length; + + assert(pos === userdata.length); + + return new Transaction({ + fee: 0, + keys: [from], + contractId: SystemContract.contractId, + userdata, + }); + } +} diff --git a/web3.js/src/transaction.js b/web3.js/src/transaction.js index 6ecb52ea5f..9be1837627 100644 --- a/web3.js/src/transaction.js +++ b/web3.js/src/transaction.js @@ -6,15 +6,6 @@ import bs58 from 'bs58'; import type {Account, PublicKey} from './account'; -/** - * @private - */ -function bs58DecodePublicKey(key: PublicKey): Buffer { - const keyBytes = Buffer.from(bs58.decode(key)); - assert(keyBytes.length === 32); - return keyBytes; -} - /** * @typedef {string} TransactionSignature */ @@ -25,6 +16,17 @@ export type TransactionSignature = string; */ export type TransactionId = string; +/** + * List of Transaction object fields that may be initialized at construction + */ +type TransactionCtorFields = {| + signature?: Buffer; + keys?: Array; + contractId?: PublicKey; + fee?: number; + userdata?: Buffer; +|}; + /** * Mirrors the Transaction struct in src/transaction.rs */ @@ -41,6 +43,11 @@ export class Transaction { */ keys: Array = []; + /** + * Contract Id to execute + */ + contractId: ?PublicKey; + /** * A recent transaction id. Must be populated by the caller */ @@ -56,6 +63,10 @@ export class Transaction { */ userdata: Buffer = Buffer.alloc(0); + constructor(opts?: TransactionCtorFields) { + opts && Object.assign(this, opts); + } + /** * @private */ @@ -74,11 +85,18 @@ export class Transaction { transactionData.writeUInt32LE(this.keys.length, pos); // u64 pos += 8; for (let key of this.keys) { - const keyBytes = bs58DecodePublicKey(key); + const keyBytes = Transaction.serializePublicKey(key); keyBytes.copy(transactionData, pos); pos += 32; } + // serialize `this.contractId` + if (this.contractId) { + const keyBytes = Transaction.serializePublicKey(this.contractId); + keyBytes.copy(transactionData, pos); + } + pos += 32; + // serialize `this.lastId` { const lastIdBytes = Buffer.from(bs58.decode(lastId)); @@ -93,6 +111,8 @@ export class Transaction { // serialize `this.userdata` if (this.userdata.length > 0) { + transactionData.writeUInt32LE(this.userdata.length, pos); // u64 + pos += 8; this.userdata.copy(transactionData, pos); pos += this.userdata.length; } @@ -131,28 +151,14 @@ export class Transaction { transactionData.copy(wireTransaction, signature.length); return wireTransaction; } -} -/** - * A Transaction that immediately transfers tokens - */ -export class TransferTokensTransaction extends Transaction { - constructor(from: PublicKey, to: PublicKey, amount: number) { - super(); - - // Forge a simple Budget Pay contract into `userdata` - // TODO: Clean this up - const userdata = Buffer.alloc(68); // 68 = serialized size of Budget enum - userdata.writeUInt32LE(60, 0); - userdata.writeUInt32LE(amount, 12); // u64 - userdata.writeUInt32LE(amount, 28); // u64 - const toData = bs58DecodePublicKey(to); - toData.copy(userdata, 36); - - Object.assign(this, { - fee: 0, - keys: [from, to], - userdata - }); + /** + * Serializes a public key into the wire format + */ + static serializePublicKey(key: PublicKey): Buffer { + const data = Buffer.from(bs58.decode(key)); + assert(data.length === 32); + return data; } } + diff --git a/web3.js/test/budget-contract.test.js b/web3.js/test/budget-contract.test.js new file mode 100644 index 0000000000..f87153b574 --- /dev/null +++ b/web3.js/test/budget-contract.test.js @@ -0,0 +1,39 @@ +// @flow + +import {Account} from '../src/account'; +import {BudgetContract} from '../src/budget-contract'; + +test('pay', () => { + const from = new Account(); + const contract = new Account(); + const to = new Account(); + let transaction; + + transaction = BudgetContract.pay( + from.publicKey, + contract.publicKey, + to.publicKey, + 123, + ); + console.log('Pay:', transaction); + + transaction = BudgetContract.pay( + from.publicKey, + contract.publicKey, + to.publicKey, + 123, + BudgetContract.signatureCondition(from.publicKey), + ); + console.log('After:', transaction); + + transaction = BudgetContract.pay( + from.publicKey, + contract.publicKey, + to.publicKey, + 123, + BudgetContract.signatureCondition(from.publicKey), + BudgetContract.timestampCondition(from.publicKey, new Date()), + ); + console.log('Or:', transaction); +}); + diff --git a/web3.js/test/connection.test.js b/web3.js/test/connection.test.js index 94bafa1767..f832b28be9 100644 --- a/web3.js/test/connection.test.js +++ b/web3.js/test/connection.test.js @@ -2,7 +2,7 @@ import {Account} from '../src/account'; import {Connection} from '../src/connection'; -import {TransferTokensTransaction} from '../src/transaction'; +import {SystemContract} from '../src/system-contract'; import {mockRpc} from './__mocks__/node-fetch'; const url = 'http://testnet.solana.com:8899'; @@ -274,7 +274,7 @@ test('transaction', async () => { ] ); - const transaction = new TransferTokensTransaction( + const transaction = SystemContract.move( accountFrom.publicKey, accountTo.publicKey, 10