Skip to content
This repository has been archived by the owner on Oct 9, 2024. It is now read-only.

Commit

Permalink
HYP-24: controller for revocation (#25)
Browse files Browse the repository at this point in the history
* HYP-24: controller for revocation

* HYP-24: linting.

* HYP-24: rebase with main

* HYP-24: zod and db  type abstraction before revocation final test.

* HYP-24: 3 a few changes to docker regarding regulator role.

* HYP-24: README update,

* HYP-24: integration test and rebase with main.

* HYP-24: invalid import.

* HYP-24: version bump.

* HYP-24: vscode imports are not perfect.

* HYP-24: missed type.

* HYP-24: ts error before merging commits.

* HYP-24: unassigned variable.

* HYP-24: file upload fails in test.

* HYP-24: switching back to octect from json/

* HYP-24: attachment is seeded

* HYP-24: oops, hardcoded value.

* Updates to finish off revocation

- Updates from reviews
- Fixed onchain tests for revocation
- Changed how dependencies are resolved in service watcher as this was causing errors

* Linting fixes

* Attachment fixes

---------

Co-authored-by: Matthew Dean <matthew.dean@digicatapult.org.uk>
  • Loading branch information
n3op2 and mattdean-digicatapult authored Dec 15, 2023
1 parent e812690 commit b0dad97
Show file tree
Hide file tree
Showing 27 changed files with 643 additions and 270 deletions.
79 changes: 48 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,32 @@

## Description

An API service for issuing hydrogen certificates
An API service for issuing hydrogen certificates

## Configuration

Use a `.env` at root of the repository to set values for the environment variables defined in `.env` file.

| variable | required | default | description |
| :--------------------- | :------: | :--------------------: | :------------------------------------------------------------------------------------------- |
| PORT | N | `3000` | The port for the API to listen on |
| LOG_LEVEL | N | `debug` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] |
| ENVIRONMENT_VAR | N | `example` | An environment specific variable |
| DB_PORT | N | `5432` | The port for the database |
| DB_HOST | Y | - | The database hostname / host |
| variable | required | default | description |
| :--------------------- | :------: | :-----------------: | :------------------------------------------------------------------------------------------- |
| PORT | N | `3000` | The port for the API to listen on |
| LOG_LEVEL | N | `debug` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] |
| ENVIRONMENT_VAR | N | `example` | An environment specific variable |
| DB_PORT | N | `5432` | The port for the database |
| DB_HOST | Y | - | The database hostname / host |
| DB_NAME | N | `dscp-hyproof-api ` | The database name |
| DB_USERNAME | Y | - | The database username |
| DB_PASSWORD | Y | - | The database password |
| IDENTITY_SERVICE_HOST | Y | - | Hostname of the `dscp-identity-service` |
| IDENTITY_SERVICE_PORT | N | `3000` | Port of the `dscp-identity-service` |
| NODE_HOST | Y | - | The hostname of the `dscp-node` the API should connect to |
| NODE_PORT | N | `9944` | The port of the `dscp-node` the API should connect to |
| LOG_LEVEL | N | `info` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] |
| USER_URI | Y | - | The Substrate `URI` representing the private key to use when making `dscp-node` transactions |
| IPFS_HOST | Y | - | Hostname of the `IPFS` node to use for metadata storage |
| IPFS_PORT | N | `5001` | Port of the `IPFS` node to use for metadata storage |
| WATCHER_POLL_PERIOD_MS | N | `10000` | Number of ms between polling of service state |
| WATCHER_TIMEOUT_MS | N | `2000` | Timeout period in ms for service state |
| DB_USERNAME | Y | - | The database username |
| DB_PASSWORD | Y | - | The database password |
| IDENTITY_SERVICE_HOST | Y | - | Hostname of the `dscp-identity-service` |
| IDENTITY_SERVICE_PORT | N | `3000` | Port of the `dscp-identity-service` |
| NODE_HOST | Y | - | The hostname of the `dscp-node` the API should connect to |
| NODE_PORT | N | `9944` | The port of the `dscp-node` the API should connect to |
| LOG_LEVEL | N | `info` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] |
| USER_URI | Y | - | The Substrate `URI` representing the private key to use when making `dscp-node` transactions |
| IPFS_HOST | Y | - | Hostname of the `IPFS` node to use for metadata storage |
| IPFS_PORT | N | `5001` | Port of the `IPFS` node to use for metadata storage |
| WATCHER_POLL_PERIOD_MS | N | `10000` | Number of ms between polling of service state |
| WATCHER_TIMEOUT_MS | N | `2000` | Timeout period in ms for service state |

