Implement price change proposal workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 41s Details

This commit is contained in:
Ladd Hoffman 2024-03-31 12:59:57 -05:00
parent a3e3ebb71f
commit 04c31e0b90
22 changed files with 599 additions and 58 deletions

View File

@ -11,9 +11,15 @@ const dataDir = process.env.DATA_DIR || 'data';
const db = new Level(`${dataDir}/forum`, { valueEncoding: 'json' });
const verifySignature = ({ author, content, signature }) => {
const verifySignature = ({
author, content, signature, embeddedData,
}) => {
let contentToVerify = content;
if (embeddedData && Object.entries(embeddedData).length) {
contentToVerify += `\n\n${JSON.stringify(embeddedData, null, 2)}`;
}
try {
const account = recoverPersonalSignature({ data: content, signature });
const account = recoverPersonalSignature({ data: contentToVerify, signature });
if (account !== author) {
console.log('error: author does not match signature');
return false;
@ -28,17 +34,26 @@ const verifySignature = ({ author, content, signature }) => {
app.use(express.json());
app.post('/write', async (req, res) => {
const { body: { author, content, signature } } = req;
const {
body: {
author, content, signature, embeddedData,
},
} = req;
// Check author signature
if (!verifySignature({ author, content, signature })) {
if (!verifySignature({
author, content, signature, embeddedData,
})) {
res.status(403).end();
return;
}
// Compute content hash
const data = { author, content, signature };
const data = {
author, content, signature, embeddedData,
};
const hash = objectHash(data);
console.log('write', hash);
console.log(data);
// Store content
db.put(hash, data);
@ -61,6 +76,10 @@ app.get('/read/:hash', async (req, res) => {
return;
}
data.embeddedData = data.embeddedData || undefined;
console.log(data);
// Verify hash
const derivedHash = objectHash(data);
if (derivedHash !== hash) {

View File

@ -480,10 +480,21 @@ function App() {
</Tab>
<Tab eventKey="worker" title="Worker">
{work1 && (
<WorkContract workContract={work1} title="Work Contract 1" verb="Work" />
<WorkContract
workContract={work1}
title="Work Contract 1"
verb="Work"
showProposePriceChange
/>
)}
{onboarding && (
<WorkContract workContract={onboarding} title="Onboarding" verb="Onboarding" showRequestWork />
<WorkContract
workContract={onboarding}
title="Onboarding"
verb="Onboarding"
showRequestWork
showProposePriceChange
/>
)}
</Tab>
<Tab eventKey="customer" title="Customer">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,9 @@ function ViewPostModal({
show, setShow, title, post,
}) {
const handleClose = () => setShow(false);
const { content, author } = post;
const { content, author, embeddedData } = post;
const embeddedDataJson = JSON.stringify(embeddedData, null, 2);
return (
<Modal show={show} onHide={handleClose}>
@ -25,6 +27,11 @@ function ViewPostModal({
<p className="post-content">
{content}
</p>
{embeddedData && Object.entries(embeddedData).length && (
<pre>
{embeddedDataJson}
</pre>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>

View File

@ -0,0 +1,56 @@
import {
useCallback, useContext, useEffect,
} from 'react';
import useList from '../../utils/List';
import WorkContractContext from '../../contexts/WorkContractContext';
function PriceProposals() {
const { workContract } = useContext(WorkContractContext);
const [priceChangeProposals, dispatchPriceChangeProposal] = useList();
const fetchPriceProposal = useCallback(async (index) => {
const priceProposal = await workContract.methods.priceProposals(index).call();
priceProposal.id = index;
dispatchPriceChangeProposal({ type: 'update', item: priceProposal });
}, [workContract, dispatchPriceChangeProposal]);
const fetchPriceProposals = useCallback(async () => {
const count = await workContract.methods.priceProposalCount().call();
const promises = [];
dispatchPriceChangeProposal({ type: 'refresh' });
for (let i = 0; i < count; i += 1) {
promises.push(fetchPriceProposal(i));
}
await Promise.all(promises);
}, [workContract, fetchPriceProposal, dispatchPriceChangeProposal]);
useEffect(() => {
fetchPriceProposals();
// TODO: Event subscriptions/unsubscriptions
}, [workContract, fetchPriceProposals]);
return (
<>
<h3>Price Change Proposals</h3>
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>Proposal</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{priceChangeProposals?.filter((x) => !!x).map((p) => (
<tr key={p.id}>
<td>{p.id.toString()}</td>
<td>{p.proposalIndex.toString()}</td>
<td>{p.price.toString()}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
export default PriceProposals;

View File

@ -0,0 +1,102 @@
import {
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';
import Modal from 'react-bootstrap/Modal';
import PropTypes from 'prop-types';
import Web3 from 'web3';
import Web3Context from '../../contexts/Web3Context';
import Post from '../../utils/Post';
import ProposalsArtifact from '../../assets/Proposals.json';
import { getContractAddressByChainId } from '../../utils/contract-config';
import WorkContractContext from '../../contexts/WorkContractContext';
function ProposePriceChangeModal({
show, setShow, title,
}) {
const {
provider, account, chainId,
} = useContext(Web3Context);
const { workContract } = useContext(WorkContractContext);
const [content, setContent] = useState('');
const [proposedPrice, setProposedPrice] = useState();
const proposalsContract = useRef();
useEffect(() => {
const web3 = new Web3(provider);
const ProposalsAddress = getContractAddressByChainId(chainId, 'Proposals');
const contract = new web3.eth.Contract(ProposalsArtifact.abi, ProposalsAddress);
proposalsContract.current = contract;
}, [provider, chainId]);
const handleClose = () => setShow(false);
const handleSubmit = useCallback(async () => {
const post = new Post({ content });
// Include price as embedded data
post.embeddedData = { proposedPrice };
// Include metamask signature
await post.sign(provider, account);
// Clear the input and hide the modal
setContent('');
setShow(false);
// Write to API
await post.write();
// Publish to blockchain -- For now, Proposals.propose() does this for us
// await post.publish(DAO, account);
// Use content hash when calling Proposals.propose
// TODO: Make durations configurable
await workContract.methods.proposeNewPrice(proposedPrice, post.hash, [300, 300, 300]).send({
from: account,
gas: 999999,
value: 200,
});
}, [provider, workContract, account, content, setShow, proposedPrice]);
return (
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group className="mb-3">
<Form.Label>New Price</Form.Label>
<Form.Control type="text" onChange={(e) => setProposedPrice(e.target.value)} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Explanation</Form.Label>
<Form.Control
as="textarea"
rows={3}
onChange={(e) => setContent(e.target.value)}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Close
</Button>
<Button variant="primary" onClick={handleSubmit}>
Submit
</Button>
</Modal.Footer>
</Modal>
);
}
ProposePriceChangeModal.propTypes = {
show: PropTypes.bool.isRequired,
setShow: PropTypes.func.isRequired,
title: PropTypes.string,
};
ProposePriceChangeModal.defaultProps = {
title: 'Propose Price Change',
};
export default ProposePriceChangeModal;

View File

@ -1,9 +1,17 @@
import { useMemo } from 'react';
import {
useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import { PropTypes } from 'prop-types';
import Web3 from 'web3';
import Button from 'react-bootstrap/Button';
import Web3Context from '../../contexts/Web3Context';
import useList from '../../utils/List';
import WorkContractContext from '../../contexts/WorkContractContext';
import AvailabilityStakes from './AvailabilityStakes';
import WorkRequests from './WorkRequests';
import ProposePriceChangeModal from './ProposePriceChangeModal';
import PriceProposals from './PriceProposals';
function WorkContract({
workContract,
@ -13,14 +21,49 @@ function WorkContract({
title,
verb,
showRequestWork,
showProposePriceChange,
}) {
const [availabilityStakes, dispatchAvailabilityStake] = useList();
const [priceWei, setPriceWei] = useState();
const [priceEth, setPriceEth] = useState();
const { provider } = useContext(Web3Context);
const [showPriceChangeModal, setShowPriceChangeModal] = useState(false);
const fetchPrice = useCallback(async () => {
const web3 = new Web3(provider);
const fetchedPrice = await workContract.methods.price().call();
setPriceWei(fetchedPrice);
setPriceEth(web3.utils.fromWei(fetchedPrice, 'ether'));
// TODO: Subscribe to price update event
// TODO: Unsubscribe
}, [workContract, provider]);
useEffect(() => {
fetchPrice();
}, [workContract, provider, fetchPrice]);
const workContractProviderValue = useMemo(() => ({
workContract, availabilityStakes, dispatchAvailabilityStake,
}), [workContract, availabilityStakes, dispatchAvailabilityStake]);
workContract, availabilityStakes, dispatchAvailabilityStake, priceWei, priceEth,
}), [workContract, availabilityStakes, dispatchAvailabilityStake, priceWei, priceEth]);
return (
<WorkContractContext.Provider value={workContractProviderValue}>
{showProposePriceChange && (
<ProposePriceChangeModal
show={showPriceChangeModal}
setShow={setShowPriceChangeModal}
/>
)}
<h2>{title}</h2>
<h3>{`Price: ${priceEth} ETH`}</h3>
{showProposePriceChange && (
<>
<Button onClick={() => setShowPriceChangeModal(true)}>
Propose New Price
</Button>
<PriceProposals />
</>
)}
<AvailabilityStakes
showActions={showAvailabilityActions}
showAmount={showAvailabilityAmount}
@ -39,6 +82,7 @@ WorkContract.propTypes = {
showAvailabilityActions: PropTypes.bool,
showAvailabilityAmount: PropTypes.bool,
onlyShowAvailable: PropTypes.bool,
showProposePriceChange: PropTypes.bool,
};
WorkContract.defaultProps = {
@ -46,6 +90,7 @@ WorkContract.defaultProps = {
showAvailabilityActions: true,
showAvailabilityAmount: true,
onlyShowAvailable: false,
showProposePriceChange: false,
};
export default WorkContract;

View File

@ -30,9 +30,8 @@ const getRequestStatus = (request) => {
function WorkRequests({
showRequestWork, verb,
}) {
const { workContract, availabilityStakes } = useContext(WorkContractContext);
const { workContract, availabilityStakes, priceWei } = useContext(WorkContractContext);
const [workRequests, dispatchWorkRequest] = useList();
const [price, setPrice] = useState();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showEvidenceModal, setShowEvidenceModal] = useState(false);
const [currentRequestId, setCurrentRequestId] = useState();
@ -43,12 +42,6 @@ function WorkRequests({
provider, account,
} = useContext(Web3Context);
const fetchPrice = useCallback(async () => {
const web3 = new Web3(provider);
const fetchedPrice = await workContract.methods.price().call();
setPrice(web3.utils.fromWei(fetchedPrice, 'ether'));
}, [workContract, provider]);
const fetchWorkRequest = useCallback(async (requestIndex) => {
const web3 = new Web3(provider);
const r = await workContract.methods.requests(requestIndex).call();
@ -73,7 +66,6 @@ function WorkRequests({
}, [workContract, dispatchWorkRequest, fetchWorkRequest]);
useEffect(() => {
fetchPrice();
fetchWorkRequests();
workContract.events.WorkAssigned({ fromBlock: 'latest' }).on('data', (event) => {
@ -90,7 +82,7 @@ function WorkRequests({
console.log('event: work approval submitted', event);
fetchWorkRequest(event.returnValues.requestIndex);
});
}, [workContract, fetchWorkRequests, fetchPrice, fetchWorkRequest]);
}, [workContract, fetchWorkRequests, fetchWorkRequest]);
const submitWorkApproval = useCallback(async (requestIndex) => {
await workContract.methods.submitWorkApproval(requestIndex, true).send({
@ -116,14 +108,12 @@ function WorkRequests({
};
const onSubmitRequest = useCallback(async ({ hash }) => {
const web3 = new Web3(provider);
const priceWei = BigInt(web3.utils.toWei(price, 'ether'));
await workContract.methods.requestWork(hash).send({
from: account,
gas: 1000000,
value: priceWei,
});
}, [provider, workContract, account, price]);
}, [workContract, account, priceWei]);
const onSubmitEvidence = useCallback(async ({ hash }) => {
await workContract.methods.submitWorkEvidence(currentRequestId, hash).send({
@ -143,9 +133,6 @@ function WorkRequests({
<AddPostModal title={`${verb} Request`} show={showRequestModal} setShow={setShowRequestModal} onSubmit={onSubmitRequest} />
<AddPostModal title="Work Evidence" show={showEvidenceModal} setShow={setShowEvidenceModal} onSubmit={onSubmitEvidence} />
<ViewPostModal title={`${verb} Request`} show={showViewRequestModal} setShow={setShowViewRequestModal} post={viewRequest} />
<div>
{`Price: ${price} ETH`}
</div>
{showRequestWork && (
<div>
<Button onClick={handleShowWorkRequest}>{`Request ${verb}`}</Button>

View File

@ -1,9 +1,9 @@
{
"localhost": {
"DAO": "0x84A5F75A39e25bD39B69F7d096d159557EaF2a59",
"Work1": "0xaB3Bf8f9BE69289B0dd2a154a6390c8D9F780c59",
"Onboarding": "0xf10E261AFF9Aa8b05716002bFf44D3e990401C02",
"Proposals": "0xBD616B6331e0953Fc20281a54A684E614D8C4026"
"DAO": "0x358A07B26F4c556140872ecdB69c58e8807E7178",
"Work1": "0xC62b0b16B3ef06c417BFC4Fb02E0Da06aF5A95Ef",
"Onboarding": "0x91B8D37F396cfb887996119CD37a0886C78a7B9C",
"Proposals": "0x63472674239ffb70618Fae043610917f2d9B781C"
},
"sepolia": {
"DAO": "0x58c8ea0ba031431423cD84787d7d57F0Bf7c6E63",

View File

@ -9,37 +9,59 @@ window.Buffer = Buffer;
class Post {
constructor({
author, content, signature, hash,
author, content, signature, hash, embeddedData,
}) {
this.author = author;
this.content = content;
this.signature = signature;
this.hash = hash;
this.embeddedData = embeddedData;
}
// Read from API
static async read(hash) {
const { data: { content, author, signature } } = await axios.get(`/api/read/${hash}`);
const {
data: {
content, author, signature, embeddedData,
},
} = await axios.get(`/api/read/${hash}`);
// Verify hash
const derivedHash = objectHash({ author, content, signature });
const derivedHash = objectHash({
author, content, signature, embeddedData,
});
if (hash !== derivedHash) {
throw new Error('Hash mismatch');
}
// Verify signature
const recovered = recoverPersonalSignature({ data: content, signature });
let contentToVerify = content;
if (embeddedData && Object.entries(embeddedData).length) {
contentToVerify += `\n\n${JSON.stringify(embeddedData, null, 2)}`;
}
const recovered = recoverPersonalSignature({ data: contentToVerify, signature });
if (recovered !== author) {
throw new Error('Author mismatch');
}
return new Post({
content, author, signature, hash,
content, author, signature, hash, embeddedData,
});
}
static deriveEmbeddedData(content) {
const dataStart = content.search(/^\{/);
const dataStr = content.substring(dataStart);
const embeddedData = JSON.parse(dataStr);
return embeddedData;
}
// Include MetaMask signature
async sign(web3Provider, account) {
this.author = account;
const msg = `0x${Buffer.from(this.content, 'utf8').toString('hex')}`;
let contentToSign = this.content;
if (this.embeddedData && Object.entries(this.embeddedData).length) {
contentToSign += `\n\n${JSON.stringify(this.embeddedData, null, 2)}`;
}
const msg = `0x${Buffer.from(contentToSign, 'utf8').toString('hex')}`;
this.signature = await web3Provider.request({
method: 'personal_sign',
params: [msg, account],
@ -53,6 +75,7 @@ class Post {
author: this.author,
content: this.content,
signature: this.signature,
embeddedData: this.embeddedData,
};
const { data: hash } = await axios.post('/api/write', data);
this.hash = hash;

View File

@ -1,9 +1,9 @@
{
"localhost": {
"DAO": "0x84A5F75A39e25bD39B69F7d096d159557EaF2a59",
"Work1": "0xaB3Bf8f9BE69289B0dd2a154a6390c8D9F780c59",
"Onboarding": "0xf10E261AFF9Aa8b05716002bFf44D3e990401C02",
"Proposals": "0xBD616B6331e0953Fc20281a54A684E614D8C4026"
"DAO": "0x358A07B26F4c556140872ecdB69c58e8807E7178",
"Work1": "0xC62b0b16B3ef06c417BFC4Fb02E0Da06aF5A95Ef",
"Onboarding": "0x91B8D37F396cfb887996119CD37a0886C78a7B9C",
"Proposals": "0x63472674239ffb70618Fae043610917f2d9B781C"
},
"sepolia": {
"DAO": "0x58c8ea0ba031431423cD84787d7d57F0Bf7c6E63",

View File

@ -0,0 +1,10 @@
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.24;
interface IOnProposalAccepted {
function onProposalAccepted(
uint stakedFor,
uint stakedAgainst,
bytes calldata callbackData
) external;
}

View File

@ -6,7 +6,11 @@ import "./WorkContract.sol";
import "./IOnValidate.sol";
contract Onboarding is WorkContract, IOnValidate {
constructor(DAO dao_, uint price_) WorkContract(dao_, price_) {}
constructor(
DAO dao_,
Proposals proposals_,
uint price_
) WorkContract(dao_, proposals_, price_) {}
/// Accept work approval/disapproval from customer
function submitWorkApproval(

View File

@ -68,6 +68,9 @@ contract Proposals is DAOContract, IOnValidate {
bool callbackOnValidate,
bytes calldata callbackData
) external payable returns (uint proposalIndex) {
// TODO: Consider taking author as a parameter,
// or else accepting a postIndex instead of contentId,
// or support post lookup by contentId
uint postIndex = dao.addPost(msg.sender, contentId);
proposalIndex = proposalCount++;
Proposal storage proposal = proposals[proposalIndex];
@ -92,6 +95,9 @@ contract Proposals is DAOContract, IOnValidate {
pools[2] = proposal.referenda[2].pools;
}
// TODO: function getProposals()
// Enumerate timing so clients can render it
/// External function for reputation holders to attest toward a given proposal;
/// This is non-binding and non-encumbering, so it does not transfer any reputation.
function attest(uint proposalIndex, uint amount) external {

View File

@ -3,7 +3,12 @@ pragma solidity ^0.8.24;
import "./DAO.sol";
import "./WorkContract.sol";
import "./Proposals.sol";
contract Work1 is WorkContract {
constructor(DAO dao_, uint price_) WorkContract(dao_, price_) {}
constructor(
DAO dao_,
Proposals proposals_,
uint price_
) WorkContract(dao_, proposals_, price_) {}
}

View File

@ -3,8 +3,14 @@ pragma solidity ^0.8.24;
import "./DAO.sol";
import "./IAcceptAvailability.sol";
import "./Proposals.sol";
import "./IOnProposalAccepted.sol";
abstract contract WorkContract is DAOContract, IAcceptAvailability {
abstract contract WorkContract is
DAOContract,
IAcceptAvailability,
IOnProposalAccepted
{
struct AvailabilityStake {
address worker;
uint256 amount;
@ -30,7 +36,15 @@ abstract contract WorkContract is DAOContract, IAcceptAvailability {
bool approval;
}
uint public immutable price;
struct PriceProposal {
uint price;
uint proposalIndex;
}
Proposals proposalsContract;
uint public price;
mapping(uint => PriceProposal) public priceProposals;
uint public priceProposalCount;
mapping(uint => AvailabilityStake) public stakes;
uint public stakeCount;
mapping(uint => WorkRequest) public requests;
@ -43,8 +57,13 @@ abstract contract WorkContract is DAOContract, IAcceptAvailability {
event WorkEvidenceSubmitted(uint requestIndex);
event WorkApprovalSubmitted(uint requestIndex, bool approval);
constructor(DAO dao, uint price_) DAOContract(dao) {
constructor(
DAO dao,
Proposals proposalsContract_,
uint price_
) DAOContract(dao) {
price = price_;
proposalsContract = proposalsContract_;
}
/// Accept availability stakes as reputation token transfer
@ -185,4 +204,39 @@ abstract contract WorkContract is DAOContract, IAcceptAvailability {
);
dao.stake(poolIndex, stake.amount, true);
}
function proposeNewPrice(
uint newPrice,
string calldata contentId,
uint[3] calldata durations
) external payable {
uint priceProposalIndex = priceProposalCount++;
PriceProposal storage priceProposal = priceProposals[
priceProposalIndex
];
priceProposal.price = newPrice;
priceProposal.proposalIndex = proposalsContract.propose{
value: msg.value
}(
contentId,
durations[0],
durations[1],
durations[2],
true,
abi.encode(priceProposalIndex)
);
}
function onProposalAccepted(
uint, // stakedFor
uint, // stakedAgainst
bytes calldata callbackData
) external {
uint priceProposalIndex = abi.decode(callbackData, (uint));
PriceProposal storage priceProposal = priceProposals[
priceProposalIndex
];
price = priceProposal.price;
// TODO: Emit price change event
}
}

View File

@ -4,9 +4,9 @@ const deployDAOContract = require('./util/deploy-dao-contract');
async function main() {
await deployContract('DAO');
await deployDAOContract('Proposals');
await deployWorkContract('Work1');
await deployWorkContract('Onboarding');
await deployDAOContract('Proposals');
}
main().catch((error) => {

View File

@ -11,7 +11,10 @@ const deployWorkContract = async (name) => {
const priceEnvVar = `${name.toUpperCase()}_PRICE`;
const price = ethers.parseEther(process.env[priceEnvVar] || 0.001);
await deployContract(name, [contractAddresses[network].DAO, price]);
await deployContract(name, [
contractAddresses[network].DAO,
contractAddresses[network].Proposals,
price]);
};
module.exports = deployWorkContract;

View File

@ -14,8 +14,10 @@ describe('Work1', () => {
const DAO = await ethers.getContractFactory('DAO');
const dao = await DAO.deploy();
const Proposals = await ethers.getContractFactory('Proposals');
const proposals = await Proposals.deploy(dao.target);
const Work1 = await ethers.getContractFactory('Work1');
const work1 = await Work1.deploy(dao.target, WORK1_PRICE);
const work1 = await Work1.deploy(dao.target, proposals.target, WORK1_PRICE);
await dao.addPost(account1, 'some-content-id');
const callbackData = ethers.AbiCoder.defaultAbiCoder().encode([], []);
@ -34,7 +36,7 @@ describe('Work1', () => {
await dao.evaluateOutcome(0);
return {
dao, work1, account1, account2,
dao, work1, proposals, account1, account2,
};
}
@ -283,4 +285,15 @@ describe('Work1', () => {
await expect(work1.submitWorkApproval(0, true)).to.be.revertedWith('Status must be EvidenceSubmitted');
});
});
describe('Propose new price', () => {
it('can propose a new price', async () => {
const {
proposals, work1,
} = await loadFixture(deploy);
expect(await proposals.proposalCount()).to.equal(0);
await work1.proposeNewPrice(12345, 'content-id', [1, 1, 1]);
expect(await proposals.proposalCount()).to.equal(1);
});
});
});