## Getting started

Expand Down Expand Up @@ -108,14 +108,31 @@ npm run flows

### Fundamental entities

there is the `attachment` entity which returns an `id` to be used when preparing entity updates to attach files.
This is project is primary based on two entities in regards to the API.

### Services
> Certificate - that can be issued, initiated and revoked by a regulator. This is something we will keep circulating arround
> Attachments - file or json information that is stored on IPFS servie
> Transaction - records representing interactions with the chain
Run `docker-compose up -d` to start the required dependencies to fully demo a single-party version of `dscp-hyproof-api`.
### Running locally

running with docker-compose -f docker-compose-3-personal.yml logs -f will render all logs, also can be parsed by `| grep`.

```
docker-compose -f docker-compose-3-personal.yml logs -f | grep regulator
```

> single persona
> Run `docker composel up -d` to start the required dependencies to fully demo `dscp-hyproof-api`.
> multiple persona
> Run `docker-compose -f docker-compose-3-personal.yml up` to start the required dependencies to fully demo `dscp-hyproof-api`.
> services
- dscp-hyproof-api (+ PostgreSQL)
- dscp-identity-service (+ PostgreSQL)
- ipfs
- dscp-node

You can run a full 3-party demonstration using `docker-compose -f docker-compose-3-persona.yml up --build -d`.
Expand All @@ -126,27 +143,27 @@ The 3-party demonstration creates 3 personas with different roles, given below.

`Heidi (the Hydrogen Producer)`:

- [localhost:8000/swagger](http://localhost:8000/swagger/#/)
- [localhost:8000/swagger](http://localhost:8000/swagger/#/)

- [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/)
- [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/)

`Emma (the Energy Provider)`:

- [localhost:8010/swagger](http://localhost:8010/swagger/#/)
- [localhost:8010/swagger](http://localhost:8010/swagger/#/)

- [localhost:9010/v1/swagger](http://localhost:9010/v1/swagger/#/)
- [localhost:9010/v1/swagger](http://localhost:9010/v1/swagger/#/)

`Reginald (the Regulator)`:

- [localhost:8020/swagger](http://localhost:8020/swagger/#/)
- [localhost:8020/swagger](http://localhost:8020/swagger/#/)

- [localhost:9020/v1/swagger](http://localhost:9020/v1/swagger/#/)
- [localhost:9020/v1/swagger](http://localhost:9020/v1/swagger/#/)

The single-party version only uses:

- [localhost:8000/swagger](http://localhost:8000/swagger/#/)
- [localhost:8000/swagger](http://localhost:8000/swagger/#/)

- [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/)
- [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/)

### Using the HyProof API

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@digicatapult/dscp-hyproof-api",
"version": "0.5.14",
"version": "0.6.0",
"description": "An OpenAPI API service for DSCP",
"main": "src/index.ts",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions processFlows.dscp
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,5 @@ pub fn revoke_cert |input: IssuedCert| => |output: RevokedCert| where {
output.energy_owner != sender,
output.hydrogen_owner != sender,
output.regulator == sender,
output.reason: File
}
12 changes: 12 additions & 0 deletions processFlows.json
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,18 @@
}
}
},
{
"Op": "And"
},
{
"Restriction": {
"FixedOutputMetadataValueType": {
"index": 0,
"metadata_key": "reason",
"metadata_value_type": "File"
}
}
},
{
"Op": "And"
}
Expand Down
10 changes: 6 additions & 4 deletions src/controllers/v1/attachment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,16 @@ export class attachment extends Controller {
this.log.debug(`attempting to retrieve ${id} attachment`)
const [attachment] = await this.db.get('attachment', { id })
if (!attachment) throw new NotFound('attachment')
const { filename, ipfs_hash, size } = attachment
let { filename, size } = attachment

const { blob, filename: ipfsFilename } = await this.ipfs.getFile(ipfs_hash)
const { blob, filename: ipfsFilename } = await this.ipfs.getFile(attachment.ipfs_hash)
const blobBuffer = Buffer.from(await blob.arrayBuffer())

if (size === null || filename === null) {
filename = ipfsFilename
size = blob.size
try {
await this.db.update('attachment', { id }, { filename: ipfsFilename, size: blob.size })
await this.db.update('attachment', { id }, { filename, size })
} catch (err) {
const message = err instanceof Error ? err.message : 'unknown'
this.log.warn('Error updating attachment size: %s', message)
Expand All @@ -155,6 +157,6 @@ export class attachment extends Controller {
}
}
}
return this.octetResponse(blobBuffer, filename || ipfsFilename)
return this.octetResponse(blobBuffer, filename)
}
}
88 changes: 83 additions & 5 deletions src/controllers/v1/certificate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ import type { Logger } from 'pino'
import { injectable } from 'tsyringe'

import { logger } from '../../../lib/logger'
import Database, { CertificateRow, Where } from '../../../lib/db'
import { CertificateRow, Where } from '../../../lib/db/types'
import Database from '../../../lib/db'
import { BadRequest, InternalServerError, NotFound } from '../../../lib/error-handler/index'
import Identity from '../../../lib/services/identity'
import * as Certificate from '../../../models/certificate'
import { DATE, UUID } from '../../../models/strings'
import ChainNode from '../../../lib/chainNode'
import { processInitiateCert, processIssueCert } from '../../../lib/payload'
import { processInitiateCert, processIssueCert, processRevokeCert } from '../../../lib/payload'
import { TransactionState } from '../../../models/transaction'
import Commitment from '../../../lib/services/commitment'
import EmissionsCalculator from '../../../lib/services/emissionsCalculator'
Expand Down Expand Up @@ -307,9 +308,10 @@ export class CertificateController extends Controller {
}

/**
* Update a certificate on-chain to include emissions data
* @summary Issue a new certificate on-chain
* @param demandAId The certificate's identifier
* Update a certificate on-chain to include
* @summary updates initiated certitificate status to issued on chain
* along with the embodied_co2
* @param id the local certificate's identifier
*/
@Post('{id}/issuance')
@Response<NotFound>(404, 'Item not found')
Expand Down Expand Up @@ -364,4 +366,80 @@ export class CertificateController extends Controller {

return transaction
}

/**
* @summary returns certificate transaction by certificate and transaction id
* @param id - the local certificate's identifier
* @example id "52907745-7672-470e-a803-a2f8feb52944"
*/
@Response<ValidateError>(422, 'Validation Failed')
@Response<NotFound>(404, '<item> not found')
@Response<BadRequest>(400, 'ID must be supplied in UUID format')
@Get('{id}/revocation/{transactionId}')
public async getRevocationTransaction(
@Path() id: UUID,
transactionId: UUID
): Promise<Certificate.GetTransactionResponse> {
if (!id || !transactionId) throw new BadRequest()

const [transaction] = await this.db.get('transaction', {
local_id: id,
id: transactionId,
api_type: 'certificate',
transaction_type: 'revoke_cert',
})
if (!transaction) throw new NotFound(id)
return transaction
}

/**
* @summary returns transactions by certificate local id
* @example id "52907745-7672-470e-a803-a2f8feb52944"
*/
@Response<ValidateError>(422, 'Validation Failed')
@Response<NotFound>(404, '<item> not found')
@Response<BadRequest>(400, 'ID must be supplied in UUID format')
@Get('{id}/revocation')
public async getRevocationTransactions(@Path() id: UUID): Promise<Certificate.ListTransactionResponse> {
if (!id) throw new BadRequest()
return await this.db.get('transaction', { local_id: id, api_type: 'certificate', transaction_type: 'revoke_cert' })
}

/**
* Updates a certificate on-chain to include
* @summary changes issued certificate to revoked
* @param id - the local certificate's identifier
*/
@Post('{id}/revocation')
@Response<NotFound>(404, 'Item not found')
@SuccessResponse('201')
public async revokeOnChain(
@Path() id: UUID,
@Body() { reason }: Certificate.RevokePayload
): Promise<Certificate.GetTransactionResponse> {
const { address: self_address } = await this.identity.getMemberBySelf()
const [certificate] = await this.db.get('certificate', { id })

if (!certificate) throw new NotFound(id)
if (certificate.state !== 'issued') throw new BadRequest('certificate must be issued to revoke')
if (certificate.regulator !== self_address) throw new BadRequest('certificates can be revoked only by a regulator')

const [attachment] = await this.db.get('attachment', { id: reason })
if (!attachment) throw new NotFound(reason)

const extrinsic = await this.node.prepareRunProcess(processRevokeCert(certificate, attachment))
const [transaction] = await this.db.insert('transaction', {
api_type: 'certificate',
local_id: certificate.id,
hash: extrinsic.hash.toHex(),
transaction_type: 'revoke_cert',
})
if (!transaction) throw new InternalServerError('Transaction must exist')

this.node.submitRunProcess(extrinsic, (state: TransactionState) =>
this.db.update('transaction', { id: transaction.id }, { state })
)

return transaction
}
}
36 changes: 22 additions & 14 deletions src/lib/chainNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { logger } from './logger'
import { Env } from '../env'
import { injectable, singleton } from 'tsyringe'
import { trim0x } from './utils/shared'
import Database from './db'

const processRanTopic = blake2AsHex('utxoNFT.ProcessRan')

Expand Down Expand Up @@ -59,7 +60,10 @@ export default class ChainNode {
private logger: Logger
private userUri: string

constructor(private env: Env) {
constructor(
private env: Env,
private db: Database
) {
this.logger = logger.child({ module: 'ChainNode' })
this.provider = new WsProvider(`ws://${this.env.get('NODE_HOST')}:${this.env.get('NODE_PORT')}`)
this.userUri = this.env.get('USER_URI')
Expand Down Expand Up @@ -260,21 +264,25 @@ export default class ChainNode {
const api = blockHash ? await this.api.at(blockHash) : this.api
const token = (await api.query.utxoNFT.tokensById(tokenId)).toJSON() as unknown as SubstrateToken
const metadata = new Map(
Object.entries(token.metadata).map(([keyHex, entry]) => {
const key = Buffer.from(keyHex.substring(2), 'hex').toString('utf8')
const [valueKey, valueRaw] = Object.entries(entry)[0]
if (valueKey === 'None' || valueKey === 'tokenId') {
return [key, valueRaw]
}
await Promise.all(
Object.entries(token.metadata).map(async ([keyHex, entry]): Promise<readonly [string, string]> => {
const key = Buffer.from(keyHex.substring(2), 'hex').toString('utf8')
const [valueKey, valueRaw] = Object.entries(entry)[0]
if (valueKey === 'None' || valueKey === 'tokenId') {
return [key, valueRaw]
}

if (valueKey === 'file') {
return [key, hexToBs58(valueRaw)]
}
if (valueKey === 'file') {
const base58 = hexToBs58(valueRaw)
const [attachment] = await this.db.get('attachment', { ipfs_hash: base58 })
return [key, attachment?.id || base58]
}

const valueHex = valueRaw || '0x'
const value = Buffer.from(valueHex.substring(2), 'hex').toString('utf8')
return [key, value]
})
const valueHex = valueRaw || '0x'
const value = Buffer.from(valueHex.substring(2), 'hex').toString('utf8')
return [key, value]
})
)
)
const roles = new Map(
Object.entries(token.roles).map(([role, account]) => [Buffer.from(trim0x(role), 'hex').toString('utf8'), account])
Expand Down
Loading

0 comments on commit b0dad97

Please sign in to comment.